agentleFS
thisisgmthisisgm/flea/AGENTS.md

flea

AGENTS.md936 starsChanged 4 days agoMITView on GitHub

A GUI file manager for Linux, keyboard first, native to Omarchy. P0 is the local browser, and remotes, search, disk operations and Flea's own terminal interface (flea --tui, src/tui) have since landed on top of it. An encrypted drive unlocks and locks from its rail row through src/unlock.rs. 1. Every per-file operation stays scoped to the viewport. Icons, MIME sniffing and thumbnails all stay inside the visible rows. Most of the measured difference on a large directory comes from this:…

  • Reads credentials

We split this AGENTS.md into the rules it gives an agent

3 rules. About 0 are common, 0 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Only rename, mkdir, bulkrename and settags answer slow.

    line 449
  2. Very specific:

    No row acts while a listing is out, held or not: After the list request the backend numbers every row for the directory asked for, so an …

    line 526
  3. Very specific:

    Look's against Theme.color.background, and the chrome is baked into every inline code span, so a shared parse would draw the wrong code …

    line 777

≈ “Common” is an estimate: at least 1 in 5 of the 36,368 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

Flea

A GUI file manager for Linux, keyboard first, native to Omarchy. P0 is the local browser, and remotes, search, disk operations and Flea's own terminal interface (flea --tui, src/tui) have since landed on top of it. An encrypted drive unlocks and locks from its rail row through src/unlock.rs.

The seven load-bearing rules

  1. Every per-file operation stays scoped to the viewport. Icons, MIME sniffing and thumbnails all stay inside the visible rows. Most of the measured difference on a large directory comes from this: the other file managers measured stat every entry in the directory and Flea stats only the ones a screen can hold. Measured: the compared apps stat 100,000 entries, Flea's first window stats 350. ui/Pane.qml owns the viewport-sized held window and calls ui/Backend.qml's window(start, count) request only when that window drifts.
  2. The QML model is an integer count, never a list. ui/Pane.qml binds its ListView to model: root.total, so it instantiates and recycles only the delegates the viewport needs. A Repeater over a fetched window was tried first and dropped 220 of 480 frames, because swapping the window destroys every delegate it held. The count reaches each view through ui/StableModel.qml, a DelegateModel over a fixed integer, so a count change moves a group's tail instead of resetting the model and keeps the delegates that stay drawn (see "The list model").
  3. list answers with the first screenful, unasked. The window is rows from 0, unless a select names a row at or past first: Listing::first_start in src/backend/listing.rs then starts it at that row. ui/Backend.qml sends one list request and emits its listed and initial rows replies; ui/PaneSwap.qml consumes those replies as a pair before accepting the new held window. Making the client ask for it separately costs a full round trip that lands mid first-paint. Measured: 64 ms if asked for afterward, 4 ms riding along with the listing.
  4. Prewarm stays disabled until it is both safe and faster. The flea --prewarm producer exists, but the proposed UI reader measured a 485 ms median against 439 ms for the live path. After its removal, ignored experimental generation measured 469 ms against 437 ms live. Production ui/WindowBody.qml intentionally calls only pane.open(start), and tools/flea-first-paint preserves the comparison. The reader was rejected as slower and stale-capable; it remains disabled until the prewarm file can prove it is still fresh and a new measurement proves a real win.
  5. Vulkan where the loader can deliver it, lazy multimedia later. src/gui.rs sets QSG_RHI_BACKEND=vulkan when the user did not choose a renderer and src/vulkan.rs has created a throwaway instance and seen a device, which costs 2.4x less memory than the OpenGL default and initialises 35 ms faster, with identical frame timing. The same probe now reads each device's PCI id and matches it against /sys/class/drm connectors whose status is connected. Flea sets VK_DRIVER_FILES and VK_ICD_FILENAMES to the ICD whose library_path belongs to the display GPU when Vulkan lists a display GPU beside one that owns no connector and that other GPU's PCI vendor differs. The vendor condition is load bearing: an ICD list is vendor granular, so two cards from one vendor, an AMD APU beside a Radeon or an Intel iGPU beside an Arc, cannot be separated by one, and a pin there would claim a restriction it did not make. That is the hybrid-GPU blank window: Hyprland compositing on NVIDIA while Qt opens Intel, which presents a mapped window with no buffer. An index pin (QT_VK_PHYSICAL_DEVICE_INDEX) cannot be used: this box enumerates NVIDIA then Intel to vkEnumeratePhysicalDevices and Intel then NVIDIA to QRhi, so the index Flea would hand Qt is the other GPU. Restricting the ICD list does not care about order. Forcing the NVIDIA ICD on every NVIDIA laptop would blank classic Optimus, where the compositor sits on Intel and the connected connector is Intel's. An explicit QSG_RHI_BACKEND=vulkan now runs the full probe too, where before this merge an explicit renderer skipped it entirely: measured on a quiet box, 25 interleaved pairs of the whole launch to a stub qs, the explicit Vulkan arm takes 10.0 to 10.6 ms against 1.9 to 2.0 ms for the explicit OpenGL arm, so it pays the same roughly 8 ms the automatic arm already paid. An explicit VK_DRIVER_FILES or VK_ICD_FILENAMES is the operator's, and an exported-but-empty one is absent, the same rule QSG_RHI_BACKEND follows. The pin is said once on stderr and names the chosen GPU's PCI pair and the ICD path, because a silent device change is the defect the OpenGL downgrade already refused to hide, and a hybrid box whose display GPU matches no ICD file says that instead and pins nothing. A connected connector is only a proxy for the compositor's render device, so a lid-closed box whose one connected panel hangs off the other GPU is a shape this heuristic can get wrong; the operator's own VK_DRIVER_FILES is the escape, and this box is single-GPU so neither the heuristic nor its limit can be validated here. The pin belongs to Qt and to nothing else Flea starts, so it carries a marker, FLEA_VK_PIN, and src/open.rs and src/terminal.rs drop the pair and the marker together when it is set. Without the marker they drop nothing, because an operator who exported a list of their own must keep it in the program they opened. Another GPU must also own a DRM card: a software ICD such as lavapipe is a Vulkan device owning no connector, and without that condition it would make a plain single-GPU box pin. A box whose every Vulkan device owns a connected connector, or that has no DRM connectors at all, leaves the loader's default and says nothing. A driver for a GPU vendor the box does not hold is not loaded where the loader can be told so, and that is not the pin. On an AMD-only box (an RX 7900 XTX beside a Granite Ridge iGPU) with nvidia-utils 610.57.04 installed, the NVIDIA ICD made vkCreateInstance take 0.63 to 1.1 s and found no device: it runs nvidia-modprobe twice, and each run took 365 to 370 ms there. Qt then paid the same again, so Super+Shift+F mapped the window 1.56 to 1.62 s after the key. In a clean Omarchy 4.0.4 lab guest with the same package and a virtio GPU, nvidia-modprobe returns in 2 ms and the NVIDIA ICD costs the launch about 20 ms, 17 to 31 ms against 2.9 to 6.2 ms over 12 interleaved pairs of launches to a stub qs, so the size of the win is the box's and not the driver's. The pin cannot help, because every device the probe lists is AMD. src/icdexclude.rs runs before the probe and before qs in every renderer arm, because the OpenGL retry inherits the shell's filter and zink draws GL through Vulkan. It sets VK_LOADER_DRIVERS_DISABLE to the manifest file names whose ICD.library_path file name is, whole, one of six packaged hardware driver libraries (libGLX_nvidia.so.0, libvulkan_nouveau.so, libvulkan_radeon.so, amdvlk64.so, amdvlk32.so, libvulkan_intel.so) whose vendor no PCI display controller (base class 0x03) and no PCI-backed DRM card carries; a vendor word inside any other name names nothing, because only these are known to drive one vendor alone. Venus, libvulkan_virtio.so, is kept: its vtest transport serves Vulkan from whatever GPU or software renderer backs it, with no virtio device on the box. Vendors are read from sysfs, never from Vulkan, which is the call being avoided, so a dGPU whose nvidia-drm is not loaded keeps its driver. Nothing is excluded when the operator set VK_DRIVER_FILES, VK_ICD_FILENAMES, VK_ADD_DRIVER_FILES, VK_LOADER_DRIVERS_SELECT or VK_LOADER_DRIVERS_DISABLE, when vulkan/loader_settings.d/vk_loader_settings.json exists under any root searched, when a display controller's or DRM card's vendor or device id cannot be read, when any DRM card's device is not on the PCI bus (a SoC GPU, a virtio-mmio GPU, an evdi dock), when neither a display controller nor a DRM card names a vendor, or when the search lists more than 256 manifests, counted before any is opened. A driver it cannot name is kept: lavapipe, swiftshader, any other library, and any manifest that is not a regular file, runs past 4 KiB (packaged ones are a few hundred bytes, and jsondoc's object parse is quadratic in its keys) or does not parse. libvulkan_intel_hasvk.so is excluded when no present display device is hasvk-class (Gen7 and Gen8 Intel), and kept beside one, where the probe's hasvk_only and the display pin decide; loading it costs a cold launch 12.7 ms, range 6.9 to 25.3, on minipc, and finds no device. Manifests are opened through backend/regfile, non-blocking, so a fifo swapped in after the check cannot hold Flea's read; a fifo the loader itself opens still holds its probe, as it did before this. The loader's filter names files and not paths, compares them case-insensitively and reads only its first 16 entries, so a name any kept manifest on the search path shares is kept, a name its glob would read as more than itself is never written, and NVIDIA's names go first in a list that stops at 16. The search is a superset of the loader's, read as raw bytes: config home, XDG_CONFIG_DIRS, /etc/xdg, /etc, data home, XDG_DATA_DIRS, /usr/local/share and /usr/share. vulkan::icd_library reads ICD.library_path through jsondoc, for the pin as well, because a first-match text scan read a decoy key placed before it. src/jsonmembers.rs finds both keys the way the loader's cJSON does, without regard to case, and sees an exact repeat, which jsondoc::parse folds into the last while cJSON takes the first, so a repeat of either key names no library. cJSON hands names and the path to the loader as C strings, so a name is compared only up to its first NUL, which makes ICD\u0000x a repeat of ICD, and a path holding a NUL names no library. A member anywhere else, or a string holding the words, changes nothing, which keeps the pin working for manifests with metadata. Measured on the AMD box, vkCreateInstance then takes 6 to 8 ms and the window maps in 154 to 185 ms over two warm launches against 1622 to 1950 ms over three for the installed 0.3.7; deciding costs well under a millisecond: over 25 interleaved pairs of the launch to a stub qs, the same build given the operator's own filter, which skips the decision, differs by a paired median of 0.06 ms both on the AMD box (deciding slower in 14 of 25 pairs, inside the noise) and in the 4.0.4 guest (19 of 25), and the explicit OpenGL arm measured above now pays it too. A loader older than 1.3.234 ignores the variable and is as slow as before. Nothing is said on stderr, because no device changes. The marker FLEA_VK_DISABLE holds the exact filter written: a launch inheriting a filter its marker names, a new window or the OpenGL retry, decides again, and src/open.rs and src/terminal.rs drop that pair; a filter its marker does not name is the operator's, so only the stale marker goes. Helpers QML starts directly inherit the filter, as they inherit the pin; it names only drivers for absent vendors, so an eGPU of such a vendor plugged in while Flea runs is the one shape it hides from them. A new window inheriting Flea's own pin adds no filter, because the pin's VK_DRIVER_FILES already loads only the display GPU's ICD. A loader that cannot deliver a Vulkan device is given opengl before qs starts at all, because Quickshell hands QRhi::create a QVulkanInstance it never created and SIGSEGVs there rather than raising the scene-graph error the QML arm listens for, issue #14 on a QEMU Virtio GPU. That downgrade is not silent: usable() answers with the call or library that refused, and with the extensions in the two arms that asked for them, and src/gui.rs prints that as one sentence on stderr, because a 2.4x memory regression the operator cannot see is the defect and not the report of it. The probe is dlopen plus vkCreateInstance plus vkEnumeratePhysicalDevices, and it costs an implicit launch roughly 8 ms on this box, so about a quarter of that init advantage is what buys the crash out; dropping the device count would save only 2 of those milliseconds and would stop catching a loader that creates an instance and then lists nothing. A fingerprinted cache in front of it (src/vkprobecache.rs) skips the probe when boot, GPUs, manifests, loader, environment and binary all match. A scene-graph failure after launch still relaunches once with OpenGL and drains the failed backend; an explicit QSG_RHI_BACKEND is never replaced, and an exported-but-empty one is absent rather than a choice, the same rule paths::has_display() applies. What the probe proves is exactly what it asks the loader for: an instance carrying VK_KHR_surface and the session's own surface extension, VK_KHR_wayland_surface here and VK_KHR_xcb_surface where only DISPLAY is set, plus a physical device. Those three names are Qt's own: libQt6Gui.so.6, libQt6XcbQpa.so.6 and libQt6WaylandClient.so.6 here carry exactly those and no other surface name. The request is not satisfiable without a driver either: with every ICD hidden this loader enumerates 5 instance extensions and no surface one, and vkCreateInstance answers VK_ERROR_INCOMPATIBLE_DRIVER. What it does not prove is that a device can present, which an instance-level request cannot ask and this probe never does. The cost is measured against a probe-free build of the same tree, 25 interleaved pairs of the whole implicit launch to a stub qs, timed in microseconds: 10.6 to 11.6 ms with the probe against 2.6 to 3.2 ms without it, medians about 8 ms apart, so the probe is most of that launch rather than a component hidden inside it. An earlier note here read 11 to 12 ms in both arms, which is wrong: only the probe arm is near that, and a 1 ms instrument resolves this gap eight times over. The retry arm is covered in two halves, because no whole of it can be driven here. ui/js/Renderer.js is the decision and the argv, driven by tests/js/renderer.js; the signal reaching that decision is driven by tests/ui.sh renderer, which starts Qt's GL backend with no EGL vendor file to load. That is the one scene-graph failure found to be raisable on this box: a broken Vulkan loader cannot stand in for it, because the shell dies first, which is the whole of issue #14. Measured here with every ICD hidden, qs warns No QVulkanInstance set for QQuickWindow and exits 255 without raising anything; the SIGSEGV is issue #14's own report on a QEMU Virtio GPU. bodyLoader.Window.window is null while ui/boot/shell.qml loads and holds the QQuickWindow once it exists, and that same Connections was measured receiving sceneGraphInitialized, the signal Qt raises at the phase sceneGraphError replaces. What no test reaches is the positive arm, a Vulkan failure leaving QVulkanInstance valid and failing at QRhi::create, which no environment variable here was able to produce. ui/boot/shell.qml no longer carries the //@ pragma DefaultEnv QSG_RHI_BACKEND=vulkan line, so src/gui.rs is the only thing that chooses a renderer for a launch, the one OpenGL relaunch in ui/boot/shell.qml aside, and a direct qs -p ui/boot launch bypasses it entirely: tools/flea-metrics-gate, tests/ui.sh, tests/drag.sh and the README.md dev loop each state QSG_RHI_BACKEND for themselves, so their numbers stay on the Vulkan baseline they were recorded against. A direct launch now bypasses two more things the launcher does, FLEA_FIRST_PAINT and the gtk3 trade of "The first window", so anything that TIMES a launch goes through $FLEA_BIN --gui instead: tools/flea-field-bench always did, and tools/flea-first-paint was moved onto it when this landed. tools/flea-metrics-gate starts nothing and times nothing, it reads the token seam over IPC from a Flea the operator started, and the four direct qs launches in tests/ui.sh assert a cursor, a selection, a scene-graph error and the retry helper's argv rather than a duration, so each is still the right launch for its job. Preview and QtMultimedia are in the tree and instantiated lazily: ui/PreviewMedia.qml is the only file that imports QtMultimedia, and ui/PreviewPdf.qml the only one that imports QtQuick.Pdf, each reached through a Loader built by the first press of play or the first PDF open. Compiling is a separate step: ui/QuickLookWarm.qml calls Qt.createComponent for the unit of the cursor's file kind (Markdown, PDF, clip or audio, image, text) after a 1 s rest on such a file, so a rest on a clip or audio file resolves import QtMultimedia and a rest on a PDF resolves QtQuick.Pdf with Quick Look never opened, which buys a first Space that compiles nothing, and a window that never rests on one of those loads neither library. Headless offscreen qs (tests/quicklook-firstkind.sh, five runs, a fresh process each), PSS from /proc/self/smaps_rollup after the rest: a text file median 121,816 kB (range 89,788 to 127,020), a clip median 129,359 kB (range 68,826 to 147,303), the per-run clip minus text difference median +5,309 kB (-20,962 to +25,487, three of five runs within 5,000 to 6,100). The spread is wide because the run shares the box, so this is a headless estimate and not a field number. Issue #160: when every Vulkan GPU is a Gen7 or Gen8 Intel GPU (Ivy Bridge to Broadwell and Cherryview, served only by Mesa's hasvk), the launcher starts the shell on OpenGL instead and says so once on stderr naming hasvk and the fallback, with no retry marker; a CPU-type device such as lavapipe is not a GPU and does not count, a hasvk device beside another Vulkan GPU keeps Vulkan, and an explicit QSG_RHI_BACKEND is never touched. The environment is the only setter either way: ui/boot/shell.qml carries no DefaultEnv pragma for it, so what src/gui.rs puts in the qs environment is what Qt reads.
  6. A hidden view is not a free view. visible: false does NOT stop a QML view doing model work: it keeps its geometry, stays bound to the listing, and pays per row. All three views were instantiated unconditionally and hidden with visible: alone, and the cost was measured at 110 bytes a row with one view instantiated, 232 with two and 262 with three, on the 100,000 file fixture. Gating the two non-default views behind Loaders was MEASURED to recover 14.7 MB at 100k and take the listing from 264 to 147 bytes a row, and was then REVERTED and is not in the tree. The repair needs Pane.qml split first, because two Loaders inline exceed the 400-line hard cap, and that split broke tests/ui.sh to 20 of 30 and then 27 of 30: worse after a fix is the signal to stop. It is a deferred ticket with a measurement attached rather than a shipped change. The recovery figure is also a list-view number and not a whole-product one, taken on a build whose other two views did not render. The same fact had already appeared as a keyboard bug and been patched at the symptom: a hidden view holding focus swallowed the keys the visible one should have had, which is why focus: is toggled explicitly beside visible:. A hidden view that can steal a keystroke is a hidden view that is fully alive.
  7. The media comparison measured strata fully on 2026-09-03, and two of Flea's figures moved. Measuring strata's work properly moved it from unranked into the table, and Flea's relative figures moved with it: Flea's memory went from fifth of six to sixth of seven at 112.6 MiB against strata's 70.3 MiB, and Flea's CPU time, which was 3.61x lower than nemo's, is now 1.28x lower than strata's, 1.42 s against 1.82 s. Flea still has the lowest settled listing time of six and the lowest CPU time of seven, and the 100,000 file group did not move at all. This was accepted with an optimization pass to follow, so it is a deferred ticket and not a defect. Item 6 above is the first place to look: its Loader gating was MEASURED to recover 14.7 MB at 100k and is not in the tree, and memory is the figure whose position changed. Read its caveat first, because that figure is a list-view number rather than a whole-product one, and the split it needs broke tests/ui.sh twice. The CPU figures in this rule predate 0.3.2 and are the reaped-tick column, which counted no sandboxed decode for Flea or strata; see "Testing" for the control-group column that replaced it.

Two-phase listing

Phase 1 (scan.rs) reads only names and d_type from raw getdents64 via opendir/readdir, resolving only DT_UNKNOWN entries by fstatat on the open dir fd with AT_SYMLINK_NOFOLLOW, so knowing whether an entry is a directory costs nothing extra except on typeless filesystems. Phase 2 (meta.rs) calls symlink_metadata(), either for the rows one window request names, through stat_range(), or for every row in the directory when a sort needs it, through stat_all(). Sorting by name and directory-first works entirely on phase-1 data; sorting by size or modification time runs stat_all(), one lstat per row split across available_parallelism() scoped threads, and metasort.rs then orders an index against the result and gathers the spans behind it. Those stats live for the one request and are dropped with it, so a listing in name order carries nothing extra and Span stays 12 bytes. kind orders by MIME group after phase 1, date is an alias for mtime, and any other key, mode included, reaches neither phase: ordering.rs refuses it with the one sentence that names the four it accepts. A size sort on a listing extclass::refuses_folder_sizes names (network or phone, the classes DirSizes.js refuses) walks no folder: dirsize::sort_walk gives each folder its own entry size marked partial, the order a walk past its deadline gives.

A gvfs FUSE path is the exception to both halves. scan.rs lists it through one gio list child (backend/gvfslist.rs) instead of readdir: the daemon answers names with size, type and mtime in one pass, where the FUSE path would block in getdents64 and then pay one round trip per row. That per-row metadata lives in Listing's compact gio store for the listing's life rather than for one request, and every re-list rebuilds it, which every Flea write triggers; a remote change raises no inotify on either route, so the watched re-read never fires for one. The store is one 24-byte GioMeta record per gio row (the span's name offset, mode, size and mtime), pushed in build order so the offsets increase and row lookups binary search it, plus the directory's device once per listing and a side store of symlink targets keyed by the same offset, since only symlink rows have one; build_listing shrinks both to their length. The offset rides in the span through every sort, so a sorted listing looks its rows up by the same key. stat_range() answers those rows from the store with no lstat, a symlink row paying the one follow-stat meta_one pays so a linked folder draws as one, and past SLOW_PASS_MS (10 ms) the remainder of a window goes across 8 threads, unless the listing's device mounts as vfat or exfat, which stay serial: the gate decides lazily at the first slow pass from /proc/self/mountinfo by that device and caches the answer per listing, so a listing never pays a statfs. A stat on FUSE or a network share is a round trip of a millisecond or more against microseconds local, so kernel cifs/nfs/sshfs mounts that serve concurrently gain threads while a warm local window (0.4 ms for 321 rows) never triggers one. The gio call carries -h on every listing and parse_line's dot filter stays the only hidden rule, so both routes list the same rows; -h adds one attribute to the same reply and the progress bound adds no round trip, which is what F13 and F19 cost on the NAS.

The open directory is watched

Issue 68: a folder open in Flea did not follow a create, rename or delete made by another program. list ran once when the pane arrived, and after that only Flea's own writes re-read the directory, so a window kept open beside a terminal drew whatever was true when it was opened.

The backend watches, the client re-reads. backend/watch.rs holds one non-recursive inotify watch on the directory being listed, armed by list BEFORE the scan and dropped by search and listpaths, whose listings are not directories. Arming after the scan lost every change the scan itself raced, a hole the width of one readdir: reproduced on the 100,000 file fixture, where a create during the scan answered no changed line at all and the same create a second later answered one. The new watch is armed BESIDE the current one rather than in place of it, so a scan that fails costs the directory still on screen nothing: commit drops the old watch only once the new listing replaced it. The scan runs on the list worker, so the arming does too: Watch::add_raw before the scan, set_incoming plus commit on success, and abandon_wd dropping the armed descriptor on failure; a worker that outlives its call hands its descriptor back as Event::AbandonWatch, and the loop drops it unless a re-list aliased it onto the live watch. Replacing it up front was the first fix and it was wrong, because a failed list then handed the open directory a new descriptor and is_current dropped anything still carrying the old one. Its reader thread sends the loop an Event::Changed(wd), and the loop answers {"t":"changed","path":"<the directory>"} and changes nothing else: the rows, the count and the sort order stay exactly what they were, because the client is the only thing that knows whether it still wants them. See docs/protocol.md "changed" for the mask and the coalescing.

The descriptor is why the payload is read at all. inotify_rm_watch delivers an IN_IGNORED for the watch it removed, so a reader that treated every wakeup as "something changed" answered one changed line for every navigation, for the directory the pane had just arrived in. The reader walks the burst for its watch descriptors and nothing else, and Watch::is_current drops any that is not the one being listed now. tests/protocol.sh's "a change in the directory just left answers nothing" is that case.

One burst is one re-read, and it is not restarted. ui/PaneWire.qml starts a 400 ms timer on the first changed after idle and lets later ones land inside it rather than restarting it: a restart would mean a directory under continuous writing never settled and so never refreshed at all. Worst case is one full re-scan per 400 ms while something is actively rewriting the folder the user is looking at, which is 25 to 38 ms of that on the 100,000 file fixture. The test for this has to sample while the writing is still going, which is what makes it a test: measured after the writer stops, a restarted timer and an absorbing one both show a re-read and the check passes either way. tests/ui.sh watch samples once inside a background writer's run and requires the count to have moved; under restart() it does not move at all. No number is quoted here on purpose, because each omarchy-drive ipc round trip costs 190 to 565 ms and the sample therefore lands later than the sleep in front of it says.

A re-read is a fresh list, so it renumbers every row, and that is what the anchor is for. ui/js/Nav.js's refreshWatched records the NAME under the cursor before the re-read and applyAnchor puts the cursor back on that name when the rows return, because a file created above the cursor shifts every index below it and restoring by index alone would move the user onto another file. The listing answers from row 0 unless the re-read names a select row at or past first (Listing::first_start starts that window at the row), so a cursor deep in a large directory also asks for its own window back and the anchor stands until that window arrives. The wait is on the window and not on a number of replies, because applyAnchor runs on every rows delivery and a counter was spent by the first screenful arriving twice; it ends when the listing has shrunk to that offset or below, the one case where the backend clamps the window to row 0 and the reply being waited for is never coming. A name that is gone from both falls back to the clamped old index, which keeps the view where the user left it. The filter query is put back too: it narrows the rows the pane holds rather than choosing which directory it holds. A rename commit, by click-away or by Enter, anchors the same way (Anchor.pointerRow carries the viewport offset too): the re-list's count passes through 0 and resets the view, and without the restore setCursor's context-0 reveal bottom-aligned the clicked row, 58 px up on a list that End had scrolled into its footer, which left the row below it under the status bar. AnchorHold.viewport names the scrolling surface, the active column's list in the columns view.

A column's directory is watched through a second inotify descriptor. Issue 244 asked why a listing does not follow another program, and the open folder always did; the neighbour columns did not, because a peek reads a directory the pane is not on and nothing watched it. src/backend/peekwatch.rs holds the watches a peek carrying "watch":true arms (only ui/ColumnsArea.qml sends it), on their own descriptor so evicting one can never remove the listed folder's. Every watching peek names the directories the columns draw in its keep list (Columns.keepAsks, state in ColumnsArea.keep) and the backend unwatches the rest before it arms, so a column on screen never loses its watch to one scrolled past; eight is only the guard behind that, and a peek answered after the client moved on is unwatched at once. A column that returns after leaving the drawn set is re-asked (again), because its cached rows went unwatched. The kernel dropping a watch (a deleted or unmounted directory, IN_IGNORED) sends PeekGone, which stops holding it. PeekWatch owns its descriptor, an eventfd and the pump thread (src/backend/peekpump.rs): Drop writes the eventfd, joins the pump, then closes both. A fire answers the ordinary changed line for that directory, and ColumnsArea.onChanged re-asks it with ask(path, true), which keeps the rows drawn until the reply replaces them. tests/watch-views.sh drives the real window offscreen through list, columns (middle, child and parent column, then again after entering the child and climbing back), grid and both dual panes with a create, a rename and a delete from another process, and was red on the child and parent columns before this.

The selection is re-anchored by file identity; the re-read no longer waits for it. ui/js/Selection.js is a set of row indices and its own rule is that a new listing clears them, because an index into a directory that has changed names another file. Re-pointing a selection at other files is how a delete hits the wrong ones, so the watched re-read carries the marks across by NAME instead (ui/js/Anchor.js selectedMarks, recorded in watched() and restored in apply()): every mark stays on the same file, a removed file's mark goes, the cursor stays on its file or on the clamped old index when its file went, and the held window is asked for again with no scroll, so the viewport does not jump. PaneWire's watchBusy, decided by ui/js/Anchor.js busy, still defers the re-read while a rename editor is open (the re-read would kill it), the context menu is up or a menu action waits on its reply (the re-read flips menuSelectionIdentity and the reply is refused), a filter is being typed (the re-read closes the query line), a search listing is showing (the walk owns the rows outright), a rubber-band drag runs, a file drag runs or waits on its paths reply (the rows must not shift under a live pointer), any paths asker resolves (clipboard, drag, compress, Copy as, permissions or the anchor's own tagged ask, so two replies never cross), an unfinished anchor for the open directory, a list is already in flight or a transfer waits on the collision card (ui/CollideHost.qml decide writes its transfer before it clears pending, so the transfer reaches the backend ahead of any re-read the card held back). A bare selection, and the path-keyed copy and cut marks which never named a row index, are not a reason to hold: another window's change shows at once with the same files marked. Marks outside the held window resolve through one batched paths round trip before the swap and one batched locate after it, each tagged to its asker (ui/PaneWire.qml onPaths routes only the anchor's tag to Anchor.fillPaths, Anchor.takeLocated matches the directory, the anchor's own locate id and transferId 0), and with no backend that can answer either the re-read lists at once and drops the marks it cannot resolve. A preference re-list also re-marks by name: a settings change to hidden, sort or grouping captures Anchor.preference before it re-lists, and ui/PaneSwap.qml applies Anchor.applyPreference after the watched anchor. When any selected row lies outside the held window, the preference re-list clears the whole selection rather than silently keeping its in-window subset. Its anchor resolves only on the rows reply for the asked window; a scroll elsewhere or a cursor move drops it. The debt is kept, not dropped: onWatchBusyChanged starts the 400 ms timer the moment the last of those clears. The debt does not travel: leaving the directory clears it, because the pane's own onPathChanged fires before the navigation clears whatever was holding it, and without that a change in the folder being left was paid for by a full re-list of the folder being opened. ui/Backend.qml's listRequests counter is what makes that assertable, the same idiom as thumbRequests and dirSizeRequests: tests/ui.sh watch counts from before the navigation and requires exactly one listing for it.

A slow click ends with any key or action. ui/js/SlowClick.js arms the pane's timer on a second tap after the double-click interval, and its fire opens the rename editor. Every key that means an action (ui/js/Focus.js handleKey, after the lookup; a bare modifier means none) and every action the pane dispatches (ui/Pane.qml act, first line) calls cancelSlowClick, so Enter, Space, Delete or the menu key pressed after the click gets its own result and never a rename editor behind it. The slow click's own rename passes through act after fire cleared the record, so it is not cancelled by itself. A harness that needs a row selected must not click one that is already the sole selection under the cursor, since that click is a slow click (tests/ui-menus.sh menus_file_menu reads dualState first).

The menu's Rename has three routes. Ops.menuRenameRoute (called by ui/PaneMenuActions.qml openRenameFromMenu) answers now for a ready snapshot over the unchanged menuSelectionIdentity (issue #170: the editor opens at once and the replaced source is refused at commit, backend.rename with renameMenuId), f2 for a snapshot still in flight over the unchanged identity (the F2 route, a fresh snapshot of the cursor row), and stale once the identity moved, which says "Selected items changed; reopen the menu." and opens nothing, as activate does for every other action. The F2 key itself never asks. While the menu is open Anchor.busy holds the watcher's re-read, so a source replaced under an open menu leaves the identity unchanged: the other five actions are then refused by the backend ("Selected item changed; reopen the menu.") and Rename opens its editor.

// corner: this is the local kernel's view of one directory. A change another machine makes to an NFS or SMB share raises no inotify event here, so a network mount is exactly as live as it was before. So is a box whose inotify instance limit is exhausted, which says so once on stderr and then never sends the line. IN_MODIFY is deliberately not in the mask: it fires on every write(2) and a listing does not draw a partial size, so a row's size follows IN_CLOSE_WRITE instead.

Flea's own writes pay for one extra scan. A rename, mkdir, trash or transfer already calls pane.refresh(), and the watch answers for the same write about 400 ms later, so the directory is read twice. The second read is anchored the same way and moves nothing the user can see. Suppressing it would mean deciding that a changed arriving during a listing belongs to that listing, which is exactly the guess that would drop a real outside change, so it is paid rather than guessed at.

Mount workers

Path syscalls leave the loop for mount-keyed workers, which is what keeps one hung server from wedging the window. src/backend/iomount.rs owns the mechanism. mount_key names the mount a path sits under lexically, never by stat, so a dead server costs no syscall there, and is_remote says whether its syscalls can wedge: gvfs, network fstypes and any FUSE daemon. A local mount runs inline with no hop. A remote one runs on a worker with a deadline, CALL_DEADLINE of 5 s for a single call and BULK_DEADLINE of 15 s for a bulk pass, and past it the loop answers "<mount> is not responding." A mount past its deadline is marked stuck for STUCK_TTL of 30 s, and a call on one answers at once without a worker until that passes, when the next request probes it again. A worker that dies before answering is this machine's fault and marks nothing: only a deadline still running marks the mount stuck. A remote rename or mkdir runs on its own worker with CALL_DEADLINE, as reads do, and the loop never waits on it longer than that: a local one stays inline with no hop, an in-time remote write answers exactly as before, and past the deadline the loop answers a slow line and moves on, never marking the mount stuck for a write still running. The worker stays tracked and reports through the op channel when it lands, journalled exactly as the in-time path would, so one journal entry per request and one undo reverses it. A link claims the one-operation slot through start_link and runs every item on a worker from the start, so the loop stays responsive with no slow line; one linked line and one journal entry land with the batch and release the slot. A batch in which every link failed answers one error line instead (<source>: this drive cannot hold links for EPERM on vfat or exfat, else the failure's own reason, as EEXIST or ENOENT give), records no journal entry and still releases the slot. Only rename, mkdir, bulkrename and settags answer slow. A worker that dies before answering marks nothing, like a dead read worker.

What runs where: list_dir scans, sorts and stats the first window on the worker; search reads each directory as one bound call returning (name, is_dir) pairs; window stats inline with no listing clone on a local mount and moves only the window's rows to the worker on a remote one; sort re-sorts on the bulk worker; peek, thumb and listpaths bound their per-row stats the same way; the search root's dev and writability go through the bound before any walk starts. listpaths' first window is the exception that still stats on the loop: its answer ends in write_window, which runs stat_range on the loop's thread directly. The watch is armed on the list worker beside the current one: Watch::add_raw before the scan, set_incoming plus commit on success, and on failure the loop drops the armed descriptor through abandon_wd; a worker that outlives its call hands its descriptor back as Event::AbandonWatch for the same guarded drop, kept when a re-list aliased it onto the live watch.

The listing swap

Opening a folder drew 3 to 4 frames of an empty list, 50 to 67 ms at 60 fps: a 60 fps gpu-screen-recorder capture on the box showed it on 12 of 12 navigations into and out of a 300-file folder, and one frame grab had the breadcrumb already on the new folder over an empty list. ui/js/Nav.js openWithoutHistory cleared the pane at the request, and the rows came back a round trip later. The round trip is not where the time went. In the container flea --backend answers a 300-file list with its rows line in 0.9 ms median over 35 requests, 4.4 ms at worst, and V4's JSON.parse of that 30 KB line costs 0.9 ms. The frames were the pane's own: the reset lands inside the input handler, so the next vsync presents an empty list however fast the reply is; listed moved the breadcrumb and set the count before rows filled it, and the two lines can arrive in separate reads, which is the grab's frame; and building the new rows' delegates holds the GUI thread for a frame interval or more while the screen keeps the last frame presented, which was the empty one.

A settled listing now stays drawn until the next one's rows land, and then everything lands in one turn. ui/PaneSwap.qml owns it and ui/js/Swap.js decides. Nav.openWithoutHistory asks pane.swap.hold(ask): a pane showing a settled listing, any listingState but loading with no query line or walk on screen, holds, and every other pane runs Nav.forget at the request exactly as before. While it holds, the listed line naming the directory asked for is kept rather than applied, and its rows line runs land(): Nav.forget, the rows, the kept listed line (count, path, state, opened), then what a rows reply always did (pending select, anchor, tab restore, settle, listInFlight false), all in one JS turn. Only the list's own listed line is kept. A sort sent just before the list answers first, a listed line naming the folder being left and then the window ui/js/Sort.js asked for; keeping the first listed line after the request swapped the sort's rows in as the new folder's. So while any listing is out, Swap.onListed drops a listed line whose path is not pane.listingPath, and the rows behind it then arrive with no listed line seen and are dropped as they always were. The path is a reliable key because a list answers with the directory exactly as it was asked for, byte for byte: run.rs sets st.base from the request's own path string and listed_line prints it, and pane.listingPath and the request are the same newPath (Nav.js). A search never meets a hold: a live session answers with no listing out and a walk on screen refuses one, so its opening line always lands unheld. The path cannot tell a sort answered after a re-list of the same folder from that re-list, so there the sort's reply lands the hold and the re-list's own lines land unheld over it, as every listing did before the swap; the order converges, and a row request sent between the two names the sort's numbering and is refused. The rows go in before the count, so each delegate the count builds is built on its row rather than built empty and bound again. The count still passes through 0, which keeps the view's reset to its top even between two folders holding the same number of rows.

The hold ends four ways. The swap above. A failed or refused listing: ui/PaneWire.qml onFailed calls Swap.failListing, which runs drop() before it clears listInFlight, so Nav.forget runs and the failure then draws the error or Locked state it always drew; a stale refusal ended only its own request and ends nothing, and so does a scan failure naming a folder that is no longer the one asked for. An empty folder: its rows line rides right behind its listed line (docs/protocol.md invariant 1), so it swaps into the empty state at once. And a listing still out after Swap.CAP_MS, 66 ms, falls back: the cap runs Nav.forget, and ui/LoadingState.qml shows its mark at once through heldOff, because the hold already spent that mark's own hold-off, which ui/PaneStates.qml sets to Swap.CAP_MS too for a directory listing. 66 ms is 100 ms from the key to the fallback's first frame less four 120 Hz frames: one for input, two for the QML Timer's 16 ms resolution and one for the fallback's own draw. A listed line kept before the cap, or arriving after it, waits for its rows line and lands with it through land(), so the mark stands until the rows do rather than giving way to a count over no rows. Once drawn, the mark stands Swap.MIN_SHOW_MS, 100 ms, before the rows replace it, so it never flashes in and out: ui/PaneStates.qml tells the swap when the mark is drawn (markShown), a listed line arriving while it is drawn is kept even when no rows were held, and rows lines that come sooner wait in waiting, in arrival order, for the minShow timer and then land as they would have. A folder that lists in under 66 ms never shows the mark; one that lists between 66 and 166 ms draws the mark at 66 ms and the whole folder at 166 ms; a slower one draws the mark at 66 ms and the folder when it lands. describe() reports how long the last mark stood as shownMs. A walk's matches never wait on the mark. The mark holds still for its first second on screen and crawls only after it (ui/Spinner.qml), so a load shorter than that never draws motion. tests/spinner-still.sh counts, never times, the frames the window swaps and the dashOffset writes per mark, so a resting mark that asks for frames, or a crawl written faster than the window draws (a timer in place of the animation), fails on any machine.

No row acts while a listing is out, held or not. After the list request the backend numbers every row for the directory asked for, so an action on a row by its index would land on another file. That was true before the swap too, and it was reachable: in a loading state the rows are forgotten but the cursor is 0, and dd sent trash [0], which the backend applied to the new directory's first file. So the rule covers the whole of listInFlight, the hold, the fallen-back loading state and a slow network folder alike, in two layers. ui/PaneStates.qml lays a MouseArea over the header, the filter strip and the listing slot while a listing is out, which takes every press and wheel notch and says "A directory is already loading." on a press, and ui/js/Focus.js handleKey swallows every listing key but Swap.ANSWERED_WHILE_LISTING and says the same sentence: the navigations, which take the place of the listing out instead (the next paragraph), escape, which touches no row, reload, which refuses itself the same way and past the wait retries the folder waited on (ui/js/Reload.js begin), and the view, rail, new-window and preview-column keys, which change the window rather than a file. The rail, the crumbs and the tab strip are navigations too, a row drag already could not start, and the list and the grid now ask for no window while one is out, the rule the columns view's active column already kept: a coalesce left running by a scroll just before the request would otherwise fetch the new listing's rows at the old scroll offset, and the swapped view, which starts at its top, would draw them as blank rows until the next fetch. The inert window is one round trip for a local folder and the cap at most, and as long as a slow folder takes after that. Swallowing was chosen over acting on what is visible because the backend can no longer resolve an old index once it has the new listing, and a path-based rewrite of every row action is a larger change than a window of refused keys; a rename editor already open keeps its keys, because a rename names its file by path.

The backend refuses rows read from a listing it has replaced, because the client cannot be the only guard. Before this it did not: list A, list B, paths [0] answered B's first file, measured on this branch's own binary. Every change of what a row index names already runs forget_rows, a list, a listpaths, a search, an accepted sort and a walk's ranking, so forget_rows now also moves State.generation, and write_window stamps each rows line with it as listing. ui/Backend.qml records the numbering of the rows the pane applied in heldListing (ui/PaneSwap.qml takeRows), and send() names it on every request that carries rows and names none yet (Swap.named). Rows read before a later send keep the numbering they were read in, because a listing can land in between: the collision card stamps its question and the transfer waiting on it at the ask (Collide.waiting), except a menu's, which the backend resolves from the menu's own capture and never by its rows, and a row drag stamps its rows at the lift (ui/FileDrag.qml dragListing), so a watched re-read or a refresh that lands before the choice or the drop gets that transfer refused rather than resolved in the new numbering onto another file. The watched re-read also waits while a transfer waits on the card, so the usual case keeps its rows. src/backend/rowguard.rs refuses a trash, transfer, link, paths, collisions, settags or menuaction whose listing is not the one in force, before anything resolves: an error line with where stale for each but menuaction, which ui/PaneWire.qml onFailed reports without ending the listing that is out, and a failed menuaction reply for a snapshot, the shape the menu already waits for. A request that names no numbering is resolved as before, which is every older client, the picker and tests/protocol.sh's own lines; thumb, dirsize, meta and window only read and are not refused. This also closes the re-sort's window: between s and the reordered rows the pane still draws the old order, and a key that reached the backend there is now refused.

The newest navigation wins. While a listing is out, the folder it asked for (pane.listingPath) is the pane's current place for every navigation (ui/js/Nav.js place): a second open, Back, Forward, Up, a tab switch, a Recent run or close, a rail row, a crumb, the folder jump and the path bar are each taken as the new request, pass supersede to openWithoutHistory, and move listingPath (a new tab and a tab received from another window do too; a tab move lists nothing and leaves the listing to land; tearing a tab out still refuses); two quick Backs step back twice, open pushes no place that never landed while a listing is out for another folder; when that listing is a Back or Forward whose entry put the drawn place on the stack open then clears (pushedOnto is forwardHistory), open pushes BackReturn.drawn(pane) onto history (ui/js/Nav.js open), and otherwise the drawn place was already pushed when that listing was asked for. Up climbs from the place asked for. The listing let go answers into nothing: its listed line names another folder and its rows follow it (Swap.onListed, Swap.onRows). A re-list of the folder drawn (sort, reload, the Esc relist) carries no supersede and keeps the refusal. On a stalled share the backend does not make the newest list wait: a list that arrives while the gate holds a plain remote list scan (not a search session's) replays the requests that waited, lets the scan go, and starts its own scan on a fresh thread (Gate::let_go, src/backend/run.rs); the let-go scan's late answer is discarded and its watch is released through Event::AbandonWatch, which the loop holds back while a newer scan is out and judges against the landed watch (Gate::park_late, inotify gives the same descriptor for the same folder). A tab switch's cursor, order and located row ride its landing (pane.tabs.pending*); a navigation that lets that listing go clears them (BackReturn.dropOwed).

What the older guarantees rest on now. The path is still written only from the answer, now in PaneSwap.applyListed, so a refused hop never moves the breadcrumb. listInFlight is still set at the request, so menuSelectionIdentity changes then and a menu raised over the old rows refuses its next row, and the guard for a re-list of the folder drawn still runs first, before any hold. Nav.forget still clears the rename, the filter, the thumbnail and dirsize maps and the selection before the new rows are set; a filter line that has the caret when a hold starts is committed there, so a key typed over the held rows meets the key gate rather than a query the swap would then forget. thumbed and dirsized lines are still dropped while a listing is out. listingPath is still recorded at the request, so dropPath sends a drop taken during a hold to the directory asked for. Each pane of the dual view has its own ui/PaneWire.qml and so its own swap. The columns view's preview column keeps the file it shows while rows are held: ui/SelectionPreview.qml no longer clears the moment listInFlight turns it unreadable, and is cleared instead by the rows, the path and the selection Nav.forget changes, which is at the request when nothing is held and at the swap when rows are. It still asks for nothing while a listing is out.

Who does not hold. A tab switch into a tab with another view, and a hop into a folder that keeps another view (ui/js/Nav.js compares the view before and after restoreView), pass clearAtOnce, because these rows were never listed in that view and would be built in it only to be built again. A pane still loading has nothing to hold. A walk's exit holds the whole search state, strip and rows, until the landing clears both in one turn: holding result rows with the strip already gone would draw half of each, and forgetting them at the request drew a blank frame, so neither happens alone (Search.leaveWalk defers the drawn close while the backend closes at the request, and Swap.begin holds after a walk for the exit's ask). A walk that stood on folder rows already, an emptied query, closes at once with nothing to keep drawn. Refresh after a write, the watched re-read, the settings re-list and the hidden toggle all hold: their rows are the same directory's, and the reset lands with the rows that replace them. The watched re-read hands its filter query back as keptQuery, and Nav.forget restores it when the hold ends; restoring it at the request, as ui/js/Anchor.js did, would be undone by the swap's own reset.

How it is proved. tests/js/swap.js drives the decisions, the Nav route with a swap stub that holds or does not, and the key gate through the real Focus.handleKey and Focus.act over a backend stub: at rest dd, Delete, F2 and Return reach it, and in a fallen-back loading state and over held rows none of them does. Removing the gate reddens six checks, dd sending trash 0 among them, and removing the hold reddens four. It also replays a sort's listed and rows ahead of the list's, a search's opening line after its escape, a listed line kept across the cap, a failed listing and a stale refusal, and a key typed while a filter line held the caret; tests/js/collide.js sends rows the card read in numbering 4 after a rows line in 5 has landed, and they still name 4. It also builds the real ui/FileDrag.qml, ui/RowDrag.qml and ui/CollideHost.qml from their own text over a stub pane and backend, lifts a row in numbering 7, moves heldListing to 8 while the drag is up as a landed re-list does, and drops it on a folder row: the question and the Replace transfer name 7, the transfer is written before the card clears pending, and Anchor.busy holds the watched re-read until then. src/backend/rowguard.rs carries the guard's unit tests and tests/protocol.sh drives it through the binary: two lists answer numberings 1 and 2, paths, trash and a menu snapshot naming 1 are refused and the file that trash names survives, 2 and an unnamed request resolve, and after a sort 2 is refused too. tests/ui.sh noblank reads swapState. PaneSwap counts, from the window's own afterAnimating, every frame synchronised while a listing was out and the pane drawn in the loading state: blankFrames before the cap, the defect, and loadingFrames after it. The case requires blankFrames not to move across Enter, a double click and BackSpace into and out of a 300-file folder in the list, grid and columns views; a listing its stub backend holds back 40 ms to be held that long and still swap; and one held back 2.5 s to fall back at the cap and count loading frames, which is the control that shows the counter sees frames drawn with no row. Each step is shot as it is issued and again once it has landed, as noblank-<step>-issued and noblank-<step>-landed, and the fallback as noblank-slow-loading.

A landing that puts the cursor on a named row is answered with that row's window. pane.pendingSelect names the row a listing re-selects: the file the cursor stood on when a search began, the folder a climb left, a created, pasted or renamed item, Show in folder and Show original. ui/js/Nav.js applyPendingSelect looks for it only in the listing's first rows reply and forgets it on a miss, and that reply covered rows 0..first, so a search left with the cursor on row 667 of 668 landed on row 0 in the list, columns and grid views. Nav.openWithoutHistory now sends the armed path as the list request's select (docs/protocol.md "list"), and Listing::first_start in src/backend/listing.rs starts the first window at index - first / 2, clamped so it ends at the last row, when the row sorts at or past first; for a row inside 0..first, an absent name or none at all the reply is the one it always was. The window still rides the list answer, so the rows, the cursor and the scroll land in the one turn the swap already lands in, and the first frame the folder draws is the landing frame. A locate inside the hold was the other way and was not taken: it costs a round trip for the index and a second for its window, both inside the listing hold of Swap.CAP_MS, 66 ms, where the lookup costs one pass over the name arena that stops at the row. tests/search-exit-deep.sh drives the real Pane over the real backend on 668 rows and counts the folder frames drawn after Esc up to the cursor standing drawn on its row inside the viewport: 1 for a row inside the first window, past it and on the last row, in all three views, where the tree before this drew row 0 and never reached the row. tests/protocol.sh and listing.rs's own tests pin the window arithmetic. A rename whose row sorts far now draws its own window with the cursor on it in the first frame, but the rename anchor still asks for its own window after the list, so one or two frames can follow with the cursor on a row not yet held, as before; a watched re-read names no row at all, so the corner below stands for both.

// corner: a watched re-read can still move after a swap. A watched re-read with the cursor deep in a large folder swaps to the listing's first screenful and then jumps back when the anchor's own window arrives. Columns keeps every answered drawn folder across a navigation when its path, peek key and order match. ui/js/ColumnsCarry.js asks incoming ancestors beside the list and seeds the folder left from held rows when their order and icons match a peek's. Kept ancestors are re-read in place, and a seeded folder takes one watching peek when the flight ends. A changing ancestor keeps its slot's old rows, inert, until its replacement answers; ui/ColumnsLanding.qml bounds that hold with Swap.CAP_MS and shows the existing reading mark for Swap.MIN_SHOW_MS on fallback. The third column keeps its shown folder's cache through the landing and its watch through the replacement wait; a new folder waits under the existing folder cap, and a file preview uses thirdSwap. The backend's changed signal continues to re-ask kept folders.

A right click on the Locked tile opens the locked folder's own menu, never the parent's background one. A failed listing leaves no rows, so every background right click in all three views took the openBackground path and drew the parent's rows, New Folder and Paste among them, over the tile of a folder those rows cannot touch. ui/js/Nav.js lockedTarget names the folder the tile draws, the directory asked for or the parent's own path on a re-read refusal, and ui/ContextMenu.qml openBackground routes to openLocked while one is drawn, which is the one entrance all three views share; the m key reaches it through ui/Pane.qml openCursorMenu. The rows are ui/js/LockedMenu.js lockedEntries, Open in terminal, Permissions and Copy path in inventory order and nothing else, each dispatched by path in ui/Pane.qml performLocked with no snapshot and no selection. Permissions reads red where it cannot act, a folder owned by somebody else (its word is not yours) or a mode never read (no word), and stays plain where its owner may still fix it, which is the way back in. tests/js/menu.js pins the rows and the absent parent rows, tests/js/nav.js the target.

A watched re-read waits out a held button. The landing of any re-read of an unchanged listing runs Nav.forget, the count passes through 0 and every delegate is rebuilt in the list, the grid and the columns alike, so a press whose release came after the landing never tapped: the right press opened no menu and the left press moved no cursor, on any folder whose files change under it. ui/PaneStates.qml holds a passive PointHandler above the rows that writes pane.pointerHeld (a press a row grabs is still seen, and so is its release after the row is rebuilt). A press that lifts a file drag hands the release to the platform drag and leaves that handler active until the platform sends its own end-of-drag release (QtWayland does, in QWaylandInputDevice::Pointer::releaseButtons; the offscreen platform sends none, so the test sends one), so ui/FileDrag.qml liftEnded clears the hold, the one other writer of pointerHeld. ui/js/Anchor.js busy reads it, and ui/PaneWire.qml reread therefore stays owed until the button is up, then onWatchBusyChanged pays it after the 400 ms burst timer. A press that begins while a listing is out never reached a row (the MouseArea above takes it), so the one window is closed; tests/presskeep.sh holds a press 700 ms across a changed line raised through the backend signal (pane.backend.changed) in each view and each button, with a quiet control per case, and lifts a drag from the pressed row in each view, through the offscreen platform drag, to check the hold clears, the owed re-read is paid and the next press is held again. It moves one ceiling, re-derived with wc -l: ui/Pane.qml 1224 to 1226 for the pointerHeld property and its comment.

The preview swap

CSV and TSV in the Columns preview expose the table body through the same viewport contract as code: wheel and F/B page the body, the sticky header stays fixed, and returning restores the file position before the preview hold releases. Restore stays pending until the visible records are held and reapplies against the final ListView origin, so fractional row layout cannot move the returning file. The body model counts every data record while requests cover only the visible records; Quick Look keeps its existing two-axis grid. CSV and TSV suffixes route both surfaces to text even when the MIME icon is a spreadsheet; binary spreadsheets retain their unsupported state. Both table and code views mark destruction before queued window requests can run, preserving their models until their children are destroyed. The text IPC reader reports the live table viewport and null for its absent wrap field. The merged startup pin is 169 units, with 1,628,263 bytes from the wc -c sum over tests/startup-units.txt; CsvTable.qml stays outside the first frame. tests/quicklook-code.qml has a 452-line ceiling and tests/js/facts.js a 361-line ceiling by wc -l for the CSV and TSV IPC and routing cases. The CodeLines.qml and PreviewScroll.qml allowances in both unqualified-read profiles use the merged source lines; the available profile is measured by tests/staticgates.py.

A cursor move drew the next preview half-built: v0.3.4 shows 145 to 151 mid frames over 18 column moves and 13 to 15 over 16 Quick Look moves, measured headless through tests/preview-swap.sh. ui/PreviewSwap.qml owns the hold and ui/js/PreviewSwap.js decides. The host's children go into a content Item; a Loader creates a ShaderEffectSource on it only while a hold runs, live: false, hideSource, visible. The queued changes run under the picture once the frame that first syncs the layer has synced (afterSynchronizing, armed in that frame's afterAnimating): the picture is taken in that frame's render from the tree as synced, and the layer's first sync queues its own repaint, so the changes land in that repaint's frame rather than one later. That holds only where the signal arrives queued (the threaded loop, Flea's own): the basic loop emits it inside the frame before the grab, and a change there left the grab drawing a Shape whose window was gone (a crash in columnsfolder), so the handler runs only once the event loop has turned since the arming frame (syncQueued), and otherwise scheduledUpdateCompleted, after the grab, runs them as before. The layer exists only while a hold does, so a settled preview draws directly and costs no texture. A ground Rectangle in the backdrop colour sits under the panes while holding, inset by the border width with the matching corner radius on Quick Look, because text in the layer blends against transparent and draws heavier without it. A capture guard gives up after 100 ms (CAPTURE_MS) if the window is not drawing, and the change goes ahead unheld.

Three traps are load bearing. Set holding = true before capturing = false: the other order re-creates the layer and captures the half-built state. On release, set the layer's sourceItem to null before the Loader lets it go: that unhides the source and drops its effect reference in the release frame, where the layer's own destructor would do both only at the deferred delete, one frame later (a layer that hides its source with no layer drawn is a blank frame). Clicks, hover and the wheel are blocked while held by a MouseArea over the picture. tests/preview-holdframes.sh counts the frames one column move draws: the change in the first frame after the key frame, nothing after the release.

The column holds with burstEnds: true on the key path + "\n" + cursorIndex. The hold starts on the move, an idle move loads at once through PreviewSettle.plan and only a move inside the 120 ms settle trails on the timer, and clear runs after the capture so pending makes Facts.state(null, loading) return LOADING. armSettle remains only the folder-to-file arm in ColumnsArea.moveThird, overridden by settleFor when idle; a held key's first repeat after the repeat delay counts as idle too. On an idle press SelectionPreview.askCursorThumb sends the cursor row's thumbnail ask before the capture, so the backend round trip overlaps it, and load() then finds the row asked through the same PreviewAsk.workFor gate and sends nothing twice; a walk step and a manual-hold class ask nothing early. A folder shows through the deferred shownIsDir and shownChildPath and never holds a picture: a move onto a folder whose peek is outstanding keeps the old column by data, and the landed peek shows it with its rows in the same pass; a folder whose peek already answered lands at once with no hold. Only a move onto a folder whose peek is outstanding restarts a single-shot fallback at Swap.HOLD_MS, which shows the pending (empty-held) state when the peek is late; any other move stops it, and a move onto an unanswered folder during a live hold hands that picture to the folder's wait, with no new hold and no new capture: the landing or the fallback releases it with the folder in one pass, and a stale queued show never draws an unanswered folder, only the fallback's pending show passing force. A different key during a column hold is held j: it gives the picture up, the loading state stays live with heldOff until loadSelection, which holds again with atWork. The picture cap is Swap.CAP_MS counted from the start of the preview's own work in load(), a PDF's included: its DOCUMENT_SETTLE_MS (120) runs under the cap, so an idle press opens on the next frame and the last step of a walk falls back while it settles. Past the cap fallBack() sets fellBack, which draws the loading state in the content, and calls scheduleUpdate() on the layer, so the held picture becomes that loading state in the next frame; it stands Swap.MIN_SHOW_MS from then (shownAt, carried across a burst and a new hold until a whole preview lands), and check() asks PreviewSwap.releaseIn before it lets a whole preview go. At the end of the minimum the minShow timer lands a whole preview, or lets a preview still building go live under the same loading state so its mark can crawl. describe() reports how long the last loading state stood as shownMs. The folder fallback is a separate single-shot at Swap.HOLD_MS, 150 ms, restarted only by a move onto an unanswered folder: like ui/LoadingState.qml outside the pane, it draws no loading state that stands Swap.MIN_SHOW_MS, so it keeps the longer hold-off until one does. A PDF page render does: renderCap in ui/PreviewPdf.qml falls back at Swap.CAP_MS, and the loading state stands Swap.MIN_SHOW_MS from fellBackAt, through Swap.showLeft, before the page that landed, or the failed render's state, shows. Quick Look holds in follow() with no apply, load() mutates under the picture and then calls start(isPdf), held j joins the hold with the old preview staying, and close() calls cancel() so nothing queued runs. Space's own open takes no picture: its card and dim wait for the content's first frame (lookReady, which sets landed), and revealed is landed || revealedEarly. The hold ends early over the loading state, shown at once (heldOff) for every kind, when the revealCap single-shot fires at Swap.CAP_MS (66 ms) counted from the key for a kind already built, armed in load() before show() builds the Loader items (the cap once started after show() returned, so the key turn added to the 66 ms and the mark drew a frame late), and from after show() for a kind's first build, so a cold PDF compile is not inside it (builtKinds tells them apart, close() clears revealCapStart, and so does an open that arms nothing while no cap runs), or when a press or the wheel lands anywhere on the overlay: the top holdShield takes it, through its own release, so no click reaches a card that never drew. That early loading state stands Swap.MIN_SHOW_MS from loadingSince: revealIfReady asks Swap.showLeft, and content ready sooner lands when the minShow timer ends, while content ready before the reveal lands at once. A move during the hold takes no picture and keeps the open's cap; an idle one loads at once and a burst still trails on followSettle, which revealIfReady waits out, so whatever reveals is the cursor's own file and a held key asks nothing per row. The card and dim read opacity 0 for the whole hold and share one fade, so a reopen inside the close fade cuts it. lookEmptyFrames in previewSwapState's lookOpen counts every frame the card or the dim draws while the open has neither landed content nor the early loading state, read from active, landed and loadingEarly rather than unrevealed, so it does not restate the opacity binding (tests/ui.sh lookopen requires none, a Space, Escape, Space reopen included, no cap reveal for its small PDF, image and text, and exactly one for an 8000x8000 PNG with thumbnails off; tests/quicklook-hold.sh drives the press, the wheel, a move that must keep the cap's running Timer, the reopen, a stalled show() after which the cap must be running, and a press on text and a PDF, headless; a Markdown open's first screen lands in Space's own turn, so it has no hold to press in).

Quick Look on a Markdown file shows its content in the first frame (038-qlopen, 2026-10-04). Space's own open reveals its card once the content can draw, so the document's first blocks must land in one event-loop turn or the reveal waits on them. Three parts, each measured headless (offscreen, software, warm QML disk cache, medians of n=10 processes, key to the first frame that holds block 0; the clock is Date.now(), 1 ms). (a) Preview.show() names the file the open is for in inlineMarkdownPath, only when Prepared.readsInline(row, class, known) holds: the folder's class has landed (storageKnown, reset to false by ui/js/Nav.js on every new path, so a hop from a local folder to a share never spends the old class), a regular file (st_mode in the row's p, so a FIFO, socket or device named *.md never), listed size 1 to 64 KiB, on a class with no ExtThumbs entry (cifs, nfs and listed FUSE shares classify network in extclass.rs). ui/PreviewMarkdown.qml takes it as blockPath, and pointFile() points the FileView in one step: blocking only for a path equal to blockPath, and never reading a path other than blockPath while one is set (a pane built at kind-set still holds the last file's path). A size not yet known never blocks: the decision comes from the listing row, not from the pane's size, which binds after path. A blocking read starts from path "", because a view holding another file takes a new path asynchronously. Both orders of a file change are pinned (the order leg). (a2) The unseen Source Text lays out nothing (text is the raw text only when shownView is Source, never view: a too-deep document shows its source under Rendered, and markdown-html 15 goes red on view): it held the UI thread 100 ms over a 1 MiB document. MarkdownRemote draws its dashed border as one Shape (18 objects against 322), which brought doc 08 under the poll. (b) QuickLookWarm.markdownUnit holds Qt.createComponent("MarkdownPane.qml", Component.Asynchronous) one second after the first listing is ready, never an instance: the first Space of a session loads no compilation unit (first open 12 ms against 14 ms, pane created at 4 ms against 6 ms) and the window PSS did not move (medians 28438 against 28465 KB, n=6). (c) The column's parse is not reusable: it is parsed against the column's chrome (Theme.color.surface) and Quick Look's against Theme.color.background, and the chrome is baked into every inline code span, so a shared parse would draw the wrong code ground. Instead ui/QuickLookPrepare.qml, for a cursor that rested restMs (120, restart on every move), reads the file with head -c 65537 (the bytes actually read are capped, whatever the file grew to; a longer answer is dropped), parses it in the Markdown WorkerScript (a retired Loader, so no UI-thread parse), and stores the one entry of ui/js/MarkdownPrepared.js when the worker answers for the request still waiting and a cursor that has not moved. Quick Look's own parse is stored there too, so a reopen also hits. A hit needs the same path, folder, chrome, ink and text, which the Space read supplies, so an edited file never shows a stale parse. Known limits: a Space on a row whose size is stale reads the whole file blocking (FileView cannot cap), a symlink to a Markdown file is read ahead never, a FUSE type outside the extclass.rs list classifies local, and a Space before the folder's class lands takes the asynchronous path, so that card can draw before its first block. tests/quicklook-firstframe.sh pins it, and the worker's prepared blocks equal a fresh parse of the same text on the merged parser, canonically (the fixture carries a badge row, a lone picture, an empty heading and an empty quote; red with the worker dropping its last block): the key's function returns with the card active and the blocks in the model (before the fix: card=true and 0 blocks), frame 1 holds block 0, no frame draws the card without it, the parse was taken from the entry, and a held key (twenty moves at 30 ms across event-loop turns, a 300 ms rest) reads no file, red with rest.start() for rest.restart(). Legs: reduced, motion, order, class-network, class-phone, class-usb, unknown (the class reply held back: no read at rest or inside the key), pipe (a FIFO listed at 900 bytes, refused only by its file type; a head child left on it fails the suite), capped.

Quick Look's first open compiles nothing and draws its small pictures (038-mdq1, 2026-10-05). The native h2h had 01 to 03 (lists, tables, strike) settling 40 ms after 04 to 06 and 07 and 17 (a local picture) 85 ms late. A headless trace (offscreen, software, a fresh process per run, n=10, Date.now() marks; the working hypothesis was that the first list or table compiles its component in the first frame) says otherwise: every Markdown*.qml is compiled before the key (the held QuickLookWarm.markdownUnit pulls the whole stack in as dependencies), and the only source compiles between the key and the first frame are QuickLookSwap.qml and PreviewSwap.qml, which the first open's lazy swapLoader built. QuickLookWarm.swapUnit holds that wrapper's compiled unit beside markdownUnit, one second after the window settles and never instantiated (window PSS +74 KB at the median of 16 interleaved runs against 16 without, noise 3 MB, so the claim is a bound, not a digit). Medians of n=20 interleaved runs, key to settled frame in ms: 02 29 to 24, 03 23 to 18.5, 04 19.5 to 14.5, and 01 31 to 30.5, which is inside its noise (range 18 to 108). A list or table costs 1 to 4 ms more than a paragraph on first build and again on the second: block count, not compilation. The picture is the first image decode of the process: QImageReader asks every image plugin before the built-in PNG one, and the reader thread starts, so a 64 px logo went from created to ready in 47 ms and a second decode of any file in 3 ms. QuickLookPrepare now also sizes the prepared document's top-level local pictures with one stat child (Prepared.pictureUrls, at most 8, Prepared.smallPictures, at most 256 KiB each, so a larger or missing one keeps the open's own load) and holds an Image per picture, built as the block builds its own (asynchronous, autoTransform, PreserveAspectFit: the pixmap cache key holds the transform and the fit, and a held Stretch image is never found), until the cursor moves. The block's Image then finds the pixel data in the cache and is Ready when it is built, inside the key. A picture the document gives both a width and a height draws Stretch and misses the cache, so it keeps today's load, with the plugins already warm. Settled frame, n=20 interleaved: 07 68.5 to 17.5 ms (picture ready 63 to 9), 17 69.5 to 18 (62.5 to 7). Known limit: a picture that decodes far past its 256 KiB file (Qt refuses past 256 MB) is now decoded when the cursor rests on its document, not at Space. The rawText binding loop (Binding loop detected for property "rawText", twice in the native cap_markdown log) came from readonly property string rawText: file.text() under a blocking first read: text() loads inside the binding, its own textChanged and onLoaded re-read the binding, and the parse reads rawText through MarkdownPane. loadedText is now set by onLoaded and taken once by pointFile for the blocking path only; a path read asynchronously keeps the last text until its load lands, as the binding did, since clearing it parsed the old file under the new path (preview-hunt parse-quick, parse-column, parse-worker went red). A failed load and a path of none (inactive, too large) do clear it, as file.text() answered "" there (parse-clear). The picture prefetch takes only pictures under the document's own folder (Prepared.insideDir, no .. segment, a stray percent skipped), so its stat never reaches another mount, runs as one sizer at a time (QuickLookPrepare.sizer, released on a move and on a new rest, dropped after PICTURE_SIZE_DEADLINE_MS so a hung mount holds nothing past it); preview-hunt pictures pins it. A picture reached by a symlink inside the folder is still stat'd by its target, a known limit. tests/quicklook-firstframe.sh pins all three on every leg: no from disk cache line (a source compile, from qt.qml.diskcache.debug) between QLFF KEY 1 and QLFF STEP 1 (red before: QuickLookSwap.qml PreviewSwap.qml), no Binding loop detected (red before: 10, 10 and 14 on reduced, motion and order), and the card's pictures Ready when the key returns, in the a-notes fixture (a badge row and a lone picture) and in the 07-shaped h-html leg (red before: built 3 of 3 picture(s) still loading when the key returned). The shape-f-table, shape-g-tasks and shape-h-html legs are the 02, 03 and 07 shapes each as a first open. The harness waits for the idle-held units before its first key, as a user's first Space comes after the one second. Pure parts: tests/js/mdprepared.js.

The first Space keeps every rest-time gain though Quick Look is built late (038-qlwarm, 2026-10-05). ui/Preview.qml is built by a window's first Space (WindowBody.previewLoader, Pane.quickLook()), so the rest prepare, the picture hold and the idle unit warm no longer live in it: ui/QuickLookWarm.qml holds QuickLookPrepare and the previewUnit, markdownUnit and swapUnit (the compiled Preview.qml, MarkdownPane.qml and QuickLookSwap.qml, one second after the window settles, never instantiated: with Preview lazy its own unit compiled inside the first key). WindowBody builds it with Qt.createComponent(...).createObject(view) on the cursor's first landing on a Markdown file (Prepared.isMarkdownRow) and keeps it, so a window that never rests on one creates no object for it (startup-objects stays 618) and the first Space takes the same prepared entry, held pictures and compiled units as every later one. A rest on a non-Markdown file builds no card, and pane.preview stays null there until Space (038-qlfirst below changed the Markdown rest, 0310-qlfirst every kind). Measured headless (offscreen, software, a fresh process per leg, Date.now()), key to the first content frame, ms: first open 10, a later open 7 (the other shapes 8 to 7, 7 to 6). tests/quicklook-firstframe.sh pins it: the window's first Space is a true first open (no Quick Look before the cursor rests, reusedParses 1, pictures ready as the key returns, no compile inside the key, red before: "no prepare or unit warm is built before the first Space", and with the unit hold dropped "compiled inside the first open: Preview.qml PreviewSettle.js PreviewText.qml MediaStrip.qml PreviewArchive.qml"), and a noopen leg rests on a Markdown file with Quick Look never opened (the one card built and closed, prepared entry and compiled units made; red on f54f077f with 0 cards), then moves off it and requires the prepared entry gone at once. Known limit: a first Space on a file that is not Markdown compiles the swap and Preview units in the key, as memfix left it. The remaining first-open overhead (key 8 ms against 5 later) is not a build and its cause was not isolated, and a card laid out ahead needs active (an invisible item has no node, an opacity of 0 draws none), so qlwarm did not build it ahead (038-qlfirst below does, at the Markdown rest).

The first Space only opens a card the rest already built (038-qlfirst, 2026-10-05). The native h2h had the first change after Space at 19 to 23 ms on 7df6dab7 and 59 to 63 ms on fc937947, one poll later, because qlwarm moved the card's build into the first Space (previewLoader.active flipped inside the key: 73 objects, startup-objects STARTUP_OBJECTS QUICKLOOK CARD 73, about 2 to 3 ms headless on the key). QuickLookWarm.cardDue (every compiled unit Ready, QuickLookPrepare.rested; 0310-qlfirst below widened it from a Markdown row to any file whose kind has a unit) turns the loader on, so the card exists hidden and inactive when the first Space comes and a window that never rests on such a file still builds none (startup-objects stays 618, the card is built by pane.quickLook() after the census). Headless first-Space key and key-to-frame medians over the Markdown legs, ms: 7df6dab7 6 and 7, 08077a23 5 and 8, f54f077f 10 and 11, after 8 and 9. tests/quicklook-firstframe.sh pins it: KEY 1 finds a card built, closed and invisible, and no Space builds a card (cards= from the loader's loaded; red on f54f077f: "a rest on a-notes.md left no Quick Look card built before the first Space" and "step 1 built 1 Quick Look card(s) inside the key, want 0" on every leg).

A rest on any kind builds the card closed (0310-qlfirst, 2026-10-12). The instant census had the first Quick Look of a kind slower than a repeat (open.ql.pdf.first 108.8 ms against 65.6). A headless census in tests/quicklook-firstkind.sh counts what a first Space builds and maps against a reopen of the same file: before, the first Space built 95 more objects than the reopen on every kind (text 95 against 0, image 112 against 17, PDF 203 against 108, clip 115 against 20), the card (73) and the swap wrapper that show() builds lazily, because cardDue built the card for a Markdown rest only; a text, picture and PDF Space mapped no new file in the key, and the clip's backend load is the media warm's (gm/0310-cql). QuickLookWarm.cardDue now takes any rested file whose kind has a unit (restUnit Ready beside previewUnit and swapUnit) and also calls ensureSwap(), so the first Space of every kind builds the same objects as the second (text 0, image 17, PDF 108, clip 20; startup-objects and lazy-objects unchanged: a launch and a rest on a folder, an archive or an unsupported file build none). What a reopen still builds is the per-open item (the PDF viewer, 108 objects) and is not a first-open cost. tests/quicklook-firstkind.sh pins it on each leg: a closed card with its swap built after the rest, no card at a rest on a file with no unit before the leg's first unit file (the loader keeps a built card, so a no-unit file after a unit one shows the kept card and is not checked), a first Space that builds no more objects than the reopen (red with cardDue limited to Markdown on all four kinds), and no new mapped file in a text, picture or PDF first Space. A clip that plays never settles in the census (open.ql.video repeats settle at 5021.1 ms against 5080.5 first): PreviewMedia autoplays by design, so the watched region keeps changing until the harness cap; it is the clip playing and not a hold in Flea.

A rested file's maths is drawn in Quick Look's first frame (038-qlmath, 2026-10-05). Traced offscreen, the first frame of a document with an inline formula came about 25 ms after a plain one because the process's first picture decode (the 1x1 Maths.CLEAR PNG of ui/MarkdownMathsText.qml's calibration Text) ran inside the key, and the formula then waited askSettleMs (50) before asking. On the same rest QuickLookPrepare holds one decoded Maths.CLEAR (warmDecoder, for any document with a formula, since a typeset figure decodes nothing at rest, released by moved()) and builds one unseen MarkdownFigure per formula (typeset, at most Prepared.FORMULA_LIMIT 8, from Prepared.formulasIn), so the figure service holds each drawing under the key the open asks with; MarkdownFigure.takeRemembered() applies a held drawing on the change itself and drops any older request in flight (ticket 0, askTimer stopped). recount settles nothing while the stat child is out (sizerDeadline still releases a child that never answers). tests/quicklook-firstframe holds the i-maths leg (figures drawn at the key, decoder warm only for a maths document, decoderWarms counted across the held-key sweep) and the paired sizer probe; tests/figure-component.qml holds the held-drawing and late-reply checks. On the same rest a document holding a table (a quote's or a list item's included) sends the warm table's own Markdown to the same worker, then builds one unseen MarkdownTable from its answer once per prepare and destroys it at once (tableWarmed, counted in tableWarms only once the table built and measured, never under a held key or for a parse, the warm's included, answered after Space), a bold cell, a code span and a link at Quick Look's body size on a stand-in preview, because no card holds a Markdown pane at rest: the process's first table build, its bold and code faces and its first rich import (2.1 to 2.9 ms in a headless probe) are then paid before Space, and tests/quicklook-tablewarm.sh pins it.

columnReady waits for LOADING never, an image for its cache file, or with none coming the original frameThumb decodes itself, drawn or refused (Ready or Error), a video for its poster or none coming, a PDF for shownPage >= 0 or failure, text and code for PreviewLines.loading false, an archive for its meta, and symlink, audio, unsupported and multi for the facts alone; a folder waits for answered(shownChildPath). lookReady waits for status not loading, or for the interim cache thumbnail shown whole at the final rect (interimShown, from PreviewImage's interimReady), and a PDF also for shownPage >= 0 or failure. PreviewImage requests the final only once the interim has settled (interimSettled, armed one turn after the build because Preview.qml binds the path before the thumbnail): the pixmap reader decodes one request at a time and the newest first, so a final queued beside the interim held it back for the whole decode, 6 of 6 loaded runs of the held phase. A close and reopen while the final still decodes lands on the Ready interim in the same turn and reveals without the cap, because the interim decodes on the GUI thread and never queues behind the reader. tests/js/previewswap.js drives the moves, the cap, the frame kinds and every ready rule; mutating columnReady reddens it. tests/preview-swap.sh grabs the swap item headless over the file kinds and asserts 0 mid frames on those holds; its one folder step is a simulated wait, and the real folder data hold is driven by tests/columnsfolder.sh through the real ColumnsArea. tests/ui-noblank.sh reads previewSwapState (holds, fallbacks, midFrames) live.

// corner: the move to the PNG keeps 2 mid frames by design. At full-resolution grabs the sharper original replacing the cache file draws twice between the settled frames. It is the decode finishing, not a half-built preview, and the count is pinned rather than fixed.

The geometry gate: a picture drawn at the wrong size never ships again. A small clip's poster was seen drawing at its own pixel size in a Columns frame no suite measured, so the rule table is now measured on every commit: images draw at min(own pixel size, aspect-fit), never enlarged, in the Columns column and Quick Look; a video poster and any playing video fill the frame on the limiting side like the player, enlarged when the clip is small; an office thumbnail fills only when its embedded picture reaches cache size and otherwise draws own-size; a PDF page contains. The headless tests/preview-geometry.sh (37 cells over both surfaces, both frame widths, every source-size class, one GEOMETRY line each plus a GEOMETRY_SHEET contact sheet) and the native previewmatrix case in tests/ui-captures.sh (real files, column poster and player content rects, Quick Look picture rect, PREVIEWMATRIX_SHEET contact sheet) assert every cell within 1 px headless and 2 px native, centred. A change to any preview surface runs both and its contact sheet is looked at before the change is called done. Quick Look PDF has no geometry cell yet: its page item is unexposed, so its contain rule is stated from ui/PreviewPdf.qml and not measured.

Decided 2026-10-03: no Rendered or Source control in the Markdown Quick Look; it renders automatically. r still flips the open Quick Look. The bar is the language mark, the name, the line count and close. Every Markdown preview opens rendered, Quick Look and column alike, and nothing stores a view: preview.markdownView is not a state leaf. ui/Preview.qml's markdownSource holds the flip for the open Quick Look only. A move to another Markdown file keeps it, close() forgets it, and the column never draws Source. A state file that still carries the key loads without a message, because an unknown key is kept as read; a patch naming it is refused.

Why the listing is an arena

Listing (listing.rs) holds one String with every entry's name written back to back, plus a Vec<Span> of {off: u32, len: u32, is_dir: bool} indexing into it. A 100,000-entry directory then costs one allocation for the names and one for the spans, instead of 100,000 individual String allocations. Sorting moves 12-byte Span values (the compiler pads the struct to 12 bytes for u32 alignment), never the name bytes themselves, so sort_by_name never touches the arena at all. Offsets are u32, so the arena tops out at 4 GiB of names, which no directory reaches but a later search phase reusing this arena could.

The comparator is macOS Finder's, not a byte compare, and it costs about 2.3x. Three properties apply per segment as two names are walked together: a run of digits compares by value so file_2 comes before file_10; letters compare case-insensitively so Apple sits beside apple instead of in an upper-case block above it; and leading zeros do not change a value, so file_01 and file_1 are equal and fall through to a tie-break. The tie-break is a raw byte compare and it is not optional: case-insensitivity makes README and readme equal, and a stable sort would then preserve readdir order, which is not reproducible across runs.

A digit run is compared without parsing, because a 40-digit run overflows every integer type: skip leading zeros, longer remaining run wins, then compare the digits as bytes. corner: ASCII only. Finder also collates a diacritic beside its base letter, which needs a collation table, and this tree ships zero dependencies with Cargo.lock byte-identical as a gate, so a byte above 0x7f falls back to byte order, which for UTF-8 is code point order. A name that is not UTF-8 never reaches the comparator at all: the arena is a String and scan.rs:16 already converts lossily at the readdir, with its own corner tag there.

Measured on this box, release build, seven runs each, medians: 100,000 rows 2.02 ms to 4.62 ms, 2,000 rows 0.225 ms to 0.586 ms. So the listing hot path pays about 2.6 ms once on the largest directory this product is benched against, which is inside the noise of a settle time measured in hundreds of milliseconds. Zero allocation per comparison: u8::to_ascii_lowercase inline, no String, nothing collected.

The name sort is stable on purpose, and sort_unstable_by is a measured LOSS here. Plan 5's Task 5b made the substitution, proved it output-identical, and reverted it on time.

The order argument, for whoever revisits this. The comparator's key is (is_dir, name), it returns Equal only when both fields match, and POSIX gives each entry in a directory a unique name, so on names that are valid UTF-8 the order is total and the substitution cannot change the output. The one hole is that scan.rs pushes file_name().to_string_lossy(), so two distinct non-UTF-8 names in one directory can collide on the same lossy string and tie; see the scan.rs entry under "Deliberate corners". That hole is harmless, because two rows that tie there carry the same name, the same is_dir and the same failed stat, which makes them byte-identical objects on the wire, so no permutation of them is observable. It was exercised rather than argued: a fixture with three file names and two directory names colliding on two lossy strings sorted identically under both. The output was byte-identical in all ten comparisons taken, four trees plus that fixture, ascending and descending, 100,000 rows down to 7, with the only difference anywhere being the rows line's own ms stat-timing field, which is a measurement.

The reason to revert is time. btrfs returns readdir in creation order, which is largely name order, so the stable sort's run detection is what this product's directories actually hand it. The 100,000-file fixture comes back in 5 ascending runs and sort_by costs a 2.09 ms median against sort_unstable_by's 14.50 ms, twelve interleaved pairs with the arms alternating and no run of either arm overlapping the other. The media fixture (401 ascending runs over 2001 entries) reads 0.227 against 0.243 ms and /usr/lib (388 over 4734) 0.698 against 0.727 ms, nine pairs each. Those two are a not-a-loss and not a win: the medians favour stable by 0.016 and 0.029 ms, but the distributions overlap heavily, media's A maximum of 0.289 sitting above B's minimum of 0.228 and /usr/lib's B minimum of 0.668 below A's minimum of 0.689. The conclusion rests on the scale fixture and the shuffled control alone. Only a deliberately shuffled 20,000-file directory (9989 runs) favoured unstable, 3.64 against 5.08 ms, and nothing on this box produces that shape by itself. The 600 KiB of stable-sort scratch that motivated the change never showed up either: peak RSS moved 4 KiB on the scale fixture, because the scratch reuses pages the arena's own growth had already faulted in.

The 7x magnitude is doubly fixture-dependent, and the direction is not. Beyond the run count, every name in that fixture shares the 15-character prefix some_file_name_, so each comparison chews 15 identical bytes before it can diverge. Both arms pay that, but the arm doing several times more comparisons pays several times more of it. A directory of short, early-diverging names would narrow the ratio without changing its sign.

The listing arena returns to the OS

backend::run calls heap::pin_mmap_threshold() before anything else, which is one mallopt(M_MMAP_THRESHOLD, 131072) through an extern "C" in the idiom src/thp.rs already uses. It is worth 4.8 MiB of the backend child's memory while a second large directory is on screen, and it costs nothing measurable in time.

The TUI front end takes a different lever. tui::run calls heap::one_arena() first, mallopt(M_ARENA_MAX, 1), because a picture job's thread opened a second glibc arena that kept its pages after the thread ended: measured on East on the 2000-file media fixture with a video poster at rest, anonymous memory 976 KiB against 768 before the poster existed, and 620 to 788 with one arena. src/tui/run_tests.rs pins the call ahead of the wire's first thread. src/heap.rs's helper_threads_share_one_arena pins the cap: it re-executes the test binary with a probe that runs before main, on the one thread tui::run has at that call, and goes red unless malloc_info names the main arena alone after four allocating threads, so a lost cap or a cap of 2 fails it. The probe cannot run inside a test, because libtest's own thread already holds a second arena there, and a cap of 2 changes nothing once two exist. With one arena, the blocks a picture's encoding freed (the job's output read from a pipe, the base64 chunks) stay inside [heap] below live ones, so graphics::send writes a picture's transmission and then calls heap::give_back(), malloc_trim(0). Measured on East on the same fixture, five runs each: anonymous memory 620 to 640 KiB with it, 756 to 876 without, [heap] 428 to 444 against 564 to 680. src/heap.rs's a_sent_picture_returns_the_heap_pages_freed_below_live_blocks frees 1 MiB below a live block and goes red unless send returns at least half of it.

The mechanism, which matters because the obvious measurement cannot see it. glibc's free ratchets mmap_threshold upward, to the size of the block, whenever it frees a block that mmap served. A listing's name arena and its span vector both cross the 128 KiB default, so the FIRST large listing is mmap'd and is handed back to the kernel when it is freed. That free is also what raises the threshold past the arena's own size, so every large listing after the first is served from the heap and never leaves the process. Naming a threshold is what stops the ratchet: glibc sets no_dyn_threshold the moment a program sets one, through the tunable or mallopt.

So the size of the win depends entirely on the sequence, and any single number quoted without its sequence is wrong. Measured through the REAL path, a live Flea window driven by h, g, j and Return to list the 100,000-file fixture, leave it, and list it again, with a negative control on the same tree and the arms alternating over six interleaved pairs. Pss from smaps_rollup in KiB, divide by 1024 for MiB:

WhenNo leverPinnedSaving
first large directory on screen4999494950 KiB, nothing
second large directory on screen988249574925 KiB, 4.81 MiB, 6 of 6 pairs disjoint
back on a 10-entry directory16331453180 KiB

VmRSS on the middle row is 11788 against 6862. Read the second row as what it is: without the lever, listing a large directory a second time roughly DOUBLES the backend, and with it the second listing costs what the first did.

The field bench will not show this and must not be expected to. tools/flea-field-bench launches an entrant once and lists one directory, so its child_pss_kb column is the first row of that table and moves by about 50 KiB. This is a session property, not a cold-start one. The 2026-08-30 field run confirmed it: the whole branch, this lever plus the shared table parse's 0.11 MiB, moved that column by 0.17 MiB on the scale fixture (5.00 MiB before, 4.83 after, identical in all three batches) and 0.16 MiB on the media fixture (1.76 before, 1.60 after). Do not go looking for the 4.81 MiB in that column.

Corroborated on the direct wire with the same THP setting, medians over interleaved pairs: big, big, small ends at 3145 against 1449 (1696 KiB, 12 of 12 pairs, every delta 1628 to 1756); big, small, big, small, which is the sequence the GUI produces, ends at 1631 against 1407.5 (223.5 KiB, 8 of 8) and reads 8851 against 4911.5 while the second large listing is still held; big, small ends at 1475.5 against 1418 with the arms overlapping, which is why the first round of this task measured that sequence and recorded a null.

The lever cannot cost time on the first large listing and is a small win on the second, which is what was measured and is neither a null nor a clean sweep. mmap faulting pages in one at a time is a different profile from a warmed heap and could have cost something, so this was chased rather than assumed.

On the first large listing the two arms are provably allocation-identical: the same threshold is in force in both and the smaps probe returned byte-identical mappings, so the lever cannot be acting on that listing at all and no difference appearing there can be caused by it. A difference does appear, 0.26 to 0.33 ms slower pinned, medians 25.75 ms plain against 25.99 ms pinned, and it is a build-layout artefact rather than a behavioural one: it localises entirely in the sort phase, 1.95 to 1.99 ms unpinned against 2.18 to 2.26 ms pinned, in every batch including both self-pair controls, an arm measured against a copy of itself. A difference that survives a self-pair control is not caused by the change under test.

The per-pair sign split is a range across batches and not a constant, and every majority favours the plain arm: 6 to 6 in the first batch, then 9 of 12 and 11 of 16 in two later batches of the same comparison. That is stated plainly rather than softened, because it is not what the conclusion rests on: those are counts on the first-listing row, which is the row the arms cannot differ on, and the self-pair control above is what discounts them.

On the second large listing, the only listing where the arms genuinely differ, the pinned arm is FASTER: by 0.923 ms in 11 of 12 pairs, against a self-pair control on that same row of 4 of 8 and a median of -0.119 ms. The earlier medians agree in direction, 24.29 ms plain against 23.74 pinned. The whole big, small, big sequence is 0.647 ms faster pinned, and the earlier three-listing sequence reads 50.28 against 50.11 ms.

So there is no speed-for-memory trade to rank against the 4.81 MiB, and what time movement there is points the same way as the memory.

The zero-warning gate is only a partial guard on src/heap.rs. Deleting the mallopt call makes cargo build emit five warnings, an unused import plus four never-used items, and the gate fails on those. But a wrong parameter number or a wrong threshold value warns about nothing and silently LOSES the 4.81 MiB above, which is why src/heap.rs now carries its own test.

The guard is a re-exec. a_freed_large_block_does_not_ratchet_the_mmap_threshold spawns the test binary again behind FLEA_HEAP_PROBE, ratchets the threshold the way a first large listing does with one 8 MiB allocation and free, and asserts a 1 MiB allocation landed outside [heap] in /proc/self/maps. A fresh process is what makes it not flaky: a heap already holding a free chunk of that size would answer from the arena for a reason that has nothing to do with the lever. 0 red in 50 runs in each profile.

The 64 KiB control is what makes it a test at all. libtest runs a test on a spawned thread even under --test-threads=1, glibc hands that thread its own mmapped arena, and nothing a secondary arena serves is EVER in [heap], so the probe would have passed for the wrong reason for ever. The child therefore runs with MALLOC_ARENA_MAX=1, which makes arena_get2 reuse the main arena, and asserts a below-threshold 64 KiB allocation is INSIDE [heap] before it asserts anything else. That tunable sets mp_.arena_max alone and does not touch no_dyn_threshold, which the deleted-call mutation proves rather than asserts.

The parent asserts the child said 1 passed, not only that it exited 0. --exact against a renamed test filters to nothing and libtest reports success on an empty run, so without that second assertion a rename would leave the guard vacuous and green. Proved by renaming the function and leaving CHILD_TEST behind: exit status 101, "the probe child ran no test, so <the test's path> no longer names one".

What it catches, measured by mutation on 2026-08-30 and not claimed. RED for the call deleted, for an undefined parameter number, and for the threshold raised to 4 MiB. GREEN, so NOT covered, for the threshold lowered to 64 KiB, because a 1 MiB probe is above both values: what it pins is the interval (65536, 1048576]. GREEN, so NOT covered, for the pin moved after the large free, because mallopt sets the threshold explicitly and so UNDOES a ratchet rather than only preventing one. The ordering run.rs depends on is about memory already given to the arena and is still held up by the comment there and by this section, not by a test. And it cannot redden for M_TRIM_THRESHOLD or M_MMAP_MAX, which freeze the ratchet by accident at a default that happens to equal the pinned value.

Why mallopt in backend::run and not GLIBC_TUNABLES in ui/Backend.qml. Both work and both were measured head to head over ten interleaved pairs, 1453.5 KiB against 1450, three and a half KiB apart with the distributions fully overlapping. Process on Quickshell 0.3.1 does carry environment and it does MERGE rather than replace: with the map set, the backend's /proc/<pid>/environ holds the tunable and still holds HOME, PATH, XDG_RUNTIME_DIR, WAYLAND_DISPLAY and FLEA_BIN, 26 variables in all, verified live. mallopt wins on reach rather than on numbers: it applies however the backend is started, including tests/protocol.sh, tests/thumbs.sh and any second front end, whereas the QML property covers only the process the GUI spawns; and it keeps an allocator decision in the module whose allocation pattern was measured rather than in the layer that is meant to be strictly a display. src/gui.rs is wrong for either form, because a tunable set before cmd.exec() lands on qs as well, whose allocation pattern is nothing like the backend's and was never measured.

There is no return value to check. glibc 2.44 here answers 1 from mallopt for every parameter number, including ones it does not define, measured at -3, -1, -99 and 3, so a test on the result could not redden on a wrong constant and would be a test that cannot fail. The evidence for this lever is the interleaved measurement above, taken through the real window with a negative control, and not a runtime check.

For the backend, malloc_trim was considered and not taken: a call site inside the listing path would reclaim at a moment we choose what pinning the threshold already reclaims by itself. The TUI does call it, through heap::give_back after a picture is sent, for the reason given earlier in this section.

Prewarm

flea --prewarm <path> <first> <dest> (launcher/prewarm.rs) runs the same scan, sort and stat-range as the backend's list command, then writes exactly the two lines --backend would have printed to stdout, a listed line and a rows line, to dest instead; the rows line names "listing":1, the numbering of a backend's first listing, which is the one the file stands in for. This producer and its secure file contract remain available for measurement, but production ignores FLEA_PREWARM: the rejected reader was slower and nothing in the file proves its directory unchanged since it was written (rule 4 above).

The first window

flea --gui maps its window with its first screen already in it. The sections below explain what keeps that map early, what decides the first frame's contents, the one accepted cost, and how the file chooser, which is off the measured path, keeps its own window whole.

The context menu waits for the first frame (2026-10-13). Each pane builds ui/MenuHost.qml, which holds the menu's inputs, its closed state and reply validation (ui/js/MenuContext.js), and loads ui/ContextMenu.qml asynchronously one idle second after the first frame swaps and the listing settles; a menu asked for before then loads it synchronously, so the first right-click or menu key never waits on that timer. The merged startup gate pins 587 objects by the census after Drag to Trash (583 before), 172 first-frame units and 1,639,271 bytes by the wc -c sum over tests/startup-units.txt: tests/startup-objects.sh checks that the nine units only the menu uses and the ContextMenu root are absent at its cut, and tests/menu-settle.sh that a warmed menu keeps one root across reopen. The merged ceilings are ui/Pane.qml 1234, ui/PaneMenuActions.qml 527, ui/ContextMenu.qml 737 and tests/js/menu.js 669, each re-derived with wc -l; both unqualified-read profiles follow the merged source lines, with the available profile measured by tests/staticgates.py.

Both land235b unqualified-read profiles are re-derived from compiler JSON: 1,436 reads with Quickshell present and 2,185 with its types missing. The missing profile uses a bare Qt import tree and unresolved Process and StdioCollector controls; its previously stale source positions are replaced with the measured positions, retaining the existing reasons, plus the imported Hyprland singleton allowance.

The land235b census compares the actual merged af9db27ab UI with the first-use cue loaders: 597 to 587 objects, 171 to 169 first-frame units, and 1,634,373 to 1,630,079 bytes, each unit weighed with wc -c. ui/Row.qml loads ui/RowDropTarget.qml by URL only while its cue or clipboard mark is drawn; ui/RailTrashRow.qml loads ui/TrashDropHandler.qml synchronously on its first matching drag, preserving a live pane binding and the existing feedback. ui/DropTarget.qml and ui/js/TrashDrop.js therefore leave the first-frame trace. tests/startup-objects.qml drives the handler after its census cut and requires the correct pane, a retained handler and the existing foreign-offer refusal.

The land235c review fixes retain 587 startup objects and the same 169 first-frame units. The tests/startup-units.txt byte sum moves from 1,630,079 to 1,630,044: the row's completion guard adds bytes, while the one-line rail comments remove more. Both unqualified-read profiles retain their allowance counts and reasons at the shifted source positions.

The land235r4 Trash backend guard and rail drag hold retain the first-frame unit list. The wc -c sum over tests/startup-units.txt moves from 1,630,044 to 1,630,730 bytes; tests/startup-objects.sh checks the census on both images.

The land235r5 catcher name retains 587 startup objects and the same 169 first-frame units. The wc -c sum over tests/startup-units.txt moves from 1,630,730 to 1,630,763 bytes. Both unqualified-read profiles retain their allowance counts and reasons at the source positions shifted by the name.

The 0.3.11 stage join of the rail highlight fix and Drag to Trash keeps both first-frame additions, the shared XDG reader and the Trash row, in one inventory of 172 units; the wc -c sum over tests/startup-units.txt is 1,639,271 bytes, re-derived from the merged files. The rail Trash row carries the same highlighted binding as every other rail row.

Qt never caches a Quickshell config. Quickshell serves its config through its own qs:@/qs/ URL scheme (src/core/rootwrapper.cpp, intercepted in src/core/qsintercept.cpp) and Qt's QML disk cache accepts local files only. Run any launch with QT_LOGGING_RULES="qt.qml.diskcache*=true" and every Flea file prints File has to be a local file. Anything loaded by a file: URL is cached, which is why the OEM bar's plugins have .qmlc files and Flea had none. So the entry stays small and hands off: ui/boot/shell.qml imports Quickshell and QtQuick and nothing else, and ui/WindowBody.qml arrives by file: URL.

The entry lives in its own directory because of a Qt rule. A document implicitly imports its own directory, and Qt loads every composite singleton that directory's qmldir names. An entry in ui/ therefore compiles Theme, ViewState, Favourites, MediaSound, Scripts and ShelfPins, plus the JavaScript they import, through qs: before the window can map, and then never instantiates any of them, because the file: side loads its own cached copies. ui/boot/ has no qmldir. It needs Commons and Ui symlinks of its own, the same targets as the two in ui/, because import qs.Commons resolves against the config root and ui/boot is the config root now. The package ships all four.

The window waits for its body. The entry is the window, and its Component.onCompleted sets the Loader's source before the window exists, so the first buffer the compositor gets already holds the chrome and the rail, and the rows land one or two frames later when the backend answers. 0.3.2 loaded the body on the window's first frameSwapped instead: the window mapped about 160 ms earlier warm and held nothing but its background colour for the 280 ms the body and the listing took, 1.2 to 2.9 s on the first launch after an update, and that bare window is what people noticed. Measured 2026-09-22 on the studio folder, warm, five launches each, exec to the first buffer that holds rows, read off WAYLAND_DEBUG: 0.3.1 624 to 655 ms, 0.3.2 401 to 421, this 305 to 312. The map itself moves from 120 to 138 ms to 281 to 287 warm, and on tools/flea-field-bench on the scale fixture from 271 to 299 to 516 to 578, against pcmanfm's 444 to 458 in the 0.3.2 field run and about the same as thunar and strata, while settled went 1105 to 1255 to 1018 to 1081 and CPU 0.50 to 0.65 s to 0.45 to 0.51. Two other orderings were measured and rejected the same day: a zero Timer after the window exists mapped 15 ms earlier and put the rows 50 ms later, because the build and the first frame contend; and a FloatingWindow created with visible: false never maps when it is set true on Quickshell 0.3.1, so hiding the window until the rows land is not available. Loader.Error logs and quits: an empty window that stays empty is the failure that would otherwise say nothing at all.

A cold launch is disk first, so the launcher prefetches. In a separate session the same day, whose warm map was 261 to 267 ms rather than the five-launch set above, the build without the prefetch mapped at 436 to 484 with the page cache dropped, and reading every file the launch opens before starting it brought that back to 287 to 290: the whole difference is reading. So src/gui.rs starts flea --launch-warm before its Vulkan probe, the first cold read a launch makes. The helper forks once for both jobs, gio first with the page-cache warm on a thread, so the launcher waits only for the fork and the shell it becomes never holds an unreaped child, then queues posix_fadvise(WILLNEED) on each range of $XDG_CACHE_HOME/flea/prefetch. The backend writes that list when it sends its first rows, from its parent shell's own /proc/<pid>/maps and pagemap: the pages the shell had in memory, not whole files. The moment matters: a second in, a media folder has decoded thumbnails and Qt has loaded every image plugin, and a list recorded then made the next cold launch 13 to 47 ms slower, four paired rounds on the media fixture. Whole files read about three times the bytes, and with the disk queue 127 requests deep the shell's own reads wait behind them: whole files measured 406 to 417 cold, the pages 331 to 346, on 2026-09-22 with the studio folder, four and five launches. The list is the next launch's, so the first launch after an install has none and behaves as before. This is not rule 4's prewarm: it reads nothing of the directory being opened and hands the shell no data, it only warms the page cache for files the shell maps anyway. The helper takes only a regular file, checked before the open because opening a device node can act on the device, opens it without following a final symlink, and bounds the list at 1 MiB and 4096 ranges; the list is written at 0600 through an exclusive temp file and a rename. exec_qs sets FLEA_PREFETCH and FLEA_PREFETCH_SHELL, its own pid, which exec makes the shell's; qs_command removes both, so a chooser started from a Flea terminal cannot overwrite the main window's list. Terminals the shell starts still inherit them, so a backend records only when its parent is that pid: a TUI or a test suite run from a Flea terminal does not. The list's second line names the shell by pid and start time, and a backend skips the record when the list already names its shell, so a second pane or window opened later cannot replace the launch's pages with its late-life ones.

Nothing the first frame does not show is built for it. ui/js/Keymap.js builds its sheet in sheetFor, never in setPreset, which a launch runs twice and which spent about 10 ms of a 127 ms body build on tables no first frame reads. A menu hint is built per action at that action's first hintFor, from that action's own rows: 0.3.5's status strip asks for the key of deletePermanently while the body builds, and a first ask that built the whole table ran about 9,700 JavaScript calls (86 lookupFor walks of the table) inside the first window: 4 to 7 ms of map on the studio folder warm, medians of 20 interleaved launches per arm against two v0.3.4 arms.

What has to stay with the window. itemRect is the window's, so centreOf, rectOf and boxOf stay in the entry and ui/Ipc.qml reads them through its own fleaWindow. sceneGraphError arrives on the first frame, and the entry is the one object that always exists, so the handler and PR119's one OpenGL retry are in the entry too; the argv rule itself is ui/RendererRetry.qml, loaded by file: URL only once the error has arrived, because the boot directory cannot import ui/js/Renderer.js through qs: and the startup path must not compile it. The body takes the window as host rather than fleaWindow: a root property of that name binds to itself through Ipc and reads null, which is how an earlier experiment's rowRect came back empty. The IPC seam (ui/Ipc.qml, ui/IpcMenuState.qml, ui/IpcPreviewState.qml, 77,651 bytes the product never reads) is built after the first rows: ui/WindowBody.qml's asynchronous ipcLoader activates once when the primary pane leaves loading (a listing that never answers ends in waiting after 10 s, which builds it too), so the first window compiles 166 of 169 units (165 of 168 before ui/Glass.qml) and the target flea exists a few milliseconds after the rows. A client that drives or times a launch through IPC waits for ready (tests/ui.sh assert_theme) and never assumes the target at map, so a native case holding the first listing reads no loading over IPC: ui:cap_heldcolumns takes that proof from a live ipc ready answer with no seam ("Target not found.") on each side of its reading shot, shooting every Columns limit from 2 to 5, and tests/heldcolumns.sh pins loading, waiting and the landing offscreen once at the shipped limit 3, since held rows read no column count; the Loader is the one object this adds to the startup gate, which is why startupObjectLimit was 619 then; it is 628 on today's tree, the total the startup-objects suite measures, and "The startup object gate" records the objects that joined since (background-only opacity, the spinner's still second, the list's cursor mark, the rail's lit place, the Quick Look warm loader, the hidden rail's dwell, the New tab mark's frame, the held button's mark and the flasher's result Connections) as history, not as a sum. The move takes the ui/WindowBody.qml ceiling from 651 to 657, re-derived with wc -l.

QT_QPA_PLATFORMTHEME=gtk3 starts GTK inside the shell, about 60 ms warm and 7 MB of PSS, and the one thing Flea takes from that theme is the icon theme name. Omarchy writes the same name to ~/.local/state/omarchy/current/theme/icons.theme, and Quickshell reads QS_ICON_THEME and calls QIcon::setThemeName itself, so src/gui.rs sets QS_ICON_THEME from that file and removes QT_QPA_PLATFORMTHEME from the child. A missing or empty file changes nothing, and an operator who set QS_ICON_THEME keeps whatever platform theme they chose. Without gtk3 the Qt defaults differ: application font Sans Serif 9 rather than Adwaita Sans 11, colorScheme Unknown rather than Dark, cursor flash 1000 rather than 1200 ms. Every shipped surface is compared with and without it, and a surface that differs is fixed with an explicit font or hint rather than by keeping gtk3.

The window's own colour is never seen. The body's Rectangle covers it on the first frame, so the entry carries ui/Theme.qml's literal fallback and nothing more. A glass launch makes it transparent instead, before the window exists; see "Theme roles and sources". 0.3.2 passed the theme's background in as FLEA_FIRST_PAINT for the frames the window spent empty, and that went with them.

One cost is accepted by design. The first launch after each update is slow once, while Qt writes its 122 cache files with an fdatasync each: measured 2026-09-22 with only that cache cleared, three launches, the window arrived 1.2 to 2.9 s after exec with its first screen in it. Nothing else is in that number. Flea never writes the Qt pipeline cache, the qqpc_vulkan under ~/.cache/quickshell is the bar's, and clearing the Mesa shader cache costs about 50 ms once per box, not per update. Every later launch is fast, across reboots. Arch's qt6-declarative ships no qmlcachegen, so the package cannot ship the cache ahead of time.

The retry helper drags ui/qmldir with it, which is another reason it is loaded late. ui/RendererRetry.qml implicitly imports its own directory, so compiling it compiles every singleton ui/qmldir names, and those import qs.Commons, which resolves against the CONFIG ROOT. Production's root is ui/boot and carries the two symlinks, so it resolves; a probe launched from any other root answers Type Favourites unavailable, which is how this was found.

The chooser's own failure path cost three attempts, and the working one is a late look. A missing ui/PickerWindow.qml is not a Quickshell config error: the entry loads, the window never arrives, and the process sits there while tools/flea-portal's caller waits out its 600 s, which is driven. Component.onCompleted on ShellRoot is Non-existent attached object; on a QtObject child it fires before LazyLoader has an item, so it killed a healthy chooser; onLoadingChanged never gave a usable edge in either arm. What works, with both controls driven, is one late Timer that looks once, well past the slowest honest load.

The chooser keeps its window whole. ui/boot/picker.qml is a thin entry whose LazyLoader takes ui/PickerWindow.qml by file: URL, so the chooser gets the cache and the boot directory, but not the body-after-the-first-frame split. Its title and size are read from ui/js/Picker.js and the Theme, so splitting them off the first map would resize a portal dialog in front of the operator, and the chooser is not on the measured path.

The startup object gate. tests/startup-objects.qml counts the objects one window has built when its first listing settles and fails above startupObjectLimit, which is 587, the total the suite prints on W after Drag to Trash adds two DropAreas and the Trash row's two first-use Loaders (583 after the context menu waited for the first frame, 628 before). What follows is a history: each object is named as it joined the first window, with the count of its day, and later work that removed or merged objects never re-summed it, so its steps do not add up to the current total and the measured total is the only number that holds. 619 once the IPC seam moved into its Loader (ipcLoader), down from 714 in 0.3.8, and two more for background-only opacity, ui/Pane.qml's glass ground and the second half of ui/TabBar.qml's strip ground, two plain Rectangles drawn only on glass. Neither can wait for first use for less, because the Loader that would hold either is itself one object, and off glass neither is visible, so neither adds a scene-graph node. One more is ui/Spinner.qml's still Timer, which holds a spinner still for its first second on screen: the pane starts life in loading, so ui/LoadingState.qml's spinner and its Timer are built with the first pane. It cannot wait for first use for less either, because a Loader holding it is itself one object, and it runs only while its spinner is visible. One more is ui/List.qml's CursorMark, the one QtObject that files each delegate by listing row so a cursor move relights only the row it leaves and the row it reaches; it is built with the first list, and it replaces the cursor binding every live delegate re-ran on each key. The release-line join of the rail's lit place and the Quick Look warm loader adds these to the count: ui/Sidebar.qml's three Connections relight the place that holds the current folder when its listing lands, ui/SidebarRow.qml draws the keyboard cursor as its own frame, and ui/WindowBody.qml's warmLoader stays inactive until the cursor first lands on a file. ui/PaneRail.qml's dwell Timer, built with the rail, shows a hidden rail's edge only after the pointer rests there; the Dual divider's Loader in ui/WindowBody.qml takes the place of the Rectangle it replaced, so it adds none. The Q9 and Q10 contrast join adds none: the menu cursor is one fill (RULINGS 141), so ui/MenuRow.qml builds no frame Rectangle beside its menuWash. ui/TabBar.qml's addFrame Rectangle, the keyboard frame of the New tab mark, is built with the strip because the mark is there from the first window (a single tab shows no tabs, so a tab's own tabFrame is built only with the second tab), and a Loader holding it would be one object too, so deferring it removes none. The press-keep fix (0130e7b35) adds ui/PaneStates.qml's passive PointHandler, the mark of a held button, which is built with the first pane, and a Loader holding it would be one object too, so deferring it removes none. The stage5 dry join adds none: no Loader is built per list row, and a Loader in front of ui/RowDrag.qml's DropArea would add one per list row and three here (631 on that day's tree, before the context menu waited for the first frame), so the limit does not rise for it. The cross-window tab drag had raised it from 702 to 714, twelve more by the suite's own count, all built in every window. ui/WindowBody.qml holds the window's DropArea and the tabAck Loader; ui/TabBar.qml holds the strip's DropArea, the ackTimer and tabDropDeadline Timers, the takenAck Process, the dragOrigin Item and one TabDragGeometry with its query Process; ui/boot/fleatab.qml holds the catcher's IpcHandler and its two Connections. Seven of the twelve cannot wait: the two DropAreas, the tabAck Loader and the catcher it holds must be live before any lift, because a drop from another window arrives unannounced, and dragOrigin is the strip's declared Drag.source. The other five (the two Timers, takenAck, and the geometry with its query) could be built on the first lift. Deleting those five was measured on 2026-10-03 (offscreen, THP off, Pss_Anon, medians of ten against a control of the same batch) and returned 62 kB of 44 MB against a run-to-run sigma of 240 kB, so they stay plain objects rather than gain a loader that buys nothing measurable. The window as a whole costs about 0.6 MB more there than at 759fd8eb (43,536 to 44,298 kB, +631 paired, and +486 and +696 in two more batches), 0.37 MB of it at the tab drag's merge, and no deletion variant located that in the instances, the JS libraries or a per-row handler. A change that raises the count names each new object here, and for each one either says why it cannot wait for first use or gives the measurement that shows waiting returns nothing. The flasher's zip probe adds one more: ui/Pane.qml's backend Connections carrying onFlashImageResult, which cannot wait for first use because the cursor and listing signals it answers fire from the first listing, so a lazily built probe would miss the first zip under the cursor. The menu rows are drawn as v0.3.9 again (RULINGS 147): the cursor and the picked row are the one menuWash fill in ui/MenuRow.qml, with no frame Rectangle, so the rows add none. Before menu deferral, the suite printed total=628 against startupObjectLimit 628.

The first-frame unit gate (UNION5, 2026-10-10). Every QML and JS unit loaded before the first rows costs every launch (the entry's qs: units compile each time, and the file: units from ui/WindowBody.qml on compile on a cold cache and load from it after), and the pair of the union1 head 3601db54 against int c7e23485 changed rows.10k.warm.content (+8.5 ms) and mem.listleg.cpu (+0.02 s). The census (startup-objects in the flea-ci image, three runs per arm, qt.qml.diskcache lines) found int at 178 units and 1,564,070 bytes, the union at 179 and 1,602,504: the one new unit was ui/DropKeys.qml, the #259 key store, compiled because RowDrag, DropInto, FileDrag and the pane's key handlers named the singleton, though only a drag uses it. So the record is now the keys object in ui/js/DragOut.js, a library every one of those files already imported (ui/Pane.qml gains the import), and the singleton and its qmldir line are gone; the collision host is built by the pane's first asks() (Qt.createComponent, as FlashHost is), owned by the pane so a closed tab takes its card, and refusing loudly with the load error if the component cannot load, so pane.collide is null until a transfer asks, its readers check it, and a merge's terminal line goes through the host while every other line stays Ops.transferDone; the archive cards' titles, sentences and leaves moved to ui/js/ArchiveCard.js, which only the cards import. tests/startup-objects.sh now pins Flea's own units the probe loads by file: URL before its rows settle (its cache is empty, so each logs a miss; the qs: ones are the probe's scaffold and Omarchy's Commons and Ui), by name and summed bytes, against tests/startup-units.txt: a unit added or dropped fails with the list, and bytes over the bytes line fail; the line is raised only with a reason here. Red on 3601db54, green on this head at 177 units and 1,606,443 bytes (union2's tags, cursor and bulk rename included). startup-objects counts 620 against its 623 limit, one Loader fewer. Ceilings, each from wc -l: ui/Pane.qml 1111 to 1116 for the DragOut import and the five-line asks() that checks the component, tests/js/focus.js 745 to 746 for the fake pane's asks. The press-keep fix raises the bytes line from 1652484 to 1653139: the pointerHeld property in ui/Pane.qml, the PointHandler and its comments in ui/PaneStates.qml, the liftEnded clear in ui/FileDrag.qml and the read in ui/js/Anchor.js, all first-frame units.

Compare stage settle (2026-10-13). The marked indices, held rows, own kinds, snapshot kinds and Compare activation live in ui/js/Ops.js, which already owns target indices and paths and is imported by both ui/Pane.qml and ui/js/Focus.js; those callers no longer import ui/js/Compare.js. The overlay's sentences, titles and diff parsing stay in ui/js/Compare.js, imported by the lazy ui/CompareView.qml and its tests. Before the capture readers were added, the Compare branch kept 175 first-frame units in tests/startup-units.txt with no js/Compare.js entry, and its bytes line was 1,683,012, re-derived with the wc -c sum in tests/startup-objects.sh, down from 176 units and 1,686,115 bytes before the split; before menu deferral, the measured object total and startupObjectLimit were 628. The moved helpers take the ui/js/Ops.js ceiling from 539 to 577 lines, re-derived with wc -l. Before menu deferral, both ContextMenu allowance profiles retained the earlier rows shifted by the same two-line delta, with the quickshell-present profile measured by the gate. The Compare capture case reads the overlay through compareState, while the design CI code-wrap and preview-column paging cases read the code viewports through previewTextState; after the Compare capture merge, these read-only readers leave ui/Ipc.qml with a current count and ceiling of 985 lines, re-derived with wc -l (983 before the merge).

On the stage, the committed bytes line in tests/startup-units.txt rose from 1,678,085 to 1,678,950 for the merged eject rules, then to 1,683,012 when the merged Compare helpers moved into the already-loaded ui/js/Ops.js; the preview paging keys then added 537 bytes to ui/js/Keymap.js and 167 to ui/js/Focus.js (1,683,716), the execute marker in ui/Row.qml another 24 (1,683,740), and the Markdown Source merge 152 more in ui/js/Focus.js for its Source wrap guard (1,683,892). On the Compare branch the capture readers raised the same pin from 1,683,012 to 1,685,828 (+2,816), with 198 added bytes in ui/Ipc.qml and 2,618 in ui/IpcPreviewState.qml; the Compare capture merge adds those 2,816 bytes to the stage's 1,683,892, so the bytes line after that merge is 1,686,708 across the same 175 units, each step re-derived with the wc -c sum in tests/startup-objects.sh. The Quick Look code reader fix, live Compare kinds and the menu diagnostics add 531 bytes (ui/IpcPreviewState.qml -5, ui/IpcMenuState.qml +463 and ui/ContextMenu.qml +73), taking the pre-deferral line to 1,687,239 over the same 175 units, re-derived with the same sum. The two ceilings in tools/flea-file-budget move by wc -l: ui/ContextMenu.qml 783 to 784 before menu deferral and tests/quicklook-code.qml 424 to 437.

The HTML preview keeps the same 175 first-frame units and the object limit of 628. Its lazy bodies and QtWebEngine helper are absent from that unit list. The first-frame byte sum in tests/startup-units.txt is re-derived from 1,686,708 to 1,688,583: ui/Backend.qml adds 346 bytes for the signal and request, including Quick Look viewport height, ui/Ipc.qml 98 and ui/IpcPreviewState.qml 1,049 for the observer, ui/js/Focus.js 25 for the HTML key guards, ui/js/Icons.js 80 for the HTML mark, ui/js/Kinds.js 70 for HTML recognition, ui/js/Messages.js 72 for reply dispatch, and ui/js/PreviewKeys.js 135 for rendered-page scroll routing. ui/ViewState.qml adds no document-view reader. The sum is the same wc -c calculation the startup gate uses. Markdown and HTML Quick Look bars draw the language mark, filename, line count and close, with no view control. Both open rendered; r flips only the open Quick Look, follows file moves while it remains open and is forgotten on close. The preview column always requests rendered documents. Neither document view is a stored preference.

The HTML helper loads file:///page/<the original filename>: the sandbox binds the confined input over that file inside the read-only page folder, so relative URLs and the document's own base keep their resolution. The policy limits base URLs and local resources to that folder and injects no base element. A leading UTF-8 BOM, whitespace and complete comments stay before the doctype; its first > ends it even inside a quoted identifier, as WHATWG 13.2.5.58/59/64/65 specifies. A cached PNG requires the HTML key and every engine-recorded local dependency to match. The bounded sidecar holds at most 256 resource entries and 64 KiB, with page-relative paths, sizes, nanosecond mtimes, inodes and devices; missing, unreadable, malformed or changed records force another render. Hit validation and refresh share one process-wide lock with eviction's final recheck and unlink. Quick Look requests a viewport height scaled to the render width, and that height is part of the cache key, so a short page covers the frame to its bottom edge. Column requests retain the helper's 600 px default. The height field adds two lines and 86 bytes to ui/Backend.qml, re-derived with wc -l and wc -c; the first-frame unit list and object ceiling are unchanged.

Unlock, after its fidelity critic (2026-10-10). The board keeps one name for an encrypted drive: the unlocked row, the folder's footer and the card all say "Backup". An unlocked crypt now takes its container's label when the owner named the container (Devices.containerName), so the row reads as its locked row did and as the footer says; an unnamed container's volume keeps its own filesystem label, as before. flea --unlock opens before the rail's first lsblk, so the card shows the device's leaf until a locked row names it: the rail's row first, else the host's one-shot DeviceMounts poller while the name is pending (Unlock.namingRow), stopped after its first listing and never started once the card is touched, taken only while the card is still untouched at its first typing state. The board draws the masked dots through Unlocking and a wrong passphrase, but the field still empties on submit: tests/unlock-stdin.qml sweeps every property for the passphrase after a submit, a refusal and a failed mount, and a field that kept its text failed six of its checks, so the field empties on submit and keeps only the submitted length as field.heldMask, and those two states draw that many of the input's own mask bullets until the retry's first edit clears them. Tab walks field, Cancel, Unlock; submitting moves the keyboard to Cancel, the one stop while unlocking, where Unlock drops out with the field and the ring is Cancel's own focus. A refusal returns the keyboard to the field, and closing returns it to the window's pane. ui:cap_unlock asserts the Tab walk, the empty field after a submit and the dropped keyboard natively, the last on the right passphrase submitted from the Unlock button, since a refusal can end inside one poll; tests/js/unlock.js reads the Unlocking stop list from the source. The unit gate now reads only the log lines before the probe's STARTUP_OBJECTS TOTAL, because the probe opens Quick Look and starts the power-off chain after it, so eight units it pinned were never the first frame's (Preview.qml, PreviewArchive.qml, PreviewText.qml, MediaStrip.qml, PowerSectorsReader.qml, js/Facts.js, js/PreviewSettle.js, js/PreviewSwap.js); the pin is 169 units and 1,543,154 bytes, Devices.containerName and the Ipc reader's fields included. The probe builds the IPC seam (Ipc.qml, IpcMenuState.qml, IpcPreviewState.qml) before that line, so it stays in the list though the product builds it only for its first IPC call. Ceilings, each from wc -l: ui/js/Devices.js 395 to 401, ui/Ipc.qml 927 to 931, and ui/UnlockDialog.qml 420 to 439, re-derived with wc -l, for the submit focus move to Cancel and the retry edit clearing the error frame and note plus the held mask count kept on submit and cleared on open, close and success plus the five-line capture-harness alias block and the three-line capture state in the frame JSON. The one-shot name poller keeps its first listing after its Loader ends and polls again when the timeout ends a listing unread, which raises the first-frame bytes line to the measured 1,543,443.

Union2 clean-up (2026-10-10). The parked Back's wait is ui/ParkedWait.qml, built by Qt.createComponent on the first park as HoverTimers.qml is, in place of a Timer parsed from a QML string, with its own qmldir line, and a load error throws with the component's error as the collision host's does. The backend answers tagstates on tagStatesResult and settagged on settagged, one signal per message as every other route in ui/js/Messages.js, and both sample lines sit together in ui/Backend.qml's block of sample inputs, which takes it from 506 to 508 lines. tests/js/tags.js routes both messages through Messages.route, so a swapped branch fails there. The first-frame bytes line is the measured 1,543,216, ui/PaneSwap.qml's load check included.

Predictable path writes

dest sits in a shared runtime directory, so its path is guessable and could be pre-planted as a symlink. write_prewarm writes to <dest>.<pid>.tmp: it unlinks any leftover of its own first, then opens with O_CREAT | O_EXCL so a planted path cannot be followed, and the pid in the temp name keeps two concurrent launchers off each other's file. The file is created at mode 0o600 in the open() call itself, never chmod'd afterward, so there is no window where it is readable by anyone but the owner. fs::rename runs last, so a reader only ever sees a complete file or none at all. On any failure, only the temp file is removed; dest is left exactly as it was, because unlinking a caller-supplied path is not this function's job even when that path came from a broken caller. The exit status is the contract: a caller must check it before trusting whatever dest currently holds.

The gvfs outcome file beside it is the same shape with a claim on top. flea --launch-warm's gio half writes either gio's listing bytes (hidden included, so either scan can adopt) or the one-line failure marker flea-gvfs-fail, both through its own exclusive 0600 temp plus rename, so a reader never sees a partial. The first backend claims with rename(dest, dest.claimed) and reads the claim, which it keeps; a reader finding the claim, or finding neither dest nor claim more than 2 s after the launch start, returns None at once and the scan takes today's gio path. Staleness is judged against the launch: mtime older than FLEA_GVFS_START is refused, and freshness is age under 10 s against a clock read after the wait, never before it. The sweep reaps dest and claim leftovers by the same 60 s age rule. The helper forks once for both jobs, so the launcher waits only for the fork and qs inherits no zombie; gui.rs exports FLEA_GVFS_* only when that spawn succeeded. The terminal interface takes the same head start: tui/warm.rs runs prepare and the helper with "-" for the page-cache list, and tui/wire.rs passes FLEA_GVFS_* to its backend only when that spawn succeeded. A local or USB launch pays only the is_gvfs prefix check in prepare and arms nothing.

The state file

~/.local/state/flea/ui.json, or $XDG_STATE_HOME/flea/ui.json when that is set and not empty, is the one file Flea writes for itself. src/uischema.rs holds the shipped shape and every default, copied from the 0.1.4 build handoff; src/uistate.rs holds the merges; src/uistore.rs holds the paths, the lock and the write. Two defaults have moved since that handoff. Row density is compact from 0.3.2, a deliberate change. Every write stores the whole document, so a state file any earlier Flea wrote keeps the normal it recorded, and only state that never stored a density takes the new default. tests/ui.sh case icons launches with no ui.json at all and pins those rows below the board's. Settings > Places > "Show unmounted drives", places.showUnmounted, is on from 0.3.3, also deliberate, and that one reaches the files earlier Flea wrote as well, once, through the stateVersion migration below.

One lock, and every write goes through it. flea --ui-state prints the merged document and writes nothing. flea --ui-state '<json object>' merges that patch through the lock and prints the result. The window reaches it from ui/ViewState.qml through a Process; the terminal interface (flea --tui, src/tui) holds a uistore::Store of its own and writes the same file under the same lock, patching its own keys only, because menus and places are the window's. Scale is on neither list: src/uischema.rs has no scale key at all, it stores an Omarchy text-size stop under display.textSize.mode, and the multiplier ui/js/Scale.js applies is a session value. The streams and the status are the contract, because that Process reads the status alone: either shape prints the whole document on stdout and exits 0, and every refusal, a patch that is not JSON, a key or value this Flea does not take, a state file it could not write, or more than one argument, prints one flea: sentence on stderr, prints nothing on stdout at all, and exits 2. Pinned in tests/uistate.sh, both streams for each of the four.

A patch names what that window changed, and nothing else. ui/ViewState.qml holds a second document beside the one it draws from, unsaved, built by the same two rebuilds and starting empty; patch() is that document. It used to render a snapshot of all four keys the window owns, and the lock cannot save that: the merge in src/uistate.rs protects a key the caller leaves OUT, and a snapshot names every key explicitly, so a window that changed only the text size wrote its startup read of keys back over a change another window made after that read. The lock was working correctly and the data was still lost. Running the rebuild over both documents is what keeps them honest in the other direction too: changeLeaf merges a leaf into the group the window DRAWS, so a sub-key a newer Flea left in display or menu stays on screen, and owes the leaf alone, because this Flea has no rule for that sub-key and check() refuses a whole patch that names one. The owed document is a union rather than the newest change alone, so a change made while a writer runs coalesces with whatever is queued behind it; a refused write keeps it owed so the next patch carries it again. A write that LANDS takes its own settings back out, leaf by leaf, the moment it exits, and never when the queue happens to drain: waiting for the drain left a setting that was already in the file still owed, so it rode along inside the patch queued behind it and overwrote whatever another window or the CLI had put there in the meantime, which is the same lost update one step later in time. The queued writer is therefore launched with what is owed when it starts rather than with the bytes that were waiting, and a setting whose value changed again while the writer that carried the old one ran is NOT taken out, because the file does not have the new one. A setter that lands the value already held owes nothing at all. tests/uiwriter.sh drives two windows over one state file both ways round and holds a queued writer at the door while the CLI writes under it, and tests/js/uistate.js pins the patch bytes.

The three map-valued keys merge per entry. A folderSorts or folderViews patch entry names one folder and a columnWidths patch entry names one column edge, so one window's write keeps what another wrote since its read. A null folderSorts or folderViews entry forgets that path instead, and only a patch may send one. A re-sort or re-view moves its folder to the newest end of its map. Past either folder map's 500 cap the oldest entries go and the newest stay.

With per-folder sorting enabled, Downloads, Pictures and Videos open in Modified order, newest first, unless their folderSorts entry chooses another order. These paths use user-dirs.dirs under XDG_CONFIG_HOME (or ~/.config), with the conventional XDG names for missing roles; a role pointing at Home is excluded. Browser listings, column peeks and the file chooser resolve the same defaults without storing an entry or using a slot in the 500-folder cap. Explicit Name ascending remains a stored choice. Turning per-folder sorting off uses the global order instead. Other folders and the terminal interface retain their existing sort behavior.

The window's read is the settled file, and not a raw one. main() calls Store::settle before it hands off to qs: an empty patch through the same lock and the same per-key validation, so whenever that settle succeeded on a document it could read, a value a hand edit left in a key this Flea knows has already fallen back to its own default by the time ui/ViewState.qml's FileView reads it. A key this Flea does not know is not one of those: merge keeps it verbatim, so a newer Flea's settings survive the settle rather than falling back to anything. Without the step the two front ends answer one file two ways, because the window's read is a JSON.parse and applies no rule of its own. The settle can fail, on an unwritable state directory or a ui.json that is a link, and then main() prints one line and opens the window anyway on a file it did not validate. The migration is read()'s and not settle's: read() falls back to view.json exactly while ui.json is absent, so every path that reads reaches it, flea --ui-state included. Measured, a first flea --ui-state '{"view":"grid"}' on a box with view.json and no ui.json writes the migrated columns, the same ones the settle writes, and leaves view.json byte for byte. settle is only where the migration is first written down on a launch that never patches anything.

A launch never spends the file to settle it. A launch with neither file writes nothing, the way a first run always has. Neither does one whose ui.json already reads back as exactly what a rewrite would render, because that rewrite would change nothing: the lock, the temp, the sync_all and the rename measured 6.7 to 18.6 ms a launch on this box, against 1.3 to 1.7 ms for a launch that only reads, which is real money against a 77 ms startup. Both are per-launch times of flea --gui driven to the missing shell, 100 launches a pass over eight passes of two harnesses that differed only in how ui.json was reseeded between launches; the read figure has the separately timed reseed subtracted, and the write figure's own eight passes spanned that whole 6.7 to 18.6, which is why each is a range here and never a number to cite. And neither does a launch whose ui.json cannot be read as a JSON object at all: that file is the only copy of whatever the operator wrote, both front ends already read it as the full default shape, and rewriting it would spend the operator's settings to close nothing, so it is left byte for byte and ui/PaneWire.qml says once that what is on disk was not used. That is the settle and not the file. read() answers the full default shape for such a document, so the first patch the window sends merges onto those defaults and renames them over the operator's only copy, every hand-written line included. Deliberate, because the sentence has already been posted and a save has to land somewhere, and pinned in tests/uistate.sh so the next change to update() is visible rather than silent.

A ui.json whose bytes cannot be read at all is refused, and that is a different case. The paragraph above is a document this can read and cannot parse. A regular ui.json whose bytes read() cannot get at all is the third instance of the data loss this release closed twice already: read() answers the shipped defaults for it too, and update() used to rename those defaults over the only copy of what the operator wrote. chmod 000 is one way in and one sudo flea is the other, because the rename needs write on the directory and never read on the file. read() still never fails, because a front end reads it before the first paint; the distinction lives in refuse_a_bad_target, which write() calls before it creates the temp, so a link, a device and a file this cannot read are all refused with one sentence naming the path and nothing is renamed over any of them. tests/uistate.sh pins both ways in, the exit status, the sentence and the file byte for byte after, with a first run beside them to prove the guard did not close the write that legitimately creates the file; tests/ui.sh settings_read_refused drives the same file through the window, where the operator is told twice, once by ui/PaneWire.qml that what is on disk was not used and once by the status bar that the setting was not saved.

A refused write reaches the operator. ui/ViewState.qml records a patch as stored only when flea --ui-state exits 0. ui/js/UiState.js holds that bookkeeping, and ui/PaneWire.qml turns the failure into the status bar's one transient sentence through ui/js/Errors.js, the same slot every other write operation reports in. The status alone is read and not the child's stderr, because a Process's own onExited can race its StdioCollector's text. A patch that was refused is attempted again the next time the same toggle is made, rather than short-circuited by a book that already believed it. A writer that never starts is that same refusal. Measured on Quickshell 0.3.1: a Process whose program cannot be run emits no exited at all, only running going false, so ui/ViewState.qml reads that transition as a status of 2. Without it the patch stays in flight for the life of the window, every later change queues behind it unwritten, and the one failure this module exists to report is the one it cannot report. A real exit clears the book before its own running goes false, and a restart inside onExited emits no false transition at all, so the handler cannot fire for a writer that ran. tests/uiwriter.sh drives both.

The lock is the sibling ui.json.lock. File::lock() is flock(2) here: advisory, exclusive and cross-process, held across the re-read, the per-key validation, the caller-key merge, the temp write and the rename, so a second Flea cannot land between this one's read and its write. Measured on the box with the lock taken out, twelve concurrent writers landed 1 of their 12 keys; with it in, 12 of 12. update() makes the state directory and takes the lock before patched() validates, so a patch this Flea refuses leaves both of those behind and only never writes the state file itself. The cost is an empty ~/.local/state/flea/ and a lock the next accepted save uses anyway, which is why the check says what it leaves rather than the code being moved to leave nothing.

Failure is per key, and per file only when the document does not parse at all. That one case is per file by construction: a document that does not parse reads as the full default shape rather than throwing, and is left on disk exactly as it was rather than rewritten into that shape, so every key in it is lost to the read at once. Every other failure costs one key: a value a key cannot take costs that key alone, and every other key in the file stands. A key this Flea does not know is kept and rewritten as it was read, at the top level and inside a nested object, so an older Flea cannot eat a newer one's settings. A patch is the other way round: it is checked whole before any of it lands, and one bad key refuses the whole patch with a sentence naming it, because a patch comes from Flea and not from a text editor. A number is measured against JSON's own grammar and not Rust's, because Json::Num keeps the literal it was read with and writes it straight back: 1., 1.e5, 0192, -0192, 01, 00 and -.5 all parse as an f64 and none of them is a JSON number, and accepting one would have let a settle rewrite ui.json into a document the window's own JSON.parse refuses, after which every setting reads as default. The shape check is is_json_number in src/jsondoc.rs, run before the f64 parse and not instead of it: the parse is what bounds the magnitude, since 1e400 is a JSON number and no finite f64. A \u escape is four hex digits and nothing else, each digit checked on its own, because u32::from_str_radix takes a leading + for an unsigned type as well and \u+041 decoded to A: the same divergence as the number grammar, one escape further in. Every parse error names a byte, the four that ran off an end included, so a hand edit is answered with a position. It is the byte the parse stopped at, except for the six messages that name where the offending token STARTED instead, because on those that is the end a reader can act on: a string that runs to the end of the document names its opening quote, a number that is not JSON or not UTF-8 names its first character, a byte that is not UTF-8 names the first byte of the sequence, and a \u escape that is short or not hex names the first of its four digits. malformed_input_is_an_error_naming_the_byte_it_is_about asserts the whole sentence rather than is_err(), which is all it could see before, and it pins a nonzero offset for each of the two messages this branch added, because byte 0 is what a hardcoded format string would print too.

columns names what the list row SHOWS, and it is a set that always holds name. Every entry is one of the five column keys, no key appears twice, and name is among them, because src/uischema.rs's own row says name is never optional and ui/js/Columns.js draws it whatever the file holds. A duplicate is not harmless: measured through the real ui/ViewState.qml singleton, a stored ["name","size","size","date"] left one header-menu "Hide Size" click still drawing name,size,date, because toggleColumn splices the first match and the second still shows the column. The header menu offers only the four optional keys onto an array that already carries name, so no click this window can produce is refused by the rule.

wrapAtEnds is read by the window and by nothing else. ui/Pane.qml exposes it off the document ui/ViewState.qml already holds, and ui/js/Focus.js step is its only reader: with the key off a cursor step past an end clamps, and with it on a step taken from an end comes round. The key exists because issue 27 asked for the wrap, and it ships off because a second user reported that same jump past the top as a bug.

pickerView is the file chooser's own last-used view and nothing else. It is "list" or "grid", default "list", deliberately separate from the main window's view, so a grid picker never flips the browser and a list browser never flips the picker. The picker reads it once at open and writes it when the user switches view, never per cursor move; a stored word it cannot draw costs the key its default, like every other key.

menu.hidden stores what is hidden, and its rule is deliberately open, an action id rather than a closed list, because a closed list would make this Flea drop an id a newer one hid. It is the Menus section's whole visibility state: the panel's master row over the six basic actions is derived from the set every time it is drawn, so there is no second value for a hand edit to leave it disagreeing with. An id here is the action id ui/js/Menu.js gives the row and never a second name for it. The shipped set named Open in terminal terminal while the menu built the row as openTerminal, so applyHidden matched nothing, the row drew in every menu whatever the setting said, and the panel carried no switch to say otherwise; both sides now read openTerminal.

keyHints is the Menus section's one row that is not an action. It governs presentation across two surfaces: ui/MenuRow.qml's key column, whose width goes with its text so a menu with hints off reads exactly as it did before that slot existed, and the tip ui/WindowBody.qml draws under an empty directory. It ships off. A hint is only ever ui/js/Keymap.js hintFor, which is generated from keys.toml, so no surface can advertise a key nothing is bound to, and no chord depends on the setting: the keymap is read by Focus.handleKey and this value is read by nobody in that path.

A dead menu row dims as a whole, key hint included. ui/MenuRow.qml draws an unavailable row's key hint in the label's muted ink at the label's 0.55 opacity; only a reason sentence (hintWrap, Extract's "bsdtar is not installed") stays at the foreground, full opacity, because the sentence is the row's content. An available row's hint keeps the label ink it has had since 0.3.7. tests/menu-card-sink.qml pins the three. The Copy as switch governs two different rows. The file menu shows the Copy as flyout; a Places or Favorites row's menu and the Locked tile show the flat Copy path of 0.3.7 (one click, that row's own path), because MenuAdditions040 replaces the file menu's row and draws no place menu. ui/js/Menu.js availableEntry turns the copyAs spec into copypath for kind P, and src/uimigrate.rs already carries a user's old copypath choice to the one switch.

display.textSize.mode is "system" or one Omarchy stop, one of 9, 10, 11, 12, 14, 16 and 20. It is one key and not two, so there is nowhere for a free number to be stored.

The write is a temp plus a rename, never a truncation, created at mode 0600 in the open() call inside a directory created at 0700, and a symbolic link at either the file or the lock is refused rather than written through. The temp carries the same guarantee and by the same mechanism as src/userfile.rs: write_new opens ui.json.<pid>.tmp with create_new, which is O_CREAT | O_EXCL, so a link or a file planted at that path is an error rather than a redirect, and the pid in the name keeps two concurrent writers off each other's temp. The direct proof is the_write_replaces_the_file_rather_than_truncating_it, which holds an fd open across an update, reads the old bytes back through it and compares inodes. The sweep beside it fires a SIGKILL 1 to 9 ms into each of 120 rounds and lands on a live process in 88 to 120 of them, measured across nine sweeps on this box and four more with other lanes live on it, the second set ranging 92 to 120; no round of the 120 has ever left the file as anything but the old document or the new one. The timed sweep's kill count is printed as a diagnostic and never asserted, because a faster box finishes more writes inside its 1 to 9 ms budget. The proof that a kill never leaves a partial ui.json is the three deterministic stage kills in tests/uistate-deterministic.py: before the temp, inside the publication, and after the rename. Each stage witnesses a live writer, requires SIGKILL termination and compares ui.json with the complete before or after document. A killed round leaves litter: write() unlinks only ui.json.<own pid>.tmp, so a process killed between write_new and the rename leaves that temp for good, and tens of the 120 rounds do, 47 to 90 across the four runs that added the check, which is a magnitude and not a number to cite. Nothing reaps them and nothing may: the concurrency block above holds twelve live temps at once, so no process can tell a peer's from a corpse, and deleting one in flight is worse than the litter. The suite lists the directory with ls -A and asserts that every leftover is a killed writer's own temp and that there are never more of them than there were kills.

0.1.3 did store something, and it is migrated. ui/ViewState.qml wrote hiddenCols and uiScale to $XDG_CONFIG_HOME/flea/view.json, so the handoff's "0.1.3 stored nothing" is wrong. hiddenCols named what was HIDDEN and columns names what is SHOWN, so the migration inverts it; uiScale is dropped deliberately, because 0.1.4 stores an Omarchy stop and never a free multiplier. The migration rides read() above, so the first paint after an upgrade already reads the migrated columns, whether the settle or a flea --ui-state patch was what wrote them down. A ui.json that exists means view.json is never read again, whether or not this Flea can read that ui.json's bytes, and view.json is never written again.

stateVersion is the record of every one-time migration, and it lives in ui.json itself. A changed default alone reaches only a fresh install: every write stores the whole merged document, so nearly every 0.3.2 file holds "showUnmounted": false whether or not its operator ever touched the switch. The switch is turned on for everyone once and respected after that, so a user who turned it off on purpose in 0.3.2 gets it back one time, an accepted cost. src/uimigrate.rs holds STEPS, each change paired with the version it arrived in, and today there are two: version 1 sets places.showUnmounted to true, and version 2 hides the MenuAdditions040 rows once for a file stamped below 2, replacing a hidden copypath with copyAs at the same position (a file that had switched Copy path on keeps Copy as visible) and appending pasteAs and invertSelection when absent, leaving every other entry and its order alone. migrated runs inside uistate::from_file and reads the stamp off the document as FOUND, never off the merged one, because the merge fills a missing stamp from the shipped default and would call every 0.3.2 file migrated. A file whose stamp is absent, not a number, or below a step's version gets that step and that step's stamp; a file at or above it gets nothing. The shipped document is already stamped 2, so a fresh install's first write is stamped too and its own switch-off is never undone. It happens on read. from_file is under every reader, Store::read, flea --ui-state with or without a patch, the TUI and the base every patch merges onto, so a read answers the switch on before anything is written, and a bare flea --ui-state still writes nothing. It is written down by the settle, the write-on-migrate place the launch already had: left_as_it_is compares the file with its own from_file rendering, an unstamped file no longer matches it, so the first 0.3.3 launch of either front end writes the migration once, and every later launch reads a stamped file that renders to itself and writes nothing. The window's FileView reads that settled file, which is how the rail shows the drives on the first 0.3.3 launch. A launch whose settle fails opens on the raw file, which still says off, until the next write lands the stamp, the same limit every other rule here has when the settle fails. Concurrent writers cannot lose it. Each one re-reads under the lock, so whichever of two windows, the TUI or the CLI writes first migrates and stamps, and every writer after it reads a stamped file; a patch names only what that front end changed, so the operator's own switch-off lands on top of the migration whatever the order. Rule::Version keeps any whole number, so a newer Flea's higher stamp survives this one's rewrite, and check() refuses a patch that names stateVersion at all, so no front end can rewind a migration a file has had. A 0.3.2 binary keeps the stamp as an unknown key, so a downgrade and an upgrade again do not run it twice. The window reads an absent showUnmounted as on: ui/Sidebar.qml and the rail rows of ui/js/Settings.js read !== false, because the window applies no schema of its own and a first launch has no file to read. A later migration appends one (version, step) pair to STEPS, raises "stateVersion" in src/uischema.rs DEFAULTS to that version, and changes only the leaves it names. src/uimigrate.rs's own tests drive a 0.3.2 file through a read, a write and two settles, a stamped file switched off, a fresh install switched off, unknown keys, a patch onto an old file, and six threads writing one old file under the lock; tests/uistate.sh drives the same through the binary, tests/ui-rail.sh case unmounted opens a raw 0.3.2 file in the window and reads the rail and the stamp the launch wrote, and tests/ui.sh case eject seeds the switch on, so the mounted volume's menu is Open, Unmount, Eject and the case proves Ctrl+E and the menu both release it.

Modes

main.rs dispatches on argv before anything else runs, but only --backend is fully insulated from the flag parsing below: it is matched anywhere in argv and always wins. --prewarm, --open and --terminal are matched only in their exact well-formed shape, args.len() == 5 for the first and args.len() == 3 with the flag in argv[1] for the other two, so a MALFORMED one is not caught here at all. It meets its flag's own usage error just below (--prewarm takes a path, a first index and a destination, --open takes one path, --terminal takes one directory) and exits 2; flea --open with no path and flea --open a b are both that case. --open takes exactly one path and exits 0 on a successful handoff, 2 on anything that could not be opened, carrying one elided sentence, and 3 where the resolved target is a directory, carrying no output at all; the window's path adds 4 and 5 below. A directory is refused rather than handed on because the desktop default for inode/directory is a file manager either way, org.gnome.Nautilus.desktop on an unclaimed box and com.thisisgm.flea.desktop once --default has claimed it, so handing one on opens a file manager from inside a file manager; the caller navigates instead. On the main.rs --open path two more answers ride beside that contract: 4 asks the run card, with one JSON line on stdout carrying the kind, the size, an AppImage's handler id and name, the R3 identity with dev and ino as strings, and the resolved target, and 5 asks Open with where no application claims the file; in-process callers (the TUI and the shelf) stay on 0/2/3 and refuse an executable program with a sentence instead of asking. On every path an ELF or AppImage missing its run bit is refused with 2, and a script missing its run bit opens with its handler the way it did before 0.3.9. See "Opening a file".

--terminal takes exactly one directory and has a two-value contract: 0 is a successful handoff to xdg-terminal-exec, and 2 is everything else, carrying one elided sentence on stderr. A path that does not resolve and a path that resolves to something other than a directory both answer that directory could not be opened in a terminal, check that it still exists; a handler that could not be run at all answers nothing on this system could be asked to open a terminal there, so the pair tells a refusal from an unimplemented mode the way --open's does. There is no third status: a terminal has no IS_DIRECTORY case to report. See "Opening a file".

--update and --update check are matched in their exact shapes (args.len() == 2, and args.len() == 3 with args[2] == "check"), and a third check catches any other argv whose args[1] is --update with the usage error --update takes nothing, or check. --update check prints one line and has a three-value contract: 0 is an update the installing source can deliver, 3 is nothing to install (up to date, or a build no release describes), and 2 is a check that could not be made, which still prints its line and adds one sentence on stderr. --update alone has --terminal's two values: 0 is a handoff to Omarchy's updater and 2 is a presenter that could not be run. See "Updates".

--ui-state is matched on args[1] alone and handles its own shapes: none reads the state file, one merges that JSON object through the shared update path, and anything more is a usage error. See "The state file".

--unlock <device> is matched in its exact shape (args.len() == 3), execs the window with FLEA_UNLOCK naming the device, which qs_command removes from every other launch, and exits 2 with one flea: sentence on any other argv, a path outside /dev, a missing display or a missing UI directory.

--clip and --clip-own are matched in their exact shapes. A malformed --clip leaves by the usage error --clip takes get, set copy|cut, or clear TOKEN and --clip-own with anything after it by --clip-own takes nothing, both with exit 2. --clip is the terminal and test seam over the system clipboard the backend's clipSet, clipGet and clipClear requests use (docs/protocol.md has their shapes). --clip get prints the clip reply line on stdout and exits 0; a failure prints the same line with ok:false and an error sentence and exits 2. --clip set copy|cut reads NUL-separated absolute paths on stdin (at most 64 MiB), prints the 32-hex-char token and exits 0; a refusal prints one flea: <sentence> line on stderr, nothing on stdout, and exits 2. --clip clear TOKEN has --clip get's shape with the clear reply line (cleared says whether anything was cleared), and an empty TOKEN is a flea: sentence on stderr with exit 2. There is no --clip watch: the watcher is the backend's clipWatch request alone. --clip-own is the detached owner behind one copy and not for direct use: it reads <op> NUL <token> NUL <path> NUL ... on stdin, offers every clipboard type, prints ready, then points its stdio at /dev/null so the copy outlives the pipes of the process that started it (setsid gives it a session of its own). It exits 0 when a newer copy cancels it, and 2 for a payload it refuses (one flea: sentence on stderr, before ready) or a compositor connection that is lost.

--default and --default off are matched the same way, in their own exact shape (args.len() == 2, and args.len() == 3 with args[2] == "off"), dispatching to main.rs's own claim_both() and release_both() rather than straight into defaults: a box updating from 0.1.3 carries a Flea with no chooser routing at all, so one command finishes the job. Like --prewarm and --open, a malformed --default does not fall through to the unknown-flag branch: a third check catches any argv with args[1] == "--default" that matched neither shape and names its own usage error, --default takes nothing, or off, before exiting 2. defaults::claim() refuses and writes nothing when Flea's own desktop entry, com.thisisgm.flea.desktop (defaults::DESKTOP_ID), is not installed under $XDG_DATA_HOME (or ~/.local/share) or any of $XDG_DATA_DIRS (default /usr/local/share:/usr/share): the packaged entry is the proof the pacman package landed, and pointing xdg-mime or Hyprland's bindings at an uninstalled binary would be a claim on nothing. Past that check defaults::claim() runs three independent halves, each one line of output through defaults::report(), which takes exactly those three and prints every one whatever the others did: the inode/directory MIME default via xdg-mime, the user-level D-Bus registration described in "Show in folder", and the additive, markered block hyprkeys::claim() adds to Omarchy's ~/.config/hypr/bindings.lua for the two file-manager chords. The first and third rewrite existing per-user files through userfile::replace_file (see "Predictable path writes"); the second creates its own file with userfile::create_file, so a symlink planted at that path is refused rather than followed.

The registration half refuses rather than skipping. claim_service() reads its Exec out of the packaged com.thisisgm.flea.FileManager1.service found on the same XDG ladder installed_entry() reads, and with none installed it returns an Err naming what is missing rather than writing a path of its own. That Err makes defaults::claim() non-zero, so claim_both() stops before the chooser step. The box that reaches it is the same one the chooser step skips: a cargo build binary run against a package that predates 0.1.4.

The chooser step is the one that skips rather than fails, and no other step does. Past the handler half, claim_both() asks chooser::backend_installed(), and with no flea.portal in any portal directory it says no portal backend is installed, so the file chooser step was skipped on stderr and counts that as no failure: that is what a source build gets, because only the pacman package installs that file. With one installed it runs chooser::claim() too, which writes its one key into the portal file the session reads, ~/.config/xdg-desktop-portal/portals.conf or the desktop's own hyprland-portals.conf when one exists, and a second markered block in the same ~/.config/hypr/bindings.lua. So a full --default touches four files, not two: ~/.config/mimeapps.list, ~/.local/share/dbus-1/services/org.freedesktop.FileManager1.service, ~/.config/hypr/bindings.lua and that portal file. A refused handler claim stops the command there, so the chooser half never writes behind a step that wrote nothing. release_both() is unconditional and runs every step back, each half a no-op when it was never claimed; no half's failure blocks another, see defaults::report and chooser::report. Running a step back is not always restoring it. The key block and the picker's window rule are marked blocks, the chooser routing is one key, and the registration is a whole file of Flea's own, so cutting them leaves what was there before; the handler half is release_mime(), which only calls drop_default() to delete Flea's line and then reports what xdg-mime query default answers next. Nothing persists the was <id> that claim_mime() printed, so a handler the operator had pinned is never written back, and on a box that had one flea --default off leaves a [Default Applications] section that no longer names it. The undo both with: line belongs to the invocation and not to a step, so main.rs prints it once, after the steps it ran, and --default off prints none at all.

--youleftmeforstrata is a second, undocumented spelling of --default off, matched in its own exact shape and dispatching to the same release_both(). It is deliberately kept out of usage(), README.md and docs/install.md, and it is written down here because this file is the internal contract: it is state-changing, so anything auditing the mode list has to know it exists.

--picker and --picker off are matched in the same two exact shapes as --default, with the same third check naming --picker takes nothing, or off, and dispatch to main.rs's claim_picker() and to chooser::release(). claim_picker() is chooser::claim() plus the undo both with: flea --picker off line, printed only when there was a backend to claim, and it lives there rather than in chooser so that --default cannot print a second undo line naming a command the operator did not run. --pick <reply> is matched in its own exact shape and is not for people: it is how tools/flea-portal opens one chooser window. See "The file chooser portal".

What remains chooses between the terminal interface and the window with two booleans, want_tui and want_gui, not an enum: there are four modes total, each dispatched exactly once, and a type nobody matches on twice would be ceremony.

--tui and --gui are mutually exclusive; giving both is a usage error naming the conflict, never a coin flip. With neither flag the window is the default, including when both handles are a terminal, because the terminal interface is reserved but not built and a bare invocation must open the product that exists. --tui is the only route to that reserved interface, and the only mode that reads the tty at all: main.rs computes the is_terminal() pair one line above the if want_tui that is its only reader, so no other mode can consult it even by accident. It requires both stdin and stdout to be a real terminal, not just one, so a future implementation cannot write escape codes into a pipeline. flea | head gives stdin a tty and stdout a pipe and an explicit --tui therefore refuses. A window launch without a non-empty WAYLAND_DISPLAY or DISPLAY refuses rather than trying and failing inside qs.

./tests/modes.sh checks both no-flag shapes: redirected handles exercise the launcher path and script from the hard util-linux dependency gives the child a real pty on both handles, pinning the terminal invocation that used to fall into the unbuilt interface.

paths::ui_dir() finds the UI the same way FLEA_BIN finds the backend binary (see "Where the backend binary comes from" below): FLEA_UI first, then the packaged /usr/share/flea/ui, then the dev tree's ui/ found by walking up three parents from the running binary (target/debug/flea to the repository root). Each candidate is confirmed by checking for its boot/shell.qml before being accepted, and an empty FLEA_UI is rejected before that check because PathBuf::from("") would test the working directory, so a stale or empty FLEA_UI falls through instead of handing qs a broken path.

gui::exec_qs calls Command::exec, replacing this process instead of spawning a child, so flea never leaves an orphaned pid behind and signals sent to it reach qs directly. It also calls prctl(PR_SET_THP_DISABLE) immediately before that exec, because the setting is preserved across exec and this is the last point that can hand it to qs; see "Transparent huge pages" below for what it is worth and what it cost.

The file chooser portal

Every application that asks the desktop to pick a file goes through the XDG portal, and the dialog that opens is whichever backend owns org.freedesktop.impl.portal.FileChooser. That is the whole reason the Tailscale bar widget raised a GTK dialog in the middle of Omarchy: its Panel calls omarchy-tailscale-send with no file arguments, that script runs omarchy-file-select, and omarchy-file-select is a 124-line portal client. Nothing about it is Tailscale specific, so neither is the answer: Flea implements the backend interface, and every portal caller on the box gets the same chooser.

Three moving parts.

  • tools/flea-portal owns the bus name org.freedesktop.impl.portal.desktop.flea, exports FileChooser at /org/freedesktop/portal/desktop and a Request object at each request's own handle, turns one call into one JSON request, runs flea --pick, and turns the reply into a response code and results. It exits after 30 s with no request open, because D-Bus starts it again on the next call.
  • flea --pick <reply> is gui::pick: it refuses without FLEA_PICKER or a reply path, refuses without a display, resolves the UI with the same paths::ui_dir() the window uses, and execs qs -p <ui>/boot/picker.qml with the same renderer choice --gui makes. One code path chooses Vulkan for both front doors.
  • ui/PickerWindow.qml is the window, loaded by file: URL from ui/boot/picker.qml; see "The first window". It instantiates the same Backend, draws the same Row behind a check box, reads the same Theme and the same Places.favorites, and carries none of the window's operations: a chooser that can rename or delete is a file manager wearing a dialog's clothes. SendPicker.html draws that row as the name, a 70 px size and an 80 px date, so ui/PickerList.qml hands Row Picker.HIDDEN_COLS and the chooser never inherits Mode or Kind from ViewState, whatever the header menu has switched on for the browser window.

Stalled chooser listings. PickerListing.qml owns a separate read-only backend for list, listpaths and window. Navigation invalidates its output immediately, kills and reaps that worker, then starts only the latest request with a fresh stdout parser. The chooser's other Backend opts into pickerOnly: only picker checks and capability reads may be sent, and shutdown can terminate a stuck identity check without touching a file-manager write backend. PickerLifecycle waits for both workers only after the reply file has been saved. tests/picker-stall.sh drives blocked FIFO reads, latest-navigation recovery, incomplete stale output, early cancellation, failed spawn and process reaping under offscreen Quickshell; it needs no production mount.

Recent, and why it is read-only. SendPicker.html draws a Recent row above Home in the rail, says the location's own name where the path would be, and draws Parent disabled with the words "unavailable in Recent". The history it lists is the desktop's own, $XDG_DATA_HOME/recently-used.xbel and ~/.local/share when the session set no data home: Flea keeps no history of its own, writes nothing to that file, and reads it only when the location is opened. Qt's XML reader does the parsing in ui/PickerRecent.qml, because a hand-rolled XBEL reader would be a second implementation of a file this application does not own. Every application on the box appends to it, so ui/js/Recent.js treats a bookmark as untrusted text: only a real local file:// URI with an empty or localhost authority becomes a path, a control character in the decoded form refuses it, and the read stops at 500 entries. That cap is ui/PickerRecent.qml's and not only the rail's: it reads the bookmarks off the model with data() rather than instantiating one QObject per bookmark, which cost seconds against a fraction of one on a 50,000 bookmark history here, a magnitude and not a number to cite. The rows come back from the backend through listpaths, which stats each path and drops the ones that are gone, so a stale entry is removed rather than drawn against a failed stat; the listing's base is / and each row is named by its path under it, which is why ui/PickerList.qml draws a Recent row by its own leaf and Picker.rowPath answers with the whole path. Recent is a location and never a directory: it is named by the token flea:recent, which no absolute path can equal, Parent refuses in it, Back works out of it, a caller's own folder still wins at startup, and a save is not offered it at all because a history is not a directory to write into. The window's Recent listing draws each file's folder in its Location column through Recent.locationUnder: relative to home with no leading ~/ ("Documents/claude"), ~ for a file directly in home, and the absolute parent outside home (Sidebar040 draws no such row); it uses Format.tilde, the home test every path display shares, and Settings, Places draws a favourite's path through the same function while the row's value keeps the stored path for removal, reorder and the keyboard seam.

Why the backend is Python. It is the second non-Rust helper in this tree, after tools/flea-gio-auth. A portal backend has to own a bus name, export objects, answer calls out of order and stay reachable while a window is open; this crate has no dependencies at all, so the Rust answer was a hand-rolled D-Bus marshaller. python-gobject is what omarchy-file-select, the client this serves, is already written against, so it is an already-installed dependency rather than new code, and the backend is a request-scoped daemon and not a hot path.

Discovery, measured against xdg-desktop-portal 1.22.1's own source, not its documentation. The backend guide says .portal files live in {DATADIR}/xdg-desktop-portal/portals, but src/xdp-portal-config.c load_installed_portals() scans $XDG_DATA_HOME/.../portals first and the data dirs after, so a user-local registration is discovered too; that is what the box test uses and what the package path does properly. load_portal_configurations() is the other half and works differently from the .portal scan: it collects EVERY configuration file it finds into an ordered list, $XDG_CONFIG_HOME first, and xdp_portal_config_find() walks that list per interface, trying the interface key and then that file's own default before moving to the next file. A user portals.conf naming only FileChooser therefore leaves default=hyprland;gtk in /usr/share/xdg-desktop-portal/hyprland-portals.conf answering for everything else, which is why chooser::claim() writes one key and never a default. Inside one directory a <desktop>-portals.conf shadows the plain portals.conf entirely, so when the session's desktop has one in $XDG_CONFIG_HOME/xdg-desktop-portal, claim() writes the same one key into that file, the only user file the portal reads. Omarchy ships no user portal configuration in any release from 3.0.0 to 4.0.4, so such a file is the user's own or another tool's, and 0.3.3's refusal to touch it left flea --default exiting 1 with file dialogs still on Nautilus for everyone who had one. A value the claim replaces is kept on a # flea replaced: <key>=<value> comment above Flea's line, and release() puts it back in every user portal file it finds, reading the directory rather than the session, because an older Flea wrote portals.conf there. A claim with no XDG_CURRENT_DESKTOP beside a desktop file refuses rather than guess which file the portal reads, before either half writes, so the picker's window rule is not left behind a routing that never happened.

The two exit codes the caller distinguishes, and which of ours map to them. In omarchy-file-select, exit 1 is nothing picked, a decision, and omarchy-tailscale-send exits 0 without sending; exit above 1 is a chooser that never opened, a fault, and the caller raises a critical notification. Those are CLIENT behaviours and not this backend's interface. What crosses the wire is a response code: 0 with uris, 1 for the user's own refusal, 2 for everything else. xdg-desktop-portal collapses 1 and 2 into the same client exit, so 2 is still the honest answer for a request the caller withdrew, a window that died and a chooser that could not open: the pair is never mixed at the boundary that can tell them apart. A response that never arrives is the real defect, because the client waits 600 s for it, so flea --pick refuses loudly before opening anything and every path out of Pick.answer answers exactly once.

The reply is a file, not the child's stdout. qs writes its own logs to stdout, so the answer travels in a JSON file inside a mkdtemp the backend owns and removes. ui/PickerWindow.qml writes it with FileView and only kills its own process on saved(): the backend reads the file after the child exits, and a write still in flight would be a lost answer read as a fault.

Show in folder

org.freedesktop.FileManager1 is the interface a desktop's "Show in folder" goes through, and issue 54 is what happens with nothing of Flea's owning it: Chromium reveals a download and Nautilus opens. Measured with strings against Chromium 151.0.7922.173 on this box, the binary carries exactly org.freedesktop.FileManager1, /org/freedesktop/FileManager1 and ShowItems, so that one call is the whole of what has to be answered for the reported symptom.

tools/flea-filemanager1 answers it, and it is the third non-Rust helper in this tree for the same reason as the second: a D-Bus service has to own a name, export an object and answer method calls, and this crate has no dependencies at all. It copies tools/flea-portal's shape, its FLEA_BIN seam, its say() elision and its release-then-quit idle exit.

Every window it opens is flea --select or flea <dir>, which is why the service is small. --select already resolves a file:// URI to its parent and puts the cursor on the entry, so ShowItems is that flag and nothing more; ShowFolders wants the folder itself open, which is the positional argument. Two items in one directory are one window, because a window can only put the cursor on one row, and the first URI named under a directory is the one its window selects.

ShowItemProperties answers org.freedesktop.DBus.Error.NotSupported. Flea has no properties dialog. Falling back to ShowItems would answer a different question than the caller asked and would look, to the caller, exactly like success; a reply that did nothing at all would be worse. The signature returns no values, so the error reply is the only channel a refusal has.

The URIs come from another application, so the boundary is the whole of the rest of the file. The decoder is GLib.filename_from_uri, glib's own, rather than a second one: measured here with PyGObject 3.56.3 on Python 3.14.7 it refuses a foreign scheme, a bare path, file:, file:// and a %00, and it hands back the authority separately. The two rules it does not carry are the two the service adds: an authority that is neither empty nor localhost is refused, and a decoded path holding a control character is refused, because %0A decodes to a newline without complaint. A path that is not there and a target with no parent are refused too. Each refusal is per URI and elided to one sentence on stderr, so one bad URI beside a good one does not take the call down; a call left with nothing to open answers org.freedesktop.DBus.Error.InvalidArgs.

The shape was verified with a real round trip, not assumed. tools/flea-portal had shipped an isinstance(value, bytes) test where PyGObject 3.56.3 delivers a list of ints for a D-Bus ay. A GLib.Variant("(ass)", ...) unpacked on this box gives a list of str, so isinstance(uri, str) is the right test here, and it is there because the check costs nothing and the last guess was wrong.

Registration is packaging/com.thisisgm.flea.FileManager1.service, named for Flea and not for the interface. Nautilus owns /usr/share/dbus-1/services/org.freedesktop.FileManager1.service on this box, so a file of that name is a pacman conflict; dolphin, thunar and nemo each ship their own vendor-named file carrying Name=org.freedesktop.FileManager1, which makes a vendor name the measured convention rather than a workaround. The user-level file flea --default writes takes the plain interface name instead, because no package installs into $XDG_DATA_HOME and there is nothing there to collide with; D-Bus keys on Name= either way.

Installing does not decide which one answers, and flea --default is what does. D-Bus keeps the FIRST registration it reads, and inside one directory the two buses disagree about which that is. Measured on 2026-09-06 with all five files in one directory, every Exec rewritten to a marker: dbus-daemon 1.16.2 ran nautilus's, which is first in ls -U and third alphabetically; dbus-broker 37 kept com.thisisgm.flea.FileManager1.service, which is last in ls -U and first alphabetically, and logged the other four as duplicates. So dbus-daemon takes readdir order and dbus-broker sorts, neither is newest-wins, and an installer can steer neither. Omarchy ships nautilus in omarchy-base.packages, so a stock box always has a second claimant; on this box on 2026-09-06, four installed and no Flea package, dbus-broker threw away nautilus's, dolphin's and thunar's, so nemo's is the one answering.

The directory order is what settles it, and it is not readdir's. $XDG_DATA_HOME/dbus-1/services is read before every $XDG_DATA_DIRS entry, so one file there outranks all four: that is the same shadowing that once let a development portal backend override the installed one as a bug, used on purpose. defaults::claim_service() writes it, defaults::release_service() removes it and the two directories it created when nothing else is in them, and both are per-user preferences pacman cannot own, which is why they are steps of flea --default and not PKGBUILD lines. Measured on this box with dbus-broker 37 and a sandbox XDG ladder: with the user file present the broker logged Ignoring duplicate name 'org.freedesktop.FileManager1' in service file '<path>' for all five system files and none for the user one.

The file leads with a comment saying who wrote it. dbus-broker 37 and dbus-daemon 1.16.2 both accept a # comment in a .service file, before the group header and after it, measured on this box; the untraceable user-level service file was this morning's production bug, so the first line is the provenance and it is also how release_service() tells Flea's file from somebody else's. A file at that path whose first line is not the marker is left alone and named, never overwritten. The Exec is copied out of the installed packaged registration, so the two can never disagree and no install path is written down twice.

tests/filemanager1.sh drives the real interface on a private session bus it starts inside its own fixture, with service directories of its own and FLEA_BIN pointing at a stub that records its argv. That makes D-Bus activation itself part of the test rather than a manual step, and the first case is the negative control: on a bus with an empty service directory the same call fails ServiceUnknown. The registration under test is the shipped file with only its Exec repointed at the checkout, so a broken Name= in packaging/ reddens the suite.

Its last five cases are flea --default's claim on the name. They run the real binary against a sandbox HOME and a sandbox XDG ladder, with xdg-mime and hyprctl stubbed on PATH, because a reachable hyprctl would reload the operator's live Hyprland config rather than anything in the fixture. The last of them is the ordering one, and it is a driven negative control rather than an assertion: the bus is given the sandbox data home and then a directory of four rival registrations, the rival first in ls -U answers, flea --default makes Flea answer over it, and flea --default off hands it back. The suite is already in tests/run-all.sh's headless list, so this coverage needed no new entry there.

Updates

Omarchy installs; Flea may check and launch. That revises the About board's "no duplicate updater" rule (decided 2026-09-22), and it is the whole design: Flea asks the source that will install an update whether it has one, and hands installing to Omarchy's own updater. Nothing in Flea downloads, verifies, unpacks or writes a package, and pacman stays the only writer of /usr/bin/flea.

What is checked. flea --update check is src/update.rs. It asks pacman -Qqo which package owns the running binary, std::env::current_exe() rather than a literal /usr/bin/flea, so a checkout build run beside an installed package reports itself and not the package. pacman -Qi under LC_ALL=C, because pacman translates its field names, then gives the installed version and "Validated By", and the owner decides the kind:

  • flea validated by a signature is OPR's package, and checkupdates is asked. pacman-contrib is a hard dependency of omarchy, so it is on every box Flea installs on. It syncs a private copy of the sync databases without root and exits 0 with updates, 2 with none and 1 on an error; it is never given --nosync, which answers 2 when it has no copy to read. GitHub is never asked for OPR, because it would announce a tag OPR cannot install yet.
  • flea-bin asks the AUR's RPC v5 info endpoint once: curl -q --silent --fail --globoff --max-time 10 --max-filesize 1M, -q first so the user's ~/.curlrc is never read, --globoff because arg[] is otherwise curl's own glob syntax, and a body over 1 MiB is refused in Rust too. src/jsondoc.rs parses it. The release workflow pushes a version to the AUR only after its makepkg proof.
  • flea-git, a flea no signature validated (a local makepkg -si build), any other owner name, and a binary no package owns are all unchecked: no source describes them, and none is asked.

Every version that reaches vercmp or a screen matches ^[0-9]+(\.[0-9]+){2}-[0-9]+$ first, which is how network text is kept out of the line; vercmp then decides. The line is <state> <kind> <installed> <latest>, the states available, current, failed and unchecked, the kinds opr, aur, git, local and unowned, and - for a version the check does not have. See "Modes" for the exit statuses.

What is launched. flea --update spawns omarchy-launch-floating-terminal-with-presentation omarchy-update, the command Omarchy's own menu and its bar widget run, as a fixed argv; nothing the network said reaches argv or a shell. It updates OPR flea through pacman and flea-bin through yay -Sua, with Omarchy's snapshot, keyring, migrations and pacman guard in step, and it asks for sudo in a real terminal. It goes through Rust for the same reason --terminal does, "Transparent huge pages": terminal::detach is the one set of guards both use, the huge page hand-back, the display-GPU pin and theme trade dropped, /dev/null on all three descriptors and a process group of its own.

Why nothing is replaced in place. A self-replacing download was rejected in the research: the binary is package-owned, so pacman -Qkk would fail and the next upgrade would overwrite it; the package also ships the QML under /usr/share/flea/ui, the helpers, the portal and two D-Bus services, so a newer binary against older QML is a skew bug; a ~/.local/bin copy would put two Fleas on the box; and it would need HTTP, TLS and archive code this crate does not have, for no more than same-origin checksum integrity. OPR's signature and the flea-bin checksum makepkg verifies remain the authenticity.

The window. ui/UpdateCheck.qml is a singleton that owns both processes, the state (pure, in ui/js/Update.js) and two Timers. A check starts four ways: Enter on Settings > About's Update Flea row, Settings > About opening (ui/AboutFacts.qml), a single look one minute after the window opens, which asks only when About would, and a six hour timer that counts from the last answer, whichever trigger asked for it. ui/WindowBody.qml starts both timers with the window. updates.autoCheck in src/uischema.rs, "Check automatically" under the row and on by default, governs the three automatic triggers and never a press. About opening does not ask again while an answer from the last six hours stands, and a failed check never stands. The row, its eight states, the caption and the note line follow the Update Flea boards (UpdateStates, Main and UpdateMenu in the 0.3.3 design canvas). Enter checks from idle and up to date, opens Omarchy's updater from available and from a check that failed, because the updater can still run, and does nothing while a check or an update runs; a rolling or local build is a fact row. A launch is final for the process: the row reads "Updating in terminal", the note asks for a restart, and no automatic check runs again, because pacman may replace QML this process still loads lazily.

The background menu carries Update Flea only while a check has found a newer build, in the last group under Settings, with the version in the hint slot beside the rail's 6 px status square; every other state leaves that menu exactly as it was. Choosing it launches, the footer says "Opening Omarchy update · restart Flea when it finishes", and the row is gone, because the state is then launched. It is an Extras switch in Settings > Menus and ships visible. ui/js/Focus.js reaches the singleton through ui/Opener.qml's updateFlea, because a .pragma library cannot name a QML singleton.

Privacy. This is the first request Flea can make on its own: Cargo.toml has no dependencies, and everything else that reaches another machine, a network mount or a LocalSend send through localsend-cli, starts from something someone did. For an OPR install it is checkupdates fetching the sync databases from the mirrors pacman already uses; for flea-bin it is one GET to aur.archlinux.org with curl's own user agent, naming the flea-bin package and nothing about the box, its installed version or its user. A rolling, local or unpackaged build makes no request at all, only the two local pacman queries. Requests happen only on a press of Update Flea, when Settings > About opens, one minute after a window opens (off the launch path, so no benchmark or first frame pays for it) and every six hours after that, all but the first only with "Check automatically" on; off means nothing leaves the box until someone presses the row. Nothing is written: the answer lives in the running process.

Tests. src/update_tests.rs feeds each parser canned output and never runs pacman. tests/update.sh drives the binary with only a stub directory on PATH, so a program the suite forgot to stub fails to start rather than reaching a mirror, and asserts the printed line, the exit status, the curl argv, the presenter's argv, descriptors and process group, and the huge page hand-back through a stub qs. tests/js/update.js, tests/js/settingsabout.js, tests/js/menu.js and tests/js/settingsmenus.js hold the row states, the About rows, the menu row and its switch. The GUI suites launch a target/ binary, which no package owns, so an automatic check there answers unchecked unowned and asks nothing.

Make Flea the default, in Settings > About

flea --default and flea --default off (see "Modes") have a switch in Settings > About, the check row "Make Flea the default" in the This box group, directly under the File manager fact, which stays as it was. The row is the label and the box, with no caption and no mark, and a one-line note under it. It is an action with a live state, not a setting: it writes no ui.json key and has no rule in src/uischema.rs, so src/uistate.rs would refuse a makeDefault patch whole. ui/SettingsPanel.qml activate() routes the id makeDefault to DefaultClaim.toggle() before the generic branch that hands every other check to ViewState.changeSetting, so Enter, Space and a click all reach the run and none reaches the writer.

The box is the truth, not the last click. It is ticked exactly when xdg-mime query default inode/directory answers com.thisisgm.flea.desktop, the same answer the File manager fact states. ui/DefaultClaim.qml, a singleton so one run is in flight per process whatever window pressed it, reads that answer when About first opens, the read ui/AboutFacts.qml used to make itself, and again after every run; a run that started counts as in flight until that re-read lands, and after a switch until the portal restart below has answered too, so the box never shows the answer from before it. Only the run's own re-read ends a run that started; one that never started ends at once (below). Reads are numbered as they start and finished() records the number the next one will carry, so settled() ignores a read another window's About began while flea was still running, or one already in flight when it exited, whose answer may predate what flea wrote. Quickshell does not restart a Process whose running is already true, so a run that exits during such a read asks for one more behind it. Each process's answer is taken once its exit and its StdioCollector's text have both landed, in either order (landed() and whole()), because onExited can race the collector's text (see "The state file"); measured on Quickshell 0.3.1 the text lands first, which tests/mount-listing.qml records, so this is the tree's guard and not a reproduced race. A program that never starts still ends the run, by ui/ViewState.qml's writer rule: Quickshell raises no exited for it, only running going false, and a real exit lands its status before that false, so a false with no status in (neverRan()) is a program that never ran. An xdg-mime that cannot start is a read answered NEVER_RAN, which states no handler, so File manager reads "Not reported" and the run it was the re-read of settles. A flea that cannot start fails at once with "<command> could not start" in the error role (unstarted()), owing no re-read and no restart. A systemctl that cannot start is caught by the same rule with claim.restarting as the book and gives the restart-failed note. A press runs [FLEA_BIN, "--default"] from an unticked box and [FLEA_BIN, "--default", "off"] from a ticked one, the running binary the way ui/UpdateCheck.qml runs flea --update, as a Quickshell Process, which never blocks the window. It is not startDetached(), because the exit status and stderr are what the note is made of. A press while a run is in flight does nothing.

The switch restarts the portal, and so does the command at a terminal. xdg-desktop-portal reads its routing once, at startup, so after a run that exits 0, a claim, a partly claim or a release, ui/DefaultClaim.qml runs systemctl --user try-restart xdg-desktop-portal.service as its own Process, and file dialogs follow at once. try-restart does nothing when the portal is not running. A failed or refused run restarts nothing. The run stays in flight until both the handler re-read and the restart have answered. A restart that exits non-zero does not fail the switch: the box keeps the re-read answer and the note says "File dialogs follow after xdg-desktop-portal restarts." in the foreground, except after a partly claim, whose own note stands because its file dialogs never follow. The switch restarts the portal so file dialogs follow at once (decided 2026-09-22), and the command then restarted nothing. The install line is omarchy pkg aur add flea-bin && flea --default (decided 2026-09-23) with no restart of its own, so chooser::report() now makes the same try-restart for flea --default, --picker and both off forms when stdout is a terminal and the routing half went through. With stdout piped or redirected, as the switch runs it, the command restarts nothing and prints the restart hint, which keeps the switch at exactly one restart; a refused or failed routing prints neither. tests/modes.sh pins each path with a stub systemctl and script(1) for the terminal.

The seven states and their notes are ui/js/MakeDefault.js, pure, so tests/js/settingsabout.js drives each one. Off has no note. On says "Folders, Show in folder and file dialogs open Flea." in the foreground. Working greys the row (inert, which ui/SettingsRow.qml draws at the disabled opacity without its handlers, the cursor still resting on it) and says "Making Flea the default" or "Handing folders back" in the muted role. Partly is a claim that exited 0 having printed claim_both()'s no portal backend is installed, so the file chooser step was skipped: the box is ticked and the note says "File dialogs need the flea package's portal files." Portal is a switch whose restart failed, as above. Failed is a non-zero exit that is not the refusal below: the note is the first stderr line that starts flea: , prefix removed, in the error role, because xdg-mime's own complaints reach the same stream; with no such line it is the first non-empty one, and with none at all the command and its status. A refusal is told from a failure by its sentence and never by its status, because both exit 1: a non-zero run whose stderr carries defaults::claim()'s is not installed in any applications directory is Unpackaged, and defaults::claim() prints it having written nothing. Unpackaged greys the row and says "Install a Flea package to make it the default.": it is known up front from a probe that walks src/userfile.rs data_file()'s ladder ($XDG_DATA_HOME or ~/.local/share, then $XDG_DATA_DIRS or /usr/local/share:/usr/share, an empty variable read as unset) for applications/com.thisisgm.flea.desktop with sh -c's [ -f ], each path its own argument, and from defaults::claim()'s refusal if the probe and the binary ever disagree. Every note is one caption line, 11 px at the base size, that elides rather than wraps (elide: "right" on the hint), so a long failure never pushes the rows under it down.

Tests. tests/js/settingsabout.js holds the rows, the seven states, the notes and their roles, inertness, what a press runs, the stderr reading, which handler read may settle a run, the exit and text join in either order, each of the three programs never starting, when the portal restart is asked for and its argv, and the probe's paths and its argv with the script pinned word for word. The refusal and the partly line it matches are not copied: it reads src/defaults.rs and src/main.rs the way tests/js/themes.js reads colors.toml and takes claim()'s and claim_both()'s one flea: sentence and DESKTOP_ID from them, so a reworded sentence reddens the suite instead of turning a refusal into Failed. tests/ui-makedefault.sh, sourced by tests/ui.sh as the makedefault case, launches with a stub flea on FLEA_BIN that answers only the two exact --default argv shapes, refuses any other argv of the modes src/main.rs routes to claim_both(), release_both(), claim_picker() or chooser::release(), and execs the real binary for everything else; a stub xdg-mime that answers only the handler query, from a fixture file; and a stub systemctl that logs every call and answers only the exact portal restart. A refused call exits non-zero into refused.log, which the case asserts empty, so a drifted argv reddens the case and never reaches the real xdg-mime, the operator's mimeapps.list or their portal. The refusal and skipped-chooser sentences the stub prints are read from the same Rust source. A fixture desktop entry is prepended to XDG_DATA_DIRS. It drives Space, a click and Enter, a second Space during a run, the partly, failed and refused runs, a failing restart, and a press on the inert row, then relaunches with the first handler read held past a claim's exit and asserts the held answer from before the claim settles nothing and the read behind it ticks the box, and relaunches once more on a PATH with no xdg-mime anywhere on it, the stubs and a link to the first of every other program on the suite's PATH, and asserts the claim still leaves working with File manager reading "Not reported". It asserts exactly one --user try-restart xdg-desktop-portal.service after each run that went through and none after a failed or refused one, and that neither the session state nor ui.json carries the id at any depth, reading each as an object first so an IPC or parse failure fails the check rather than passing it.

Module map

  • main.rs dispatches on argv, and this is every flag it matches: --backend runs the command loop, --prewarm <path> <first> <dest> writes the prewarm file, --open <path> hands one file to the desktop's handler, --terminal <dir> opens the configured terminal there, --run <path> <dev> <ino> <size> <mtime_s> <mtime_ns> and --run-terminal with the same identity start the card's program outside or inside a terminal, answering 0, 2, 6 for a file changed since the ask, 7 for a noexec volume or 8 for a spawn failure, --open-text <path> opens one file in the desktop's text editor through the resolved desktop file rather than its bare id, --open-handler <path> <desktop id> with the same identity hands an AppImage to the handler the card named, --update [check] asks the installing source for a newer Flea or opens Omarchy's updater, --default [off] claims or releases the OS-level default, the "Show in folder" registration and the chooser routing together, --youleftmeforstrata is the undocumented second spelling of --default off, --picker [off] claims or releases the desktop's file chooser alone, --pick <reply> opens one chooser window for tools/flea-portal, --ui-state [<patch>] reads or merges the shared view state, --unlock <device> opens the unlock dialog over the window for one device, see "Modes", --launch-warm <list> <gvfs-path> <gvfs-dest> runs both launch jobs under one fork, "-" skipping one, which gui.rs starts and tui/warm.rs starts with "-" for the list, see "The first window", --figure-helper runs the sandboxed quickjs-ng figure renderer on stdio, see "Markdown figures", --version prints the version, --print-target resolves --select's pair for the tests, and anything else opens the window, on --select's parent directory when one is given, unless explicit --tui requests the terminal interface, --gui being the explicit spelling of the window a bare invocation already means, see "Modes".
  • paths.rs resolves the UI directory and whether a display is available.
  • gui.rs execs qs against the resolved UI directory.
  • thp.rs the prctl(PR_SET_THP_DISABLE) declaration behind disable() and enable().
  • icdexclude.rs disables the Vulkan drivers of GPU vendors the box does not hold, before the probe and qs; see rule 5.
  • icdmanifests.rs the manifest inventory behind icdexclude.rs and the probe cache's key: which ICD manifests the loader would read and what each one's library names.
  • vkprobecache.rs answers the launcher's Vulkan probe from a fingerprinted cache, probing only on a miss; the key lives in vkprobekey.rs and its library search in vkprobelibs.rs.
  • open.rs hands one file to gio open and waits for it, see "Opening a file".
  • terminal.rs hands one directory to xdg-terminal-exec --dir= and does not wait, see "Opening a file"; its detach is the set of guards every program Flea starts and does not wait for carries.
  • tearoff.rs names the three tab tear-off variables and removes them from a child's environment, for detach and open.rs.
  • unlock.rs flea --unlock <device>: opens the same unlock dialog a locked rail row opens, through its own exec_unlock beside gui::qs_command.
  • figurehelper.rs flea --figure-helper: maths and diagrams through quickjs-ng under the thumbnail jail's flags and caps, see "Markdown figures".
  • update.rs flea --update [check]: the install kind, the source's answer, and Omarchy's updater, see "Updates".
  • defaults.rs claims or releases the OS-level default: the desktop-entry install check, the inode/directory MIME default via xdg-mime, and reporting each half, see "Modes".
  • hyprkeys.rs adds or removes the additive, markered block in Omarchy's ~/.config/hypr/bindings.lua that binds the two file-manager chords to Flea, see "Modes".
  • userfile.rs resolves $HOME, $XDG_CONFIG_HOME and $XDG_DATA_HOME, walks the XDG data ladder for an installed file, and rewrites a per-user file through an exclusive temp plus rename, see "Predictable path writes".
  • error.rs the one error type, naming the failing operation and input.
  • json.rs the wire's JSON: read one named field out of one line, escape one string into one.
  • jsondoc.rs one whole JSON document in and out, which the one-line scanner above deliberately is not.
  • jsonstring.rs one JSON string in and one Rust String out: the escapes and the surrogate pairs.
  • jsonmembers.rs one member of a JSON object found as the Vulkan loader's cJSON finds it: any case, every repeat seen.
  • uischema.rs the shipped ui.json shape and the rule each key is measured against.
  • uistate.rs the ui.json merges: a file onto the defaults, one caller patch, and 0.1.3's view.json.
  • uimigrate.rs the one-time changes a stored ui.json is owed, applied on read and recorded by its stateVersion stamp, see "The state file".
  • uistatecli.rs is flea --ui-state, the window's path to read or patch ui.json (the TUI writes through its own uistore::Store), and the shelf switch that follows a patch.
  • uistore.rs where ui.json lives and the one locked, atomic way it is rewritten, see "The state file".
  • backend/mod.rs is module declarations and nothing else, the #[cfg(test)] ones included. It declares more modules than the list below names, which is the load-bearing ones and not a census.
  • backend/listing.rs the arena-backed Listing.
  • backend/aliases.rs resolves a MIME alias to its canonical name, see "MIME aliases".
  • backend/scan.rs phase 1: readdir plus fstatat for typeless entries only.
  • backend/sort.rs parses the sort key, holds the name order, and holds the one sentence a key it does not know answers with.
  • backend/meta.rs phase 2: stat a row range for a window, or every row for a size or date sort.
  • backend/metasort.rs the size and date orders: sorts an index over the metadata pass, then gathers the spans behind it, see "Two-phase listing".
  • backend/owner.rs resolves a uid to its login name from /etc/passwd alone, for the meta reply.
  • backend/mime.rs resolves a file name to a MIME type from the shared globs2 database.
  • backend/icons.rs resolves a MIME type to a freedesktop icon name from generic-icons.
  • backend/md5.rs computes the thumbnail cache's filename digest, see "Thumbnail cache".
  • backend/thumbspec.rs parses the .thumbnailer files on the search path into a table of validated specs, see "Thumbnailer specs".
  • backend/thumbargv.rs turns one spec plus one input into the exact argv a child is exec'd with, see "Thumbnailer specs".
  • backend/thumbcache.rs names and reads entries in the shared thumbnail cache, see "Thumbnail cache".
  • backend/thumbwrite.rs creates the temp, stamps the PNG and writes the fail marker.
  • backend/sandbox.rs wraps a thumbnailer's argv in bwrap and prlimit, and the extract's argv in that same jail without the CPU cap, see "Thumbnail sandbox".
  • backend/child.rs runs one argv under a deadline and says whether it succeeded, failed or never started, which is the whole of what decides a fail/ marker, see "Thumbnail pool".
  • backend/thumbs.rs the bounded, cancellable thumbnail pool, see "Thumbnail pool".
  • backend/proto.rs the wire types, the request dispatch and the one-line responses.
  • backend/rows.rs serialises one window of rows and its per-response Kind dictionary.
  • backend/rowguard.rs stamps each rows line with its listing's numbering and refuses a trash, transfer, paths, collisions or menu snapshot that names an older one, see "The listing swap".
  • backend/thumbreq.rs the thumbnail request policy: cache lookup, queueing, cancel and result reporting, see "Thumbnail requests".
  • backend/run.rs the command loop, see "Thumbnail requests". stdin is read on its own thread and the pool answers on its own channel, and a forwarder thread joins the two, so client requests and worker results arrive on one recv, because std has no select.
  • backend/dirsizeworker.rs runs one cancellable size job at a time, with generation-checked replies and an explicit 16 MiB stack for the recursive walker. Navigation never joins it.
  • backend/events.rs the Event those sources arrive as, and the forwarding threads that join them onto the loop's one channel. It came out of run.rs at 398 of the 400 hard cap, and it is one job: how work reaches the loop, as against what the loop does with it.
  • backend/watch.rs the one inotify watch on the directory the current listing came from, its reader thread and the changed line it answers with, see "The open directory is watched".
  • backend/copyfile.rs the copy primitives every transfer is built from: streaming, preserving symlinks and refusing to overwrite.
  • backend/copyread.rs the copy source reader: at most four FUSE reads use workers, with Cancel polled every 25 ms while a source read is stalled. Local sources read directly, as do FUSE sources when the worker cap is full or a spawn fails. A cancel still unlinks the partial destination synchronously, so a destination on a device that stops answering waits for it, as in 0.3.10.
  • backend/undoshare.rs the one undo history every backend in the session shares, under $XDG_RUNTIME_DIR/flea/ with an flocked lock file and predictable-path writes; a take is one locked read-modify-write, so two windows pressing Ctrl+Z undo two different operations.
  • backend/undocodec.rs that journal's versioned wire form and its read-time checks, both directions.
  • backend/undostage.rs that journal's file text, in and out: read parses the file once (a document, a newer writer's file, or garbage) and Staged renders each entry once, so trimming to the cap sums piece sizes and a write is a join. backend/undorebase.rs is RebaseMap, one walk of the document for a whole batch of moves.
  • heap.rs pins glibc's mmap threshold for the backend, keeps the TUI to one arena and gives the TUI's freed heap pages back after a picture is sent (give_back), see "The listing arena returns to the OS".
  • launcher/mod.rs re-exports prewarm, nothing else.
  • launcher/prewarm.rs writes the listing and first screenful before the UI starts.
  • ui/boot/shell.qml owns the pragmas, the window, its geometry readers and the scene-graph error path; it holds nothing else, see "The first window".
  • ui/WindowBody.qml owns everything inside that window: the view, the startup path, the quit handshake and the read-only IPC seam. It is loaded by file: URL, so it has no qmldir line.
  • ui/RendererRetry.qml owns the OpenGL retry argv, loaded by file: URL only once the scene graph has failed.
  • ui/Backend.qml is the only QML component that talks to the Rust child, and carries thumb and thumbcancel out and thumbed in alongside list, window and sort.
  • ui/ViewState.qml reads ui.json once at startup with a blocking FileView and writes nothing itself: every change, the header menu's columns and all three settings sections alike, goes back out through flea --ui-state as a patch naming that change alone, see "The state file". It also watches the file and applies another window's settled preferences live through UiState.applyExternal, owning the apply/prune settler whose exit and collected text join the landed/whole way before anything is parsed.
  • ui/js/UiState.js is ViewState's writer bookkeeping and the two pure rebuilds every writer goes through: the newest patch a writer landed, what the running writer carries and what waits behind it, and the key and group rebuilds ViewState runs over both the state it draws and the patch it owes. It also owns the refusal prune (invalidKeys, dropInvalid, revertedState) and the settle join and order (landed, whole, pruneAsk, settleNext). It imports no QML, so tests/js/uistate.js can redden on a mutation of the rule that only a zero exit proves a save.
  • ui/Theme.qml owns the singleton palette, type and spacing tokens from the Omarchy theme plus the user override.
  • ui/Pane.qml owns one directory view, its integer model, held window, actions, the settle timer and the row-to-thumbnail map, see "Thumbnail requests in the GUI".
  • ui/PaneWire.qml is where every reply from outside the window lands: the backend's, and those of the three foreign programs the pane runs (ui/Opener.qml, ui/ShareLink.qml, ui/Taildrop.qml); it writes through the pane handed in and owns only the state a reply needs before the pane has a row for it: the folder a made line will open an editor on, and the watched re-read's debt, timer and cursor anchor, see "The open directory is watched".
  • ui/PaneSwap.qml is where a listing's listed and rows replies land, and holds the old rows on screen until they do, see "The listing swap"; ui/js/Swap.js makes its decisions.
  • ui/Header.qml renders the column header band and its rule, and owns nothing else: it was lifted out of Pane.qml at the 400-line hard cap and has no behaviour.
  • ui/Row.qml renders one row delegate: the icon slot, which a thumbnail replaces in place, the PlainText name and the semantic colours.
  • ui/Opener.qml is the only component that runs Flea's own opening modes, by running flea --open, flea --terminal and a one-line sh that pipes into wl-copy, which is why wl-clipboard is a depends entry, see "Opening a file".
  • ui/ContextMenu.qml is the one pane-owned right-click popup and its single Open action.
  • ui/StatusBar.qml renders the path, row counts and transient messages.
  • ui/TabBar.qml is the window's tab strip, hidden with no height until a second tab exists.
  • ui/ChromeBar.qml renders the top chrome, and owns the path bar: the same strip typed into rather than drawn, opened by :, Ctrl+L or a double click on the path. Decided 2026-10-03, comparing one focused dialog field drawn three ways (0.3.6, 0.3.7's ring, the 0.3.8 stage's cut ring): 0.3.6's drawing is the correct one. Fields, an accent hairline frame as 0.3.6 drew it; buttons, their own frame. Every text field's focus is its own one hairline frame in Theme.color.accent (the error role in ui/RenameField.qml while the pane holds an error), the frame is Theme.color.muted while it does not hold the caret (ui/DialogField.qml, the dialogs' own fields, the picker's save name), and no field draws a ring. This replaces DESIGN-040's "A focused field keeps its muted frame and a foreground caret". The 2 px foreground frame (Buttons.RING) is a button's own frame drawn inward where the hairline cannot show focus, and nothing that takes text draws it. The path field keeps the strip geometry 0.3.6 and 0.3.7 drew (two hairlines above, one below, the flush Jump dropdown), a list row keeps Theme.fileRowHeight while its editor is open, and the rename editor's muted extension patch lies a hairline inside the frame so all four sides are whole. The rename editor is one field in every view (decided 2026-10-03): ui/RenameField.qml owns its height (lineBox, the row's text line box, or the typed line and its two hairlines where a small stop is shorter), and a host only places it, centred on whole pixels, never sets another height and never covers a neighbouring cell wherever its row can hold it, as tests/rename-frame.sh pins for the list, dual, columns, grid and rail: in every host whose row or cell is at least the frame's height, the mapped frame stays inside it and overlaps no other row, cell or column drawn beside it. At a stop where the row is shorter than the frame, the frame overhangs the row by the same amount top and bottom, and so reaches that far into the rows beside it. The stem selection is one box filling the frame's interior edge to edge (RenameField.selectionBox, the field's own is transparent), the grid centres the field on its caption's first line, and in the columns the renaming row grows by the error line as the list's does (ColumnRow.errorGrowth), the line running from the label column to the column's right inset. A button's focus frame is never cut: CardScroll carries bleed, room inside its clip that the caller's margins give back, on every dialog that holds a Flea.DialogButton or Flea.CheckBox (all of them: Collide, Trash, menu action, open with, convert, permissions, network and the save picker). CardScroll.reveal() keeps bleedY of clearance on the side an item is revealed from, so the first control returns the view to the top and the last to the end with its frame whole (a menu sets no bleed and lands flush as before). tests/ring-bounds.sh pins all of it from the real window at every text stop: the content positions on whole pixels after the card snap, in tests/ring-bounds.js (every key compared once, every other dialog listed unpinned with its reason), no ring on any button and every button's own frame inside each clip, and a scrolled probe card revealed last, first, last. Every button's focus frame draws inside its control, but a focused filled check box draws its keyline two hairlines outside its box, so bleed and Theme.focusClearance stay: dropping both together would move no control (the pins hold) but would cut that keyline at a body's edge and shrink each card's clip and reveal room. Only the rename editor has an error frame, so only it is driven in the error role. tests/ui.sh case_click reads the open path field's accent frame, its four sides and the two pixels outside each side from the native chrome capture, counting a pixel as an ink within chrome_ink_tolerance of its theme hex (the window composites over a translucent ground) after asserting accent, foreground and the ground are further apart than twice that.
  • A dialog card lands on whole device pixels, because a hairline drawn at a half pixel renders at half strength. Every card that centres itself (ui/MenuActionDialog.qml, ConvertDialog, NetworkDialog, OpenWithDialog, TrashConfirm, CollideConfirm, KeymapSheet, SettingsPanel, PermissionsDialog and the Preview surface) takes its size from Theme.cardSpan(want, room) (the want rounded up, inside the room) and its origin from Theme.cardOrigin(room, span), set as x and y and never anchors.centerIn; NetworkDialog and Preview add their rise to y. tests/permissions-adv.sh reads the helpers in odd and even rooms and the Permissions card's mapped rectangle at every text stop in an odd window, tests/ring-bounds.sh reads every opened card's mapped rectangle in its even 1000 x 650 window (a subject with no cardItem fails unless it lists why it is no card), and tests/js/permissions.js pins that each card's own bindings, children excluded, use the helpers and never centerIn. The Permissions card is the board's 480 at base size 14 (round(480 * bodySmall / 13)), its strip sits inside the card's border like the board's title, its bit columns are whole thirds, and its note is a 1.5 x caption line box with the glyphs centred in it (lineHeightMode: Text.FixedHeight plus a top padding, as CSS line-height centres). The mixed check box draws the board's bar as a rectangle (8 x 2 at an 18 px box, whole pixels, the check's cut-out ink), not the minus glyph at stroke 3.
  • ui/PathJump.qml is the path bar's folder jump, the Jump board: a name typed into the bar lists matching folders from Flea's favourites, zoxide's ranking and recently-used.xbel in one ranked dropdown under the field, and Enter opens the cursor row. ui/JumpPath.qml draws one row's path. ui/js/Jump.js ranks the rows in this order: a match on the folder's own name, then the more contiguous match, then zoxide's frecency, a favourite winning a tie, five rows a source. ui/js/Fuzzy.js ports backend/fuzzy.rs, whose exact scores tests/js/jump.js and the Rust test the_exact_scores_the_jump_port_mirrors share, value for value. The history is read once and kept, and a FileView that never loads it re-reads it only after the file changes. backend/jump.rs answers the three sources once per open of the bar, see docs/protocol.md "jump". A recent file stands for its folder only after that folder resolved, checked through existing() on the shared budget under its own mount key, so a wedged recent file costs its row and the recent rows after it, never the answer or the other two sources. An open behind a slow zoxide, its run still in flight or past its limit, draws the last ranking that answered in time, empty on a first-ever open. A line with a slash in it, starting with ~, or . or .. alone, is typed as a path exactly as before the jump, and so is every line once Tab has completed it, so "Wo", Tab, Enter still opens ./Work; so is a name that matches nothing. A name's Enter before the backend has answered is held for the answer, the keys after it are not typed, and each open's answer carries its own id, so the same keys open the same folder however fast they come. tests/jump-ui.sh drives all of this through the real bar, offscreen.
  • keys.toml is the one key table, and tools/flea-keymap-gen turns it into Keymap.js.
  • ui/js/Keymap.js is the generated key-to-action lookup and imports no QML.
  • ui/js/Format.js is the pure size, date, permission and name-elision formatter.
  • ui/js/Errors.js turns a backend failure into the one sentence the status bar shows, and imports no QML; it was lifted out of Pane.qml at the hard cap and has no behaviour of its own.
  • ui/js/Thumbs.js is the pure row-to-path map and the visible-row request plan, and imports no QML so ./tests/js.sh can redden on a mutation.
  • ui/js/Tabs.js is the directory-tab snapshots and the t/w/1-9 policy, and imports no QML. A snapshot carries a cursor and a selection only across a switch that re-lists nothing, because an index names a row and a re-read can put a different file behind the same number.
  • ui/js/Trash.js is the dd pair's arm-and-fire policy, split out of Focus.js at its cap.
  • ui/js/Collide.js is the paste-collision card's pure decisions: its words, where h, l and Tab move its focus, and the question and transfer it shapes. ui/CollideHost.qml holds the transfer waiting on the answer and ui/CollideConfirm.qml draws the card; backend/collide.rs answers the question and applies the choice, see "Write operations and the undo journal".
  • ui/js/TextSize.js is the Display section's text size: the seven stops the SettingsScale board documents, 9, 10, 11, 12, 14, 16 and 20 px, the two modes it names, and the sentence a chord announces. ui/ViewState.qml stores display.textSize as {"mode":"system"} or {"mode":N}, which is src/uischema.rs's own rule for that key and the only stored shape, and owns the writers, ui/Theme.qml derives its whole token ladder from the size in force, and keys.toml aliases textSizeUp, textSizeDown and textSizeReset onto the same three writers, so no surface reads the chord itself and a chord and a row cannot hold two different sizes. The monitor scale is the compositor's: ui/Theme.qml reads it once from hyprctl monitors -j and the panel shows it read-only, because the board rules that Flea does not step or cycle it.
  • ui/js/Settings.js is the settings panel's whole model: the three sections, the context-menu action inventory and its groups, the tri-state master over the six basic actions, which masterState derives from menu.hidden rather than storing beside it, the Shortcuts group's single keyHints row, which is the one check that is not a menu action and so is never in MENU_GROUPS, and the row list each section draws. Pure, so tests/js/settings.js drives every control without a window. ui/SettingsPanel.qml paints what rows() returns and owns the panel's two-sided keyboard, ui/SettingsRail.qml the section rail and ui/SettingsRow.qml one row of the pane. ui/SettingsPane.qml builds View and the open section's rows at the first open and the rest one section per 150 ms tick (idleBuild), so the cold open builds about 2.3k objects, not 11.9k; a section shown first builds when shown and none is torn down. tests/settings-tail.qml pins the first-open build, a show of a section the held walk has not reached, and the Columns subpage keeping every section column.
  • ui/js/SettingsAbout.js is the About section's rows, the Updates group among them; ui/js/Settings.js routes rows("about") to it.
  • ui/UpdateCheck.qml is the updater singleton: the check and launch processes and the six hour poll, see "Updates". ui/js/Update.js is its state and every word the row, the note and the footer use.
  • ui/DefaultClaim.qml is the Make Flea the default singleton: the handler read, the entry probe, the flea --default [off] run and the portal restart after it. ui/js/MakeDefault.js is its state, the row, the note and what a press runs; see "Make Flea the default, in Settings > About".
  • ui/js/MenuRefresh.js is where the menu's keyboard cursor lands when a provider refresh rebuilds the rows under it, and ui/MenuEdgeFade.qml is one edge of a menu that scrolls; both came out of ui/js/Menu.js and ui/ContextMenu.qml whole, see "File budget".
  • ui/js/Menu.js applyHidden is the only consumer of the stored hidden set, and it runs at the end of listingEntries, so a switched-off action leaves every menu that carried it at once. open and toggleHidden are refused there as well as drawn locked in the panel, so a hand-edited state file cannot empty the menu.
  • ui/js/PreviewKeys.js is what the preview overlay does with a key, and the 5 s seek step only it reads, split out of Focus.js at its cap the second time it reached one.
  • ui/js/RailKeys.js is what the rail does with a key, split out of Focus.js at its cap the third time it reached one; Focus.handleKey calls it directly, as it already called PreviewKeys. Enter or l on a row (#181) arms Sidebar.focusOnOpen and moves focus into the folder only when its open lands through openFrom, so a mount, or a share whose gio info is slow or never answers, leaves focus on the rail rather than in the folder it came from.
  • ui/js/Menu.js is what either menu holds and where its frame sits: the submenu test, the edge clamp, the listing and header row lists, and openAtCursor, the keyboard's own entrance, which came out of ui/Pane.qml when PR 34's openTerminal would have pushed it past its cap.
  • ui/js/Scale.js is the interface scale's step, clamp and sentence; ui/ViewState.qml holds it for the window and ui/Theme.qml multiplies its own tokens by it, so no surface reads the chord itself. It is a session value: the state file stores an Omarchy text-size stop and no multiplier.
  • ui/js/PathBar.js is what a typed path line means: the tilde, the relative name, the file:// URI, the interior . and .., and what Tab makes of one directory's names. Pure, so tests/js/pathbar.js drives all of it; the field itself is ui/ChromeBar.qml's.

Where the backend binary comes from

ui/Backend.qml spawns the child as [Quickshell.env("FLEA_BIN") || "flea", "--backend"]. A relative path cannot be right, because Process resolves it against the working directory of the qs process rather than against the config directory, and qs is started from several different directories here, including a non-interactive ssh whose cwd is $HOME. Measured with command: ["./target/release/flea", "--backend"] and qs started from $HOME: the child never ran, and the log read Process failed to start, likely because the binary could not be found. Command: QList("./target/release/flea", "--backend").

workingDirectory does not rescue it. It can only relocate a relative path that is wrong in the packaged layout anyway, where the binary installs to /usr/bin/flea and the UI to /usr/share/flea/ui/, so no single working directory makes ./target/release/flea correct in both layouts.

The default is therefore the bare name flea, which is exactly what a packaged install puts on PATH, and FLEA_BIN is the development seam that points at target/release/flea in this tree. Every script that starts qs for a test exports it. It is a deployment seam and not a tuning knob: a user never sets it.

Every component needs a qmldir line

ui/qmldir is the type authority for that directory, and the qualifier decides whether a reference goes through it. An unqualified name resolves only through the qmldir, so a bare Backend is a type only because a line declares one, however correct Backend.qml itself is. A namespace-qualified name does not: under import "." as Flea an undeclared sibling still resolves by file name, so Flea.StatusBar loads StatusBar.qml with no qmldir line at all. WindowBody.qml carries both import "." and import "." as Flea, so both spellings are in scope and only the qualified one falls back to the file on disk. Deleting the Header, Row or StatusBar line therefore does nothing whatever with the caches cleared, not an error and not a warning, while deleting the Backend line breaks the load outright: StatusBar and Backend are both used from WindowBody.qml, and the qualifier is the only thing separating them. Adding ui/Backend.qml alone left the config refusing to load, and the entire message was ERROR: Failed to load configuration followed by caused by @shell.qml[25:13]: Backend is not a type, which names the line that uses the type and says nothing about the qmldir. Deleting that one line from a working tree reproduces it exactly and putting it back clears it.

Every component added to ui/ still needs its own line, <Name> 1.0 <Name>.qml, alongside singleton Theme 1.0 Theme.qml. The qualified form loading undeclared is not a licence to skip it: a singleton has no unqualified fallback and Theme is referenced bare everywhere, and the first bare reference anyone writes to an undeclared component fails the whole config load with a message that points at the use and never at the qmldir.

Where the UI patterns come from

ui/ContextMenu.qml is a plain overlay Item, not a Controls Popup. It fills the pane, sits at z: 1 above the list, and stays invisible until openAt places its frame and sets opened. A backdrop MouseArea gives close-on-press-outside and the action's Keys.onPressed gives close-on-escape, which together are what the popup's closePolicy used to provide. Pane.qml reads menu.opened and never menu.visible, because a full-pane overlay's visibility is not the menu's state.

That was the only Controls import in the tree, and it cost 10 to 12 ms of warm startup and 2.2 MB of PSS, measured across nine interleaved pairs and slower in 9 of 9. Dropping it moved the warm product path from about 205 ms to about 193 ms, faster in 16 of 18 pairs over two nine-pair runs with the arms swapped between them.

Closing a popup restored focus to the item that had it for free. The overlay does not, so openAt records the sibling holding active focus and close hands it back. Without that the list stopped answering the keyboard after Escape, which is exactly what ./tests/ui.sh menu catches by pressing a cursor key after Escape. The pointer convention the menu answers is /usr/share/omarchy/shell/Ui/WidgetButton.qml:94-116, where left and right arrive through one handler and the button decides. No QML is imported from shell/Ui: the pointer convention was copied, not imported. qs.Commons is a different matter: not a public API, but already load-bearing tree-wide (Theme, NetworkDialog, SidebarRow, ContextMenu and Preview all import it for Style and Color), so an omarchy update reshaping it is a standing, tracked risk rather than a rule any one file breaks.

The Trash strip's Empty Trash is the one button, not a second recipe (decided 2026-10-03, after two Empty Trash buttons were found drawing differently). ui/TrashView.qml instantiates Flea.DialogButton with destructive: true and inStrip: true: the host's height (Theme.chromeHeight for the press area, Theme.chromeControlHeight for the frame, centred in the strip less its rule) at caption size, a muted frame that never changes, the error label, hover 8% and press 14% of the ink with whole pixel insets on frame and wash only, keyboard focus as its own frame in one foreground hairline, 0.55 when disabled, and Tab in the focus chain. The strip's former own control, whose frame rose to the ink and whose focus was a wash, is deleted. tests/button-system.sh pins the ladder in five states against a destructive dialog button, plus the sweep table of the ui/ files that declare the Button accessible role and draw a border.width (the control, the ruled set members, the marks, the picker's filter trigger PickerFilter, and the deferred NetworkForm TLS box); a new file of that shape fails its completeness check, and a hand-built button that spells neither is not seen by it. The retired second recipe is searched for across the whole tree bar the changelog. The harness is destructive-capable (it drives the real TrashView and activates Empty Trash), so the suite pins every XDG root under its marked mktemp -d root, the QML proves the environment it was given and the paths the product resolved from it (the view's home, the scripts directory, the state file, and the data home of Recent.historyPath with gio's /Trash under it, which is the one row the QML only derives, because the backend that resolves the Trash never starts here) lie under it before anything is activated, and assigns the view state only after that passed. No backend answers, so a prepare request is dropped, the confirmation card never opens and a delete or restore request fails the run. Its in-QML cap is elapsed time, half the outer timeout passed down, so the "harness did not finish" branch fires first whenever qs starts in under that half; a slower start meets the outer timeout instead. In tests/ui-trash.sh each Empty Trash shot waits on ipc trashEmptyState (hovered|focused|pressed|available), and the case hands the pointer and the keyboard back afterwards: ipc trashFocusListing releases the strip button before forcing the view, because a focus scope forced alone returns to its last child, and tests/button-system.qml drives that same body headless.

A held DialogButton keeps its native label untransformed. Its frame and wash inset each side by Math.round(size * (1 - Buttons.PRESS_SCALE) / 2), independently for width and height, with int properties animated over Buttons.PRESS_MS using Easing.OutCubic. The pressed wash and inset draw only while the held point is inside the press area; dragging off returns to rest and dragging back presses again. Reduced motion draws the wash without the inset. ChromeButton keeps its existing scale because its mark uses Shape.CurveRenderer. tests/button-press.qml pins mapped label geometry and whole pixel frame geometry at rest, during animation and held, plus drag-off, drag-back and reduced motion at text sizes 12 and 14.

The rail has one filled row and accent edge: Sidebar.highlightedIndex chooses cursorIndex while cursorDrawn, otherwise litIndex. No rail row draws a keyboard cursor frame. Trash takes Style.selectedAccentFill and an accent glyph with its label in the foreground; other highlighted rows take Style.selectedFill while focused and Style.normalFill otherwise. SidebarRow.lit remains current-place identity for IPC, separate from highlighted; Places.heldBy, settleLit, open-menu suppression and the eject mark's brightening and focus frame keep their rules.

Focus on a button or mark is its own frame, never a ring and never an accent line. ui/ChromeButton.qml draws one Theme.spacing.hairline square frame in Theme.color.foreground for the keyboard on every mark, the window chrome's, the PDF viewer's in Quick Look and in the Columns preview, and the Markdown Quick Look's close alike, on every theme. ui/DialogButton.qml draws focus in its buttonFrame, the shape Buttons.focusFrame names: "foreground", one foreground hairline, for every button; "accent", one accent hairline, for a set member (ui/ProtocolChip.qml) where the accent has a hue; and "thick", the same frame Buttons.RING (2 px) wide in the foreground, where a hairline would not read apart from the rest: a primary whose accentFrame is already the foreground, as on kanagawa, and a set member where the accent has no hue, whose current member already rests in a foreground hairline. A border draws inside its rectangle, so the thick frame grows inward: the button's size, position and label stay put, and the hover and press wash (buttonWash) is inset by the frame's width so it never paints over it. No item draws outside a button. Fields and chips keep the accent frame, and a focused filled ui/CheckBox.qml keeps its keyline. Over a strip rule (ruleRows) the glyph centres with its frame in the rows above the rule, and each Quick Look strip's kind mark takes the same centre. Quick Look's PDF viewer opens on the document with no control picked: Tab enters its strip at the first enabled control and Shift+Tab at Close, as Tab reaches the Markdown strip's close. tests/chromering.sh holds a strip mark's frame to the foreground hairline on a coloured and a hueless theme, tests/button-members.qml and tests/button-hueless.qml hold the thick frame's width, colour and unmoved geometry, tests/js/buttons.js checks that no host gives a mark a focus line and that ui/DialogButton.qml holds no buttonRing, and tests/ui-pdf.sh reads every mark's frame and Next's hover and press back through ipc pdfState and drives keys with no control picked. A focused DialogButton or ChromeButton answers Return, keypad Enter and Space itself, and a dialog that owns its Tab order hosts the button directly with tabHandle and tabStop (an unavailable stop leaves the walk and keeps its look), never a focus wrapper that copies the keys; tests/button-keys.qml drives all three keys on the Copy to and Permissions buttons and the Permissions close mark.

How the list renders

Language marks default on through the persisted langMarks boolean in src/uischema.rs; saved false stays off. The file and background menus offer Hide code icons directly after Show hidden files, and Settings > View has the same switch. ui/ViewState.qml shares it across panes and windows. Off, ui/Row.qml, ui/GridTile.qml, ui/ColumnRow.qml and ui/QuickLookBar.qml keep the generic glyph slot and create neither a LangMarkLoader nor a LangMark; toggling changes the slot in place without a re-list.

ui/Pane.qml's ListView sets clip: true because the top row of a wheel-scrolled viewport is only partly inside the list, and the rest of it draws over the column header: the cursor's accent band, its edge bar and the row's filename all landed on top of the header labels and the header rule. tests/ui.sh cursor counts the accent pixels in the header band to hold that closed.

It also sets reuseItems: true, and that is safe here only because nothing in the delegate depends on setup that runs once. Every property the delegate draws, save cursor, is a binding that reads index, so a row leaving the small cacheBuffer is re-bound rather than destroyed and rebuilt. Component.onCompleted does NOT re-run on reuse, so imperative setup added there has to be repeated from a handler that fires on reuse (onListingIndexChanged, ListView.onReused) or move to a binding, or this line has to go. The one value set imperatively is cursor: ui/CursorMark.qml writes it on the delegate the cursor leaves and the one it reaches, the delegate's Component.onCompleted files a new delegate, and onListingIndexChanged refiles a reused one, whose listingIndex moves on reuse. Measured in Plan 5 Task 5a on the 100,000-file fixture, nine interleaved pairs each driving 2400 wheel detents then 400 single-row j steps: the fling cost 1.60 s of process CPU without reuse and 1.46 s with it, reuse winning every one of the nine pairs, and the row-by-row steps cost 0.94 s against 0.72 s, again nine pairs out of nine, about a quarter less on the churn case reuse exists for. Input to rows for one G jump, stamped inside the UI, went from a median 39 ms to 37, reuse winning eight pairs and tying one. It is not free, and the direction reverses here: peak PSS after that fling rose from 76,146 KiB to 77,299 KiB, reuse the WORSE arm in all nine pairs, about 1.1 MiB of pooled delegates, pss_kb over 1024. That cost does not reach either field column, because the benchmark samples PSS from launch to settle and never scrolls: first-paint PSS moved by a median 0.16 MiB on the scale fixture over five interleaved pairs, with per-pair deltas of 16 to 626 KiB against a 455 KiB spread inside the base arm, and by 0.06 MiB on the media fixture over nine, where reuse is the higher arm in 6 of 9 and the lower in 3, and the 57 KiB median-to-median sits well inside the base arm's own 727 KiB spread. Call it a fifth of a megabyte at worst where the fling costs 1.1.

The correctness hunt was the point rather than the timing. A delegate that failed to re-bind shows the wrong icon on a row, so the check pairs delegate state against model state through the read-only seam: rowIcon(i) is what the row draws and thumbFile(i) is what the pane holds for that index, and they must agree for every instantiated row. A purpose-built fixture of 100 jpegs and 100 text files interleaved by name puts both kinds on every screen. Zero violations across eight dumps, with and without reuse, after a hard fling and after scrolling out past the buffer and back to the top. The dumper and the comparison both live at ~/bench/t5a-rows.sh and ~/bench/t5a-checkrows.py, outside the repo, so the next session re-runs them rather than rebuilding them.

The comparison was proved able to fail twice, and the second control is the one to copy. A mutation of a saved dump, swapping two rows' drawn icons and relabelling a thumbnailed row as .txt, reddens it with three violations. Stronger, and run live on the box: an arm whose delegate takes thumb in Component.onCompleted instead of binding it, which is exactly what a real reuse break looks like, produced 21 violations across the 21 thumbnailed rows of the screen while the qs log still reported LOGCLEAN. The log check alone would never have caught a live reuse break, so anything that touches delegate reuse runs this comparison and not a clean log.

The list model

Task 8's registered decision rule, written before any measurement was taken through the product path: ship ScriptModel plus Qt's documented ItemSelectionModel only if BOTH an interleaved fling on the scale fixture drops no more frames than the integer model does within the run-to-run spread AND the scale Pss regression sits inside a 1 MiB budget; otherwise keep model: root.total (rule 2 above) and hand-roll selection as an index set. A windowless probe had already measured ScriptModel holding 100,000 integers at about 3.7 MB more Pss than the same process holding nothing, so the prediction, registered in advance, was decline on memory. This section is that prediction checked against the real application rather than a probe.

Two scratch UI trees, one shared binary, because the model lives only in QML. Arm I is the shipped ui/ unmodified, ui/List.qml's model: pane.total. Arm S patches ui/List.qml to model: rowModel, a ScriptModel filled through one setRowCount(n) function that assigns [] before the new array, because a single disjoint replace of 100,000 values diffs quadratically (about 294 s in the windowless probe; clear-then-fill there cost 17 ms). Both arms launched flea --gui against the same target/release/flea, so only FLEA_UI differed. Nine interleaved pairs on the scale fixture (/home/flea-sandbox/flea-bench-btrfs, 100,000 files), Pss read from smaps_rollup in KiB after settling to a 256 KiB band held for three 300 ms samples, order alternated pair to pair:

Arm I median (KiB)Arm S median (KiB)delta
Pss76,30878,064+1,756 KiB, 1.71 MiB
Pss_Anon37,40039,052+1,652 KiB
USS (Private_Clean + Private_Dirty)54,98856,704+1,716 KiB

Arm S cost more Pss in 9 of 9 pairs, fully disjoint from Arm I in every one (per-pair delta 567 to 1,925 KiB, no overlap). This box permanently holds a resident quickshell, which is the sharing condition Pss divides against.

Ruling: ScriptModel is declined on memory, per the registered rule: 1.71 MiB median against a 1 MiB budget, over budget in 9 of 9 pairs. The fling half of the gate was not run. The ship rule needs both halves to pass and this half already fails on its own with no overlap across nine pairs, so a second box-time measurement on the scale fixture cannot change an outcome the first one already settled; this project already used the same reasoning to stop the row-mark gate once its own decisive axis (the toolchain) made the rest of that measurement moot. ui/List.qml keeps model: pane.total, unchanged. Task 9's selection is a hand-rolled index set in ui/js/Selection.js, not ItemSelectionModel.

A count change keeps the drawn delegates. Assigning a new integer to a view's model goes through QQuickItemView::setModel, which clears the view: every item is released as not reusable, the position goes back to the start, and refill() builds every visible delegate again, so reuseItems never applies. Every filter key and every live search answer paid that rebuild (11 of 12 drawn rows on a narrowing in tests/listnarrow.sh, before the change). ui/StableModel.qml is a DelegateModel over a fixed integer of 100,000,000 rows with a shown group holding the first total of them, and the view filters on that group. A new count adds or removes rows at the group's tail, so the delegates that stay drawn are kept and re-bound, and the model still holds no value per row. It keeps what the reset did: the view goes back to its start and to row 0, and the Grid, whose current row follows the cursor, is placed on that row after the change. A move asked for from inside the change's own layout is dropped, which is why the Grid places itself on the model's resized signal. tests/listnarrow.sh, tests/gridnarrow.sh and tests/columnnarrow.sh gate it: a narrowing and a widening build no delegate, every kept delegate draws its new row, and no pooled one lights a cursor, rename or drop mark.

A search's first answer builds nothing. A folder holding fewer rows than the viewport holds fewer delegates than a search's first answer draws, so that answer built the rest, and every row built its location layout as it located: on tests/search-warm.sh's fixture, 62 rows and 895 items in the list, 80 and 1,200 in Columns, 160 and 1,200 in the Grid, all in the answer's frame. One turn after the frame that first draws the search strip, ui/PaneSwap.qml sets searchWarm. Every row then builds its location layout hidden (ui/Row.qml locateArmed, ui/ColumnRow.qml markArmed), and each view's ui/StableModel.qml fill grows its count to the rows a full layout from the top builds and shrinks it back in the same turn, with the cache buffer added to the display margin for that pass, so those rows build at once instead of incubating for rows the shrink removes. The delegates it laid out wait culled in the view's reuse pool, which only a geometry change or hiding the view drains, and the answer that grows the count takes them. No frame sees the grown count. The gate checks that the first answer's frame builds no row and no item and destroys none in any view, and that the warm turn draws nothing the strip's frame did not.

A new listing reuses the old screenful from the pool. A shown view keeps its pooled delegates when it empties, and the next listing's rows take them back instead of building anew (warm Enter built 43, 50 and 163 delegates in List, Columns and Grid; it builds 0). Emptying lays out on its own (forceLayout), because Qt pools a layout's removed rows only after it builds the inserted ones. Only a hidden view drains its pool (StableModel.drainHidden, on the count reaching 0 or on visibleChanged). A pooled delegate is parked: ListView.onPooled/GridView.onPooled clear its live flag, and every binding that follows the listing reads its own last value while parked: the row, thumbnail, size, clip mark, kind names, search run, filter and Recent flags, and Columns' lifted trail, which follows the pane's path. listingIndex is -1, cursor and selection are false and its HoverHandler is off. renaming and dropTarget test that -1 first, and Columns' renaming and errorGrowth the false cursor, so none of them reads the pane or the drag further. What a parked row still reads is view-wide: List's paneFocused and dualMode, the pane reference itself, each view's shared widths, column set and name budgets, and the drag session's copy and link flags. These follow focus, the window, dual mode and Recent, never a listing's rows, so a plain Enter or Back re-runs none of them on a parked row; entering or leaving Recent re-runs the column set on parked rows as on live ones. onReused sets live before the first frame, so a reused row draws its new row and no stale mark. tests/poolpark.sh gates it in all three views: a warm Enter, and the clear of a filter narrowed far enough to park delegates, build no delegate, and no parked or reused delegate shows a cursor, selection, rename, hover or thumbnail that is not its own. Each arm is first seen to light on its own row (a rename on the cursor row, a hover driven by a synthesized pointer, a thumbnail file per row, compared both as the delegate's thumb and as the path its Image draws), and a positive control shows the past-read count sees a delegate's read.

A parked row still arms its marks, and a pooled Grid tile keeps the editor it held. The match and location arming (List's markArmed and locateArmed, the Grid's markQuery and markArmed, Columns' own markQuery and markArmed) stays live on parked rows: it follows the strip, never a listing's rows, so a pooled row builds its mark with the strip and not on a later keystroke. A Grid tile owns its park flag (ui/GridTile.qml pooled, which ui/GridArea.qml reads as live), so its editor state is set in the same handler as the park. A tile pooled while it holds the rename editor records the row in pooledEditorRow and keeps that editor, hidden, while the pane's edit stays on that row, so the same-row replacement takes over the draft and selection (tests/rename-grid-bottom.sh). Like every parked delegate its listingIndex reads -1 and its renaming false, and editing covers the held editor for the tile's own faces. A pooled tile's Loader is active only for the row recorded when it was pooled, and the record clears when the edit leaves that row or the tile is reused, so a pooled tile never builds an editor.

File budget

The RAW3 join onto the 0.3.11 stage records the merged wc -l ceilings at src/backend/metareq.rs 541, src/backend/menu_actions.rs 674, tests/js/facts.js 415 and ui/js/Facts.js 402. ui/MenuActionDialog.qml crosses the unchanged 400-line cap at 402 for the initial photo facts combined with the deferred file facts, each pair drawn once in the board's order; its scoped ceiling is the merged count. No code or comment was compressed to meet a cap. The same join keeps all 66 stage Properties checks and all 7 RAW3 checks, then adds 4 checks for duplicate pairs, approved values and deferred completion of missing facts, and 8 checks (2 for each of a partial ISO, a partial camera, complementary fields and conflicting fields) that the immediate and deferred photo facts merge field by field before each pair is drawn once, so tests/menu-contents.sh expects 85. Both lint profiles retain their allowance counts, source text, symbols and reasons, with the ui/MenuActionDialog.qml reads one line lower (body 325 to 326, root 328 to 329 and 342 to 343). The 169 first-frame units still sum to 1,628,263 bytes; the changed RAW3 UI units are deferred, so tests/startup-units.txt stays unchanged.

Qt 6.12 sequence handling adds js/Lists.js to the first-frame unit list. The merged 169 units sum to 1,628,263 bytes, re-derived with the wc -c sum used by tests/startup-objects.sh, from 1,627,754 bytes over 168 units. The added helper contributes 455 bytes and the Menu.js and MarkdownPrepared.js changes 54 bytes; the deferred menu-only units stay outside the first frame. The shared list test changes sequence recognition in Compare and nested Markdown readers. The ui/js/Menu.js ceiling moves from 502 to 503, re-derived with wc -l for its helper import. The 26 existing QuickLookPrepare.qml lint allowances move one line for the same import; their source text, symbols, reasons and counts are unchanged.

The shared XDG directory reader is ui/UserDirs.qml, 25 lines, replacing the rail and picker readers; ui/js/UserDirs.js holds the existing parser without importing menu code. ui/ColumnsArea.qml moves from 572 to 576 lines for re-asking drawn columns when those directories change. The first-frame unit inventory adds UserDirs.qml and js/UserDirs.js and its summed source ceiling is 1,630,789 bytes, re-derived from the listed files with their current byte counts. tests/columnsfilter.qml moves from 402 to 403 lines for the backend stub's codeResult signal, matching the Markdown preview connection. ui/PickerWindow.qml moves from 812 to 813 lines for resolving each requested folder's sort while keeping the held rows' sort mark stable between requests.

Columns landings retain their answered peeks and bound changing ancestor slots through the existing swap timings. The scoped ceilings are re-derived with wc -l: ui/ColumnsArea.qml 590, ui/ColumnPane.qml 461 and tests/columnsfilter.qml 403. The filter fixture declares the backend's codeResult signal so the Markdown preview keeps its strict log check.

The land235c review fixes re-derive the ui/Row.qml ceiling from 458 to 459 and tests/rowcost.qml from 417 to 438 with wc -l. The row's completion handler loads a cue that was active from creation, and the construction probe requires both initial clipboard marks and an initial move cue. Global limits stay unchanged.

The land235b shared-cue deferral re-derives the ui/Row.qml ceiling from 496 to 458 with wc -l. The cue and clipboard mark move into ui/RowDropTarget.qml, loaded by URL only while drawn; the global limits stay unchanged.

The Drag to Trash merge on the 0.3.11 stage re-derives two ceilings with wc -l: ui/js/Drag.js 349 to 357 for the listing-generation field after the existing lift bits, and ui/Sidebar.qml 713 to 709 after its Trash delegate moves into ui/RailTrashRow.qml. Global limits stay unchanged.

The document-view state test keeps src/uistate.rs at 849 lines (wc -l) to cover fresh defaults with no view leaves, loading retired Markdown and HTML leaves unchanged, unrelated patches preserving them and refusal to patch either retired leaf.

The HTML still-image request moves these scoped composition ceilings, each re-derived with wc -l: src/backend/proto.rs 471 to 473 and src/backend/run.rs 1114 to 1116 for the request variant, parse, dispatch and row-count arm; src/backend/sandbox.rs 399 to 401 for the companion wrapper export; src/uischema.rs 570 to 572 for default assertions covering both retired view leaves, with DEFAULTS and PREVIEW identical to 723b235c8; ui/Backend.qml 597 to 606 for the HTML signal and tokenized request with optional viewport height; ui/Preview.qml 1011 to 1007 and ui/PreviewColumn.qml 553 to 559 for the shared lazy loader target, transient Quick Look flip and HTML failure route; ui/ViewState.qml stays at 549 without document-view accessors; and ui/Ipc.qml 985 to 986 for the HTML observer. The HTML renderer, cache, jail argv and lazy UI bodies live in their own files below the soft budget. The preview key fixture moves from 320 to 328 lines for HTML routing and the transient-state assertion; the Markdown state fixture stays at 474 and pins column independence. The Markdown render fixture is 893 lines after replacing segment checks with mark, name, count, close geometry and keyboard assertions. Global limits do not change.

COPY-TRASH on the 0.3.11 stage moves the src/backend/trashbrowse.rs ceiling from 900 to 1025 (wc -l) for the approved typed refusals, per-item damaged-record failures and backward target naming, retaining the stage's newest-first listing and membership checks. The same port moves src/backend/trashdelete.rs from 938 to 965 (wc -l) for the approved outcome wrappers and cleanup logging, with the failed child put-back logging credited once to this port. Copytrash5 raises src/backend/trashdelete.rs from 965 to 984 (wc -l) for truthful record completion outcomes, preserving the first claim refusal after rollback and logging cleanup failures, plus stable temporary-folder paths. Copytrash6 raises src/backend/trashdelete.rs from 984 to 987 (wc -l) to retain the metadata claim's OS error kind and request confirmation again when its file disappeared, preserving the rollback and other claim diagnostics.

Compare for marks outside the held window records the merged ceilings at ui/PaneMenuActions.qml 527 and src/backend/menu_actions.rs 658: the menu snapshot's selected kinds, the row refresh that offers Compare once the snapshot answers and the inspected kinds in the snapshot reply join the existing menu changes, each re-derived with wc -l.

xw6 adversarial fixes retain the tab handoff detail and exact payload paths. Each lift captures a stable tab identity and its source pane until acknowledgment or the existing 15-second deadline. Accepted lifts wait for pane settlement beyond that deadline, and source closures wait for a held strip reorder to finish; pending receiver drops are refused without acknowledgment. A desktop tear-off uses the same acknowledgment after its initial folder lists. The geometry query restarts for the latest overlapping lift, from either its process exit or its stream end. The tear-off variables FLEA_TAB_SOURCE_PID, FLEA_TAB_CURSOR and FLEA_TAB_TOKEN reach only the window a tear-off starts: src/tearoff.rs drops them from the terminal and opened-program launchers, and Tabs.freshLaunch drops them from the new-window launch. The wc -l ceilings are ui/TabBar.qml 607, ui/js/Tabs.js 723, ui/Pane.qml 993, ui/WindowBody.qml 635 and ui/boot/fleatab.qml 50, with merge-base comments kept verbatim with their original line breaks. tests/js/tabs.js is 686: the hunt cursor-name checks join the existing no-such-tab, navigation acknowledgment and captured tear-off token checks, and the new-window check, the two geometry hook orders and the hand-off name comparison add to them. The cursor handoff uses held rows or the existing name-only locate request, then fetches only its destination window; desktop tear-off carries the filename through the launch environment. ui/PaneWire.qml is 539, ui/js/Drag.js 372 and tests/js/drag.js 427, for the locate reply route and percent-encoded drag source field with bounded legacy shapes. The Columns floor uses the same loading refusal and dropPath as List and Grid. The test fixture loads shipped QML function bodies and the geometry query's exit and stream-finished hooks, so cancel, completion, acknowledgment and restart assertions exercise the actual transitions.

xw6 round 13 records commit 5b91e7bd: ui/TabBar.qml 569 to 588 for the sibling drag source, source geometry snapshot and shared own-return path. ui/js/Tabs.js 672 to 686 adds the pure catcher outcome decision using the existing insertion math. Geometry query plumbing lives in ui/TabDragGeometry.qml, below the soft budget; no global limit changes.

The deferred-row queue moves one ceiling: src/backend/thumbreq.rs 448 to 454 for the queue_row, cancel_row and report_done bookkeeping, then to 457 for pumping before the cap check and passing the wire to the shared pump, then to 464 for cancel_all, the cancel-everything arm the dispatcher and its regression share. src/backend/thumbdefer.rs is 93 lines for the FIFO, returned-job accounting and its forced stale-room regression; src/backend/thumbdefer_tests.rs is 273 for the request, cancellation and freed-room regressions, with a queued-row cancel that admits the oldest waiting row at once and a cancel-everything that takes every waiting row. The State field adds 2 lines to src/backend/state.rs; the run.rs call sites are net-zero lines. All counts are re-derived with wc -l. The Quick Look volume track moves four ceilings, each re-derived with wc -l: src/uischema.rs 502 to 503 for the preview.volume default and its Count(0, 100) rule, src/uistate.rs 674 to 704 for the two tests that pin a stored level and a refused patch for anything but a whole level, ui/ViewState.qml 536 to 539 for mediaVolume and setMediaVolume, and ui/Preview.qml 739 to 744 for the volume track handle and the strip's hide timer waiting out a held seek or volume press. The new tests/mediastrip-volume.qml is 390, for the track width, accessibility step and held-press legs.

tools/flea-file-budget scans src, ui, shelf and tests for .rs, .qml, .js and .mjs files (.mjs takes the JS budgets). Rust and QML get a 250-line soft budget and a 400-line hard cap; JS gets 200 soft and 300 hard. Going over the hard cap fails the tool; going over the soft budget only warns. The md3 Markdown rhythm harness recorded tests/markdown-render.qml at 493 lines; its marker-baseline follow-up raises that one scoped ceiling to 628 for two-size painted-baseline and adjacent-item geometry checks, with the details beside the md3 paragraph below. tests/rename-cost.qml is recorded at 482 lines: its per-key census (frames, replies, selection bumps, delegates built, destroyed and rebound, row field reads) runs for each rename end in all four views and cannot split from the one real WindowBody it drives. tests/js/runfile.js is recorded at 313 lines, from 305, for the extensionless-program check. Such a program reaches the card under either icon mark, pinned at Nav.openCursor. ui/GridArea.qml is 665 lines by wc -l, its recorded ceiling (moved from 663 for the viewTop() helper and its comment, which keeps the viewport top at contentY - originY for the drift check and coversCursor); the column and row-count handlers restart that drift check with coalesce.restart(), so a resize that re-flows the tiles asks for the window they draw without waiting for a scroll.

tests/csv-preview.qml is recorded at 520 lines by wc -l: one real-backend harness drives every CSV and TSV case (record-aligned jumps, first-screen continuation, capped replies, plain line counts and the drawn Quick Look elision) over one fixture set, including the record-ordinal regressions and two-row quoted fixture checks; splitting it would start a second backend per file. The budget is a smell detector, not a target, and it is not a reason to refactor a stable file. Three files cross the hard cap purely as arithmetic of a clean merge for 0.1.4, with no conflict and no new code: ui/ChromeBar.qml, ui/Sidebar.qml and ui/Row.qml, the last by a single line. ui/NetworkMounts.qml was a third until its own reconciliation extracted authFailure to ui/js/Errors.js and brought it to 398, so it is not listed. They are listed in tools/flea-file-budget as known exceptions so the tool still fails on anything else, and each prints its own line rather than being hidden. The view fixes of 2026-09-07 took ui/NetworkDialog.qml, ui/Ipc.qml, ui/Pane.qml and ui/PreviewColumn.qml, all already at the cap, 2 to 6 lines over each (overlay sinks, the column player in its frame, per-view IPC readers, the columns thumbnail relay); they are listed the same way, as 0.1.6 exceptions. Every count below is wc -l on the file, and every test-module count runs from its #[cfg(test)] line to the end of the file; run the tool rather than trusting these if the two disagree. Three of them had gone stale by a whole plan and were re-derived from wc -l in Plan 5 Task 5a, so when you touch a file here, re-derive its count from the artefact rather than adjusting the nearest number.

mga round 1 records src/backend/opsdispatch.rs at 2204 and src/backend/opsreq.rs at 551 after deleting the obsolete slow-link lifecycle and its duplicate landing. src/backend/run.rs is 662: its test-only adopt is gone, and the held-link regression now drives the production request dispatcher here, checking read responsiveness, the operation slot, completion and undo. Each ceiling is re-derived with wc -l; src/backend/proto.rs is 363, below the 400-line hard cap, so its unused recorded exception is removed. Global limits stay unchanged.

The FAT card's Rename reaching the names inside a copied folder records src/backend/durable.rs at 803 (the rename_fat field and its comment, lines 224 to 225) and src/backend/opsreq.rs at 617 (the line that sets it from the policy, 289, and the one that journals it on the copy steps, 493), each re-derived with wc -l; Durability is the one per-operation object every copy already carries down to copy_dir_entries, so the flag rides it rather than a new Progress field at 29 literals.

tests/js/rail.js is 446: the auto-hidden rail's slide checks (dwell token, durations, curve, reduced-motion gate, the x and visible bindings, the build-once Loader) join the Mounts and cursor-follow checks already there. ui/Pane.qml is 1182: railRevealed and railX pass the sliding rail's state to railState in ui/Ipc.qml.

src/vulkan.rs is 0.3.2's own exception, recorded rather than split. PR119's display-GPU pin took it from 472 to 604 lines, the growth being icd_for_displays, display_pin and the tests that drive a hybrid tree this box cannot produce. The file has one subject, which is what Vulkan can be asked for on this machine, and splitting the fake-tree tests away from the function they pin would make the seam the test harness rather than the subject.

src/backend/thumbworker.rs is 0.3.2's second recorded exception, at 855 lines: 498 before its #[cfg(test)] line and 357 from it. The file is one job's confinement, the descriptor hand-off, the limits, Landlock, the seccomp call filter and the reap, and its walk test checks the filter's tables against asm/unistd_64.h and linux/sched.h beside the tables themselves. The same release moved three recorded ceilings: src/backend/thumbs.rs 432 to 441 for routing a video to the worker, ui/Row.qml 410 to 421 for the row diet's Loaders, and ui/Ipc.qml 762 to 780 for the three readers of a dual pane's path and the file row height a density sets. Each count is wc -l on the file at the commit that recorded it.

0.3.3 records its growth the same way, each count re-derived with wc -l at the commit that recorded it. src/backend/thumbworker.rs 855 to 903 for the aarch64 tables of #187, whose test still checks each against the kernel header, then to 953 at v0.3.3 itself for the checks against the numbers older headers carry, recorded in 0.3.4 once the gate showed it had been failing since the tag. 0.3.9 takes it 953 to 962 for the av_cpu_force_count(1) wiring, because one libav thread per video costs less CPU for the same PNG bytes. src/vulkan.rs 604 to 620 for the skip on a build box with no Vulkan loader. src/backend/localsend.rs crosses the cap at 403, from 399, for its test's tolerance of a descriptor sleep closes just after exec; the file is the LocalSend bridge and its tests, one subject. src/tui/actions.rs 999 to 1016 and src/tui/model.rs 1174 to 1196 for the anchored re-sort and the cursor keys that spend it. ui/PickerWindow.qml 632 to 676 for the chooser's sort header, PR #185, which yields to the save form, then to 706 for the file double-click send (w9 doubleActivate, the mark-then-accept reaching Enter's own accept). ui/ChromeBar.qml 409 to 421 for the handlers that stop under Quick Look. ui/Sidebar.qml 532 to 543 and ui/NetworkMounts.qml 553 to 560 for the rail's one-step settle. ui/OpenWithDialog.qml 583 to 588 and ui/Ipc.qml 780 to 781 for the scrollbars of PR #128, and ui/WindowBody.qml 476 to 484 for the dual view's launch folder. src/backend/run.rs goes from 429 to 441, 9 lines for the anchored re-sort's reply and 3 for U7's prefetch record at the first rows reply, then to 442 for U9's collisions request, and U7 takes ui/js/Keymap.js from 310 to 313 for the generator's first-use hint build. ui/Pane.qml goes from 640 to 641 for the dual path strip's inputLive, the chrome's Quick Look gate on the pane's own crumbs, and to 645 for U9's CollideHost, the collision card every paste, drop and menu transfer asks through, then to 647 for "The listing swap"'s swap alias and its comment. That section takes ui/Ipc.qml two lines further, to 791, for its swapState reader, and src/backend/run.rs from 442 to 449 for the stale-rows refusal ahead of the dispatch, the numbering's first value and its move in forget_rows; the refusal itself and its tests are src/backend/rowguard.rs. It moved the listing's listed and rows handling out of ui/PaneWire.qml whole into ui/PaneSwap.qml, inside the soft budget, which took ui/PaneWire.qml from 485 to 453, under its recorded ceiling. Its review then moved the list arm's success tail into run::adopt_listed, which the list_dir writability test drives, and the backend's start into State::new and Tables::load, which took src/backend/run.rs back down to 427, now its recorded ceiling. src/uistate.rs goes from 440 to 442 for Rule::Version, one arm in fits and the refusal in check with its comment, less one stray blank line in its tests; the showUnmounted migration itself went to its own module, src/uimigrate.rs, 174 lines inside the soft budget with its #[cfg(test)] at 41, so 40 lines of implementation and 134 of tests, rather than raising that ceiling further. ui/Ipc.qml goes from 781 to 789 for U9's collideState, the card's reader for tests/ui.sh collide. src/backend/opsdispatch.rs stays at its recorded 536, because the menu selection lookup moved into the new src/backend/collide.rs with the rest of the collision logic.

The updater made room rather than raising a ceiling. ui/js/Settings.js is 407 of its recorded 427: the About section's rows moved whole to ui/js/SettingsAbout.js, and Update Flea's Menus switch took two lines back. ui/js/Menu.js, which stood at the 300 line hard cap, is 287: refreshedCursor moved whole to ui/js/MenuRefresh.js, 23 lines with its one caller in ui/ContextMenu.qml, and the Update Flea row took seven lines back. ui/ContextMenu.qml is 524 of its recorded 534, because its two scroll-edge fades became ui/MenuEdgeFade.qml, and ui/SettingsPanel.qml is 541 of 542, because the About rows now carry their own URLs. ui/SettingsRow.qml 408 to 409 and ui/WindowBody.qml 483 to 484 stay inside their recorded ceilings. Make Flea the default put its processes in the new ui/DefaultClaim.qml (134 lines, 73 before its reads were numbered, each answer joined from exit and text, and a program that never starts caught) and its state in ui/js/MakeDefault.js (174, from 139), and took ui/AboutFacts.qml's handler query with it (110 to 103). tests/js/settingsabout.js went from 187 to 250 lines for the same review, over the 200 line soft budget and not the hard cap. The row spends the last line of ui/SettingsPanel.qml's ceiling on its route, 542 of 542, and raises ui/SettingsRow.qml's from 409 to 410 for the note that elides on one line instead of wrapping. src/update.rs is 301 lines with its tests in src/update_tests.rs, over the soft budget and not the hard cap; the seam if it needs one is the six parsers of what each command printed.

Movebatch moves one ceiling, re-derived with wc -l: src/backend/opsreq.rs 402 to 467 for driving cross-device moves through the batch (full, folder-change, cancel, failure and end closes); the batch itself is the new src/backend/movebatch.rs at 349 with its tests in src/backend/movebatch_tests.rs at 279, both over the soft budget and inside the hard cap, and src/backend/durable.rs stands at 364 with the batch's one-confirm union flush.

Durable copies record two ceilings, each re-derived with wc -l on the integrated 0.3.6 branch. src/backend/copyfile.rs 404 for the per-file sync a removable or network destination takes, and src/backend/opsreq.rs 402 for carrying the batch's durability context and its verdict on the done line; the policy itself is all src/backend/durable.rs.

Fix3-durable2 moves three ceilings, each re-derived with wc -l: src/backend/copyfile.rs 432 to 434 for the cancelled-tree forget, src/backend/renamecompat.rs 390 to 416 for the flush-failure take-back and its test, and src/backend/durable_tests.rs 323 to 520 (beside the dest-only confirm test) for the cancel, empty-finish, file-fail, drive-name and parent-classify tests; src/backend/durable.rs stands at 297 inside the hard cap for the mount-named drive, the landed count and the full-word rename.

Durability's UI half records one ceiling, re-derived with wc -l at the commit that recorded it. ui/StatusBar.qml 397 to 446 for the clickable "z undoes" (the undo segment beside the rest the secondary already drew, its hover lift, the click that reaches the strip's own pane through the same Ops.undo the z key takes, and the hintRestMetrics that keeps the reservation exact) and for the final-flush Esc guard. The card's own flush state lives in ui/js/Transfer.js 94 to 121 and ui/TransferCard.qml 271 to 276, both inside their budgets; ui/js/Ops.js keeps its recorded 367 by extending two object literals in place, ui/PaneWire.qml stays inside its recorded 485 at 453, ui/Backend.qml at 400 is exactly at the hard cap, and ui/Ipc.qml goes from its recorded 792 to 793 for the undo target's own textState in the footer JSON (the hover-lift read), beside the transfer-card headline, writing flag and undo control state that one extended line already carries.

Fix-status takes ui/StatusBar.qml from its recorded 446 to 451, re-derived with wc -l: the busy mark moves behind a Loader active on busy and sizes from Theme.chromeMarkSize with every centre child vertically centred, the undo texts stay empty unless split, the secondary lane moves ahead of the undo target so esc keeps the 0.3.5 order ahead of z, hintWidth returns to hintMetrics.width, and clickUndo passes the rename, search typing and selection band gates the z key passes.

F037qml moves three ceilings, each re-derived with wc -l: ui/StatusBar.qml 451 to 457 for honouring every centre role instead of collapsing to foreground, ui/TrashView.qml 474 to 476 for the named press scale, and ui/Theme.qml 405 to 415 for the body-face advance and the clamped stored column widths. Later work leaves ui/StatusBar.qml at 456 and ui/TrashView.qml at 475, each re-derived with wc -l.

Integrated on the 0.3.6 branch after #194, the hidden-rail eject takes ui/WindowBody.qml from 497 to 507, re-derived with wc -l on the integrated branch.

0.3.6's folder jump records three ceilings, each re-derived with wc -l at the commit that recorded it. ui/ChromeBar.qml 421 to 438 for the jump's request signal and alias, the strip's rise over the listing while the dropdown shows, the field's Keys.forwardTo and the PathJump it owns with its decline back to the typed path, and to 439 for the dropdown padding's dismiss into closeEdit; the dropdown, its sources and its keys are all ui/PathJump.qml. ui/WindowBody.qml 484 to 486 for carrying the request to the pane's backend and its answer back, the two lines Tab's peek already spends. src/backend/run.rs 427 to 428 for the jump arm, whose work is all src/backend/jump.rs. Fix4-lazyjump takes ui/ChromeBar.qml from its recorded 439 to 468 for the lazy jump Loader with its three live bindings and its signal connections, and ui/WindowBody.qml from its recorded 557 to 558 for the null guard that drops a jumped answer arriving before the bar ever opened, each re-derived with wc -l.

Trashone takes ui/js/Ops.js from 335 to 367 for the five singular/plural sentence helpers every delete confirm and status line now shares, ui/TrashView.qml from 466 to 467 for the one import that reaches them, src/tui/render.rs from 822 to 835 for the singular TUI confirm and its test, and src/tui/model.rs from 1196 to 1197 for the noun on its Deleted line.

Fix3-rest takes src/tui/render.rs from its recorded 835 to 889 for the shared one-vs-many helpers (word, items, marked, hidden_note) with their tests, and src/tui/model.rs from its recorded 1197 to 1247 for the singular-aware clipboard, search, trash, transfer and bulk lines with their one-and-many tests plus the echo-wire helper in src/tui/wire.rs, each count re-derived with wc -l.

ExtThumbs moves seven recorded ceilings, each re-derived with wc -l: src/backend/run.rs 428 to 429 for the fsinfo class beside the figures and the thumb flag; ui/Pane.qml 673 to 687 for the storage class, its menu binding and the class toggle; ui/ContextMenu.qml 566 to 570 for the class property and the two fields the row reads; ui/PreviewColumn.qml 480 to 489 for the manual hold, its note and the text cap; ui/Preview.qml 417 to 420 for the Quick Look text cap; ui/js/Focus.js 334 to 336 for the class row's dispatch; ui/Ipc.qml 791 to 794 for the class and hold readers. The row's own decisions went to the new ui/js/ExtThumbs.js, 69 lines inside both budgets, rather than into ui/js/Menu.js, which takes the inventory line and one call and stands at 297 with 3 lines under the hard cap; ui/js/Settings.js takes the three checks, the hint and the renamed cell at 419 of its recorded 427.

Fixext moves three recorded ceilings, each re-derived with wc -l: ui/Pane.qml 705 to 717 for the storageKnown gate, the ExtThumbs refresh and the preview change hook; ui/PreviewColumn.qml 495 to 499 for the manualHold terms on text, PDF and the original fallback plus the truncate flag; ui/Preview.qml 456 to 457 for the Quick Look truncate flag. Inside their budgets: ui/js/ExtThumbs.js at 91, ui/PreviewLines.qml at 99, ui/PreviewText.qml at 83, ui/SelectionPreview.qml at 251, ui/ColumnsArea.qml at 326, ui/js/Nav.js at 251, ui/PaneWire.qml at 479 of its recorded 485, ui/js/Settings.js at 420 of its recorded 427.

The Locked tile's own menu raises two recorded ceilings rather than splitting a third file. ui/ContextMenu.qml goes from its recorded 534 to 566 for the tile entrance (tileTarget and tileMode, openLocked, the openBackground route, the buildEntries arm and the provider-refresh guard), and ui/Pane.qml from its recorded 648 to 673 for the tile target, the menu bindings, the locked dispatch and the m key's locked arm. The rows themselves went to the new ui/js/LockedMenu.js, 43 lines inside both budgets, rather than into ui/js/Menu.js, which stands at 292 with 8 lines under the hard cap; ui/js/Nav.js takes lockedTarget and stays at 248, inside the soft budget. tests/js/nav.js holds its new locked cases at 299, one line under the hard cap and not over it. Each count re-derived with wc -l at the commit that recorded it.

Eject with a hidden rail raises three recorded ceilings rather than keeping a poller alive. ui/Pane.qml goes from its recorded 673 to 676 for the ejectHidden route into the rail, ui/WindowBody.qml from its recorded 487 to 497 for the mid-dialog rail-hide guards on the network save and the share-browser mount, and tests/js/focus.js from its recorded 374 to 403 for the hidden-rail release, completion and not-inside cases. ui/PaneRail.qml holds the transient one-shot host at 152, inside the soft budget, and ui/js/Eject.js stays at 153, inside its own. Each count re-derived with wc -l at the commit that recorded it.

Trashflake takes src/backend/trashdelete.rs from its recorded 895 to 901 for remove_tree's arrival gate, which checks child counts through matches() because a same-tick arrival keeps a directory's size and mtime, plus the thread-local walk counter that proves one delete runs exactly one matches() walk. Count re-derived with wc -l.

The GVFS bridge raises two recorded ceilings rather than splitting a third file. ui/NetworkMounts.qml goes from its recorded 560 to 578 for the bridge hold (the sticky signal, the GvfsBridge service with its four handlers, and the info leg opening through bridge.ensure instead of at once), and ui/Sidebar.qml from its recorded 543 to 545 for the one onSticky line that hands the wait's line to the pane that asked. The decisions went to the new ui/js/GvfsBridge.js, 177 lines inside the soft budget, and the processes and timers to the new ui/GvfsBridge.qml, 122 lines inside it; ui/StatusBar.qml takes the busy mark and stays at 397, inside its 400 hard cap. Each count re-derived with wc -l at the commit that recorded it.

The #323 hotfix takes ui/WindowBody.qml from its recorded 816 to 817 for the clearSurface flag ui/boot/shell.qml reads, re-derived with wc -l.

Issue 194 takes ui/NetworkMounts.qml from its recorded 560 to 656 for the smb FUSE guard and the repair that peeks gio's folder, then opens the matching entry out of the FUSE root through backend.peek instead of reading a wrong mount as an unreadable directory, and ui/WindowBody.qml from its recorded 487 to 497 for routing a typed network address to the rail's own open-a-share path instead of a local listing. The matching lives in ui/js/Protocols.js, 202 to 296, and the bar's hand-back in ui/js/PathBar.js, 204 to 214, both over the soft budget and under the hard cap. Each count re-derived with wc -l.

RailEject and CloudMounts raise five recorded ceilings rather than splitting a sixth file. ui/NetworkMounts.qml goes from its recorded 703 to 727 for the cloud FileView on /proc/self/mountinfo, the FUSE rows it merges after Dropbox, and the cloud activation that opens the mountpoint directly; ui/Sidebar.qml from its recorded 553 to 569 for ejectRow and its two repeater wirings; ui/js/Ops.js from its recorded 367 to 371 for the verdict note on the done line; tests/js/ops.js from its recorded 384 to 390 for that sentence's four checks; and tests/js/status.js crosses the hard cap at 304 for the secondary-lane checks, recorded rather than split. The mark predicate went to ui/js/Eject.js, 153 to 179, the cloud table to the new ui/js/Cloud.js, 67 lines inside both budgets, the hint split to ui/js/Status.js, 188 to 199, and the rclone verdict to src/backend/durable.rs, 223 to 249, each inside its budget. Each count re-derived with wc -l.

The GVFS bridge review moves four recorded ceilings, each re-derived with wc -l: ui/NetworkMounts.qml 727 to 768 for the repair waiting on the bridge before its first peek, the file-or-folder ready that routes a typed file URL to the opener instead of listing it, the repair-wait in the single-flight guard and cancel, and the openFileRequested signal; ui/WindowBody.qml 507 to 535 for the lazy window-long network host Loader, the typed address opening through it instead of the rail, the dialog mounting and landing through it so a rail unload mid-mount loses no answer, and the share browser activating through it; ui/Pane.qml 717 to 730 for the injected network service, its first-need builder and the dropbox service reading the host rather than the rail; ui/Ipc.qml 796 to 801 for the result reader preferring the host while the rail is hidden. ui/GvfsBridge.qml 122 to 157 for the detached start, the whole-ensure deadline with its consume-once flag and the check-file-classify ops, ui/Sidebar.qml falling 569 to 562 inside its recorded ceiling for the injected service, and ui/js/GvfsBridge.js 177 to 198 inside its 200 soft budget for the timeout, the file-aware checks and the classify.

src/backend/ops.rs split to src/backend/renamecompat.rs at 455: composing PR 35's safe rclone rename into the release tree put the rename exception over the 400-line hard cap, so the exception and its tests moved to the module that already owned classifying which rename failures need it. Both sides re-derived with wc -l on the files after that split and its review rounds: src/backend/ops.rs was 305 and src/backend/renamecompat.rs was 382, so both were under the 400 hard cap and both over the 250 soft budget, which tools/flea-file-budget warns about and does not fail on. src/backend/ops.rs later crossed the hard cap and is recorded in tools/flea-file-budget at 444.

src/backend/renamecompat.rs split to src/backend/mountinfo.rs at 396: a review round needed one more test and the file had four lines left under the hard cap, so mount_type_in, the two helpers that decode its octal escapes, and the three tests over them moved out. The seam is the parser's own subject, which is the filesystem type of the mount that owns a path, decided from a body of text it is handed and from nothing about renames; the one line that reads /proc/self/mountinfo stays with the caller that needs it. Re-derived with wc -l after the move and the test it made room for: src/backend/renamecompat.rs was 325 and src/backend/mountinfo.rs 97; later rounds pinned five of its tests to the arm each names and took the rename module to 346, so the parser is under both budgets and the rename module is under the hard cap and over the soft one.

ui/PreviewColumn.qml is at 477: the Columns sharp stage (a second Image decoding the original at the frame size over the cache file) was removed before 0.3.5 shipped, because it cost 0.4 to 0.6 s of CPU a visit on a 33 Mpx PNG and 2 to 4 MB of PSS over v0.3.4. As in v0.3.4 the frame draws the row's 256 px cache file, and decodes the original only when no cache file exists, now at the exact fit of the frame and EXIF-upright; a cache file is never enlarged past the original's own size. The sharp frame returns in 0.3.6 from a disk-cached sharp file, by design. tests/preview-decode.sh pins it from outside the column: a key-repeat sweep opens only the first moved row's cache file t0.png, no more often than one load opens a cache file, and no original, and a rest opens the cache file and no original. It also pins Quick Look's ui/PreviewImage.qml: a 3000x100 banner decodes at the exact fit (754x25, not the covering 14130x471), and an EXIF-turned photo decodes upright and draws at the exact fit, decoded whole when its stored size fits the box before the turn, because Qt weighs sourceSize against the stored orientation.

The counts unit groups every row, item, file and match count in thousands and moves three recorded ceilings, each re-derived with wc -l: ui/js/Ops.js 331 to 335 for the Format.js import and the deletedLine helper that took ui/PaneMenuActions.qml's delete verdict (which falls 404 to 402, inside its recorded ceiling), shelf/Model.js 655 to 669 for the shelf's own grouped thousands separator, which shelf/Run.js imports rather than reaching up to ui/js/Format.js, and src/tui/render.rs 807 to 822 for the TUI's grouped beside bytes with its three vectors. src/tui/model.rs holds every call site and stays at its recorded 1196.

Fix2-net moves two recorded ceilings, each re-derived with wc -l: ui/NetworkMounts.qml 768 to 802 for the peek-first #194 repair with its one bridge start, the rail-count poll gate and the own-waiter cancel; ui/WindowBody.qml 535 to 556 for the single origin router that replaces the per-rail duplicate handling and the dialog's corrected save and cancel names. The Photos-driven ui/Pane.qml 730 to 734 growth it also carried left with that feature. ui/Sidebar.qml falls 562 to 557 inside its recorded ceiling for the dropped wrappers and the loaded-only bookmark push; ui/PaneRail.qml falls 174 to 161 inside both budgets for the removed duplicate router.

ui/PreviewColumn.qml is at 505: the sharp settle, the mtime-keyed cache identity and the three decode counters tests/sharp-decode.sh read were reverted under review, and the replacement sharp test pins the product path from outside the column instead of from inside it. The office thumbnail's own "no preview" gate (Unsupported shows the mark only when no thumbnail is drawn) took it from 504 to 505.

Every count in this section is a SNAPSHOT, not a live figure, and eleven of the eighteen had drifted by 2026-09-01: src/heap.rs was claimed at 15 and is 100, ui/Row.qml at 166 and is 310, ui/Theme.qml at 264 and is 187. Nothing was over the hard cap when that was checked; only the record was wrong. So read the numbers below as the reasoning's own context, never as the current size of anything, and run tools/flea-file-budget for what a file measures today. The warning directly above this paragraph was already there and was violated eleven times in the paragraphs beneath it, which is why this one states the failure rather than repeating the instruction.

src/backend/proto.rs was 241 lines when this was written, under both budgets: 71 are the wire types, the request dispatch and the four one-line responses, the other 170 the test module. It has been split twice, each time at the seam that leaves each file a single job. At 399 of the 400 hard cap src/json.rs took the whole of what this tree's JSON then was, the field scanner as well as the escaper: the scanner is protocol-agnostic by construction, the decoding half of the encoder already living there, and the same mutations still redden the four tests that moved with it. That is no longer the whole of this tree's JSON: the state file needed a whole-document type, so src/jsondoc.rs was written beside it and json.rs kept the one-line wire scanner alone, which is what its own header comment and the module map at "Module map" both now say. At 489, after the dirsize and Kind wave pushed it through the cap, the rows serialiser moved out to src/backend/rows.rs: every other response is one format! line, and the one that loops, allocates and owns the Kind dictionary was the file's whole growth. The old rule that every consumer imports the wire layer from proto alone went with it: run.rs and prewarm.rs now import rows_line from rows and everything else from proto, the same shape as the thumbspec/thumbargv split. Ten serialiser tests and the literal-database dbs() helper moved with rows_line unchanged; the request and one-line response tests stayed.

src/backend/rows.rs is 175 lines by wc -l, under the soft budget: rows_line, its Kind dictionary and the execute-bit mount decision. Its serialiser tests live in src/backend/rows_tests.rs (255 lines) and its mount tests in src/backend/rows_mount_tests.rs, sibling files the module names with #[path], since the two merged row fields took the file past the 400-line hard cap with its tests inline. It is one job: serialise one window of rows, with the per-response dictionary that sends each distinct Kind string once and an integer per row. kind_for lives here because the dictionary is its only caller.

src/heap.rs is 15 lines by wc -l, well inside both budgets: two named constants, one extern "C" declaration and one call, in the same shape as src/thp.rs.

src/json.rs is 187 lines, under both budgets. 101 are the scanner and the escaper and 86 are the test module. It is one job in two directions: pull one named field out of one JSON line, and escape one string into one. Nothing here builds or validates a document, because the wire is one object per line and never anything else.

src/jsondoc.rs is 379 lines by wc -l, over the soft budget and 21 under the hard cap, with its #[cfg(test)] at 273, so 272 lines of implementation and 107 of tests. Those three read 352, 272 and 271 until this round and all three were one out: wc -l on the commit that wrote them said 353, the fourth count on this branch read off the shape of an edit rather than off the artefact. It is one job in two directions, the same shape as json.rs: a whole JSON document in, and the same document back out with its own numbers written the way they were read. It stood at 398, and this register said the next change here would split parse off from the Json type plus render. That is not the cut that was taken. jsondoc::parse is called from 20 sites in four other modules, two of which (uistore.rs and uistate.rs) are themselves over the soft budget, so that split renames 20 lines in four files and buys a reader nothing. That count read 17 until this round, and it was counted off the shape of the source rather than off grep -o over src/, which is the fourth count on this branch to be wrong that way. The string decoder went instead, to src/jsonstring.rs: a different job on the same bytes, with exactly one caller in this file. parse against render is still the cut if this file needs another one.

src/jsonstring.rs is 113 lines by wc -l, inside both budgets, with its #[cfg(test)] at 92, so 91 lines of implementation and 22 of tests. It is one job: one JSON string in and one Rust String out, the \u escapes and UTF-16's surrogate pairing, which is the reading half of what json.rs's escape writes. It came out of jsondoc.rs when parse_number needed JSON's own number grammar and that file had two lines of headroom left; parse_value is its only caller.

src/uischema.rs is 237 lines by wc -l, inside the soft budget, with its #[cfg(test)] at 117, so 116 lines of implementation and 121 of tests. It is one job: the shipped ui.json shape and the rule table beside it. Its tests are half the file because each one is a table read back, and the rule-edge test lives here rather than in uistate.rs because the rules it bites are declared here and that file has 37 lines of headroom left. It stood at 250, exactly at the soft budget, and the five keys that came out are the round's own subject: display.opacity, display.hyprlandIcons and display.shadows, which are the compositor's and which Flea mirrors rather than owning a second writable copy of, and language and updates, which nothing in this release reads.

src/uistore.rs is 397 lines by wc -l, over the soft budget and 3 under the hard cap, with its #[cfg(test)] at 195, so 194 lines of implementation and 203 of tests. Just over half the file is that test module because every claim it makes is about a real file, a real symlink, a real lock and a real rename, and each of those costs a fixture on disk. The seam for the next change is the four write helpers at the bottom, make_dir, take_lock, refuse_a_bad_target and write_new, which know nothing about Store beyond the paths they are handed.

src/uistate.rs is 363 lines by wc -l, over the soft budget and 37 under the hard cap, with its #[cfg(test)] at 188, so 187 lines of implementation and 176 of tests. It is one job in three shapes and all three are the same merge: a file onto the shipped defaults, one caller patch onto a document, and 0.1.3's view.json onto the defaults. They share merge, fits and the SCHEMA walk, so cutting patched away from from_file would put the validator on one side of a file boundary and the rule table's only other reader on the other. The seam, if this ever needs one, is the value predicates at the bottom, one_line, is_column_set, is_action_id and is_a_place, which know nothing about a document or a schema.

src/backend/thumbspec.rs is 312 lines, over the soft budget and under the hard cap: 190 the discovery, the desktop-entry parser and is_runnable, the other 122 the test module. Those three are one security boundary and are read together, so they stay in one file: splitting the validation from the exec line it validates would be the wrong cut. argv moved out, to src/backend/thumbargv.rs, when this file reached 398 of the 400 hard cap. That is a different job: the table answers "which validated program declares this type", and argv answers "what exact argv does that program get for this input". argv reads Spec.exec and nothing else here, and the seam is where the two-URI rule lives, which is easier to see in a file that holds only the substitution. Every test in the table file builds its fixture through the one-line one(body) helper rather than repeating a seven-line from_entries call.

src/backend/thumbargv.rs is 121 lines, well inside both budgets: 24 the substitution, 97 the test module. That ratio is four to one and it is right, not a smell: this file is a security boundary, and every placeholder, the refusal, the absolutisation and both spellings of the input need a real file, a real symlink and a real directory on disk before they prove anything. A reader arriving without that context will read the ratio as top heavy; it is the cost of testing a boundary against the filesystem rather than against a mock.

src/backend/child.rs is 231 lines by wc -l, inside both budgets and so not one of the files the tool warns about, with its #[cfg(test)] at line 96, so 95 lines of implementation and 136 of tests. It runs one argv under a deadline and reports Ran::Succeeded, Ran::Failed, Ran::TimedOut or Ran::NotStarted. It came out of thumbs.rs at 397 of the 400 hard cap, and it completes a three-part story each of whose parts is one file: thumbargv builds the inner argv, sandbox wraps it, child runs the result. Nothing in it knows about thumbnails, which is why the pool's JOB_TIMEOUT stays in thumbs.rs and is passed in.

ui/Pane.qml is 393 lines by wc -l, over the soft budget and 7 lines under the hard cap. It stood at exactly 400 of 400 and could not gain a line, which is why ui/Header.qml came out of it first and alone, before any behaviour was added; it then took on the settle timer, the thumbnail row map, the opener wiring, the input-to-rows stamps and the first-screen settle, and reached 398. There was almost no room left, and PR 34's three-line openTerminal would have put it at 401, so openCursorMenu's body was the helper that went: it is menu placement, and ui/js/Menu.js already owned where a frame sits when it opens at a point. It stays one component because the integer model, held window, list-reply pairing, focus and action dispatch share one state owner; splitting them would add a state boundary in the exact path that prevents untagged backend replies from crossing directory navigation.

ui/Header.qml is 70 lines, well inside both budgets. It is the column header band, its labels and its rule, lifted out of Pane.qml whole and carrying no behaviour of its own. The cut shipped as its own commit exactly so that no behaviour change could hide inside a move.

ui/js/Errors.js is the second such cut, made for the same reason and shipped the same way. Pane.sentence was already a pure function of two strings with no QML in it, so it moved to ui/js/ whole and gained the suite every other file there has, tests/js/errors.js, which is what makes the move provable rather than merely asserted.

ui/NetworkMounts.qml is 265 lines by wc -l, and it is the third cut of that class. It was 343 on its own branch and 400 on the 0.1.4 composition branch, over the same base: the two changes are additive to different halves of one file, so no resolution of that merge fitted the 400 cap, which is a number no branch gate can see because neither branch is over it alone. Two whole subjects came out before the merge rather than inside it, which took it to 250, exactly the soft budget; the gio info deadline below then took it to 265, so the file reports over soft again and has 135 lines left before the cap. ui/MountListing.qml is 84 lines and owns the five second gio mount -l poll, its 10 s bound, the re-read queued mid-listing and the collector fallback; the Service keeps one _mountListing string and rebuilds on its listed() signal. ui/NetworkPlaces.qml is 74 lines and owns the bookmarks file: the write FileView, rename(), forget(), and the one blocking write() those two share. Both cuts shipped as one commit carrying no behaviour change, the way ui/Header.qml did; what forget() writes changed in the commit after it. Neither could go into ui/js/Mounts.js or tests/js/mounts.js: rowMenu(), railLabel() and removeBookmark() are what took the first to 299 of its 300 hard cap, the second is exactly 300, and the file that owns the subject was already full.

ui/Row.qml is 166 lines, under both budgets. It gained the icon Image and its sourceSize, the two read-only aliases the icon checks assert through, and iconSource, which answers a thumbnail URL when the pane holds one for this row and the OEM two-step icon lookup otherwise.

ui/Opener.qml is 92 lines, well inside both budgets: three Process objects, three functions (open, openTerminal and copyText) and five signals. flea --open waits for gio open and not for the application, which is 11 to 15 ms against an Exec= handler but 0.32 to 0.75 s against a DBusActivatable one, so one process serves every open and a second Enter is dropped for that long and told to try again; this is the only component in the tree that runs flea --open or flea --terminal.

ui/js/Thumbs.js is 77 lines by wc -l against a 200-line soft budget, and its suite tests/js/thumbs.js is 66. It is arithmetic over the row map and nothing else, with no QML import, which is what lets ./tests/js.sh mutate it and watch a check redden.

src/backend/thumbwrite.rs is 280 lines, over the soft budget and well under the hard cap: 179 the temp creation, the chunk writing and the marker, the other 101 the test module. It went over the soft budget when write_stamped gained the strip pass that keeps a published entry to one of each key it owns, and the test that pins it walks the chunks twice, once first-match and once last-match, because agreeing is the whole property.

src/backend/thumbcache.rs is 312 lines, over the soft budget and well under the hard cap. 176 of those are the test module, which carries its own on-disk fixtures: lookup's ordering and its Thumb::MTime guard are both invisible to a pure-function test, so they are proved against real files written under a per-pid temp root through Cache::at. The implementation stays one file because the URI builder, the digest-named paths and the tEXt reader are three parts of one cache key: a change to any one of them silently stops matching entries the rest of the desktop wrote, and the tests that pin that against a real entry have to see all three.

src/backend/run.rs is 425 lines by wc -l, already over the hard cap before this change, and has no test module at all: it is the command loop and the window writes, and every one of its behaviours is proved through the real binary by tests/protocol.sh and tests/thumbs.sh. The two small structs it carries, Tables for the four databases read once per process and State for everything the loop mutates, exist so a handler takes six arguments instead of the twelve the flat form reached; their fields are pub(crate) because the thumbnail request policy in src/backend/thumbreq.rs is the same loop's other half. It reached 451 of the 400 hard cap in the dirsize wave and was split there: this file already described it as "the command loop and the thumbnail request policy", two jobs, and the cut is that sentence.

src/backend/thumbreq.rs is 133 lines by wc -l, inside both budgets and, like run.rs, without a test module: tests/protocol.sh and tests/thumbs.sh prove it through the real binary. It is the thumbnail request policy alone, the per-row cache lookup, the queue-once map, cancel and result reporting, moved out of run.rs whole. Nothing in it touches the event loop, and run.rs keeps forget_rows because a listing change invalidates dirsize state too, not only thumbnails.

src/backend/thumbs.rs is 393 lines by wc -l, over the soft budget and under the hard cap. Its #[cfg(test)] is at line 250, so 144 of those are the test module, and that is where the cost sits: proving cancellation without a race needs a worker pinned inside a real child, and the round trip through a real thumbnailer, the real sandbox and the rename is the only test that proves the subsystem end to end. The implementation is the other 249 lines, after the PNG writing and the temp creation moved out to src/backend/thumbwrite.rs and running one child under a deadline moved out to src/backend/child.rs. What is left is the queue, the worker and one job's lifecycle, and it stays one file because the queue, the pop and cancel share one invariant: splitting the worker from the queue it pops would put the two halves of that invariant in different files.

0.3.5 takes ui/WindowBody.qml from 484 to 485 for the status bar's arm owner, the Trash view while that view is open and the pane otherwise, so a dd prompt ends the moment its arm does.

ui/Theme.qml is 264 lines, over the soft budget and under the hard cap. Theme and user-override parsing plus palette and token application stay together as the single theme owner. Splitting its pure parsers is deferred because that change needs its own automated regression coverage.

The EXIF orientation pass records four ceilings, each re-derived with wc -l at the commit that recorded it. src/backend/imagesize.rs 354 to 475 for the Header type, the jpeg_header walk that reads the first Exif APP1's head in the same pass, exif_orientation and the byte-order test; it is one job, header bytes in and stored dimensions plus orientation out, so it stays one file. src/backend/metareq.rs 404 to 423 for the orientation field, the header() call and the orient slot on the line plus its test. ui/Preview.qml 391 to 417 for imageRow, imageTurn, askImage() and the turn-before-path bind. ui/PreviewColumn.qml 478 to 480 for the turned swap on its original fallback; ui/PreviewImage.qml stays inside its budget at 98.

The preview swap records three ceiling moves, each re-derived with wc -l at the commit that recorded it. ui/Preview.qml 417 to 452 for the swap wrapper round the Quick Look panes, the load/show split and lookReady; it crosses the 400 hard cap, so the ceiling above is its recorded exception. ui/PreviewColumn.qml 480 to 486 for pending, loadingHeldOff, pdfDrawn and the fallback's heldOff. ui/Ipc.qml 791 to 792 for the previewSwapState reader. The new ui/PreviewSwap.qml (238), ui/js/PreviewSwap.js (72) and tests/js/previewswap.js (70) sit inside their budgets and carry no ceiling.

Quick Look's photo no longer waits for its EXIF turn: ui/Preview.qml 452 to 453 for clearing imageRow once the answer lands, the flag that re-asks a meta a listing change swallowed.

The menu unit moves three recorded ceilings, each re-derived with wc -l. ui/ContextMenu.qml 570 to 578 for the locked carry (lockedChosen, read before close() the way railKey is) and ui/Pane.qml 702 to 705 for its onLockedChosen dispatch, which replaces the post-close menu.forLocked read that always answered false. tests/js/focus.js 404 to 407 for the volumeMenu: true stick fixture and ui/js/Eject.js stands at 171, inside the soft budget, for releaseFromRows, which picks the eject or unmount row by name instead of taking rows[0]. tests/ui.sh case_eject seeds showUnmounted on now, with menu_seek Eject and a Mount tail.

Fix2-rail moves two recorded ceilings, each re-derived with wc -l. ui/js/Focus.js 339 to 366 for canUndo, the one predicate the z key and the status bar's click refuse through, and tests/js/status.js 304 to 354 for its checks (the verdict-note undo carry on both notes, the displayed-Starting busy rule and the shared gate). Inside their budgets: ui/js/Status.js at 216, ui/StatusBar.qml at 450 of its recorded 451, ui/SidebarRow.qml at 284, ui/PathJump.qml at 392, ui/JumpPath.qml at 51, tests/jump-ui.qml at 288.

Fix2-preview moves one recorded ceiling, re-derived with wc -l: ui/Pane.qml 730 to 737 for the ExtThumbs verdict (extVerdict, the edge refreshExtThumbs spends, and the single refresh toggleExtThumbs keeps through onPreviewChanged). ui/PaneWire.qml takes the verdict sync on fsinfo and stays at 483 inside its recorded 485; ui/PreviewColumn.qml keeps its recorded 499 with no added line.

Fix2-gvfsparse records one ceiling, re-derived with wc -l: src/backend/gvfslist.rs 393 to 434 for the always-on -h, the spoof-proof target split, the progress-bound gio wait with its chunk-stamping reader, and the three tests that pin them. src/backend/meta.rs falls 388 to 385 inside the soft budget for the one-line SLOW_PASS_MS comment; src/gvfsprefetch.rs keeps 288 lines with the raw_output call shortened in place.

Review round 2 on the gvfs listing moves src/backend/gvfslist.rs 434 to 435, re-derived with wc -l, for the assertion that pins the cached mtime of a gio symlink row.

Fix3-pss2 moves one recorded ceiling, re-derived with wc -l: src/backend/gvfslist.rs 435 to 458 for replacing the per-row owned meta_cache map with the compact offset-keyed store (the 10,000-row store-shape test and the two tests its build_listing rewrite touched). src/backend/listing.rs stands at 192 inside the soft budget for the GioMeta record, the symlink side store and the two binary-search lookups; src/backend/meta.rs stands at 395 inside the hard cap for the store lookups and the cached-sort test with its injected-stat seam.

Review round 3 moves src/backend/copyfile.rs 428 to 432 and src/backend/copyfile_tests.rs 411 to 412, re-derived with wc -l, for the unconfirmed EXDEV copy handed to the caller as a partial.

The FUSE move fallback (issue 282) moves two ceilings, each re-derived with wc -l: src/backend/copyfile.rs 730 to 732 for the move_any arm that sends a refused RENAME_NOREPLACE to move_cross_device, and src/backend/copyfile_tests.rs 607 to 668 for its two cases and their shared tree and forced-fallback helpers.

Fix2-photosmove moves two recorded ceilings, each re-derived with wc -l: src/backend/copyfile.rs 404 to 428 for the EXDEV confirm before the remove (move_cross_device with its confirm_dest, which flushes the touched folders or the parent when no context is set and answers DIR_UNCONFIRMED keeping the source), and src/backend/copyfile_tests.rs new at 411 for the two tests that pin it. src/backend/redo.rs falls 329 to 327 for the single journalled step.

Fsinfoearly moves one recorded ceiling, re-derived with wc -l: src/backend/run.rs 455 to 470 for the slow-mount hook (the FsInfo owner, its Event::FsInfo arm, the list hook after the rows and the fsinfo answer that never blocks). The worker is the new src/backend/fsinforeq.rs at 319, over the soft budget and inside the hard cap; src/backend/extclass.rs stands at 219 with gvfs_root and classify_entry, and src/backend/mountinfo.rs at 142 carries the mount point.

The fsinfo ask gate and the symlink class take src/backend/fsinforeq.rs from 319 past the hard cap to a recorded 406: the asked gate and its test, the resolve that follows the raw mount check with its tests, and the one-statfs reader seam, each count re-derived with wc -l; src/backend/extclass.rs stands at 291 with resolved and classify_in.

Review round 3 on the 0.3.6 fixes moves src/backend/fsinforeq.rs 406 to 407 for the assertion that the worker reports a symlinked share's figures, and records src/backend/jump_tests.rs at 403 for the tests that pin the zoxide keep guard and the reaped slot, and takes src/backend/meta.rs from 395 to 399 inside the hard cap for the sorted-store and device assertions, each re-derived with wc -l.

A share's statfs reads the folder's first plain file rather than the folder (docs/protocol.md "fsinfo"), and its tests move to src/backend/fsinforeq_tests.rs at 334, so src/backend/fsinforeq.rs falls from 407 to 147 and leaves the over-cap list; src/backend/listing.rs ends at 313 with first_plain_file and its test, each count re-derived with wc -l.

Review round 5 and its second run take src/backend/renamecompat.rs from 412 lines (recorded 416) to 447 for the take-back seam and the test that a copy which will not go back answers rename, re-derived with wc -l.

The rail's network host fixes take ui/NetworkMounts.qml 802 to 803 (one arrival poll) and ui/WindowBody.qml 556 to 557 (the RailKeys import), each re-derived with wc -l. Sorting moves five recorded ceilings, each re-derived with wc -l at the commit that recorded it. src/uischema.rs enters the tool's list at 403 for hiddenLast, rememberSort and the folderSorts map with its shape, defaults and cap constants. src/uistate.rs 442 to 475 for the FolderSorts rule, its place-to-order validation and the oldest-first truncation past 500. ui/Backend.qml 400 to 425 for the folder-order resolution, the remember and forget writers and the hiddenLast both requests carry. ui/Pane.qml 702 to 704 for the hasFolderSort binding into the menu and the folder-aware resetSort. ui/ContextMenu.qml 570 to 572 for the hasFolderSort property the Sort by flyout reads. The map itself went to the new ui/js/FolderSorts.js, 66 lines inside both budgets, rather than into ui/js/Menu.js, which takes the flyout row and one call and stands at 300, exactly at the hard cap; ui/js/Sort.js takes the folder write and the forget branch and stands at 90, and ui/js/Settings.js takes the two Sorting rows at 425 of its recorded 427.

p036lazy moves four ceilings, each re-derived with wc -l: ui/NetworkMounts.qml 803 to 851 for the lazy bridge Loader with its source: URL, the ensureBridge builder, the null-guarded readers and the asynchronous, change-gated pollAnswered; ui/WindowBody.qml 557 to 580 for orderNetworkHost, its first-rows trigger and its 2000 ms fallback; ui/Sidebar.qml 569 to 576 for the on-demand networkHost and the arrival-pushed bookmarks; ui/Preview.qml 457 to 463 for the eager panes container, the lazy swap Loader and its guards. The wrapper itself is the new ui/QuickLookSwap.qml, 13 lines inside both budgets, and ui/PreviewSwap.qml stands at 242 inside the soft budget for the captureSource seam; ui/GvfsBridge.qml keeps 164 lines with no added line.

r5ui moves two ceilings, each re-derived with wc -l: ui/NetworkMounts.qml 851 to 867 for building the bridge before reporting success, the bridgeMissingReason and failBridgeMissing helpers with no password retry, and the Mounts.pollDecision change gate; ui/WindowBody.qml 580 to 585 for the NETWORK_HOST_AFTER_ROWS_MS 100 ms timer that orders the host after the rows paint beside the 2000 ms fallback. ui/Preview.qml falls 463 to 461 for the one-line swap comment and the Qt.binding groundInset, and ui/Sidebar.qml falls 576 to 572 now that the rail never builds the host, both inside their recorded ceilings.

f036net moves one recorded ceiling down and deletes two files, each count re-derived with wc -l: ui/WindowBody.qml 585 to 551 for building the network host with the window again (networkHost a plain Flea.NetworkMounts child, no Loader) and deleting ui/NetworkHostGate.qml with its qmldir line, tests/lazy-host-live.qml and the host half of tests/lazy-objects.sh; the rail's arrival keeps working because the host exists before it. ui/Sidebar.qml stands at 571 inside its recorded 576 and ui/Pane.qml at 740 inside its recorded 741 for the two comments that no longer promise a lazy build. ui/NetworkMounts.qml keeps 867 lines with its bridge Connections rewritten to one handler style, because Qt connects only one style per Connections object and the mixed block left onReady unwired; tests/connections-style.sh sweeps every Connections block under ui/ for that mix and is wired into tests/run-all.sh.

Railmenus2 records one ceiling, re-derived with wc -l: ui/js/Menu.js 297 to 335 for the rail rows living in INVENTORY with kinds R (Mount, Open, Unmount, Eject, Rename, Edit address, Remove from Network, Remove from Favorites), the rail availability and the hidden-check skip, plus the New tab and shelf glyph rows. ui/js/Mounts.js falls 289 to 250, over the 200 soft budget and under the 300 hard cap, for the table-driven railMenu/rowMenu; ui/js/RailMenu.js stands at 127 inside both budgets for the INVENTORY favourite row and the generic Open release; ui/MenuRow.qml stands at 306 inside the hard cap for the compact removal; ui/ContextMenu.qml stands at 577 inside its recorded 578.

Fixr3r3 records one ceiling, re-derived with wc -l: tests/js/network.js 300 to 305 for the volume shape offering both Unmount and Eject and the eject-first release check.

The drop device asks record one ceiling, re-derived with wc -l: tests/js/dropsflow.js passes the 300-line JS cap at 301 for the driven columns-area ask and devs-merge checks, rather than a compressed body.

Durprog moves two recorded ceilings, each re-derived with wc -l: src/backend/copyfile.rs 434 to 459 for the slice confirms (CONFIRM_BYTES with its one-line justification, the sync_file_range loop, its progress reports and the fallback when a filesystem has no range writeback), and src/backend/durable_tests.rs 520 to 555 for the tests that a durable copy confirms slices while it writes and that EINVAL from a range flush is not a failed copy; src/backend/durable.rs stands at 347 for the sync_range wrapper, its syscall declaration and range_unsupported.

Fixr4r1 moves two recorded ceilings, each re-derived with wc -l: src/backend/copyfile.rs 459 to 480 for the pipelined writeback (WRITE alone on slice k, then WAIT on k-1 with reports through k-1, the final fsync confirming the rest, the EINVAL fallback kept on both legs), and src/backend/durable_tests.rs 555 to 638 for driving copy_file_at (EINVAL falls back to final-only with one fsync and zero waits, a slice failure notes file_failed with one flush and journals the partial, mids equal waits); src/backend/durable.rs stands at 389 for sync_range_write, the RANGE_WAITS counter and the EINVAL seam. tests/colhero.* are deleted: the real ColumnsArea needs qs.Commons, Theme/ViewState and Flea children no qml6 harness can load, so any stub rebuild would re-declare the wiring it claims to prove; tests/ui.sh views is the gate.

Fixr4r2 updates the Fixr4r1 ceilings, each re-derived with wc -l: src/backend/copyfile.rs stays at 480, the pipelined writeback unchanged (WRITE alone on slice k, then WAIT on k-1 with reports through k-1, the final fsync confirming the rest); src/backend/durable.rs 389 to 421 for the split write and wait seams (FAIL_RANGE_WRITE plus FAIL_RANGE_WAIT carrying its errno, RANGE_WAITS incremented after a successful wait, RANGE_LOG in call order, EINVAL and EIO named once); src/backend/durable_tests.rs 638 to 724 for the order-visible proofs (each report records waits completed with mids seeing 1 then 2, wait-leg EINVAL falls back final-only with zero waits, wait-leg EIO fails with file_failed and the partial, three slices log write 0, write 1, wait 0, write 2, wait 1, then fsync). The review fix after it takes src/backend/durable.rs to 422 (EIO kept for tests only) and src/backend/durable_tests.rs to 732 (the three-slice stop at the failing wait, the errno kind check).

P036statfs records one ceiling, re-derived with wc -l: src/backend/meta.rs 399 to 433 for the vfat/exfat serial gate (the gated parallel branch and the slow-pass test that a slow vfat/exfat pass stays on one thread while slow ext4/cifs still thread). src/backend/listing.rs stands at 214 inside the soft budget for the per-listing fs_magic with its threaded_for decision and test; src/backend/scan.rs stands at 163 inside the soft budget for the one statfs per local listing every window reuses.

R5src takes the statfs back out of the listing: src/backend/scan.rs 163 to 168 for the no-statfs test, src/backend/meta.rs 433 to 436 for the seeded slow-pass test, src/backend/listing.rs 214 to 250 for the OnceLock per-listing answer (one stat and one mountinfo read at the first slow pass) with its fstype and cache tests, src/backend/mountinfo.rs 142 to 168 for the dev-to-fstype lookup, src/gui.rs 382 to 407 for the warm beside gio with its both-jobs test (recorded 407 over the hard cap, one subject, the launcher), src/prefetch.rs 311 to 300 and src/gvfsprefetch.rs 306 to 296 for removing the two superseded subcommands, src/main.rs 376 to 360 for dropping their dispatch, and src/backend/testdir.rs 264 to 263 for the one-line script comment, each re-derived with wc -l.

P036ramp takes src/backend/copyfile.rs 480 to 491 for the doubling slice ramp (FIRST_CONFIRM_BYTES with its one-line reason, the next_slice_len helper, and the offset-plus-len inflight pair) and src/backend/durable_tests.rs 732 to 766 for the ramped expectations (four mids at 1, 3, 7, 15 MiB, the ramped wait log, and the first-slice red test), each re-derived with wc -l.

p036gio moves one recorded ceiling, re-derived with wc -l: src/backend/gvfslist.rs 458 to 538 for the exact gio wait (one channel message per chunk plus EOF, recv_timeout on the idle budget, kill and reap on timeout with no join) and its overshoot-drift test copied from child.rs. src/gui.rs 288 to 382 for the single-fork launch helper with its builder tests stays under the hard cap; src/prefetch.rs 325 to 311 and src/gvfsprefetch.rs 328 to 306 for removing the two superseded spawners.

The 0.3.6 performance fixes, integrated, take src/backend/gvfslist.rs 538 to 549 (the post-EOF exit deadline and the one-script-per-lifetime exact-wait test) and src/backend/durable.rs 422 to 439 (the batched folder confirm the cross-device move uses), each re-derived with wc -l.

R5mb moves one recorded ceiling, re-derived with wc -l: src/backend/opsreq.rs 467 to 477 for the regular-files-only batch gate with its same-thread single-item move. src/backend/movebatch.rs 349 to 385 for the identity-checked source removal, the completing cancel close and the test-only force-copy seam, src/backend/movebatch_tests.rs 279 to 393 for the replacement, cancel and transfer-level cases, and src/backend/undo.rs 301 to 309 for the size and mtime the re-check reads, each inside its budget.

R6src round 2 moves five recorded ceilings, each re-derived with wc -l: src/backend/movebatch.rs 385 to 428 for the fail-closed source re-check with its test-only inspect-failure seam, src/backend/movebatch_tests.rs 393 to 466 for the unverifiable-source and in-place-edit cases and the deterministic cancel transfer, src/backend/opsreq.rs 477 to 513 for the test-only cancel-at seam that lands a cancel past the loop-top check, src/backend/opsreq/tests.rs 422 to 460 for the cancel-on-the-last-item case, and src/gui.rs 407 to 432 for the injected warm job with its rendezvous test.

R7src moves two recorded ceilings, each re-derived with wc -l: src/backend/movebatch.rs 428 to 456 for the gone-source arm that journals no step, and src/backend/movebatch_tests.rs 466 to 501 for the vanished-source undo test.

Round 4 of the 0.3.6 review moves 2 ceilings (the errno test seam and the stale-handle test), each re-derived with wc -l: src/backend/movebatch.rs 456 to 466, src/backend/movebatch_tests.rs 501 to 528.

Nophotos pulls the phone Photos rail entry out of 0.3.6 and moves ten recorded ceilings down to their re-derived wc -l: src/backend/run.rs 470 to 444, src/backend/proto.rs 466 to 285, ui/NetworkMounts.qml 867 to 838, ui/Pane.qml 741 to 724, ui/PaneWire.qml 485 to 462, ui/Backend.qml 435 to 400, ui/Sidebar.qml 576 to 566, ui/WindowBody.qml 551 to 546, ui/js/Focus.js 366 to 359 and tests/js/focus.js 407 to 406. The deleted walk backend modules, the rail-entry UI module and its suite leave no record behind, and neither do the two walk wire requests.

ClipMarks and Names040 move one recorded ceiling, each count re-derived with wc -l. ui/Row.qml 421 to 453 for the clipboard mark (the clipMark property, the clipLoader built only on a clipboard row, the name-slot reservation and the cut-dim opacities) and the middle-elision budget (nameBudget off the shared advance, elidedName for unmarked names). The decisions went to the new ui/js/ClipMarks.js (42 lines) and ui/js/Names.js (40 lines), each inside both budgets, rather than into ui/js/Ops.js, which keeps its recorded 367 with no room to grow; ui/js/Collide.js and ui/js/Focus.js are untouched because the paste that spends a cut already cleared the clipboard through CollideHost.decide. ui/GridTile.qml stands at 202, ui/ColumnRow.qml at 214 and ui/Theme.qml at 398, all inside their budgets.

RecentDates moves two recorded ceilings, each re-derived with wc -l at the commit that recorded it. src/uischema.rs 403 to 408 for the highlightToday key with its default, rule and edge tests. ui/Ipc.qml 796 to 802 for the rowDateColor reader the live check asserts through. The boundary itself went to the new ui/js/RecentDates.js, 34 lines inside both budgets, rather than into ui/js/Format.js; ui/RowDate.qml takes the switch, the start and the stamp at 32 lines, ui/Row.qml hands them down at 412 of its recorded 421, ui/ViewState.qml owns the midnight timer at 393 under the hard cap, and ui/js/Settings.js takes the View row at 427, exactly its recorded ceiling.

On the 0.3.7 branch, today dates join the clipboard marks in ui/Row.qml, which goes from 453 to 456, re-derived with wc -l on the integrated branch. Later work leaves it at 444.

Density040 and GridStops move one recorded ceiling, re-derived with wc -l: ui/js/Settings.js 427 to 428 for the Tight density row with its hint and the Huge and Largest thumbnail stops. The maths went to the new ui/js/Density.js, 46 lines inside both budgets, rather than into Theme.qml, which stands at 400 at the hard cap; ui/js/GridGeometry.js takes the cell height at 22, ui/GridTile.qml at 203, ui/GridArea.qml at 277, ui/ViewState.qml at 395 and src/uischema.rs keeps its recorded 408 rather than any soft budget. Later work leaves ui/js/Density.js at 44, ui/js/GridGeometry.js at 21, ui/GridArea.qml at 279 and ui/ViewState.qml at 398, each re-derived with wc -l.

Buttons040 variant A moves four recorded ceilings, each re-derived with wc -l at the commit that recorded it. ui/OpenWithDialog.qml 588 to 592 for the fixed Open primary, the rule-14 checkbox and a search field ring that went again on 2026-10-03. ui/PermissionsDialog.qml 426 to 432 for the fixed Apply primary and an octal field ring that went the same way. ui/TrashView.qml 467 to 474 for the dead Up at 0.55 and the Back press. ui/ChromeBar.qml 438 to 448 for a path field ring that went with the others, the field being the accent frame again. The control itself went to the reworked ui/DialogButton.qml at 111 lines, its decisions to the new ui/js/Buttons.js at 45 lines and its suite to tests/js/buttons.js at 41 lines, each inside both budgets, rather than into any dialog; later work leaves them at 107, 52 and 54, each re-derived with wc -l. ui/TransferCard.qml falls to 239 and ui/PickerSave.qml to 177 as their hand-built buttons leave, and ui/MenuActionDialog.qml stands at 307, over the soft budget and under the hard cap.

Columns moves seven recorded ceilings, each re-derived with wc -l. src/uischema.rs 408 to 424 for the columnsLimit cap and the columnWidths map with its defaults, rules and edge tests. src/uistate.rs 475 to 518 for the ColumnWidths rule, its rails validation and the limit-and-widths patch tests. ui/js/Settings.js 427 to 430 for the View group's Columns view limit segment. ui/Pane.qml 704 to 706 for the held rows the header's fits read. ui/Ipc.qml 802 to 808 for the live count, the two deeper ancestor centres and the stored widths, then to 813 for the header cell centre the edge-drag case clicks through. ui/js/Focus.js 339 to 345 for the F4 autofit route, and ui/js/Keymap.js 313 to 314 for its generated row. The count, clamp and fit live in ui/js/Columns.js at 139 lines, the fit strings in the new ui/js/ColumnFit.js at 32 lines, the grab zone in the new ui/ResizeHandle.qml at 40 lines with its own ui/qmldir line, ui/Header.qml takes the handles and the fits at 346 lines and ui/ColumnsArea.qml the ancestor columns at 373 lines, all inside their budgets; ui/Theme.qml reads the stored widths at 399 lines, one over its last note and still under the hard cap. Later work leaves src/uischema.rs at 421, ui/Header.qml at 355, ui/js/ColumnFit.js at 30 and ui/ResizeHandle.qml at 37, each re-derived with wc -l.

w037w2 takes ui/Header.qml 355 to 364 for the fit-metrics Loader with its sync-read comment and the two autofit deactivations, and ui/ResizeHandle.qml 37 to 41 for the accent-line Loader with its rest-only comment, each re-derived with wc -l; the suite is the new tests/headercost.sh with tests/headercost.qml at 131, inside both budgets.

w037w2 round 2 takes ui/Header.qml 364 to 362 for the one-line comments, keeps ui/ResizeHandle.qml at 41 with the Loader centred hairline-wide instead of filled, and takes tests/headercost.qml 131 to 206 for the QQuick isType match, the hot-accent geometry check, the at-rest Loader asserts and the two driven autofit release checks, each re-derived with wc -l.

0.3.7 integration records ui/Theme.qml at 401, re-derived with wc -l, where the button system and the list columns each added their tokens.

The 0.3.7 replay onto the 0.3.6 head moves 8 ceilings, each re-derived with wc -l: ui/ContextMenu.qml 578 to 579, ui/Ipc.qml 813 to 818, ui/Pane.qml 724 to 728, ui/js/Settings.js 431 to 432, ui/js/Menu.js 335 to 338, ui/js/Focus.js 359 to 365, ui/ChromeBar.qml 468 to 477, ui/Theme.qml 401 to 405. Later work leaves ui/js/Menu.js at 337, ui/js/Focus.js at 364 and ui/Theme.qml at 415, each re-derived with wc -l.

w59 R2 takes ui/Theme.qml 415 to 414, re-derived with wc -l: bodySmallGlyphMetrics carried byte-identical inputs to glyphMetrics, so bodySmallAdvance aliases root.glyphAdvance and the second TextMetrics goes, with the token and every Grid and row output unchanged.

f037jump moves one ceiling, re-derived with wc -l: ui/ChromeBar.qml 477 to 478 for the one-line comment on the editor frame's single-hairline bottom inset, which centres the field in the strip so the folder-jump dropdown sits flush under it the way the Jump board draws it. The comment-style pass leaves it at 477.

f037logic moves three ceilings, each re-derived with wc -l: src/uistate.rs 518 to 554 for the per-entry folderSorts healing on read, the whole-pixel width rule and the tests pinning both, ui/Backend.qml 425 to 426 for the hiddenLast the peek request carries, and src/backend/proto.rs 285 to 287, under the hard cap, for the same field parsed off the wire.

f037r6 records one ceiling, re-derived with wc -l: tests/js/columns.js 298 to 317 for the first-echo peek key and the scoped re-ask checks, crossing the 300 hard cap and recorded rather than split; ui/js/Names.js 125 to 199 and tests/js/names.js 106 to 128 stay inside their budgets, src/backend/peek.rs 217 to 230 stays inside the soft budget, ui/js/Columns.js 178 to 184 stays inside its budget, ui/Backend.qml falls 426 to 424 inside its recorded 426, ui/ColumnsArea.qml keeps 400 at the hard cap and ui/GridTile.qml keeps 203. Later work leaves tests/js/columns.js at 310, ui/js/Names.js at 204 and ui/js/Columns.js at 183, each re-derived with wc -l.

f037hasvk moves two ceilings, each re-derived with wc -l: src/vulkan.rs 620 to 722 for the hasvk-only decision (is_hasvk_library, is_hasvk_intel, hasvk_only) with its sysfs-fixture tests, and src/gui.rs 432 to 485 for the automatic-arm LaunchRenderer with its three probe tests; the file keeps one subject, what the launcher hands qs, and the decision adds no probe and no sysfs read to the launch.

w037w1 takes ui/js/Names.js 204 to 250 for the linear-time caption rewrite (one width pass with prefix sums, fast string path under FAST_LIMIT, shared elide and wrap cores, non-finite budgets hand through) with tests/js/names.js 128 to 196 for the frozen reference corpus and the non-finite pins (its speed is gated by the paired perf runs, not by wall-clock checks in a suite PKGBUILD check() also runs: on West the driven leg's CPU fell 35 ms and a 60-char caption about 19x), and the new tests/js/namesreference.js at 205 carries the frozen copy at afcd453f over the soft budget and under the hard cap, each re-derived with wc -l.

The review round 1 fixes move four ceilings, each re-derived with wc -l: src/gui.rs 485 to 497 for the renderer arms applied through apply_renderer and the tests that read their environment, src/uistate.rs 554 to 625 for map patches merged per entry with null forgets and their tests, src/vulkan.rs 722 to 728 for the CPU-type device skip, and ui/Backend.qml 425 to 431 for the map-entry writers and the listed path the sort reset reads. The true lengths, read off each commit piped to wc -l, are 98404bc7 400 and Sorting 1a4fb969 425 and f037logic 458113df 426 and 7425a142 425 and 132a9105 425 and b02c633d 426 and f037r6 4639025a 426 to 424 and 521b1432 424 to 425 and round 1 b49d0e6a 425 to 431, so the missing link is the +1 at 521b1432 and the chain ends at the recorded 431.

f037move moves five ceilings, each re-derived with wc -l: src/backend/durable.rs 439 to 574 for batch_syncfs with held descriptors, scoped release, syncfs_dir and its seams, src/backend/copyfile.rs 491 to 534 for the held success path and the batch-aware single-item confirm, src/backend/movebatch.rs 466 to 478 for the release plus syncfs confirm and the test-only remove log, src/backend/durable_tests.rs 766 to 830 for the mountinfo classification, large-file hold and finish-drain pins, and src/backend/movebatch_tests.rs 528 to 709 for the 64-file order log, failed-syncfs, cancel, copy-error and non-block pins; src/backend/mountinfo.rs 168 to 170 stays inside the soft budget for the entry source, src/backend/opsreq.rs keeps 512 inside its recorded 513 for the mut finish.

f037movefix moves six ceilings, each re-derived with wc -l: src/backend/durable.rs 574 to 631 for the settle_held primitive every confirm calls, the hold-at-cap syncfs with its sticky unsettled flag, the begin_from mountinfo helper and the success-only syncfs count, src/backend/copyfile.rs 534 to 524 for the primitive-owned single-item confirm, src/backend/movebatch.rs 478 to 469 for the primitive-owned batch confirm, src/backend/durable_tests.rs 830 to 1121 for the hold-at-cap, settle-order, rename, redo, failed-syncfs, failed-cap-finish, begin-order, sticky-drain and per-primitive drained-confirm pins, src/backend/copyfile_tests.rs 412 to 460 for the batch move_cross_device pins, and src/backend/movebatch_tests.rs 709 to 751 for the failed-cap 64-file pin; src/backend/mountinfo.rs keeps 170 inside the soft budget, and src/backend/opsreq.rs keeps 512 inside its recorded 513.

f037cancel moves two ceilings, each re-derived with wc -l: src/backend/metareq.rs 423 to 424 for the sandbox-pid watchdog kill beside the group kill, and src/backend/thumbs.rs 441 to 442 for the status-fd argv on the exec path; the kill tree itself is the new src/backend/jail.rs at 192, inside both budgets, src/backend/sandbox.rs keeps 300 inside the hard cap for the status-fd argv helper, and src/backend/archivework.rs, src/backend/child.rs, src/backend/mediaprobe.rs, src/backend/workerlink.rs and src/tui/job.rs stay inside their budgets.

f037cancelfix moves three ceilings, each re-derived with wc -l: src/backend/jail.rs 192 to 387 for the CLOEXEC status pipe, the owned-pid kill gate with its tests, the pre-kill bounded wait with the EOF 0-store and the named reader and gone bounds; src/backend/metareq.rs 424 to 416 for routing both sandbox kills through that gate and collapsing two stacked comments; and src/backend/mediaprobe.rs 219 to 216 for routing its watchdog kill through the same gate. src/backend/sandbox.rs keeps 300 and src/backend/child.rs keeps 272, both re-derived, over the soft budget and under the hard cap. The sentinel naming leaves src/backend/jail.rs at 389. Later work, the #211 archive jail split then U3, leaves src/backend/sandbox.rs at 365.

f037u11 moves three ceilings, each re-derived with wc -l: src/backend/run.rs 444 to 448 for the quit drain cancelling the slot and each detached job under one shutdown budget beside the thumbnail queue, src/backend/opsdispatch.rs 536 to 540 for the detached registry field, its init and the terminal arm that empties it, and src/backend/opsreq/tests.rs 460 to 461 for the convert call's two new arguments. The tracker itself went to src/backend/opscancel.rs, 87 to 134 inside both budgets beside Live, the flag threading keeps src/backend/archivereq.rs at 362 and src/backend/archivework.rs at 353 over the soft budget and under the hard cap, the archive tests moved out of line the way opsreq/tests.rs already was, leaving src/backend/archiveops.rs at 140 with its tests in the new src/backend/archiveops_tests.rs at 264, and the quit tests went to the new src/backend/archivequit_tests.rs at 290, over the soft budget and under the hard cap. Round 2 widens the registry to converts, which keep their CPU cap on the cancellable runner and get no UI cancel. Round 3 removes the dead blocking runner convert vacated and re-drives the cap pin through the runner convert uses, with a burner that counts its own CPU time. Round 4 keeps the one 25 s budget under the UI's 30 s quit deadline and pins it against ui/Backend.qml's quitDeadline. Round 5 has the event loop drop a job from the quit registry when it writes the job's terminal line (OpMsg::DetachedDone), so a drain that sees the registry empty has written every line, and pins the CPU cap each job actually runs under through the runner it calls.

u13 moves one ceiling, re-derived with wc -l: tests/js/columns.js 310 to 313 for the default-at-2560, stored-5 and narrow-window checks. src/uischema.rs keeps 421, src/uistate.rs keeps 625, ui/js/Settings.js keeps 432 and ui/ColumnsArea.qml keeps 400, each re-derived for the in-place fallback edits, and ui/js/Columns.js goes 183 to 185 for the default constant and its comment, inside its budgets; docs/protocol.md states no shipped limit, so nothing there moves.

e1rowcost moves three ceilings, each re-derived with wc -l: ui/GridTile.qml 203 to 208 for the rename Loader active only while renaming, the way ui/Row.qml builds its own editor, plus the same shared dim; and ui/ColumnRow.qml 209 to 211 for that same shared dim. ui/Row.qml goes 444 to 452: the four cells are plain Text elements binding straight to the row's own state, so the RowMode, RowSize, RowDate and RowKind files with their ui/qmldir lines are gone, cellInk serves the four cells plus the search location with one evaluation instead of five cellColor() calls, dimOpacity serves eight dimmed children with one ternary instead of eight, and cellColor() is gone with ui/Ipc.qml's rowCellColor reading cellInk in one unchanged line. The growth is the four cells' own lines inside Row and one lead comment, less the inlined clipCut; the comments keep their original breaks rather than being joined to fit the old ceiling. The suite is the new tests/rowcost.sh with tests/rowcost.qml at 121, inside both budgets, registered in tests/run-all.sh beside grid-gap; tests/js/rowcells.js pins the diet at 57 lines and the today-lift checks moved into tests/js/recentdates.js, each inside its budget.

e6 columnscost moves two ceilings, each count re-derived with wc -l: ui/ColumnsArea.qml keeps 406 for the two ancestor Loaders active only while their count shows them, with the grandparent and great-grandparent itemAt readers routed through the Loader's item under a null check; the parent column stays always built, and every other reader (rowsFor, peeks, focus, drag targets) names no pane id. ui/ColumnRow.qml 211 to 215 for the clipX and clipExpectedX test seam, functions so no row at rest binds to name geometry; the mark, dim and elision draw exactly as before. The suite is tests/columnscost.sh with tests/columnscost.qml 175 to 299, over the soft budget and under the hard cap, for the positive 4 and 5 column case (ancestors built, bound to rows, itemAt non-null, then gone again at 3) and the marked versus unmarked clip x check; the measured counts are pinned.

e5byid moves three ceilings, each re-derived with wc -l: src/gui.rs 499 to 501 for the qsregistry::prune_dead call in exec_qs and pick before the builder, so no Command builder touches the runtime dir. The prune itself goes src/qsregistry.rs 180 to 192 for the divergent-link fixture (by-id/spoof live while by-shell/flea/spoof points at by-id/target) with its untouched asserts, inside both budgets, with SHELL_ID and PICKER_SHELL_ID beside paths::ENTRY; src/paths.rs 124 to 135 for the ShellId contract test that include_strs both boot files, inside the soft budget. Flea deletes its own dead Quickshell registry entries because Quickshell keeps them forever: prune_dead removes every dead by-id entry of Flea's own shells but the newest KEEP_DEAD, dead by Quickshell's own lock test, keeping qs log's last crash reachable.

w5 rowaudit moves no ceiling, each count re-derived with wc -l: ui/Row.qml keeps 452, ui/List.qml keeps 292, tests/rowcost.qml 121 to 164 for the clip rare-state phase. Per-row gains since 0.3.6 (98404bc7), each with the commit that added it and its verdict:

gaincommitobjects at restverdict
clipMark/clipPx props, List markForRowb0f33b60, empty clipboard early-returnskeep
clipLoader Loader, inactive unless markedb0f33b6+1 shell, builds nothingkeep, already deferred
nameBudget/elidedName, elideMiddleb0f33b60, fast-path string scankeep
cellInk/dimOpacity folding cellColor, clipCut89115b40, fewer evaluationskeep, the fold
eight dimOpacity opacities89115b40keep
date RecentDates.isRecent call04485bf1, e24 short-circuits on the switch0, never entered when offkeep
Scroll.contentWidth delegate widtha783cc50keep
dirSizes wantsSizes gate9e3e1990keep

Nothing was removed: the only object a Row at rest gained is the inactive clipLoader shell, and a List-level delegate variant would trade that one shell for a second delegate shape; the today call and the clipboard lookup both return before any work when their switch is off. tests/rowcost.qml pins the rare states instead: a scissors row dims and builds over idle, a copy row builds undimmed, clearing returns to idle.

w8 colroot moves two ceilings, each re-derived with wc -l: ui/ColumnsArea.qml 406 to 410 for the root-stops-the-climb gate (parentShown/grandparentShown/greatGrandparentShown off Columns.ancestorShown, the refreshNeighbours ask gate, the blank-but-width-kept ancestor slots and the null-while-hidden Ipc readers); tests/js/columns.js 313 to 356 for the w8 checks (ancestors at /, /home, /home/gm, the no-equal-neighbour sweep, Left at / opening nothing and the area gating pins). ui/js/Columns.js 185 to 227 for ancestorParent/ancestors/ancestorShown, over the soft budget and under the hard cap.

ddtarget moves one ceiling, re-derived with wc -l: ui/js/Focus.js 364 to 365 for the Marks import carrying the lone-selection follow on the eight plain-move cases, each call appended to its case's own line so the import is the only added line. ui/js/Marks.js 66 to 76 for follow, ui/js/Selection.js 50 to 58 for the lone flag with follows, ui/js/Tabs.js 292 to 299 for the snapshot follows with the only() restore, ui/js/Grid.js 32 to 33 and ui/js/PreviewKeys.js 85 to 86 for their follow calls, each inside its budget; the suite is the new tests/js/ddtarget.js at 166, inside both budgets, registered in tests/js/harness.qml at 170.

pathretry moves two ceilings, each re-derived with wc -l: ui/ChromeBar.qml 477 to 479 for the pathFailed bool and the shouldNavigate gates on commitEdit and the jump's onChosen, and ui/WindowBody.qml 546 to 547 in the budget for a file that measured 545 to 547, its hunk adding the comment plus the binding and nothing else. The retry input is the failed listing and not the shown one: ui/js/Nav.js pathFailed answers true only on listingState error with no search, no Trash and no listing in flight, so the same line retries an error and stays a no-op settled, in Trash, in search and in flight, while a different path always navigates into Nav.open's own refusal there. The decision itself is ui/js/PathBar.js shouldNavigate, 228 lines over the soft budget and under the hard cap; ui/js/Nav.js keeps 279 over soft for the eight-line pathFailed; tests/js/pathbar.js takes the nineteen checks at 251 over soft.

trashrow moves one ceiling, re-derived with wc -l: ui/Sidebar.qml 566 to 567 for the RailKeys import the Trash row cursor rule reads; the rule itself is RailKeys.trashCursor, tested in tests/js/railkeys.js.

w037w17 moves one ceiling, re-derived with wc -l: ui/ColumnsArea.qml 410 to 425 for the folder data hold (the Swap import, the single-shot folderFallback at Swap.HOLD_MS, the folderDataHold early return with its restart, the stop on every other move, and the showFolderOnPeek landing in onPeeked); ui/js/Columns.js 227 to 239 for folderDataHold and showFolderOnPeek, over the soft budget and under the hard cap, and tests/js/previewswap.js 70 to 95 inside both budgets. e18 moves one ceiling, re-derived with wc -l: ui/ColumnsArea.qml 425 to 431 for the column hairlines (thirdShown, one showDivider per pane with the active pane following the preview column); the shared ink is the new ui/Divider.qml at 9 lines, ui/ColumnPane.qml keeps 340 inside the hard cap for the showDivider prop and its edge line, ui/Sidebar.qml falls 567 to 564 using the same component, and tests/columnscost.qml keeps 384 over soft for the N-1 divider checks.

The hairline merged onto the Columns row unit records one ceiling, re-derived with wc -l: tests/columnscost.qml crosses the hard cap at 416 as the arithmetic of the two units, the row unit's layout-count probe and the hairline's N-1 divider checks, recorded rather than split because both pin the same Columns delegate cost.

e19 round 2 moves one recorded ceiling down, each count re-derived with wc -l: ui/ChromeBar.qml 479 to 478 for the one-line shouldNavigate gate comment (a settled same path stays a no-op to keep the selection, a failed or locked one retries). ui/js/Nav.js keeps 279 with pathFailed answering true for locked beside error under the same guards, so a folder fixed through the Locked tile retries on same-path Enter. tests/js/pathbar.js 251 to 254 for the two locked retry checks, tests/js/ddtarget.js 223 to 221 for the hidden-first filtered follow case (shown [1,2]) and its one-line header, tests/js/filterfixture.js 107 to 106 and ui/js/Marks.js 83 to 82 for their one-line mirror comments, and tests/ui.sh carries the stride-exact grid leg of case_ddclick (61 files, cursor equals gridColumns).

e24 moves one ceiling, re-derived with wc -l: ui/Row.qml 452 to 453 for the one-line short-circuit comment, so the off path never enters the today library; the binding reads the switch first and the call carries true.

The drop-frame merge records one ceiling, re-derived with wc -l: ui/Row.qml 453 to 454, the frame and label now one Loader (a row at rest builds 18 objects, the 0.3.6 count, pinned in tests/rowcost.qml) and the two one-line comments that keep the frame token and the label placement. w037w20 moves one ceiling, re-derived with wc -l: ui/ColumnsArea.qml 431 to 435 for settling the data-held empty hero (the shouldSettleHero gate with its entrance skip and mark settle in onPeeked); ui/EmptyState.qml 129 to 131 for the animateEntrance gate, ui/FleaMark.qml 86 to 89 for settle, ui/js/Columns.js 239 to 249 for shouldSettleHero over the soft budget and under the hard cap, and tests/js/previewswap.js 129 to 138 inside both budgets.

w037w23 moves one ceiling, re-derived with wc -l: ui/ColumnsArea.qml 435 to 440 for ending the leaked picture hold (the isFileRow file-load guard, the thirdSwap.cancel in the folderFallback trigger and in the showFolderOnPeek landing); ui/js/Columns.js 249 to 255 for isFileRow over the soft budget and under the hard cap, ui/SelectionPreview.qml 257 to 262 for the folder guards, and tests/js/previewswap.js 138 to 173 inside both budgets. w20's entrance skip stays: a data-held empty landing still appears async after a wait, so its entrance would animate a late arrival.

e26 moves one ceiling, re-derived with wc -l: ui/Row.qml 454 to 465 for the review round 1 fixes (the today switch leaving the library to its caller, the drop label's dropReserve the name's own margin spends so a long name never overprints it, and the four function seams the rowcost drop phase reads them through).

e30 moves one ceiling, re-derived with wc -l: ui/Row.qml 465 to 472 for the review round 2 follow-ups (the drop reserve measured from the label's own implicitWidth with the hidden-cell room subtracted, so a fitting name keeps its width, and the dropExtra seam beside the four rowcost readers). e31 moves three ceilings, each re-derived with wc -l: ui/ColumnPane.qml 346 to 348 for the two per-column budgets (nameBudgetPlain without the chevron slot, nameBudgetChevron with it) and the delegate pick by the showChevron condition; tests/columnrow-geom.qml 391 to 396 for the dir row at index 2 with selectedIndex: 2, the budget-param fit checks and the plain plus chevron tight checks at 366 and 853, active and peek; tests/js/names.js 217 to 221 for the two-budget pins and the chosen-directory handoff check.

w037w26 moves one ceiling, re-derived with wc -l: ui/ContextMenu.qml 579 to 582 for the locked-empty refusal gate (the lockedRefusal read in openLocked that emits refused instead of placing an empty frame); ui/js/LockedMenu.js 43 to 51 for LOCKED_REFUSAL with lockedRefusal, tests/js/menu.js 263 to 273 for the shipped-hidden refusal pins, all inside their budgets. w037w27 records one ceiling, re-derived with wc -l: tests/columnrow-geom.qml 396 to 410 for the three pane-built chevron-budget picks (a file row under the cursor keeps the plain budget, a directory row off the cursor keeps it, a lifted directory row takes the chevron budget, each with fit and tight checks), the checkTight short-fixture guard and the dead index fallback removal; ui/ColumnPane.qml keeps 348 for reading the row flag (cell.showChevron, which never reads nameBudget, so no binding loop); tests/js/names.js falls 221 to 219 for the one anchored whole-binding check that replaced the three polarity-blind hits. w037w25 moves one ceiling down, re-derived with wc -l: ui/ColumnsArea.qml 440 to 438 for handing the live picture to the folder's wait (the data-hold cancel goes, the stale-show guard and the fallback force take two lines back). tests/columnsfolder.qml stands at 256, over the soft budget and under the hard cap, for the kept-picture phase b; tests/js/previewswap.js stands at 198 inside both budgets for the handoff pins. e32 moves one recorded ceiling, each re-derived with wc -l: ui/Row.qml 472 to 483 for folding the clip mark into the drop Loader (one Loader active on drop or clip, the drop component drawing the mark too) and folding the cut dim into the drawn colours (cellInk, name, icon, search and date through dimmed, the thumbnail keeping its opacity, MatchText wash multiplying the incoming alpha); ui/ColumnRow.qml 225 to 248 inside the soft budget for deleting the content wrapper, the always-built drop frame and label, and the clip Loader in favour of one Loader for all three, with the dim folded the same way; ui/MatchText.qml 91 to 92 inside its budget for the wash preserving the dim; tests/rowcost.qml 279 to 309 and tests/columnscost.qml 384 to 385 over soft for the lower object ceilings (row 18 to 17, column row 20 to 17) with the drawn-colour dim checks and the clipboard drop mark; tests/columnrow-geom.qml 396 to 395 over soft for reading name, size and mark alpha instead of content opacity; tests/js/rowcells.js, tests/js/recentdates.js and tests/js/names.js inside their budgets for the dimmed today lift and the Loader-folded geometry pin. e33 moves one ceiling, re-derived with wc -l: ui/Pane.qml 728 to 729 for the one-line viewMode wire into the header. ui/Header.qml 362 to 367 keeps inside the hard cap for the one handlesLoader gating the four edges on list view, header shown, not dual, sortable and pane !== null, with each handle placed by x off its cell; tests/headerhandles.qml at 149 carries the headless pin.

The integration of those units records the actual merged ceilings after e32, e33 and w25: ui/ColumnsArea.qml 438, ui/ContextMenu.qml 582, ui/Row.qml 482, ui/Pane.qml 729, tests/columnrow-geom.qml 409. Each count is re-derived from the merged files; duplicate budget entries from the West pick are removed. The headerhandles and listcost suites are registered in both the aggregate runner and flea-ci.

e32-r2 moves one recorded ceiling, each re-derived with wc -l: ui/Row.qml 483 to 468 for replacing the two explicit Component resources with one inline Item factory under the one drop/clip Loader (frame, label and mark as conditional children, so a row at rest builds 17 objects) and scoping dropExtra to the mode gap alone; tests/rowcost.qml 309 to 314 over soft for the clip-right-edge clears label check and the cellInk differs from foreground guard; tests/js/recentdates.js keeps 96 lines for the exact dimmed reverse lift check; ui/ColumnRow.qml keeps 248 inside the soft budget, untouched.

e32-r3 keeps the recorded ceiling, each re-derived with wc -l: ui/Row.qml keeps 468 with the clipRight visible Glyph proof; ui/ColumnRow.qml 248 to 250 at the soft budget for splitting the Loader in two (the wash Loader ahead of the content, the label and mark Loader behind it in document order so it paints above, no explicit Component) with the stackingOk order seam and the visible mark clipX proof; tests/columnscost.qml 385 to 394 over soft for the wash behind content stacking check; tests/rowcost.qml keeps 314; tools/flea-file-budget keeps every recorded ceiling.

The row-repair integration records ui/Row.qml at 468 lines after the earlier comment join; the e32 unit note above keeps its own 468-line provenance. The merged file is the measured ceiling. e37 moves three ceilings, each re-derived with wc -l: ui/js/Focus.js 365 to 372 for the shared sidebar chord (sidebar joins the window actions answered before the rail dispatch) and the railHidden handoff a hidden focused rail owes the listing; ui/Pane.qml 729 to 731 for the one-line onRailHiddenChanged wire and its comment; tests/js/focus.js 406 to 430 for the Ctrl+B hide and handoff pins with the hidden-rail Tab guards. ui/js/PlaceMenu.js 63 to 72 and tests/js/placemenu.js 75 to 125 carry the rail handoff open and its pins inside their budgets. Round 2 aligns same-folder Open with rail Enter through RailKeys.openFrom, so a ready folder takes focus with no new listing while a refused busy open stays on the rail; comments joined to single lines throughout. Round 4 guards the handoff to the active pane, re-derived with wc -l: ui/js/Focus.js 372 to 373 and tests/js/focus.js 430 to 451 for the inactive-ownership pins (active list/grid/columns take their own listing, an inactive rail view and an already-list pane move nothing). w28 moves two ceilings, re-derived with wc -l: src/uistore.rs 483 to 540 for the clippy incompatible_msrv fix (the flock(2) helpers lock_exclusive, try_lock_exclusive and unlock replacing File::lock, try_lock and unlock, which need Rust 1.89 over the 1.77 floor, each a single shot like the original's cvt, plus the held-lock try pin) and src/backend/localsend.rs 403 to 405 for the relinquished probe handle (mem::forget after the pid marker, so the PDEATHSIG stand-in outlives its parent with no allow). src/shelf.rs 303 to 328 for the absolute() 1.77 spelling of std::path::absolute with its contract pins, src/backend/copymanifest.rs 371 to 373 and src/backend/undo.rs 309 to 311 for the ENOTEMPTY const each, and src/shelf_tests.rs 180 to 198 with src/backend/copymanifest_tests.rs 168 to 185 for those pins, all inside their budgets; every other touched file keeps its line count.

e39 moves one ceiling, re-derived with wc -l: tests/js/columns.js 356 to 367 for the neighbour-gate pins (neighbourAsks narrow/wide/empty/root checks, the fresh-list source pins and the folder-wait stopCap pin) and the runColRoot refresh-gate reword. ui/js/Columns.js 255 to 263 for neighbourAsks, ui/ColumnsArea.qml 438 to 435 for the fresh-gate refresh and the folder-wait stopCap, ui/PreviewSwap.qml 242 to 245 for stopCap, all inside their budgets; tests/columnsfolder.qml 271 to 393 for the windowed capture-gated holds, the IMAGE-stubborn file rows, the wait-pinned keptName (identity holds only across unanswered waits; a landing or fallback hides the preview and clears its data by onCanReadChanged design) with peeked-rows checks, and the w25 kept-landed-bounded phases; the new tests/columnspeekgate.qml at 189 carries the windowed width/path/root/hidden first-peek pins and is registered in tests/run-all.sh. e39 moves two ceilings, re-derived with wc -l: tests/js/columns.js 367 to 378 for the folder-hold wiring pins, and tests/columnsfolder.qml 393 to 396 for the honest manual-bypass wait (network storage holds the frame on listing facts, Ctrl+Space queues one load before the folder move, MANUAL trace witnesses loaded 0 cursor 1 ready waiting manual holding started) and the HOLD_MS-derived restart window. ui/PreviewSwap.qml 245 to 252 for the folderHold guard on check() and the cap expiry; ui/ColumnsArea.qml keeps 438 for the folderWaiting predicate and binding; ui/js/Columns.js keeps 263.

w34 moves one ceiling, re-derived with wc -l: ui/Row.qml 468 to 472 for the assignable shared name budget (assigned plus local fallback for PickerList and drop targets, ordinary rows share List plain and clip budgets, local is a function so ordinary rows pay no per-row floor binding); ui/List.qml 300 to 320 inside the hard cap for the shared plain and clip slots with exact drawn geometry; tests/listnamebudget.qml at 280 over soft for the real List probe over resize, dual, hidden and stored widths, pinned-20 text size, tight density, clipboard and filter with Picker and drop fallbacks.

w34-r2 holds those ceilings with comment joins only and no logic change; the fixture builds its pinned-20 mode through TextSize.nearest with a baseSize 20 assert, switches density tight with density and densityRatio asserts, and escapes the wide-glyph codepoint instead of emitting it.

e41 adds neighbour background-menu intent and landing guards. Re-derived integration ceilings: tests/js/columns.js 482, ui/ColumnsArea.qml 476, ui/Pane.qml 735, ui/PaneWire.qml 463; the recorded ColumnsArea ceiling 483 covers the unit variant, and the merged head is 476. The existing e39 gate checks remain alongside the new ColumnMenu checks. w36 moves one ceiling, re-derived with wc -l: tests/js/focus.js 451 to 456 on integration for the Pane railHidden wire pin (Source import with the handler line, labeled wire pin not execution); tests/listcost.qml 245 to 254, tests/rowcost.qml 314 to 334, tests/columnscost.qml 394 to 416 and tests/js/names.js 219 to 222 stay inside their caps. R2 uses the actual drawn glyphs (Row direct-Glyph match, ColumnRow markItem) so glyph and markSlot opacity mutants RED.

e41 records its ceilings, each re-derived with wc -l: neighbour-column backgrounds navigate to the drawn directory and open its background menu once the rows land. ui/ColumnPane.qml keeps 353 over the soft budget and under the hard cap for the peek background signal and its route; ui/ColumnsArea.qml keeps 459 inside its recorded 483 with the routing delegated to the tested decision; ui/Pane.qml keeps 735 for the pending intent and its one-line opener; ui/PaneWire.qml 463 to 468 for dropping both deferred menus only at the terminal-listing boundary; ui/js/Nav.js keeps 290 over soft with the thin consume/clear wrappers and the unrelated-hop drop guard; the decisions live in the new ui/js/ColumnMenu.js at 111 inside both budgets; tests/js/columns.js 467 to 518 for the executed routing, stale-keeps/failure-drops lifecycle and wiring-order pins; tests/ui-columns-background.sh at 166 carries the five live New Folder surfaces.

e41-r4 integration records tests/js/columns.js 533 and ui/ColumnsArea.qml 456, retaining the existing neighbour gate and focus checks alongside the new routing and failure-boundary tests.

e43 records one ceiling outside the tool's own scan, re-derived with wc -l: tests/hook-gate.sh at 595 lines, over the 400 hard cap the scan gives .rs and .qml files (.js and .mjs get 300). The scan does not read shell suites, so this is a recorded rationale rather than an exception entry: one file holds every hook-gate case (commit rules, message adapter with normalization agreement, guard, installer, stub clippy transports with concurrency, and real git/hk wiring), because scattering them across per-checker suites would re-create the uninvoked-suite defect tests/run-all.sh exists to close. tools/flea-commit-msg stands at 153 lines; the scan reads no extensionless tool, so that count is stated, not budgeted. The r10 canonical rewrite leaves both counts re-derived with wc -l: tests/hook-gate.sh at 613 lines for the cleanup matrix and stored-agreement pins. The r13 attribution repair leaves tests/hook-gate.sh at 684 lines for the compound-credit, generated-by and overlapping-stream pins with tools/flea-commit-rules at 214 lines for the word-boundary verbs and exact-name credits, each re-derived with wc -l. The r14 token repair leaves tests/hook-gate.sh at 716 lines for the Claudette, parenthesized, qualified-machine and company-email pins with tools/flea-commit-rules at 216 lines for complete-token providers and name-only AI identity, each re-derived with wc -l. The r15 brand repair leaves tests/hook-gate.sh at 725 lines for the brand-name CLI pins with tools/flea-commit-rules at 225 lines for brand tokens and brand compounds, each re-derived with wc -l. The r16 verb repair leaves tests/hook-gate.sh at 736 lines for the generated-using and billing-prose pins with tools/flea-commit-rules at 224 lines for the precise verb phrases, each re-derived with wc -l. The r17 filename repair leaves tests/hook-gate.sh at 755 lines for the filename-masking, non-Latin credit and hyphen-brand pins with tools/flea-commit-rules at 224 lines for the masked spans and Unicode word splitting, each re-derived with wc -l. The r18 qualifier repair leaves tests/hook-gate.sh at 764 lines for the underscore-brand range plus adapter pins with tools/flea-commit-rules at 224 lines for the non-word-plus-underscore split, each re-derived with wc -l. The 0.3.9 merge-hook repair leaves tests/hook-gate.sh at 953 lines for the open-merge and real-merge pins with tools/flea-commit-msg at 173 lines, from 152, for the MERGE_HEAD parents, each re-derived with wc -l; tools/flea-commit-rules, untouched by it, measures 357 lines, raised from the r18 record of 224 by 65a4c161's contributor-history rules.

w34-r5 answers the cold review (identifier presence admits a comma-call arm and a condition-only arm) and takes tests/js/listbudget.js 31 to 77 with a scoped ternary parser pinning each arm exactly: drop tests dropTarget with only the measured fallback, the inner test names the assignment, the assigned arm is the bare assignment, the fallback stays measured; both R2 branch mutants go red on that one check and the baseline holds 13 of 13. e41 R5/R6 records execution provenance without moving production: tests/js/columns.js 518 to 577 for compiling the actual PaneWire onFailed callback from shipped source and running it under realistic doubles (stale/sort preserve both intents with the listing in flight, scan drops both and ends the listing, a dead backend drops both at once) plus the executed routeBackground sample showing same-folder opened; ui/js/ColumnMenu.js keeps 111 with the corrected sample showing same-folder opened. R6 joins the onFailed test comment to one line. R7 adds the parser sample and the source-length bound with its unterminated onFailed throw, leaving tests/js/columns.js at 580.

e41 R7 integration records tests/js/columns.js at 618, retaining all e39 neighbour, e47 real Qt guard and focus checks alongside the actual PaneWire failure callbacks.

w64 R2 moves two ceilings, each re-derived with wc -l: src/backend/durable.rs 631 to 661 for the held-file clone baseline (the first held file is cloned once before any close, sharing its pre-write description, then originals close in parallel, syncfs runs on the clone and the clone drops on every path, with clone failure sticky unconfirmed); src/backend/durable_tests.rs 1121 to 1248 for the clone lifetime, clone-failure and clone-syncfs-failure pins. src/backend/movebatch.rs keeps 469 and src/backend/movebatch_tests.rs keeps 751 with no added line.

Tabs040 moves three recorded ceilings, each re-derived with wc -l at the commit that recorded it. ui/js/Tabs.js 299 to 323 for the pane-level move and moveCurrent over the new ui/js/TabMove.js, which holds the pure reorder, step and insertion math at 34 lines inside both budgets rather than raising that ceiling further. ui/js/Keymap.js 314 to 320 for the generated {, }, [, ], ctrl-shift-pageup and ctrl-shift-pagedown rows. src/tui/actions.rs 1021 to 1035 for the TUI's own move arm. ui/TabBar.qml takes the drag state, the DragHandler, the ghost and the insertion bar at 266, over the soft budget and under the hard cap.

t2-tabrestore moves seven recorded ceilings, each re-derived with wc -l: src/uischema.rs 439 to 484 for the lastTabs key with its Rule::LastTabs, the MAX_LAST_TABS cap and the edge tests beside the existing ones; src/uistate.rs 625 to 653 for the is_last_tabs validator beside is_a_place; ui/js/Tabs.js 326 to 378 for remembered, the pure restorePlan and the restoreItems snapshots; ui/js/Settings.js 440 to 450 for the Last folder hint spliced under its control; ui/ViewState.qml 398 to 404 for the rememberTabs writer beside rememberLastPath, entering the tool's list as the one file this unit pushes over the hard cap; ui/WindowBody.qml 547 to 564 for the tab write beside the path write, the rememberTabStrip the primary pane's onTabsChanged signal owes (a strip reassignment only, never a cursor move), and the startup restore beside the single open; and tests/js/settings.js 319 to 334 for the hint's presence-only-under-Last-folder pins. The suite is the new tests/js/tabrestore.js at 101, inside both budgets, registered in tests/js/harness.qml at 176; restoreItems goes through the same snapshot() openNew records, so the suite asserts equality with a fresh tab rather than literals. Tabs are per pane and dual keeps two panes, but dual startup keeps its own pair: only the single view remembers and restores the strip, and the TUI never reads the start setting, so it restores nothing.

ClickAndRefresh moves eleven recorded ceilings, each re-derived with wc -l. src/uischema.rs 421 to 439 for escapeUp, openMode and clickRename with their defaults, rules and edge tests. ui/js/Settings.js 432 to 440 for the Cursor escape-up check and the Opening open-mode segment with the slow-click check greyed under single mode. ui/js/Focus.js 373 to 388 for the reload route and the escape-up gate. ui/Pane.qml 735 to 753 for the three settings mirrors, the reload count, the slow-click stamp and the context-aware showRow dispatch. ui/ColumnsArea.qml 459 to 465 for the slow-click rename in the active column, ui/List.qml enters the tool's list at 416 for the slow-click rename and the context showCursor, and ui/GridArea.qml enters it at 405 for the slow-click rename. ui/js/Keymap.js 320 to 322 for the regenerated reload bindings, over Tabs040's rows. tests/js/focus.js 456 to 521 for the escape-up gate and the reload route, and tests/js/settings.js enters it at 319 for the new rows. The reload notice went to the new ui/js/Reload.js, the three-row context to the new ui/js/ScrollOff.js and the slow-click window to ui/js/Tap.js, which stays inside its budgets; their suites are tests/js/reload.js, tests/js/scrolloff.js and tests/js/slowclick.js.

Sidebar040 moves eight recorded ceilings, each re-derived with wc -l. ui/Sidebar.qml 553 to 661 for the Recent rail row (the showRecent gate, the Home split, the history Loader with its watcher cache, and the favourites drag reorder with its insertion line). ui/Pane.qml 704 to 739 for the recentMode listing (the mode, the return, the guards and the header wire). ui/Row.qml 456 to 461 for the recenting name split with Used beside it. ui/SidebarRow.qml stays inside its budget at 257 for the reorder drag and its two insertion bars. ui/SettingsFavourite.qml stays inside its budget at 118 for the 19 px mark slot and the 24 px handle and remove boxes. ui/KeymapSheet.qml stands recorded at 585 for the four-section query (actions, menu rows, places, recent files) with its query field that appears on the first typed key. ui/Header.qml stays inside its budget at 186 for the Used title and the hidden Mode and Kind. ui/js/Settings.js 427 to 430 for the Built in Recent row. ui/js/Focus.js 339 to 343 for the Recent reveal, escape and write guards. ui/js/Tabs.js holds its 300 hard cap by calling into the new ui/js/RecentMode.js, 125 lines inside both budgets, rather than carrying the tab logic itself; the sheet query went to the new ui/js/SheetQuery.js, 195 lines inside both budgets, rather than into the generated ui/js/Keymap.js, which is never hand edited. ui/Ipc.qml 802 to 807 for the recentMode, recentFrom and keymapQuery readers. src/uischema.rs 408 to 412 for the showRecent key, off by default, with its rule and default test. tests/js/focus.js 404 to 405 for the new field on its stub panes; tests/js/recentmode.js at 159, tests/js/sheetquery.js at 78 and tests/js/sheetcandidates.js at 79 hold the mode, the rank and the candidates.

Ported onto the 0.3.6 release head, the same change re-derives to ui/Sidebar.qml 669, ui/Pane.qml 799, ui/js/Focus.js 387, ui/js/Settings.js 443, ui/js/Tabs.js 321, ui/Ipc.qml 823, ui/WindowBody.qml 559, ui/SidebarRow.qml 340, ui/Header.qml 355, src/uischema.rs 446 and tests/js/focus.js 473, each recorded in tools/flea-file-budget; the 0.3.6 rail restructure carries the network and photos opens through the window host, so the port adds only the Recent row's recentRequested signal and never the old networkOpened or photosOpened pair.

P3 ports that change onto this head, each re-derived with wc -l: ui/Sidebar.qml 564 to 672 for the Recent rail row (the showRecent gate, the Home lead/rest split, the history Loader with its watcher cache, and the favourites drag reorder with its insertion line). ui/Pane.qml 735 to 770 for the recentMode listing (the mode, the return, the guards, the chrome name and the header wire). ui/Row.qml 474 to 478 for the recenting name split with Used beside it. ui/SidebarRow.qml keeps inside the hard cap at 338 for the reorder drag and its two insertion bars. ui/SettingsFavourite.qml keeps inside its budget at 118 for the 19 px mark slot and the 24 px handle and remove boxes. ui/KeymapSheet.qml stands recorded at 585, over the 400 hard cap, for the four-section query (actions, menu rows, places, recent files) with its query field that appears on the first typed key. ui/Header.qml keeps inside the hard cap at 391 for the Used title and the hidden Mode and Kind. ui/js/Settings.js 432 to 435 for the Built in Recent row. ui/js/Focus.js 373 to 377 for the Recent reveal, escape and write guards. ui/js/Tabs.js 299 to 302 for the resting-path and dropOverlay calls into the new ui/js/RecentMode.js, 125 lines inside both budgets, rather than carrying the tab logic itself; the sheet query went to the new ui/js/SheetQuery.js, 195 lines inside both budgets, rather than into the generated ui/js/Keymap.js, which is never hand edited. ui/Ipc.qml 818 to 823 for the recentMode, recentFrom and keymapQuery readers. ui/WindowBody.qml 547 to 549 for the chrome's Recent name. ui/PaneWire.qml 468 to 471 for the floor and fsinfo guards. ui/List.qml 400 to 402 for the recenting wire, recorded rather than split. src/uischema.rs 421 to 425 for the showRecent key, off by default, with its rule and default test. tests/js/focus.js 456 to 457 and tests/js/settings.js 298 to 301, the second recorded rather than split, for the new fields and the Recent row pins; tests/js/recentmode.js at 159, tests/js/sheetquery.js at 78 and tests/js/sheetcandidates.js at 79 hold the mode, the rank and the candidates. The Photos roll is gone on this head, so the port drops the prepared roll-clearing lines and ui/js/Photos.js stays deleted; the rail menus, eject marks and auto-hide rail are main's and are carried untouched. The sheet ranks all four sections in order and SheetQuery.rank holds their exact-place, exact, matched and keyed buckets.

kb1b (17f14f7d) moves ten recorded ceilings, each re-derived with wc -l at the commit that recorded it. tests/js/focus.js 522 to 595 for the Escape climb landing-mark, armed-pair and sheet-snapshot pins. tests/js/menu.js 335 to 348 for the hidden Copy as and Paste as lone-flyout pins. tests/js/tap.js enters the tool's list at 317 for the single-click tap-count suite, over the 300 hard cap and recorded rather than split. ui/ContextMenu.qml 625 to 675 for the lone-flyout state the c and P keys open while their rows hide. ui/Pane.qml 992 to 998 for the Recent paste refusal in pasteLink. ui/PermissionsDialog.qml 589 to 627 for the multi-row card with its accumulated inspect and skip notes. ui/Row.qml 478 to 479 for the Recent location branch, railed in ea0f9a7d on the same merged head. ui/js/Focus.js 408 to 439 for the deliberate-marks Escape and sheet-snapshot dispatch. ui/js/Menu.js 425 to 436 for flyoutEntries over the Copy as and Paste as leaves. ui/js/Tabs.js 390 to 399 for the pending restore carried across a reorder. kb1c's comment cuts then left every one of those files under its ceiling, and kb1d took ui/Pane.qml 998 to 1000 for the two sort declarations Recent hands back. This round takes ui/Pane.qml 1000 to 1003 for the hop restore in openWithoutHistory and its pane route, and ui/Sidebar.qml 675 to 677 for the asker list a history read answers one by one. The held-row guard records ui/ContextMenu.qml at 693 lines after restoring the original submenu glyph function and comment, with live main and flyout press checks and an owed-refresh flag instead of the stale counter; the batch Permissions read-only IPC fields take ui/Ipc.qml from 842 to 854 lines. The flyout-close hook takes ui/ContextMenu.qml to 694, the held-window ask for a search takes ui/js/Nav.js from 303 to 308, and the Permissions040 distances for several items take ui/PermissionsDialog.qml from 629 to 634.

Paste into the query lines (issue 287) moves four ceilings, each re-derived with wc -l: ui/Pane.qml 1073 to 1076 for clipboardText(), the one read of Quickshell's clipboard text the "/" filter and the search line make on Ctrl+V or Shift+Insert, ui/js/Filter.js 259 to 262 and ui/js/Search.js 356 to 359 for each line's paste branch into the shared ui/js/Input.js pasteInto, and tests/js/search.js 309 to 336 for its cases (first line only, control characters dropped, one generation per paste, empty and missing text).

Advfix-picker records ui/PickerWindow.qml 706 to 778, each re-derived with wc -l: the grid view with its thumbnail plan and the persisted pickerView (706 to 770), the scroll-ask follow-up that re-plans newly visible tiles (770 to 776), and the named coalesce interval and window lead the two views share (776 to 778).

The picker sort writes (PR #281) and the gui tab digits (PR #284) move three ceilings, each re-derived with wc -l: ui/PickerWindow.qml 779 to 781 for the folder sort and default sort requestSort now writes, tests/js/picker.js from the 300 cap to 306 for those writes, the case that must write nothing (the same order again) and the two that write only the default sort (Recent, no folder), and tests/js/keymap.js 309 to 312 for the gui digit pins in the listing and in two contexts where a digit stays unbound.

MenuAdditions040 moves sixteen recorded ceilings, each re-derived with wc -l against the 0.3.8 integration head. ui/js/Menu.js 337 to 399 for the Copy as and Paste as flyouts with their leaves, Show original and Invert selection with their availability rules, and the multi-row Permissions entry, over the 300 hard cap and recorded rather than split: the inventory, its availability and its flyouts are one subject, and the leaves carry the board's own glyphs. src/backend/opsdispatch.rs 540 to 719 for the link, link-target and permissions-batch operations with their journal entries and the dispatch tests that prove one undo removes every created link. ui/Pane.qml 788 to 869 for the Copy as, Paste as, invert, show-original and multi-permissions routes with the menu's new bindings. ui/PermissionsDialog.qml 432 to 589 for the multi-row card, its mixed bars, its note and its one-step Apply. ui/PaneMenuActions.qml 407 to 443 for the Copy as leaves, Show original, Paste as links and invert dispatch. ui/ContextMenu.qml 584 to 620 for the copy/paste flyout entrance, the leaf letters and the keyHint-gated flyout hint. ui/js/Ops.js 371 to 409 for the Copy as clipboard flow and the link status line. ui/PaneWire.qml 468 to 486 for the linked and link-target replies. ui/Backend.qml 431 to 435 for the two new reply signals. ui/js/Focus.js 373 to 387 for the new listing and menu actions. ui/js/Settings.js 432 to 434 for the four new Menus switches. ui/js/Keymap.js 322 to 335 for the generated listing and menu bindings. ui/WindowBody.qml 547 to 551 for the batch dialog route. src/backend/run.rs 448 to 454 for the three new request arms. src/uischema.rs 421 to 422 for the hidden list the new rows ship in. tests/js/menu.js enters the tool's list at 307 for the flyout, availability and multi-selection pins, over the 300 hard cap and recorded rather than split. The new ui/js/CopyAs.js (57 lines) and src/backend/link.rs (185 lines) sit inside their budgets rather than growing ui/js/Format.js and src/backend/ops.rs; ui/js/Marks.js 82 to 114 takes invert, ui/js/Permissions.js 11 to 36 the multi summary, ui/js/Collide.js 93 to 105 the link question and ui/js/Messages.js 97 to 104 the two replies.

Advfix-backendops round 2 moves two recorded ceilings, each re-derived with wc -l: src/backend/opsdispatch.rs 719 to 1068 for the link leftover reporting (the inspect and remove helpers with their test-only fault hook, the named-leftover failure with its test) and the usable_dest refusal pin, and src/backend/permissions.rs 477 to 545 for the link, busy and permissions-batch operations, their undo steps and their tests (540 at the lk1 merge, recorded at 543 without re-deriving the 545 HEAD).

e80 optional preview candidate records ui/Preview.qml at 490 with its existing scoped ceiling, ui/SelectionPreview.qml at 302, ui/js/PreviewSettle.js at 14 and tests/js/previewswap.js at 267, each re-derived with wc -l. The exact tests/preview-settle-live.qml ceiling is 450, the file at 450 lines against its recorded cap of 450, for the real Window parent, production-shaped meta and selection signals, runner-provided readable images, gate diagnostics and fresh close/reopen checks. It preserves 38 automatic and 3 seeded manual checks, the 120ms timer and <200ms duplicate bound, storage and no-swap gates, identity refresh, visibility restoration and the exact picture-capture queue order. Its runner generates a tiny JPEG with the existing ffmpeg dependency and copies it into eight names inside each fresh marked sandbox and requires exit 143, one clean DONE, exact PASS counts, no FAIL and no warnings. Native acceptance remains with the controller.

The drag verbs and the slow-click rename move nine recorded ceilings, each re-derived with wc -l at the commit that recorded it. 0c77b1bf (Finder verbs between Flea windows) takes ui/js/Drag.js 302 to 346 for the verb, offer and modifier decisions and tests/js/drag.js 301 to 363 for their pins, with ui/GridArea.qml 410 to 411, ui/List.qml 435 to 436 and ui/Row.qml 478 to 479 for the one drag wire each. 86343a01 (offer copy out, keep refused rows dark) takes tests/js/drag.js 363 to 398 for the copy-out offer and the refused-row pins. 5cec3768 (a slow-click rename keeps the scroll still) takes tests/js/ops.js 430 to 448 for the context pins, ui/PaneMenuActions.qml 458 to 468 for the context-carrying rename route and ui/js/Ops.js 438 to 439 for the context argument. aab6ba79 (the round 1 harness settle) takes ui/Ipc.qml 832 to 833 for the one reader it added. tests/js/drag.js and ui/js/Drag.js sit over the 300-line JS hard cap and are recorded rather than split: the verbs and their pins are one subject.

The menus lane (a slow click ends with any key or action, and the menu's Rename refuses a moved selection) moves four recorded ceilings, each the real wc -l: ui/Pane.qml 1022 to 1024 and ui/js/Focus.js 433 to 435 for the one cancel each, and ui/PaneMenuActions.qml 464 to 465 and ui/js/Ops.js 425 to 426 for the three-route rename decision (tests/js/ops.js stays at 511: its two source-text pins gave way to the driven tests/menu-snapshot-retire.qml checks). tests/js/slowclick.js passes the 300-line JS cap at 308 for the live-editor pins and takes a recorded row, the real wc -l, rather than a compressed body.

The stage2 merge of fs1 and fs5 records ui/js/Devices.js at 366, fs1's udisks hide rules beside fs5's mediaRemovable for the USB power-off chain, and tests/js/devices.js at 303 for both units' pins, each over the 300 hard cap and recorded rather than split, re-derived with wc -l.

The mounted-loop rail fix (an opened .iso in DEVICES) on the 0.3.11 stage moves four recorded ceilings, each re-derived with wc -l: ui/js/Devices.js 405 to 440 for the mounted-loop pseudo test, the loop flag on the walk and the row, mounted parents with children, and ejectCommand, which checks the same non-empty backing file before detaching after any unmount status and exits apart when that detach fails, ui/DeviceMounts.qml 537 to 540 for a loop row's -u eject route around the power-off chain and its failed-detach verdict, and tests/js/devices.js 406 to 437 for the not-loop pin on a real stick and the mounted-whole stick fixture, and tests/js/mounts.js 349 to 359 for the loop menu checks beside the stage's Unmount and Flash image rows. The loop cases and the driven DeviceMounts exit paths live in tests/js/loopdevices.js. The fix keeps the 168 first-frame units and raises tests/startup-units.txt from 1,627,609 to 1,630,211 bytes, re-derived by summing wc -c over its units: DeviceMounts.qml adds 324 bytes, js/Devices.js 1,942, js/Eject.js 290, js/Menu.js 25 and js/Mounts.js 21. The DeviceMounts allowances in both unqualified-read profiles follow the final source; its missing-module rows retain their original symbols and reasons at the derived lines, while the present-module profile has no DeviceMounts allowances.

The stage4 merge records the merged ceilings, re-derived with wc -l at the merge: tests/js/watch.js 354 (us1's kill-sweep pins beside xw4r2's two-window settle pins) and ui/Pane.qml 1013 (xw4r2's settler wiring beside stage2's Anchor import and drag state). The merged undo ceilings are also re-derived with wc -l: src/backend/undo.rs 612 and src/backend/undoshare_tests.rs 532. The duplicate src/backend/iomount.rs row is gone: two rows made the tool read the ceiling as two numbers, fail its comparison and skip the file.

The mgb merge-resolution pass records src/backend/undo_tests.rs at 423, re-derived with wc -l: its round 4 pins legacy records without birth time through rebase, move, copy and new-file undo, which belong beside the undo journal's own tests rather than in a second file.

sg1 records nine narrowly changed ceilings, each re-derived with wc -l: src/backend/undoshare_tests.rs 513 to 514 and src/uischema.rs 500 to 501 split fused function bodies back into separate lines; tests/js/focus.js 728 to 735 adds DEL and normal-letter event regressions; ui/Pane.qml 1009 to 1011 exposes its existing wire to the reload dispatcher and the live drag state; ui/OpenWithDialog.qml 607 to 608 and ui/js/Focus.js 434 to 435 import the shared printable helper. ui/List.qml 436 to 437, ui/GridArea.qml 411 to 412 and ui/ColumnsArea.qml 477 to 480 expose the live drag state used by the existing slow-click tests. Global limits and all checks remain intact.

The sg1 merge into int re-derives three ceilings with wc -l: src/backend/undoshare_tests.rs 533 and ui/Pane.qml 1015 add sg1's line splits and wire alias to the stage4 sizes, and ui/OpenWithDialog.qml 608 keeps sg1's helper import.

The xw5r2 merge into int re-derives every exception ceiling with wc -l, one row per file. The joined watch keeps marks by identity, the failed paths and locate anchors still list and end, and the reload requests changed-row counts only when asked. ui/Pane.qml is 1018 lines for the writable dragHolding and awaiting-paths holds beside the readonly dragActive, true while a drag is held or the live view reports one; ui/PaneWire.qml is 577 for tagged anchor replies and the count-free watch caller. tests/js/drag.js is 405 with both sides' drag pins, and tests/js/xwwatch.js is 312 with every cross-window case. ui/js/Anchor.js is recorded at 393, over the 300 hard cap, because the joined file owns the watched, delete and preference anchor contracts together; ui/js/AnchorHold.js is 119, keeping the count request and reload notice across deferred paths resolution. tests/js/xwrl4.js is 263, over soft and under hard, with the row-height, clamp, grid and delayed counted-reload pins. ui/PaneSwap.qml is 186, applying the watched anchor with row height before the preference anchor. tests/xwsettings-tabs.qml is 416 for the native immediate-reload, kept-file and selection-clear pins, preserving the two-window watch coverage under xw5. Global limits and checks remain unchanged.

The x5m round 3 fixes re-derive the touched ceilings with wc -l: ui/js/Anchor.js is 405 for request-scoped locate IDs, separate send statements and the unfinished-anchor hold; ui/PaneWire.qml is 575 and tests/js/xwwatch.js is 310 after folding the cited comment blocks and updating the locate reply fixture. The new tests/js/xwanchor-races.js is 129 lines and needs no exception. tests/xwsettings-tabs.qml remains 416. Global limits and all checks remain intact.

The x5m round 4 fixes record ui/PaneWire.qml at 579 lines, re-derived with wc -l, for ending the open directory's anchor after a window refusal without losing the error line or cursor. The race suite is 183 lines inside both budgets, with locate-ID range and window-refusal pins. Global limits and all checks remain intact.

mdfast-r2 moves one ceiling: ui/PreviewMarkdown.qml 847 to 867, re-derived with wc -l, for the unplaced theme probe (a Loader that exists only for a document with figures) and the warm request that carries both kinds' themes to FigureService.warm, so the warm query names the cache keys the asks will use. src/figurehelper.rs grows 162 to 174 and ui/vendor/figure-helper.mjs 107 to 116 for the --report flag, ui/FigureService.qml 397 to 400 for the keyed warm query, at its 400 cap, and src/figurebuild.rs at 327 against its soft 250, which only warns. Global limits and all checks remain intact.

mdfast-r3 moves one ceiling: ui/FigureService.qml 400 to 405, re-derived with wc -l and git diff --numstat: the idle retry of a store still active after its stop (refused, or draining and perhaps restarted for a queued put) adds a comment, an if and a restart and keeps the disk.stop(); line (3), warmKeys, the keys of the last warm query that the store suite compares with the keys a placed figure puts, adds 3 (a comment, the property and the assignment), and the warm comment joined to one line takes 1 off: 3 + 3 - 1 = 5. tests/figure-store.qml is 298 against its soft 250, which only warns. Global limits and all checks remain intact.

mx2 renders Markdown maths and Mermaid in a sandboxed quickjs-ng helper, each re-derived with wc -l: src/figurehelper.rs at 178 for the pure qjs and vendor resolution (qjs_from and resolve_with, with qjs_path and resolve the thin env-reading wrappers) with its env-free tests in src/figurehelper_tests.rs, the jailed argv with its three read-only binds and the 127 refusal; ui/FigureService.qml at 405 for the lazy Process with its stdin and SplitParser, the ticket deadlines with the timeout kill, the deadline timer running only while waiting holds a ticket, the 64-entry LRU with its key mirror, the idle exit and the 127 latch; ui/js/FigureWorker.mjs 645 to 619 (the 0.3.9 de-slop deleted the worker's unused copy of the cache key, cacheKey, themeKey, tableDigest and CACHE_MAX, because FigureService.cacheKeyOf builds the only key, plus two dead checks; before that 632 to 645, when mathsize dropped the display-scale constant and mermaid-r1 and mermaid-r2 added the two advance tables to the key and to the request), which is 513 after deleting everything that only existed for the Qt worker engine plus 85 for the canvas's left trim, its tspan lines with SVG's nearest dy and its per-class glyph advances, plus 33 for baking each dy into its y because QtSvg ignores dy, keeping the post-processing both node and qjs import, then merged with the md stack's theme-role, canvas and marker post-processing; the helper itself is the new ui/vendor/figure-helper.mjs at 116, and tools/flea-file-budget reads ui/js/FigureWorker.mjs like any .js file while it excludes ui/vendor/ outright. src/backend/sandbox.rs 365 to 401 (source sha256 4a732e3e123e01ee68dae16d2cb4cd623a87a13e4aeb067872bc12f64477319f) for wrap_readonly_extra with its nothing-writable bind list; the 0.3.11 HTML change adds only pub use super::htmlsandbox::wrap_html; and its following blank line to the 399-line source at 723b235c8f1ae640d6de5079e2de47e9df32a8d3, leaving the figure sandbox implementation byte-identical. Its existing 401-line recorded composition exceeds the unchanged hard cap of 400 and is retained by tools/flea-file-budget; src/main.rs 365 to 395 for the dispatch (361 to 391 before the copy pass added its four copysrc_tests module lines above main), the same, where 367 was the stack's own, the stage's clip and tear-off dispatch adds 28, PR #248's icdexclude and jsonmembers module lines the next two and the vkprobecache module line one more to 398, and moving flea --ui-state into src/uistatecli.rs (50) brought it to 352, the --unlock dispatch with its module line five more to 357, the run card's --run, --run-terminal, --open-text and --open-handler dispatch with its three module lines and four usage lines twelve more, and the blank line between the two dispatch blocks one more, to 370; the flasher's flea flash verb adds five and folding --favourites, shelf and flash into one match takes seven back, to 368; the picker guard flash_picker with its test module, one usage line and the relative start and reveal resolution add 22, to 390, and the procsignal module line one more, to 391. tests/markdown-figures.qml 495 to 399 for the service suite (answers, cache hit with no new helper line, idle exit, timeout restart, 127 latch and fence, the deadline-timer stopped checks, and the FIGPSS phases with the FIGHELPER peak), leaving its recorded ceiling with nothing over it; tools/vendor-js/build.sh 27 to 43 for dropping the assembler check with the classic worker it assembled, refusing a missing bundle target before the download, then applying the beautiful-mermaid patch. Deleted with nothing left behind: ui/vendor/figure-worker.js and tools/vendor-js/assemble-figure-workers.py.

The columns-watch work records two ceilings, each re-derived with wc -l at the commit that recorded it: ui/Ipc.qml 880 to 882 for the read-only columnNames and columnPeekNames readers, and ui/ColumnsArea.qml 483 to 491 for the drawnNames and peekNames functions behind them, which the native colwatch case (tests/ui-colwatch.sh, ui:colwatch) reads to see a side column's rows. The in-flight return fix (ask gains rearm, ask counts replace the ask set in ui/js/Columns.js, now 292 lines) moves no ceiling: ui/ColumnsArea.qml stays 491, corrected to 493 by the pre-capture askCursorThumb call (r35-asks). Global limits and all checks remain intact.

rflake 2026-10-05 (the exact-wait test compared least overshoots against a 2.5 ms bound and missed 3 in 12 loaded runs at 2.51, 4.44 and 3.49 ms, scheduler spikes under saturation, never the product) moves one ceiling, re-derived with wc -l: src/backend/gvfslist.rs 549 to 560 for the hardened estimator (64 samples with two warmup pairs, per-side lower quarter instead of least, same GAP, SLOWEST_CAUGHT, threshold and attempts; the restored 20 ms poll loop still fails it at 4.99 ms). Global limits and all checks remain intact.

polltest 2026-10-06 replaces both poll-boundary timing tests with deterministic wait-count gates (one blocking poll per child exit in src/backend/child.rs 280 to 254, whose pidfd wait now also reaps gio after EOF in place of a 1 ms try_wait look, and one recv per gio chunk plus EOF, then that one exit poll, in src/backend/gvfslist.rs 560 to 500), each re-derived with wc -l.

2026-10-09 moves one ceiling, re-derived with wc -l: src/backend/gvfslist.rs 500 to 513 for a failed exit wait that names its OS error, the test that pins that sentence, and the test that tells the idle deadline from the exit deadline by the exit wait it counts.

clipjoin 2026-10-05 records src/clip/watch_end_tests.rs at 406, re-derived with wc -l: the waiter-join pin reads each waiter thread's PF_EXITING flag from /proc (waiters_joined, three named stat constants) and holds a stopped waiter for WAITER_STOP_HOLD_MS, beside the end tests it guards rather than in a second file. Global limits and all checks remain intact.

cliphunt 2026-10-05 moves one ceiling, re-derived with wc -l: src/backend/menu_actions.rs 560 to 641 for request() answering each outcome of the new one-slot queue (a displaced snapshot is answered as cancelled, a full slot as still running, a closed one as stopped) and three tests pinning a queued snapshot displaced by a newer one, queued work kept, and the reopened snapshot answered end to end. The queue lives in src/backend/menu_slot.rs; the worker holds a guard that closes it when the worker returns or panics. tests/menu-clipboard-hunt.qml is 258 lines, over the soft budget with a warning only, for the menu-state evidence its stage-12 timeout prints. Global limits and all checks remain intact.

Issue #217 moves one ceiling, re-derived with wc -l: tests/js/devices.js 303 to 366 for the multi-boot fixture with real parttype GUIDs and the checks that the system disk's siblings follow Show unmounted drives, its plumbing and root leaf stay hidden, and the mounted and unmounted menus.

mdfid2 records one ceiling, re-derived with wc -l: ui/js/MdDocument.js 299 to 311 for the HTML heading route (the one heading check with its flush, block and align lines). MergeFolders ports the f4882ac0 folder-merge unit onto this head, each re-derived with wc -l: src/backend/opsreq.rs 560 to 597 for the folder-fold dispatch through the shared landing chain, the merge place, count and into-name calls, the MergeDone send and its enum arm; src/backend/opsdispatch.rs 2204 to 2212 for the merge terminal report through the borrowed line; src/backend/opsreq/tests.rs 484 to 489 for the merge terminal pin off its own line with the red-then-green batch-order test beside it in src/backend/merge_tests.rs; src/backend/proto.rs 378 to 383 for the one collisions arm parsing both words into its merge flag; src/backend/undo.rs 606 to 627 for the emptied-folder step with its reverse arm and the merge-top constructor whose undo refuses outside the window that made it; src/backend/durable.rs 790 to 801 for the merge-layer touch count; src/backend/run.rs keeps 719 for the guarded two-arm question dispatch; ui/Backend.qml 472 to 477 for the card flag, the walk answer, the merged counts and into; ui/PaneWire.qml 586 to 591 for the Collide import and the done-line call; and tests/js/ops.js keeps 511 with the merge line checks beside it in tests/js/collidemerge.js at 105. The walk and its cards live in src/backend/merge.rs at 250, the landing, manifests and emptied-folder records in src/backend/mergeapply.rs at 293, and the link walk in the new src/backend/collidelink.rs at 53, so none takes a row; that split freed collide.rs to carry real Policy methods and end at 372 with no row, and redo.rs and undocodec.rs sit exactly at the 400 hard cap with only one-line calls each. ui/js/Ops.js keeps 426: the merged status line and the done line moved to ui/js/Collide.js at 162 as whole %1/%2 templates filled in one place with the segments joined once, and tests/js/collide.js keeps 293 for the five-button card. Four adaptations the port forced: the apply side bundles landings in LandCtx and the terminal line borrows its OpMsg, so the clippy baseline stays 18 with no allow; the fold closes an open move batch first and reports through the shared landing match, so deferred items keep index order; the emptied-folder step rides the shared journal's re codec arm; and the second card spends the cut through the clip owner, never ClipMarks directly, with Merge never offered on a link transfer and the shared DialogButton untouched. The review fix that keeps deferred moves out of the merge count until they land grows two rows: src/backend/opsreq.rs 597 to 601 for the close_merged call after every batch close, and src/backend/movebatch.rs 474 to 477 for the kept_ok tally a landed deferred item adds; its pins sit in src/backend/mergefix_tests.rs at 190, so merge_tests.rs keeps 395 with no row. Mergecopy moves two ceilings, re-derived with wc -l: src/backend/opsreq.rs 601 to 608 for one deferred trash session per transfer, begun by its first Replace and settled once when the batch ends, and src/backend/collide_replace_tests.rs 487 to 492 for the imports its two single-replace tests need now that they drive replacing_deferred. The batch tests live in the new src/backend/replacebatch_tests.rs at 75. Replace261 takes no budget row; each file was re-derived with wc -l and split by concern rather than raised. The Trashed step's journal record (the settle-time before URI set and time an unattributed step carries) is encoded and decoded by encode_step and decode_step in src/backend/trash.rs, the way mergeapply owns its emptied-folder record, so src/backend/undocodec.rs falls from 400 to 395 with one-line calls; trash.rs ends at 262 because its tests moved to src/backend/trash_tests.rs at 278, which also holds the round-trip pins, so src/backend/undoshare_tests.rs keeps its 533 row at 530. The deferred Replace session (Deferred and replacing_deferred, with the lazy before list, the up-front refusal, the settle retry and the unresolved marking) moved from src/backend/mergeapply.rs to its child src/backend/replacedefer.rs at 150, re-exported so callers are unchanged, leaving mergeapply.rs at 316; and the orphan, partial, fifo and out-of-space pins sit in src/backend/hometrashfix_tests.rs at 148, so src/backend/hometrash_tests.rs keeps 375. The Replace pins of a merge (one Trash list per merge, the old file put back, the up-front and vanished-destination refusals, the settle retry and the unresolved marking) sit in src/backend/mergereplace_tests.rs at 221, a child of src/backend/mergefix_tests.rs, which ends at 193.

Drops ports the hover-open, autoscroll, FAT-names, clipboard-image and crumb and Columns-parent drop unit onto the merge head and records twelve ceilings, each re-derived with wc -l and each thin wiring in a host already over its cap: src/backend/opsreq.rs 597 to 599 for the Pasted reply variant, src/backend/opsdispatch.rs 2212 to 2213 for its one arm landing the journal entry through pasteimage::land, src/backend/run.rs 719 to 720 for the pasteimage arm, the fat field riding the existing collisions arm, src/uischema.rs 499 to 501 for openFoldersWhileDragging and its rule (its edge checks sit in tests/uistate.sh), ui/Backend.qml 477 to 480 for the fatnames signal with its sample line, ui/js/Settings.js 456 to 459 for the View check, ui/ChromeBar.qml 472 to 475, ui/ViewState.qml 536 to 538, ui/ColumnsArea.qml 491 to 493, ui/PaneWire.qml 591 to 592, ui/Pane.qml 1043 to 1044 and ui/WindowBody.qml 651 to 652 for the setting's read, the crumb pane and the peek rows' dropScope, each one line or two. The FAT answer rides the existing collisions round trip, so no standalone request exists: src/backend/fatnames.rs (234 lines) holds the filesystem test, the rename rule and the answer, src/backend/pasteimage.rs (248) the image paste, src/backend/collide.rs ends at 389 and src/backend/proto.rs at 387 with no row, and their tests sit in src/backend/collide_fat_tests.rs and tests/js/drops.js with tests/js/dropsflow.js. The card, the crumb target and the timers are built on first use (ui/FatNamesConfirm.qml, ui/CrumbDrop.qml, ui/HoverTimers.qml), and the card words are templates in ui/js/Collide.js at 185, so no first-frame file imports a new module and ui/js/Drag.js is untouched.

BulkRename ports 61dc5a36 onto int d79462fc, each count re-derived with wc -l against d79462fc: src/backend/opsreq.rs 560 to 562 for the BulkRenameDone variant, src/backend/opsdispatch.rs 2204 to 2209 for its landing arm, ui/Pane.qml 1050 to 1074 for the bulk signal with the bool pair beside openPermissions, the act and menu routes through it, the askPaths reply and the one-line batch verdict, ui/Backend.qml 472 to 483 for the bulk signal with its sender and sample, ui/Ipc.qml 886 to 895 for the card state reader, ui/WindowBody.qml 660 to 670 for the card property with its first-use builder, the two handler words, the guard word and the ipc wire with no Loader and no Connections, ui/js/Ops.js 426 to 439 for the bulk paths branch and the bulkDoneLine templates, ui/js/Menu.js 454 to 457 for the multi-row Rename enable, ui/js/Messages.js 116 to 118 for the bulkrenamed route, ui/PaneWire.qml 586 to 588 for the one-line batch reply into the pane, tests/js/menu.js 515 to 517 for the multi-row Rename pins, tests/js/ops.js 511 to 514 for the bulkDoneLine singular and plural pins, tests/js/harness.qml 221 to 223 for the bulkrename and bulksheet suite rows, tests/protocol.sh 1002 to 1032 for the swap, chain, refusal, unchanged and undo pins, src/backend/run.rs 718 to 719 for the one-line bulkrename arm, src/backend/proto.rs 382 to 389 for the variant and its parse arm, and ui/js/Errors.js 165 to 169 for the bulk sentence. The batch itself is the new src/backend/bulkrename.rs at 253 lines, inside both budgets for the plan, the occupant staging and the slow-write routing with its BulkOutcome alias, and its tests are the new src/backend/bulkrename_tests.rs at 166 lines, inside both budgets for the swap, chain, cycle, replaced-source and journal pins; its decisions are the new ui/js/BulkRename.js at 100 lines, inside both budgets for the WORDS templates with counts, reasons, the UTF-8 name length, footer and title beside the board strings; the card is the new ui/BulkRenameDialog.qml at 370 lines, over the soft budget and under the hard cap for the count-model names editor with its private-file terminal flow and direct backend accept, and its old-names column is the new ui/BulkOldNames.qml at 36 lines; the sheet fallthrough is the new tests/js/bulksheet.js at 17 lines, proving a non-rename sheet action still reaches the shared dispatch; and the card suite is the new tests/js/bulkrename.js at 119 lines with the card-title and name-length pins, each inside both budgets. The run-card block and the stacked-tree paragraph the old unit carried are dropped: this tree holds no run card, so only the bulk hunks ported.

Tags moves ceilings on int d79462fc, each re-derived with wc -l and each only on a file already over the cap, by thin wiring: ui/Pane.qml 1050 to 1060, ui/Sidebar.qml 670 to 684, ui/ContextMenu.qml 738 to 746 (two of them hold a flyout at the menu width while its rows build, as rowsBuilding does for the card, so the Tags flyout's fit reads its rows once instead of looping), ui/Row.qml 482 to 489, ui/PaneWire.qml 586 to 589, ui/Backend.qml 472 to 474, ui/Theme.qml 434 to 439, ui/js/Keymap.js 355 to 363 (generated), ui/js/Menu.js 454 to 457, ui/js/Focus.js 435 to 437, ui/js/Ops.js 426 to 428, src/backend/copyfile.rs 730 to 733, src/backend/undo.rs 606 to 608, src/uischema.rs 499 to 500, and for the remote settags worker and the fsinfo probe test src/backend/run.rs 718 (ceiling 719) to 724, src/backend/opsdispatch.rs 2204 to 2209 and src/backend/opsreq.rs 560 to 561; src/backend/redo.rs 398 to 399 and src/backend/undocodec.rs 398 to 400 stay inside the 400 hard cap, so they take no row, and ui/ColumnPane.qml has no net change. Every decision lives in new files that load only on first tag use: ui/js/Tags.js 118, ui/TagState.qml 137, ui/TagSlot.qml 52, ui/TagMenuMark.qml 61, ui/TagRailSection.qml 68, src/backend/tags.rs 376, src/backend/tagsreq.rs 197 with its tests in src/backend/tagsreq_tests.rs 368, src/backend/tagsundo.rs 107 and src/backend/run_tags_tests.rs 43, and the palette lives in ui/js/Contrast.js 102 to 157 because Theme already imports it, so no startup file gains an import and no object is built before first use: tests/startup-objects.qml counts 622 against its startupObjectLimit of 622, the count int carries since the spinner hold (afee13c3), and lazy-objects passes. src/backend/rowguard.rs guards 7 commands, the shipped 6 plus settags, because tags ride the rows reply and the one tags read, tagstates, acts on no file, and v0.3.8 ships no mergecollisions request for the old unit's extra name. tests/js/settings.js 352 to 354 for the Tags switch count and its board-row pin.

pickhunt 2026-10-06 moves one ceiling, re-derived with wc -l: tests/picker-hunt.qml 445 to 449 for the claim-first stage guard, its settle restamp and the marked-file length guard.

N03 2026-10-06 records src/gvfsprefetch_tests.rs at 411, re-derived with wc -l: the claim checks that every adopted, refused or failed prefetch leaves an empty claim, and the FIFO check that a claim which is not a regular file is never opened for write, so the first GVFS scan cannot block on it.

2026-10-09 moves one ceiling, re-derived with wc -l: src/gvfsprefetch_tests.rs 411 to 416 for the FIFO check's held non-blocking reader, which sees POLLHUP once any writer has opened and closed the claim, so the check fails when the regular-file guard before the write-open is gone.

unmount-r2 2026-10-06 moves ceilings, each re-derived with wc -l: src/backend/fsinforeq.rs 407 to 429 for answer carrying the system flag (system_mount_for_dev on the listing's own device, one dev_of stat beside the existing statfs, the slow branches answering false) and one test pinning a link into a data volume; ui/DeviceMounts.qml 496 to 508 for releasing (the synchronous _unmounting flag with its end on the Process change signal), the busy sentence with its quiesce first and the two eject sentences moved into the shared table; ui/Pane.qml 1043 to 1045 and ui/PaneWire.qml 586 to 587 for the pane's systemVolume fact; ui/Sidebar.qml 669 to 670 for its releasing forward; tests/js/nav.js 362 to 365 for the reset check; ui/js/Devices.js 366 to 365 for the dropped unmounted parameter; and tests/js/rail.js newly recorded at 307 for the release-resolution checks that replace the row-reading ones (the cloud, FUSE, link, Recent and $$ label cases), beside the fixture they share.

kmount 2026-10-06 moves four ceilings, each re-derived with wc -l: tests/js/mounts.js 262 to 326 for the issue #257 kernel join pins (the gio-plus-mountinfo rows, the Open-plus-Unmount menu, the fstype mark split and the holding, key and fstype-equality checks, one subject with the rail rows they pin, recorded rather than split); ui/js/Mounts.js 271 to 312 for kernelShares beside parseMounts, the one rail-row parser for kernel mounts, recorded rather than splitting gio's own inventory across two modules; ui/NetworkMounts.qml 849 to 863 for the gio-joined kernel share rows replacing the mountinfo-only rows, the straight-to-path open and the mountinfo-text seam the suite rebuilds through; ui/Sidebar.qml 670 to 675 for the rename guard a kernel row owes because no saved place exists to rename.

kmount-r2 2026-10-06 corrects the source and moves two ceilings, each re-derived with wc -l: tests/js/rail.js 307 to 319 for the restored mountinfo-row pins beside the mark-split pins, and ui/NetworkMounts.qml 863 to 869 for lending gio names and release spellings to the mountinfo rows instead of sourcing rows from gio. ui/js/Mounts.js keeps its recorded 312 with gioKernelMounts in place of the deleted kernelShares, and tests/js/mounts.js keeps 326 with the enrichment pins in place of the join pins.

kmount-r3 2026-10-06 moves two ceilings, each re-derived with wc -l: tests/js/rail.js 319 to 323 for the smb2 classifier pins and the gvfsd-fuse no-row pin, and ui/NetworkMounts.qml 869 to 873 for the fusermount3 release branch. tests/js/mounts.js keeps 326 with the rclone row pins inside it, and src/backend/netfs.rs keeps 52 lines inside both budgets with the smb2 row the parity test holds against the JS list.

kmount-r4 2026-10-06 moves one ceiling, re-derived with wc -l: tests/js/rail.js 323 to 325 for the protondrive-local pin beside the in-home share-row pins. ui/NetworkMounts.qml keeps its recorded 873 with the home argument gone from the kernel rows, and ui/js/Cloud.js keeps 100 lines inside the soft budget with the network skip and the comment cuts the lint gate asked for.

kmount-r5 2026-10-06 moves one ceiling, re-derived with wc -l: tests/js/rail.js 325 to 329 for the in-home protondrive share-row pins. src/backend/netfs.rs keeps 52 lines with protondrive back in the shared list, and ui/js/Cloud.js keeps 100 lines with the matching sample comments.

ip257p 2026-10-12 proves the #257 ship conditions without new ceilings: tests/network-kernel-mounts.sh runs the rail under strace and fails on any syscall naming an idle systemd automount (an autofs line draws no row and is never opened or stat'ed), with a sed mutant that opens it and must go red, tests/js/mounts.js keeps 327 with the stacked autofs-plus-nfs4 pin, and tools/flea-share-route times one share through its kernel path and its GVFS path in fresh Flea launches on minipc (--dry-run proves the script on stubs).

2026-10-08 drops one ceiling, re-derived with wc -l: src/backend/fsinforeq.rs 429 to 149, under the cap, because the system flag and the share probe meet with the tests in src/backend/fsinforeq_tests.rs, which ends at 354 with the system-volume test.

backfix 2026-10-06 moves four ceilings, re-derived with wc -l: src/backend/watch.rs 533 to 535 for set_incoming storing the mount the list worker computed and its commit pin, src/backend/iomount.rs 606 to 622 for the worker computing that mount into ListOut.watch_mount and its list_dir pin, src/backend/undoshare.rs 403 to 413 for the bounded nonblocking journal read and the regular-file refusal, src/backend/undoshare_tests.rs 533 to 553 for the FIFO and oversize pins.

gridown 2026-10-06 moves two ceilings, re-derived with wc -l: ui/GridArea.qml 420 to 423 for cursorView() giving the cursor reveal and the current index one shared row, and the one-line note on why the reveal runs a loop turn after currentIndexChanged; and tests/rename-grid-bottom.qml 424 to 425 for the stub pane's cursorSeq, the member ui/Pane.qml declares beside cursorIndex.

rgbfix 2026-10-10 moves one ceiling, re-derived with wc -l: tests/rename-grid-bottom.qml 425 to 432 for the stub pane's filterTyping, searchMode and swap, which GridArea and GridTile now read, with their one-line note, and the two steps that move Grid's current index off the edited tile so the scroll can pool it.

gridpool 2026-10-10 moves two ceilings, re-derived with wc -l: ui/GridArea.qml 429 to 431 for placeCursor, which places the cursor from StableModel's resized instead of from countChanged inside the model's own layout, with its one-line note; and tests/rename-grid-bottom.qml 432 to 438 for the tiles() helper and the two checks that showing a hidden Grid builds only the tiles its layouts hold and that only a laid-out tile builds the renaming row's editor.

gridreuse 2026-10-10 moves one ceiling, re-derived with wc -l: tests/rename-grid-bottom.js 305 to 344 for the reuse case and the count case's hook (the case itself lives in tests/rename-grid-count.js), which pools the editing Grid tile and hands it back to its own row and to another row, and checks that one laid-out tile keeps the owning editor with its draft, selection and caret.

rev1 2026-10-12 sets two ceilings, re-derived with wc -l on the merged files: tests/rename-grid-bottom.js 344 to 350 for the same-row reuse next-turn focus, draft and caret checks (13 checks, 157 prior), and a new tests/js/runfile.js ceiling at 305 (the file had no row and ran at 291 over the soft budget) for the three Run and Open as text checks past a waiting listing, with a text lane record of its own that pins Open as text to the window's run lane.

navwait 2026-10-06 moves six ceilings, re-derived with wc -l: ui/js/Nav.js 308 to 309 for loading, the guard that lets a waiting pane's routes through; ui/Pane.qml 1043 to 1044 for the clear in openWithoutHistory; ui/ContextMenu.qml 739 to 741 for the block that keeps a lone flyout's highlight on a provider refresh; ui/js/Tabs.js 719 to 722 for the Nav import and the two comments on the waiting-pane re-lists in apply and openNew; tests/js/nav.js 362 to 428 for the waiting-pane route cases, the openWithoutHistory pin and the first-listing retry cases; tests/js/menu.js 515 to 530 for the lone-flyout refresh pin.

2026-10-08 moves two ceilings, re-derived with wc -l: src/backend/watch.rs 535 to 551 for begin letting a pending watch go before it arms the next and the test that a second begin of one folder keeps the watch it arms, and tests/js/nav.js 428 to 436 for running ui/Pane.qml openWithoutHistory as written on a waiting pane, where a navigation lets the wait go and an in-place re-list keeps it.

2026-10-09 moves one ceiling, re-derived with wc -l: ui/Pane.qml 1044 to 1045 for the one-line comment beside the wait clear in openWithoutHistory, so no two comments stack above the function.

pickpath 2026-10-06 moves two ceilings, re-derived with wc -l: ui/PickerWindow.qml 779 to 786 for the typed-path probe (one property), openPathBar and the listing and backend handed to PickerChrome, and ui/ChromeBar.qml 472 to none, because its typed half now lives once in ui/PathEntry.qml and the bar is 324 lines, under the hard cap. Global limits and all checks remain intact.

pickread 2026-10-09 moves two ceilings, re-derived with wc -l: ui/PickerWindow.qml 786 to 787 for the pathBarOpen seam the visual lane reads while the path field shows, and tests/button-members.qml recorded at 402 for its showHidden and listingFailed stubs. Global limits and all checks remain intact.

Issue #237 moves five ceilings, each re-derived with wc -l: ui/ChromeBar.qml 472 to 473 and ui/Pane.qml 1043 to 1045 for carrying one display alias into the single- and dual-pane breadcrumb builders, ui/Ipc.qml 885 to 887 (on top of the glass reader) for reading both rendered strips, ui/WindowBody.qml 651 to 653 for the current pane alias and the saved-favourites input to the window-long network service, and ui/NetworkMounts.qml 849 to 880 for remembering a resolved GVFS root under its saved name, relabelling it only from persisted GTK bookmarks or Flea favourites, and capturing that identity and pane origin before an asynchronous bridge wait can admit another request. The decision and path-boundary rules stay in ui/js/Crumbs.js at 177, inside both budgets; ui/PickerPlaces.qml stays inside both at 198 for handing the same favourites to its service. The files over cap keep their existing jobs: splitting the one-property breadcrumb wire would add a state boundary, while the alias belongs beside the URI-to-local-path resolution whose asynchronous outcomes NetworkMounts already owns. tests/js/crumbs.js pins a saved label against a live gio row carrying the same normalized key. tests/network-open-share.sh also runs tests/network-alias.qml (181 lines, inside both budgets): a real bridge's test -d waits at a fixture barrier while a second open replaces pending request state, then a real gio-listing refresh supplies conflicting live labels, and favourite and GTK renames update the same bindings the panes read, a bridge wait that fails stores no alias, and the smb repair and picked arms each remember theirs. Isolated mutations restoring gio-label overwrites and pending-state reads in onReady, writing the alias before the bridge check lands, or dropping the repair or picked arm's alias write each fail their respective assertion. tests/ui.sh network adds the collision through the window with a saved label unlike both the host and gio's name; that graphical case remains unrun here because omarchy-drive is unavailable.

webdav 2026-10-06 moves one ceiling, re-derived with wc -l: ui/NetworkMounts.qml 880 to 885 for holding a bridge wait's label and URI by path and writing the alias only when its check lands, so a failed wait stores none.

mdrefs moves one ceiling, re-derived with wc -l: tests/js/mdstructure.js 626 to 655 for the container definition, container title across quote and list frames, title-keeps-its-quote and multi-line full reference checks.

Issue #255 moves two ceilings, each re-derived with wc -l: shelf/Model.js 669 to 683 for railStrip (the rail strip the card mask cuts, right at screenW - w, left at 0, bottom centred, else none), and shelf/Panel.qml 430 to 442 for railCut and the mask Region it subtracts with; tests/js/shelfmodel.js 277 to 286 stays under the hard cap for the strip pins, and tests/probes/shelf-rail-drop.sh carries the native drop proof.

2026-10-06 moves two ceilings, re-derived with wc -l: src/backend/run.rs 735 to 744 for the list arm landing an inline local list through finish with its fsinfo and poller steps beside the Event::List arm, both through one land helper, and src/backend/iomount.rs 606 to 608 for the mount table riding into list_dir as its eighth input with the one-line suppress its count names.

2026-10-09 moves two ceilings, re-derived with wc -l: src/backend/run.rs 744 to 758 for dispatch, the one function the loop hands every event to, lifted out of run() so src/backend/run_gate_tests.rs (155 lines, inside the soft budget) drives the gated arms the loop runs, and src/backend/iomount.rs 608 to 604 because its two dead-worker tests call the test-only src/backend/quietpanic.rs (17 lines) instead of swapping the process-wide panic hook and back.

2026-10-09 moves one ceiling, re-derived with wc -l: shelf/Panel.qml 442 to 443, where the rail cut recorded under Issue #255 (430 to 442) and the Qt 6.12 palette import (430 to 431) land in the same file.

2026-10-08 moves one ceiling, re-derived with wc -l: ui/WindowBody.qml 657 to 659 for the current pane alias and the saved-favourites input beside the IPC seam's Loader.

2026-10-07 moves one ceiling, re-derived with wc -l: src/tui/model.rs 1258 to 1261 for the held-frame flag the key gates read beside a listing still out.

2026-10-06 moves one ceiling, re-derived with wc -l: src/backend/run.rs 719 to 844 for the live search session (the open, query and close arms with the empty-query folder answer, the burst collapse, the walk-landed and own-write patch hooks, and the session close on list, sort and old search). The session itself is the new src/backend/searchsession.rs at 344, its arena walk at src/backend/searchindex.rs 391, its wire answers in src/backend/searchreq.rs at 266, and its wire tests in the new src/backend/run_session_tests.rs at 163, each inside its budget.

2026-10-06, for the search index patches, moves four ceilings, each re-derived with wc -l: src/backend/run.rs 844 to 858 for the sync rename, mkdir, newfile and undo patch arms; src/backend/opsdispatch.rs 2204 to 2243 for the landed-path returns on rename, mkdir, newfile and undo plus the redo touched snapshot; src/backend/undo.rs 606 to 639 for the reversal path record with its take and the shared redo touch; src/backend/undoshare.rs 403 to 405 for the shared redo touch. The compact arena is the rewritten src/backend/searchindex.rs at 281 with its walk in the new src/backend/searchwalk.rs at 206, the packed scorer in src/backend/fuzzy.rs at 388, each inside its budget.

2026-10-06, for the live search requests, moves three ceilings, each re-derived with wc -l: ui/Pane.qml 1043 to 1052 for the searchGen prop, the searchBase listing base, the stopwatch stamps and the strip note and way-out bindings; ui/Backend.qml 472 to 479 for the searchopen, searchquery, searchclose and searchCancel requests with the generation and walking reply fields; tests/js/focus.js 745 to 761 for the session close and re-list pins with the stub session members. The swap's live-answer hold is ui/js/Swap.js keepLive with the cursor file kept by ui/js/Search.js keptIndex; ui/js/Focus.js 435 to 441 for the typing cursor dispatch through the one route with the grid arrow first; ui/Ipc.qml 883 to 886 for the searchStamps stopwatch reader; ui/js/Ops.js 426 to 427, ui/js/Drag.js 342 to 343 and ui/ColumnsArea.qml 491 to 492 for the listing-base joins; tests/js/tap.js 333 to 337 and tests/js/tabs.js 686 to 697 for the standing-query pins. Round 3's backend moves four ceilings, each re-derived with wc -l: src/backend/run.rs 858 to 904 for the session cancel arm, the empty-query landing line, the folder-count note and the session gen window; src/backend/proto.rs 378 to 408 for the listed and rows gen lines with the cancelled terminal line; src/backend/searchsession.rs enters the tool's list at 436, over the 400 hard cap and recorded rather than split, for the cancel re-answer, the empty landing line, the probes-plumbed constructor and the per-walk hold seam; src/backend/undo.rs reads 638, the true number at c26f4c3c.

2026-10-06 splits src/backend/searchsession.rs at the reviewer's seam, each re-derived with wc -l: Answer and Cancelled move to src/backend/searchreq.rs 286 to 309, which already owns every other session wire line, and the walk-thread lifecycle (the spawn body of open_async_inner, poll_walked, cancel_walk, close, join_walk, the WalkProbes plumbing) moves to src/backend/searchwalk.rs 206 to 344, which already owns fill, walk and probes; the Session left behind is the index plus the standing query and gens at 288. All three sit under the 400 hard cap, so the 436 row leaves the tool with no new row. The move costs three visibility bumps and no behaviour: answer, open_async_inner and join_walk go pub(crate) for the cross-module calls, and the Session fields open_async_inner constructs and the lifecycle reads go pub(crate) with it. cargo test search counts 37 passed before and after. ui/PaneMenuActions.qml 467 to 468, re-derived with wc -l, for the Search import the scope-relative joins read.

2026-10-06, for the filter stamps, moves three ceilings, each re-derived with wc -l: ui/js/Filter.js 257 to 259 for the filter stopwatch stamps in apply (keyAt on a changed query, narrowedAt on a non-empty one), ui/Pane.qml 1043 to 1046 for the two stamp properties with their comment, and ui/Ipc.qml 883 to 887 for the filterStamps reporter with its comment. Its round 5 moves ui/ColumnsArea.qml 491 to 492 for the Filter import, so a filter that hides the cursor row hides the third column it drives.

2026-10-06 joins the live search and the filter stamps, each re-derived with wc -l after the merge: ui/Ipc.qml 890 and ui/Pane.qml 1055 carry both stopwatches (searchStamps and filterStamps with their stamp properties), ui/js/Filter.js 259, ui/js/Focus.js 438, and ui/ColumnsArea.qml 493 for the live search's listing-base joins beside the filter's Filter import.

2026-10-07 moves one ceiling, re-derived with wc -l: ui/Theme.qml 433 to 435 for the shared searchNameShare token Row and ColumnRow read.

2026-10-07 moves three ceilings, re-derived with wc -l: ui/ColumnPane.qml 400 to 403 and ui/ColumnsArea.qml 493 to 509, for the drag a peek column's row starts: each peek column carries its directory, device and writability; ui/Ipc.qml 890 to 892 for columnChildRowName and columnActiveRect, which the native case reads to find each child row by name and the active column's floor.

2026-10-07 moves one ceiling, re-derived with wc -l: tests/columnsfilter.qml 400 to 405 for the Columns name ink check, which runs last under a coloured theme because the sandbox accent equals its foreground.

2026-10-07 moves four ceilings, re-derived with wc -l: src/backend/run_session_tests.rs 529 to 533 for the wire undo, window-first and folder-gen tests, src/backend/undo.rs 638 to 639 for clearing the touched paths when a redo starts, and src/backend/run.rs 995 to 993 and src/backend/opsdispatch.rs 2243 to 2238 because one-line comments and a single undo answer shrank them while the folder answer took a small adopt mode enum; the search records above stop short of the tool, which already carried src/backend/run.rs 995 for the first-window ranking and the cancel arm, src/backend/run_session_tests.rs 529 for the wire tests those added (163 at the first record), ui/List.qml 438 and ui/Row.qml 486 for the match marks and the answered query, ui/Pane.qml 1057, ui/js/Nav.js 311 and tests/search-live.qml 422 for the one-frame exit and its blank-frame counts, and ui/js/Search.js 337, over the 300 cap and recorded, for the live results, the landed gen and the one-frame exit.

2026-10-07 moves four ceilings, re-derived with wc -l: ui/js/Search.js 337 to 350 for the walk from Recent that asks the folder's own rows and for nothingMatches, the empty-state sentence filled from the answered query; ui/Pane.qml 1057 to 1058 for searchOpenedGen, the gen a session opened at, so a closed session's late line never lands on the next; tests/js/columns.js 666 to 677 for the failed-listing checks after a search and after an Esc relist; ui/Backend.qml 479 to 478 after the session comment folds to one line.

2026-10-07 moves three ceilings, re-derived with wc -l, for a column that draws its folder's remembered order: src/backend/proto.rs 408 to 411 and src/backend/run.rs 995 to 1000 carry the peek's by and desc into peek::Order and refuse a key the wire never defined with the sort's error line; ui/Backend.qml 479 to 480 sends FolderSorts.peekOrder.

2026-10-07 moves two ceilings, re-derived with wc -l, for a column held under an order its folder has since left, which asks again under the new order and drops a reply sorted under the old one while its held rows stay drawn: ui/ColumnsArea.qml 509 to 527 for that check, and tests/js/columns.js 666 to 676 for the order tag and its ask keys.

The stable row model merge moves one ceiling, re-derived with wc -l: tests/js/tap.js 384 to 388, because the search stage's tap checks and the moved-row tap checks both land in it.

stablemodel moves three ceilings, each re-derived with wc -l: ui/GridArea.qml 420 to 422 for the cursor tile written from cursorView instead of a currentIndex binding, which a count change drops as a reset does, and the one-line ui/StableModel.qml model that places the grid on its cursor through resized; ui/List.qml takes the listingIndex >= 0 guard on renaming that ui/GridArea.qml already had, because a pooled row past a narrowed count reads row -1, ui/ColumnPane.qml changes its model line only, and tests/staticgates-unqualified.tsv moves the ui/GridArea.qml rows below it by those lines. tests/js/tap.js 333 to 384 for the five checks that a later tap split from its first by a re-list does nothing, in a list, on a Columns folder and in single-click mode, and that the same gesture's next two taps select and open the new row, and tests/js/slowclick.js 308 to 309 for the rowFor its pane stub now carries, which Tap.gestureCount reads.

2026-10-08 moves four ceilings, re-derived with wc -l: src/backend/proto.rs 408 to 409 for the sizes field a searchquery parses; ui/Pane.qml 1058 to 1060 for sizeWish and its comment, the count of leading result rows a live answer may carry folder sizes for; src/backend/run.rs 993 to 999 for keeping that count from every searchquery, the empty query included, and for the folder sizes the empty query's rows line carries; src/backend/run_session_tests.rs 533 to 537 for the wire check of those sizes.

2026-10-08 moves six ceilings, re-derived with wc -l: ui/js/Search.js 350 to 356 for the Recent base the history rows keep until the folder's answer lands and owesRelist, the re-list rule abort and a tab switch share; ui/js/Focus.js 438 to 441 for the query line's listing and exit hold and its Quick Look and share listing guard; ui/Row.qml 486 to 487 for the hidden name that builds no mark while the row locates; tests/js/tabs.js 697 to 710 for the emptied-query switch checks; ui/js/Tabs.js 719 to 716, its real count; tests/js/search.js enters the tool at 309, over the 300 JS cap and recorded rather than split, for the drag source and drop checks beside the session checks they join.

2026-10-07 moves one ceiling, re-derived with wc -l: ui/ColumnsArea.qml 527 to 534 for moveThird deferring the binding pass in which Filter.apply has written a query but not yet moved the cursor that query hid, and settleThird, which lands that pass one event later when nothing moves the cursor (tests/columnsfilter-edge.qml).

prevstate 2026-10-06 moves two ceilings, re-derived with wc -l: ui/PreviewMarkdown.qml 623 to 624 for the path handler's one call that forgets the old document's table places, and tests/markdown-preview-state.qml 469 to 471 for the stub that counts that call and the check that pins it.

fence14 2026-10-06 moves one ceiling, re-derived with wc -l: ui/Ipc.qml 883 to 884 for the read-only previewBlockKinds reader that ui:cap_markdown_fences14 logs from.

mdql 2026-10-06 moves one ceiling, re-derived with wc -l: ui/PreviewMarkdown.qml 624 to 625 (623 to 624 alone, prevstate's line above it) for the onOriginYChanged handler that makes a re-estimate of the unbuilt blocks move the end-follow the way a height change does.

decode 2026-10-06 moves one ceiling, re-derived with wc -l: tests/preview-decode.qml 507 to 517 for the held phases' check that the interim is loading and the final not yet requested as open returns, and the fresh close() with the stub's listArea it needs.

The int d79462fc merge re-derives one ceiling with wc -l: tests/markdown-preview-state.qml 474, the base's 469 plus this batch's three new lines and int's two (prevstate's 469 to 471 above). ui/js/MdInline.js is 154, under both JS budgets, so int's recorded exception at 295 is removed. Batch 4's de-slop review also moved tests/js/previewswap.js 460 to 461, re-derived with wc -l, for the follow pin's followSettle.running term.

The thumbreq race fix moves two ceilings, each re-derived with wc -l: src/backend/thumbs.rs 460 to 466 for the test-only Pool::idle, a pool that starts no worker, and src/backend/thumbreq.rs 418 to 417 because its harness builds that pool instead of a one-worker one. a_symlink_row_keys_the_cache_on_its_target_mtime read the queue back through cancel_all, and the harness's worker popped the job first in 2 of 5 runs.

pool moves two ceilings, each re-derived with wc -l: ui/GridArea.qml 422 to 427 and ui/List.qml 436 to 441 (its ceiling was 437) for each delegate's live park gate, its onPooled/onReused handlers and the parked read-backs; ui/ColumnPane.qml 399 to 400, ui/ColumnRow.qml 264 to 269 and ui/StableModel.qml 48 to 59 (drainHidden and its visibleChanged connection) stay inside their budgets, and the new tests/poolpark.qml is 375 over the soft budget and under the hard cap with tests/poolpark.sh at 50.

qlatomic moves one ceiling, re-derived with wc -l: ui/Preview.qml 669 to 720 for holding the card and dim until the open's content can draw (revealed, landed, the revealCap single-shot at PreviewSwap.capMs, the lookEmptyFrames counter on afterAnimating and its openState reader), with ui/IpcPreviewState.qml inside its budget for the closed overlay's openState.

The qlatomic note round moves two ceilings, each re-derived with wc -l: ui/Preview.qml 720 to 735 for the hold's early reveal (the readonly revealed, revealedEarly, unrevealed, loadingEarly and revealNow), the cap armed in load() after show(), the shared fade that lets a reopen cut the close fade, and the holdShield that takes a press or the wheel during the hold; tests/js/previewswap.js 460 to 462 for the source check that a move during the hold takes no picture.

The qlab round moves two ceilings, each re-derived with wc -l: ui/Preview.qml 735 to 737 for the reveal that waits out followSettle and the followSettle rerun of revealIfReady once it stops; tests/preview-settle-live.qml 457 to 532 (one line for the poll's landed term, then the rest) for the live burst inside Quick Look's open hold that comes back to the open's file, which must land nothing while the settle runs and land once it stops, well before a lengthened cap. tests/js/previewswap.js stays at 462, since that live check replaces the source check that a settling move lands nothing.

2026-10-08 moves one ceiling, re-derived with wc -l: ui/js/MdHtml.js 300 to 307 for the count of HTML links wearing the ink (inked.open), so an </a> takes off only ink its own opener put on.

The CPU-only skip moves one ceiling, re-derived with wc -l: src/vulkan.rs 747 to 751 for the test's early return when every Vulkan device is a CPU device, the shape a VM's virtual VGA beside lavapipe reports.

The Qt 6.12 wheel rule moves four ceilings, each re-derived with wc -l: ui/GridArea.qml 420 to 421 for the ui/js/Scroll.js import and the phase read its zoom wheel hands Scroll.zoomTravel, ui/FastScrollHandler.qml 529 to 528, its menu step asking Scroll.movesByPixels in one line fewer, and tests/touchpad.qml 747 to 756 for the case that drives the real list handler with Qt 6.12's notch (pixels 15 beside angle 120, no phase). ui/js/Scroll.js takes a recorded ceiling of 304, four over the JavaScript cap, for movesByPixels and zoomTravel, the one wheel rule every reader shares, kept beside the notch arithmetic it gates rather than split into a second module.

2026-10-09 moves one ceiling, re-derived with wc -l: tests/js/rail.js 329 to 353 for the Ctrl+E checks that now take the system flag from the backend's own fsinfo line through Messages.route and the shipped PaneWire.onFsInfo body, with a late-line pin in place of the check that only restated the default.

The polish batch 4 merges with int re-derive seven conflicted ceilings with wc -l of the merged file: ui/Theme.qml 436, ui/FastScrollHandler.qml 474, tests/js/previewswap.js 461, src/backend/dirsizeworker.rs 573, src/backend/trashbrowse.rs 778, src/backend/watch.rs 526 and src/backend/ops.rs 423. Each is the de-slopped file with int's additions on top, so some sit below one parent's figure; FastScrollHandler's menu step takes int's Scroll.movesByPixels test with MenuWheel.touchSteps, which the batch kept when it folded the pixelSteps wrapper into it. The int 12d1af88 merge re-derives two more from the merged file: ui/PreviewColumn.qml 584 and ui/TabBar.qml 640, each int's background-opacity figure (585 and 642) less the lines the batch's de-slop removed.

Background-only opacity ([flea] background-alpha, see "Theme roles and sources") moves seven ceilings, each re-derived with wc -l: ui/Pane.qml 1043 to 1050 for the pane's own glass ground, ui/TabBar.qml 632 to 642 for the strip's ground split around the current tab's plate and its drag case, ui/WindowBody.qml 657 to 660 for the transparent body ground and the Glass.launch() call, ui/Sidebar.qml 669 to 670, ui/ColumnsArea.qml 493 to 494 (491 to 492 before r35-asks landed its two lines) and ui/PreviewColumn.qml 583 to 585 for one comment each beside a ground that changes on glass (and, for the preview column, the player's letterbox), and ui/Ipc.qml 883 to 885 for the seam's glass reader.

2026-10-09 moves two ceilings, re-derived with wc -l: ui/GridArea.qml 423 to 426 for the match query and arming each tile reads, the two ui/List.qml hands its rows, and tests/rowcost.qml 400 to 408 for the armed and marked tile counts, beside the locating row's on merge.

2026-10-09 moves one ceiling, re-derived with wc -l: tests/rowcost.qml 408 to 416 where the search branch's filtered-run check meets the rewrapped caption mark checks in one file.

2026-10-09 records one test ceiling, re-derived with wc -l: tests/gridmark.qml at 419, over the 400 line cap, for the step that shows captions Qt lays out again, one elided and one wrapped, draw no mark box, after a same-step check that both drew one.

2026-10-09 moves three ceilings, re-derived with wc -l: src/backend/iomount.rs 606 to 608 for the first_start field ListOut carries and the lookup of a list request's select before the first window is statted, src/backend/run.rs 1004 to 1005 for that field in its remote-landing test's ListOut, and tests/js/nav.js 362 to 370 for the checks that a climb and a refresh name their row to select on the listing's own request.

2026-10-09 moves four ceilings, re-derived with wc -l, for the warm turn a search runs after its strip's first frame: ui/Row.qml 487 to 490 for locateArmed, which builds the location layout hidden; ui/List.qml 438 to 441 for passing it and for the model's warm and warmRows, each on a line of its own; ui/GridArea.qml 423 to 424 and ui/ColumnPane.qml 403 to 404 for the same two model properties on a second line.

2026-10-09 moves one ceiling, re-derived with wc -l: src/backend/run.rs 1004 to 1009 for the loop's arm that answers a search query held past the listing swap's cap and the module line of the tests that drive that hold over the wire.

The stage3 merges (int 12d1af88, 927f4a20, 8b0647e9 and f641286e with the gridmark, escdeep, colhid, srch3 and gridtip branches) record the merged ceilings, re-derived with wc -l at the merge: src/backend/run.rs 1010, src/backend/proto.rs 412, tests/js/columns.js 687, ui/ColumnPane.qml 426 (424 until int f641286e brought gm/039p-265, whose Columns rename hands the keys back in two lines), ui/ColumnsArea.qml 537, ui/GridArea.qml 427, ui/Ipc.qml 895 (894 until the int d79462fc merge brought int's one added line), ui/Pane.qml 1067 and ui/Theme.qml 436.

A Shift range after a wheel scroll (issue 286) moves two ceilings, each re-derived with wc -l: ui/Pane.qml 1050 to 1052 for cursorPulled, the record Filter.clampCursor keeps of the row the cursor held before the wheel pulled it onto the viewport, and ui/js/Filter.js 257 to 268 for that record and pulledFrom, which ui/js/Marks.js latchAnchor reads so a Shift+click or Shift+J started right after the pull ranges from that row; any cursor move, selection change or new listing since the pull makes the range start at the cursor again.

The Qt 6.12 menu rest rule moves two ceilings, each re-derived with wc -l: ui/ConvertDialog.qml 435 to 446 for the pointer point its format rows share (the property and its reason, the reset on open, the two row bindings) and the ground and card sink that report where the pointer is, and ui/OpenWithDialog.qml 608 to 613 for the same two reports. ui/MenuRow.qml (319) and ui/ContextMenu.qml (738) stay inside their limits.

2026-10-09 moves one ceiling, re-derived with wc -l: tests/js/tap.js 388 to 401 for the middle-column check that a file's taps are counted once, so the two taps after a swallowed one still open it.

2026-10-10 moves one ceiling, re-derived with wc -l: ui/ColumnsArea.qml 537 to 539 for re-moving the third column when the cursor row goes to none, or comes back, with the child path unchanged (a search answer with no rows leaves childPath empty, as a file row had it), so the column drops the file it showed and shows it again; tests/columnsfilter-edge.qml checks the drop in its zero-row stage and the return in the stage after it, inside both budgets.

tuimedia moves two ceilings, re-derived with wc -l: src/tui/model.rs 1247 to 1252 for the cursor row's media probe (the field with its line, its start value, its reset and the meta answer that fills it), and src/tui/input.rs 453 to 455 for kitty's shared memory reply (the field and one test line). The resting player stays in src/tui/media.rs and its poster in src/tui/graphics.rs, which grows from 155 to 274 for the video poster, the store-once placement and its base64 round trip with their tests, and mpv's process, arguments and output forwarder moved to the new src/tui/mpv.rs at 308; both are over the soft budget with a warning only and inside the hard cap.

The tuicombo merge with int d79462fc records two ceilings, each re-derived with wc -l at the merge: src/tui/mod.rs 416, over the 400 cap, where the run loop carries both the frame hold (381 on the branch) and the resting player, poster and media probe start (396 on int), and src/tui/model.rs 1266 for the held-frame flag beside the media probe field.

The TUI quit fix moves one ceiling, re-derived with wc -l: src/tui/mod.rs 416 to 418 for the two lines that rebind the model after the terminal, so the model and its player drop first on every exit and mpv's relay writes nothing after the restore (tests/tui-quit-playing.sh).

Mergespeed moves one ceiling, re-derived with wc -l: src/backend/copyfile.rs 730 to 745 for SMALL_CHUNK, the copy buffer a file below the 256 KiB chunk reads through (its own size, at least 8 KiB, so a tree of small files maps and unmaps no chunk per file), and the test-only record of every buffer's length that src/backend/mergespeed_tests.rs reads.

Archives040 moves eight recorded ceilings, each re-derived with wc -l after the advloop round 4 fixes: src/backend/proto.rs 378 to 387 for the password and encryptNames request fields; ui/Backend.qml 472 to 485 for the password compress and extract sends and the refusal to queue a password; ui/ContextMenu.qml 739 to 741 for the cursor name the disk-image rule reads; ui/Pane.qml 1043 to 1048 for the two card signals and the cursor name the disk-image rule reads; ui/PaneWire.qml 586 to 592 for the one doneAction call routing the encrypted and wrong-password cards over the archive and destination the done line names; ui/WindowBody.qml 651 to 674 for the property and builder that create the ArchiveCards host on first open, failing loud when it does not build, with two pane-signal routes and the archiveCards handle the capture reads; ui/Ipc.qml 883 to 899 for archiveCardsState, which a capture waits on instead of sleeping; ui/js/Focus.js 435 to 438 for the ArchiveOps import with the password leaf dispatch. ui/js/Ops.js is back at its 426 record after one commit at 428. The two password cards and their loaders live in the new ui/ArchiveCards.qml at 68 lines, ui/ArchiveCompressDialog.qml at 382 and ui/ArchivePasswordDialog.qml at 255 (both over the soft budget, a warning only, inside the hard cap); the dialog open, resolve and submit flows in the new ui/js/ArchiveOps.js at 44; the password checks in the new tests/js/archives.js at 117, the cards, the backend queue and the done line built from their real QML in the new tests/js/archivecards.js at 115 over tests/js/archivefixture.js at 55 and the stubs in tests/js/archivestubs/, with tests/js/ops.js and tests/js/menu.js kept at their 511 and 515 records; the key mark in ui/js/Icons.js at 202; ui/js/Archive.js takes the widened table and the card decisions at 176, ui/js/Kinds.js the Quick Look route at 103, ui/js/Facts.js stays at 298 under its 300 hard cap and ui/js/Menu.js takes the submenu row with no added line at 454. On the Rust side the password plumbing is the new src/backend/archivepass.rs at 218 lines with its tests in src/backend/archivepass_tests.rs at 353, the encryption probe the new src/backend/archiveenc.rs at 100 with src/backend/archiveenc_tests.rs at 57, the refusal the new src/backend/archivefree.rs at 118, the list-only start test the new src/backend/archivestart_tests.rs at 21, src/backend/archiveops.rs stands at 210 with its tests at 303, src/backend/archivereq.rs at 400 and src/backend/archivework.rs at 388, and src/backend/archive.rs gave its test module to src/backend/archive_tests.rs at 187 lines, keeping the widened reader at 244, rather than take an over-cap row; every one of those is inside its hard cap.

The int 12d1af88 merge into gm/039f-archives sets three ceilings, each re-derived with wc -l with both sides' lines together: ui/Ipc.qml at 901, ui/Pane.qml at 1055 and ui/WindowBody.qml at 683, the last carrying archiveCards into the test seam's deferred Loader.

Unlock (U039) moves six recorded ceilings, each re-derived with wc -l on this tree: ui/js/Devices.js 366 to 395 for the locked-container rows and the system-disk locked walk, ui/DeviceMounts.qml 496 to 516 for the unlock request signal, the locked glyph and fields, the activate branch, the unmounted device and the lockCrypt creation with its two calls, ui/WindowBody.qml 651 to 666 for the UnlockHost creation, its forward, the back-button guard, the FLEA_UNLOCK startup and the unlockHost handle the capture reads, ui/Ipc.qml 883 to 891 for unlockState, which a capture waits on instead of sleeping, ui/Sidebar.qml 669 to 674 for the unlock-request forward and the pollDevices an unlock calls so its row turns at once, and ui/js/Menu.js 454 to 457 for the Unlock rail row and its availability. The dialog's sentences, D-Bus parsers and the backing-container lookup live in ui/js/Unlock.js at 108 lines, imported only by lazily created unlock files and the suites; the lock leg lives in the new ui/DeviceLock.qml at 33 lines and the dialog host in the new ui/UnlockHost.qml at 116 lines, the dialog itself is the new ui/UnlockDialog.qml at 319 lines, each inside their budgets with no new startup import and no placeholder object; src/unlock.rs at 71 holds the mode's dispatch and exec beside gui.rs, which keeps its recorded 501, and the --unlock dispatch in src/main.rs (its module line, a comment and a three-line if let, 352 to 357) is counted in the mx2 record above; the card's header and scrim are the shared ui/DialogHeader.qml and ui/DialogScrim.qml. The U1 stdin proof is tests/unlock-stdin.qml at 286 with tests/unlock-stdin.sh, registered in tests/run-all.sh headless.

Unlock round 1 moves src/gui.rs 501 to 503 for the one env_remove of FLEA_UNLOCK in qs_command with its comment, and leaves ui/DeviceMounts.qml at 516 and ui/js/Devices.js at 395; ui/UnlockDialog.qml is now 320 lines, ui/UnlockHost.qml 116 and src/unlock.rs 92, all under the hard cap.

Unlock round 2 moves ui/UnlockDialog.qml 320 to 355 for the quiet late lock (the abandon stage a Cancelled unlock leaves, the resubmit guard, and the opened and quiet gates on the lock exit), tests/unlock-stdin.qml 286 to 397 for the cancelled-unlock phases with the go-flag stub arms (the attempt phases, the kill-and-reap teardown and the erase-once count poll), its pure field reader moved to tests/unlock-stdin-find.js at 16 so it keeps under the 400 hard cap with no row, src/unlock.rs 92 to 109 for the drop guard on the env override, and tests/js/unlock.js 127 to 129 for the attempt-counting eject stub; tools/flea-file-budget records the harness at its real count.

The int 12d1af88 merge into gm/039f-unlock sets three ceilings, each re-derived with wc -l with both sides' lines together: ui/Ipc.qml at 893, ui/Sidebar.qml at 675 and ui/WindowBody.qml at 675, the last carrying unlockHost into the test seam's deferred Loader.

The int 927f4a20 merge into gm/039f-unlock sets src/gui.rs at 505, re-derived with wc -l: int's 503 plus unlock's one env_remove of FLEA_UNLOCK in qs_command with its comment.

The several-items note check sets tests/permissions-adv.qml at 402, re-derived with wc -l: the 400 line cap plus the one-line check that the note holds one line at every TextSize stop and its comment.

RunFile ports the run card onto v0.3.8, each re-derived with wc -l: ui/js/Menu.js 454 to 460 for the two inventory rows and the file-only rule; ui/PaneWire.qml 586 to 589 for the raw ask line and the Open with path; ui/Pane.qml 1043 to 1066 for the window-routed ask and text lane, their loud refusal when no window hosts the pane, and Make executable and Open with acting on the row the card's path still names; ui/Ipc.qml 883 to 891 for runCardState, which a capture waits on instead of sleeping; ui/js/Focus.js 435 to 438 for the two run entrances; ui/js/Nav.js 306 to 321 for the one cursor guard openableRow that openCursor and runCursor both call, and the run entrance; ui/WindowBody.qml 651 to 658 for the once-per-window card host and lane. The bodies live in the runfile module: the classifier and the three CLI modes in the new src/runfile.rs at 359 with its tests in src/runfile_tests.rs at 201 and the dispatch in src/main.rs counted in the mx2 record above, the R3 identity in the new src/runid.rs at 78 and the R6 spawn hardening in runfile.rs nested runharden.rs at 41, the card's decisions in the new ui/js/RunFile.js at 131 and the card itself in the new ui/RunCard.qml at 252 with the window's one run lane, its header and scrim the shared ui/DialogHeader.qml and ui/DialogScrim.qml, each inside its budget; the lane moved out of the opener so startup objects stay at 618, and RunFile.js is imported only by the card, so the first frame compiles nothing new. The card's Make executable choice reuses the menu's permissions-batch path, so no second chmod lives here, and the handler-first AppImage default focuses the desktop's own handler. The unit's new pins live in the new tests/js/runfile.js at 271, over the soft budget with a warning only; five stale expectations in the shipped suites move one word each to the 51-row reality and add no lines.

The int 12d1af88 merge into gm/039f-runfile sets three ceilings, each re-derived with wc -l with both sides' lines together: ui/Ipc.qml at 893, ui/Pane.qml at 1073 and ui/WindowBody.qml at 667. The driver exclusion int added to every launcher now lives in open::prepare, which terminal::detach and the run card share.

The gm/039f-union1 union of #261, archives, unlock, runfile and dropmods on int d79462fc sets ten ceilings, each re-derived with wc -l with every member's lines together: src/backend/opsreq.rs at 610, src/backend/proto.rs at 392, src/backend/run.rs at 721 (the merge and FAT collision arms side by side), ui/js/Focus.js at 441 (the run card rows and the password compress row), ui/Backend.qml at 493, ui/Ipc.qml at 918, ui/Pane.qml at 1077, ui/PaneWire.qml at 601, ui/WindowBody.qml at 706 and ui/js/Menu.js at 462. ui/UnlockHost.qml drops its unused Quickshell import in the union, 116 to 115 lines, the one unused-imports finding the qmllint gate holds at 0.

The union's native run found four input paths the headless suites never drove. Shift+Tab reaches a Keys handler on Hyprland as Backtab or as Tab with Shift held, so RunFile.moved and Collide.walk, which the collision and FAT names cards now share, take the modifiers and treat both as back, as the picker and network forms already did. Backspace or Delete on a password field's held bullets (DialogField.heldMask, kept after a wrong archive password) is an edit and emits edited, so the error sentence goes with the bullets; tests/button-heldmask.qml (70 lines) proves it as button-system's fifth harness. Ipc.controlState reports hovered and pressed, which tests/ui-captures-collide.sh asserts before each hover and press shot. The union's captures move, park and steer the pointer only through pointer_glide in tests/lib/pointer-glide.sh, ydotool's uinput device, because Hyprland's cursor move sends Qt no pointer frame; it sends at least one motion even when the pointer is already there, and tests/pointer-glide-proof.sh, a headless suite of its own, pins it against a fake compositor.

The int c7e23485 merge into the gm/039f-union1 union re-derives its conflicted ceilings with wc -l; each file changed on one side only, so each keeps that side's figure: ui/Backend.qml 493, ui/ChromeBar.qml 475, ui/ColumnsArea.qml 496, ui/ContextMenu.qml 741 and ui/Ipc.qml 918 from the union, ui/ColumnPane.qml 407 and ui/GridArea.qml 433 from int.

The union2 adds bulk rename and tags to that union and moves these ceilings, each re-derived with wc -l with every member's lines together: src/backend/copyfile.rs 745 to 748, src/backend/opsdispatch.rs 2213 to 2223, src/backend/opsreq.rs 610 to 613, src/backend/proto.rs 392 to 409, src/backend/run.rs 721 to 728, src/backend/undo.rs 627 to 628, src/uischema.rs 501 to 502, tests/js/menu.js 515 to 517, tests/js/ops.js 511 to 514, tests/js/settings.js 352 to 354, ui/Backend.qml 493 to 506, ui/ContextMenu.qml 741 to 745, ui/Ipc.qml 918 to 927, ui/Pane.qml 1077 to 1111, ui/PaneWire.qml 601 to 604, ui/Row.qml 482 to 489, ui/Sidebar.qml 675 to 689, ui/Theme.qml 434 to 439, ui/WindowBody.qml 706 to 716, ui/js/Focus.js 441 to 443, ui/js/Keymap.js 355 to 363, ui/js/Menu.js 462 to 468, ui/js/Nav.js 321 to 320, ui/js/Ops.js 426 to 441. src/backend/redo.rs stays under the 400 cap by moving its inline tests to src/backend/redo_tests.rs, as redo_birth_tests.rs already sits. There src/backend/rowguard.rs guards 8 commands: the shipped 6, #261's mergecollisions and tags' settags. Cursor joins union2 after them and moves three more by wc -l: tests/js/nav.js 362 to 367, ui/PaneWire.qml 604 to 608 for the BackReturn import, the onLocated hand-off and the parked landing, and ui/js/Nav.js 320 to 314, a ratchet down.

The stablemodel pool line's merge of int re-derives two ceilings with wc -l: ui/GridArea.qml 428 (the pool's park gate on top of the Qt 6.12 wheel rule's 421) and ui/ColumnPane.qml 402 (the pool's delegate line on top of the rename focus return's 401).

cursormark moves three ceilings, each re-derived with wc -l: ui/GridArea.qml 427 to 432 and ui/List.qml 441 to 446 for the view's CursorMark and each delegate's markRow with its two place calls, in place of the cursor binding every live delegate re-ran on each key; ui/ColumnPane.qml 400 to 405 takes a row over the global QML cap for the same five lines in the column's own delegate. The new ui/CursorMark.qml is 41 lines, and tests/cursormark.qml is 343 over the soft budget and under the hard cap with tests/cursormark.sh at 60.

The stablemodel pool line's merge of cursormark re-derives two ceilings with wc -l: ui/GridArea.qml 433 (cursormark's five lines on top of the int merge's 428) and ui/ColumnPane.qml 407 (cursormark's five lines on top of the int merge's 402); ui/List.qml 446 is cursormark's own figure.

The gm/039x-s3union union's merge of int c7e23485 re-derives three ceilings with wc -l: ui/GridArea.qml 438 (the union's 431 with the cursor mark and the park reads, the park handlers now in ui/GridTile.qml), ui/List.qml 451 (the union's 441 with the cursor mark and the park reads) and ui/ColumnPane.qml 432 (the union's 426 with the cursor mark and the park reads, less the cursor binding). ui/GridTile.qml grows from 290 to 296 for the park flag it now owns, the held editor's row, pooledRename with the handler that clears the row, and editing, and tests/rename-grid-owner.js from 281 to 283, each under its hard cap.

The variadic prctl declarations move two ceilings, each re-derived with wc -l: src/backend/localsend.rs 405 to 406 and src/backend/menu_registry.rs 563 to 564, one c_ulong import each for the casts the variadic call needs, since an untyped literal passes as a 32-bit int.

The Columns prebuild moves one ceiling, re-derived with wc -l: ui/ColumnPane.qml 423 to 518 for the active column's parked-row driver, whose quiet 16 ms ticks build the rows the next landing adopts: the pool counters and the first-landing hold, the 16 ms Timer with its quiet test, start, tick, adoption and drop, the count, height and visibility hooks, and the delegate's parked branch. The new tests/columnsprebuild.qml is 317 lines, over the soft budget and under the hard cap, with tests/columnsprebuild.sh at 43.

The unfocused Dual selection and the rail's lit place move three ceilings, each re-derived with wc -l: ui/Sidebar.qml 693 to 729 for litIndex, settleLit with the pane signals it settles on, and a lit and a cursor binding on each row group in place of the one cursor binding; tests/sidebar-flows.qml 473 to 545 for the leg that navigates the real pane and reads which row draws the lit look and which the cursor frame; and ui/js/Mounts.js 298 to 303 for inside, the one prefix test holding and Places.heldBy both ask. tests/startup-units.txt keeps its units while its bytes line moves from 1596965 to 1602134, the sum the probe's tool takes over them with the new lines in ui/Row.qml, ui/Sidebar.qml, ui/SidebarRow.qml, ui/js/Mounts.js, ui/js/Places.js and ui/js/RailKeys.js.

The Dual Tab cycle, the bulk card column inset, the bulk rename menu label and the flasher fill move seven ceilings, each re-derived with wc -l: ui/js/Focus.js 449 to 463 for dualStep and its two stop names, ui/Pane.qml 1180 to 1181 for the switchToRail signal, ui/WindowBody.qml 739 to 745 for the Focus import, switchToLeftRail and its one onSwitchToRail call per pane, and the line that hands the left pane its list back when focus moves to the right, ui/js/Menu.js 475 to 480 for the count template and its .arg row, tests/js/focus.js 760 to 834 for the cycle pins over the three rail modes, from actions and from the three key events, and the two-pane window rig, tests/js/keymap.js 315 to 319 for the sheet wording pins, and tests/js/menu.js 553 to 559 for the count pins; tests/dialog-board.qml 399 and ui/BulkRenameDialog.qml 372 stay under the hard cap. Shift+Tab in a list is bound in keys.toml (listing joins the focusPrevious rows, labelled "focus back", and the move sheet group claims it), so no key is hand-coded in ui/js/Focus.js.

Three ceilings that earlier joins moved are recorded with their reasons, each from wc -l: ui/js/Format.js 299 to 305, a known_over_cap row past the 300 JS cap, because the one Format.fill replaced four copies of the filler and RunFile.js and Tags.js lost five lines each; ui/Backend.qml 550 to 552 for the settagged sample line (tags) and the archive failure kind (R4 minors), still 552 here; ui/UnlockDialog.qml 439 to 441 for the Cancel race fix (a refusal that leaves _stage alone, and closing stdin so --key-file /dev/stdin reads EOF), 441 here. The shared Format.fill takes ui/js/Mounts.js from 298 to 293 by deleting its forwarding fill; ui/DeviceMounts.qml stays at 528, because its Format import is paid for by the shorter comment.

The picker save line's whole-sentence templates and name joints move three ceilings, re-derived with wc -l: ui/js/Picker.js 328 to 335, for the two save templates and jointName, the sentences filling through the shared Format.fill; tests/js/picker.js 343 to 352 for the Format import and the two sentence pins; and tests/picker-hunt.qml 448 to 449 for the joint-free plain text its collision checks read.

The Tags flyout that stays open moves three ceilings, each re-derived with wc -l: ui/ContextMenu.qml 751 to 752, ui/PaneMenuActions.qml 490 to 491 and ui/Pane.qml 1180 to 1181, one comment line each for the Tags branch of chooseSub, the repeatable tag activation and the tg-blind held-rows identity.

The Empty Trash body and the Network password hint move four ceilings, each re-derived with wc -l: ui/Ipc.qml 966 to 967 for the confirmation body and the password hint readers, ui/NetworkDialog.qml 457 to 458 for its hint reader, ui/NetworkForm.qml 413 to 426 for the hint row and its property, and tests/button-members.qml 488 to 513 for the three hint checks.

The muted text lift and the menu cursor frame move two ceilings, each re-derived with wc -l: ui/Theme.qml 443 to 462 for the named text and frame contrast floors, the mutedFrame, metadata, metadataFrame, mutedLit and errorLit roles with their defaults, and the two lifts that derive them in applyColors; tests/button-members.qml 488 to 490 for the frame and ink readings it now takes apart. ui/MenuRow.qml stays over its soft budget and under the hard cap with the cursor row's own frame. tests/startup-units.txt keeps its 170 units while its bytes line moves from 1596965 to 1600322, the sum the probe's tool takes over them with the five Theme roles, the error inks lifted to the caption's ratio, the lit-row inks, the cursor frame and the wash's object name. The new tests/theme-contrast.qml is 152 lines and tests/theme-contrast.sh 92, both under the soft budget.

The Quick Look thumbnail ask (#263) moves four ceilings, each re-derived with wc -l: ui/Preview.qml 739 to 810 for the image pane's owed thumbnail (thumbRow, thumbAsked, thumbRelist, imageUndecodable, thumbClear, thumbOwed, resolveThumbRow, settleThumb and the pane's thumbStateChanged and listInFlightChanged handlers): an image-kind Quick Look opened with no thumbnail, whose final decode then fails, asks the backend once for that row's thumbnail (never cache-only, only where thumbClear holds, the one gate maybePrefetch shares: listing settled, class known, no ExtThumbs.manualHold, Thumbs.allowed) and keeps the loading state until the reply, which binds to the shown file's row only. A cache-only mark already on the row is waited on, never forgotten and re-asked: ui/PaneWire.qml's miss path returns it to unasked and settleThumb then asks in full. A re-list that clears the map under the owed row is followed by one ask on the new listing, the shown file found by name with the cursor fallback as resolveImageRow does, and the sentence stands only on a refusal, a forgotten ask or a vanished file. tests/preview-decode.qml 649 to 903 for the legs that pin it (landed, refused, asked, forgot, relist, vanish, thumbnails off, manual hold, cursor move, drifted row, decodable image), whose reply() calls ExtThumbs.thumbReply, the one function ui/PaneWire.qml records a thumbed line with, and for the STL leg with no thumbnail. ui/PreviewImage.qml 141 to 146 and tests/js/previewswap.js 463 to 469: a 3D model (Kinds.isModel) is never handed to Image, which logged "Unsupported image format" on every open; on a row no thumbnailer serves (row.t false and none held) it takes the generic file pane (Kinds.quickLookKind answers unsupported), and on a served row it opens the image pane at once, asks for the thumbnail and draws the reply, whether or not the thumbnail exists yet.

The keyboard reach of the chrome bar, the tab strip and a rail row's eject mark moves eight ceilings, each re-derived with wc -l: tests/js/keymap.js 320, ui/Ipc.qml 967, ui/Pane.qml 1183, ui/Sidebar.qml 699, ui/WindowBody.qml 746, ui/js/Focus.js 471, ui/js/Keymap.js 368 (regenerated from keys.toml) and ui/TabBar.qml 673, for the stop state, the focus frames and the ring step. The stops' key handling lives in the new ui/js/ChromeKeys.js, below the soft budget. tests/startup-units.txt gains js/ChromeKeys.js, which ui/js/Focus.js imports, 171 units, while its bytes line moves from 1596965 to 1610091, the sum the probe's tool takes over them.

The join of that Tab ring with the Dual Tab walk keeps one ring in ui/js/Focus.js: the rail is the left pane's, so a step onto it from either Dual pane hands the keyboard to that pane through switchToRail, and the two lists, the rail, the bar and the strip are consecutive stops in Dual too. It re-derives three ceilings with wc -l: ui/js/Focus.js 476, ui/js/Keymap.js 365 and ui/Sidebar.qml 705, and moves the bytes line of tests/startup-units.txt to 1638469, the sum the probe's tool takes over its 174 units.

WindowBody.focusPane hands the pane the keyboard left its list back whichever side is taken, so a click on either pane never leaves the other holding a rail, bar or strip stop the ring would resume from; it moves two ceilings with wc -l, ui/WindowBody.qml 809 to 810 for the second pane's reset and tests/js/focus.js 834 to 837 for the Dual rig.

The refused-stop wording moves one ceiling, re-derived with wc -l: src/backend/menu_registry.rs 569 to 570 for the test-only REFUSED_KILL errno hook in terminate, which lets the wording test drive a refused stop through Registry::cancel without sending a signal.

The office preview queue records one ceiling, re-derived with wc -l: src/backend/officepdf.rs passes the 400-line cap at 404 for the newest started sequence that the end of a job does not clear, which drops a request older than it, running or already finished, instead of converting it after, rather than a body with the comment cut.

The code column reload guard records one ceiling, re-derived with wc -l: tests/code-column.qml crosses the hard cap at 411 for the listing guard check, which counts the code asks and queued loads made while the reload's listing is out, and the fixture restore that lets both harness runs share one fixture; recorded rather than split because both pin the same reload.

The AppImage icon route, merged from gm/0310-pv-appi onto the 0.3.11 stage, moves two ceilings, each re-derived with wc -l on the merged tree: src/backend/thumbs.rs 495 to 503 for the route dispatch in run_one, the publish split and the table argument that record_failure now takes, and src/backend/thumbspec.rs 410 to 426 for the internal AppImage spec, is_runnable and the with_appimage_route hook. src/backend/appicon.rs (373) and src/backend/appicon_tests.rs (398) stay under the hard cap.

The RAW eviction race pin moves one ceiling, re-derived with wc -l: src/backend/rawpreview_tests.rs 394 to 410 (new row) for the test that serves a copy between the scan and its removal.

The power-off fix moves two ceilings, each re-derived with wc -l: ui/js/Devices.js 391 to 397 for powerOffQueue reading the last listing, hidden partitions included, and tests/js/devices.js 363 to 365 for its pins. An unparsable listing falls back to the mounted volume rail rows on the same disk and says that once on stderr, naming the disk and parse error; queue legs are deduplicated by device path, so a dm node repeated under two partitions is unmounted once. These adversarial fixes move ui/js/Devices.js 397 to 404 for the rail fallback and shared dedupe, and tests/js/devices.js 365 to 392 for the unparsable, truncated and empty listing checks and the repeated dm node, each re-derived with wc -l. ui/DeviceMounts.qml stays at 537, passing its rail entries beside the listing for that fallback. The fallback warning moves ui/js/Devices.js 404 to 405 and its warning count, message and valid-listing checks move tests/js/devices.js 392 to 405, each re-derived with wc -l; tests/js.sh checks each marked call's stderr. Its one-line lsblk sample moves tests/js/devices.js 405 to 406, re-derived with wc -l.

The Markdown Source merge records these current counts and ceilings for Markdown Source and fence colours in tools/flea-file-budget, each re-derived with wc -l: ui/PreviewColumn.qml 553 for the backend binding beside its loader; ui/PreviewMarkdown.qml 668 for pane slots, shared fence reply routing, first-screen waits, Source wiring, refusal readiness and reader-state forwarding; ui/Preview.qml 1011 for the Markdown backend and wrap bindings; and tests/preview-layout-loop.qml 408 for per-line Source geometry checks. The same merge sets the current counts and ceilings for the Source wrap key: ui/js/Focus.js 492 for its Source-only dispatch guard and tests/js/previewkeys.js 320 for four preset checks and the wrap handler over every preview kind.

Source uses one Flea.CodeLines, built on the first active Source view, in ui/MarkdownSource.qml (155 lines). Fence colours use the shared code-run formatter in ui/MarkdownBlockView.qml (315 lines); a backend change resets the fence's request state and asks again. Only viewport fences hold the first screen, while cached fences still ask ahead. ui/CodeLines.qml (229 lines) calls its fresh/reset/ask path when the backend changes, dropping the disconnected backend's token and held rows before asking the new backend. Its plain fallback omits the terminal empty row, matching the engine. ui/CodeRefusal.qml (52 lines) shares Quick Look's refusal strings, mark and geometry with Source; ui/PreviewText.qml is 117 lines. These counts also come from wc -l and stay below the hard cap.

After the Markdown Source merge, the current count and ceiling of tests/markdown-code.qml are 477 lines, re-derived with wc -l, for real oversized, permission-denied and binary Source checks with visible text bounds, engine-reply and colour checks on every presented frame, per-slot ask evidence, and the named unknown-language reply delay with its reason. tests/markdown-code-rebind.qml (198 lines, from wc -l) keeps live fence and CodeLines views across two real backends, requires exactly one new-backend ask for CodeLines, drops the old backend's late reply despite colliding tokens, and checks that engine colours draw and release the first screen. The Source roundtrip proves the terminal newline byte and the absence of an extra row.

The gmfix rail and held-button repair moves three ceilings, re-derived with wc -l: ui/Sidebar.qml 713 to 721 for the single highlighted index and its separate current-place and visual bindings; tests/sidebar-flows.qml 687 to 691 for the fill, accent edge, Trash ink and unfocused recipe checks; and tests/button-members.qml 518 to 523 for the whole pixel press height, frame settling and save-review fixture. tests/startup-units.txt retains its 169 units and moves its bytes line from 1,628,263 to 1,629,710, the wc -c sum with the frame-only button animation and the rail highlight bindings and comments; no new first-frame unit is imported, and startupObjectLimit stays 583.

The 0.3.11 stage join of the rail highlight fix and Drag to Trash re-derives two ceilings with wc -l on the merged tree: ui/Sidebar.qml 721 to 716, because the Trash rows leave for ui/RailTrashRow.qml, which carries the rail's highlighted binding, and tests/button-members.qml 523 to 525 for both sides' checks. tests/rail-highlight.qml shows the Trash row and checks that the keyboard cursor on it draws the fill; tests/sidebar-flows.sh rejects a Trash row without that binding.

The 0.3.11 language-mark port re-derives three file ceilings with wc -l: ui/Row.qml 459 to 466 for the filename binding and demand-created Loader, tests/rowcost.qml 438 to 441 and tests/columnscost.qml 416 to 418 for the unchanged ordinary-row object census. ui/LangMark.qml (51 lines) retains the approved file page and filled logos; ui/LangMarkLoader.qml (25) supplies the existing glyph slot, ink and size without building on non-code rows. The first-frame unit pin adds js/LangMarks.js and sums its bytes with wc -c; the ordinary-window object ceiling stays 587 and its measured baseline stays 585.

Under the superseded default-OFF ruling, the langtoggle follow-up re-derived src/uischema.rs 570 to 590 for the then opt-in boolean and its type checks, ui/Pane.qml 1234 to 1235 for dispatch, ui/ViewState.qml 549 to 553 for shared state, ui/ContextMenu.qml 737 to 740 for the live menu context and open-menu label refresh, ui/js/Menu.js 503 to 513 for the row recipe, ui/js/Settings.js 475 to 476 for its checkbox, tests/js/menu.js 669 to 673 and tests/js/settings.js 394 to 397 for the matching row assertions. tests/startup-units.txt keeps the same 173 units and sets bytes to 1644259, summed with wc -c; the default-off code listing still builds 585 objects. No code was compressed to fit a ceiling.

The controller extension to the language-mark port uses the same demand-built mark in ui/QuickLookBar.qml (126 lines), following CodePreview and RenderedPreviews in the existing foreground slot. tests/quicklook-code.qml moves its recorded ceiling from 452 to 463, re-derived with wc -l, for the actual Rust mark, hidden kind glyph, ink and size checks and the native reader of drawn coloured text. The Markdown bar check reads both the approved page stroke and the original filled logo.

Before the default-ON ruling, the language-mark startup follow-up kept the filename map, 35-key list and demand-construction helper in ui/js/LangMarks.js (114 lines, 3,453 bytes). The unchanged SVG table and lookup move to ui/js/LangMarkPaths.js (47 lines, 108,011 bytes), imported in production only by the demand-created ui/LangMark.qml. The first-frame unit set remains 173 and its source byte pin is 1,643,188, adding 3,917 bytes over b8: the mapping unit plus 464 bytes in the existing row bindings. The startup gate explicitly refuses both language QML units and the path-data unit before its startup cut; the object census remains exactly 585 with zero language objects.

The default-ON language-mark ruling keeps the existing glyph slot and ink and respects a saved false. The first real default-ON startup census measured 600 objects, three LangMark and three LangMarkLoader objects, against 585 with explicit OFF. The unchanged two-object headroom puts the default ceiling at 602 and keeps explicit OFF at 587; both exact baselines are asserted. The measured default unit set is 176, adding LangMark.qml, LangMarkLoader.qml and js/LangMarkPaths.js to the prior 173; current source-byte sums are 1755027 ON and 1644293 OFF. The saved-OFF profile retains the same unit names and cold language units. File ceilings are re-derived with wc -l: src/uischema.rs 595, src/uistore.rs 568 and tests/js/settings.js 398 for missing/invalid fallback, actual pre-GUI settle and saved-false checks. No artwork, key or geometry changes.

The key table is generated

keys.toml at the repository root is the single source of truth for every binding. ui/js/Keymap.js is generated from it by tools/flea-keymap-gen and must not be hand edited: change keys.toml, run the tool, commit its output.

docs/images/glyphs.svg is generated the same way, by tools/flea-glyph-sheet from the PATHS table in ui/js/Icons.js. It has no diff guard, so re-run the tool whenever a mark joins or leaves that table: the sheet kept drawing send twelve minutes after e4b4911 took it out, and never drew drive, filter or lock at all.

Three guards cover it, and each catches what the others cannot. ./tests/keymap-gen.sh regenerates into a temp file and diffs that against the committed one, so an edit to keys.toml with no regeneration and a hand edit to Keymap.js both turn it red, and the diff names the stale file either way. The same script then hands every Qt.Key_* the generator emitted to a qml6 probe and fails naming any that is undefined, because a mistyped key = "Downn" generates key === Qt.Key_Downn, which is false forever and diffs perfectly clean. tests/js/keymap.js, run by ./tests/js.sh, asserts the lookups themselves, so it catches a binding wired to the wrong action; it is a hand-maintained list, so it says nothing about a binding nobody wrote an assertion for.

The reasoning the hand-written Keymap.js used to carry lives in keys.toml now, one comment above the block it explains: control-modified keys are checked first, so plain d can mean cut while ctrl-d pages, and the character is authoritative for letters, so shift-g is distinguishable from g. The generated file carries only the header saying where it came from.

A [[shift]] table joined this for Task 9's shift+arrow selection extend. Unlike [[ctrl]], whose block returns "" on no match so an unbound ctrl-combo never falls through to a plain binding, the generated [[shift]] block has no such return: a shift-modified letter is already distinguished by its own capital text (the G idiom above), so an unmatched shift+key needs to fall through to the ordinary key and text switches rather than being swallowed. The table exists only to give the arrow keys, which carry no text at all, a shift-modified meaning.

The generator's block parser is a plain while read loop over name = value lines, not the multi-statement awk the plan sketched, because the project's code rules send a multi-statement awk back for exactly that rewrite and the loop came out shorter and needs no second language. Do not restore the awk.

A [[pointer]] table joined this on 2026-09-02, when a single left click stopped opening a row and the second tap started to. It declares what each button, modifier and tap count does in five places, and the generator emits it as Keymap.POINTER: the listing, the columns view's two neighbour columns, the rail, the window itself and the chrome. ui/js/Tap.js is the only code that decides the first three and tests/js/tap.js drives every listing and neighbour row of the table through that file, so neither side can move without the other. tests/ui.sh case click then drives real clicks at the window, which is the half a JavaScript suite cannot reach: it is what says a delegate hands Tap.tapped the tap count and the modifiers the click actually carried.

The last two places landed with issues 20 and 45 and are not Tap.js's. window is the mouse's two side buttons, which belong to no row: ui/WindowBody.qml carries one handler for both, ui/js/Nav.js mouseBack decides between the history and the climb and ui/js/MouseNav.js forward hands a press to Pane.goForward, which is the key's own entry and so refuses in Trash and in Recent. Both go through MouseNav.refused, the context-menu and collision-card refusal. chrome is the path above the listing and, in the dual view, each pane's own path, whose segments ui/Crumb.qml draws as their own click targets for both, placed by ui/ChromeBar.qml and by ui/PanePath.qml, from ui/js/Crumbs.js. Until 0.3.2 a dual pane's path was one Text answering only the double click, so a tap on a parent there did nothing, measured on 0.3.1 and on the 0.3.2 candidate after the second report on issue 45; the row column reads parent there because the leaf is the directory already listed and answers no single click. Neither where has a pointercase_ driver in tools/flea-acceptance-drive, so both report as derived and undriven in that battery; tests/js/tap.js holds their counts and drives neither, because both are QML bindings rather than Tap.js calls, and tests/js is structurally unable to press either one. tests/ui.sh case click is what presses them at the real window, and case dual presses a pane's crumb from paneCrumbCentre, then double clicks that pane's path in its padding, past its last crumb and on its own folder's crumb from panePathRect. A pane's path builds no crumbs while it is hidden, because the single view keeps it at height 0 and rule 6 is exactly that cost.

The Qt.BackButton binding is now driven at the product, and the probe that used to stand in for it is the reason to say what changed. That probe was a standalone Quickshell FloatingWindow carrying nothing but a TapHandler, clicked with ydotool click 0xC3: it shows Hyprland delivering that button to a Quickshell surface and Qt reporting it as Qt.BackButton, and it never loaded the shipped tree, whose ListView, delegates, rail and overlays all sit under the same handler. grep -rn acceptedButtons ui/*.qml finds no child that accepts the button, so nothing should take it first. tests/ui.sh case click now closes that gap the way this paragraph said it had to: after the crumb press it parks the pointer over a listing row with omarchy-drive move, sends ydotool click 0xC3 twice, and reads the path back through the IPC seam both times, so the history branch and the climb branch of mouseBack are each pressed through the shipped tree. The crumb half is pressed the same way, from crumbCentre, which is the seam ui/Ipc.qml grew for it. Between the two back presses the case sends ydotool click 0xC4, button 4, which Qt reports as Qt.ForwardButton, requires the path forward again, and backs once more so the climb still finds the history spent; tests/js/navmouse.js drives MouseNav.forward and the window's own handler (sliced from ui/WindowBody.qml, every overlay in turn, Trash and Recent), because tests/js/nav.js is at its hard cap.

A crumb click answers on the first tap (decided 2026-09-22). ui/Crumb.qml used to carry exclusiveSignals: TapHandler.SingleTap | TapHandler.DoubleTap, which makes the tap count decide by holding singleTapped until the double-click interval expires: measured on this box, 392 to 433 ms after the press against Qt's 400 ms interval, on every crumb click. Nobody double-clicks a breadcrumb, so a parent now opens at once, a double click on a parent is simply two taps, and the double click that types the path lives only where a single tap opens nothing: the leaf segment, the collapsed marker and the strip beside the crumbs, which keys.toml calls inert. Ctrl+L and : still open the bar anywhere. tests/ui.sh case click double clicks a parent with the backend stopped, because the path is written only when a listing answers: a listing landing between the two taps rebuilds the crumbs and hands the second tap to a fresh delegate, which would pass with the guard in onDoubleTapped deleted. The same case double clicks the collapsed marker, the guard's other arm.

Its effect field is does and not action, and that is load bearing. tools/flea-acceptance derives its whole key checklist with one sed for ^action = over this file, with no table scoping, so an action = in a pointer block would put an item on that checklist that no key can press. The generator refuses an empty or unreadable [[pointer]] table for the same reason [digits] is expanded by hand in that battery: a checklist derived from an empty table passes by having nothing in it.

A [[preset]] table joined this with the settings panel's Keys section, and it is the whole of the four-value chooser SettingsKeys.html draws. Each row names the preset it belongs to, the modifier state, the Qt key, the chord as the Keys section prints it, the action and a label. The generator emits it twice from that one row: as Keymap.PRESET_KEYS, which the settings panel lists, and as the if-chain inside Keymap.lookupPreset, which lookup() consults before every shared table. Emitting both from one row is what stops the panel advertising a chord the preset does not bind, and emitting the binding as a literal Qt.Key_* is what keeps it inside the qml6 probe above.

The live preset is a module-level var preset in the generated file, set by ui/ViewState.qml's onKeysPresetChanged. A .pragma library holds one copy per QML engine, so that one assignment reaches every caller of lookup() with no second wire and no plumbing through ui/js/Focus.js or ui/Pane.qml, both of which sit against their file budget. Every one of the four presets owns a row in that table. SettingsKeys.html's inventory gives default and vim a dash on the three view rows, but view switching is bound in no shared table, so a Default that overlaid nothing shipped with no keyboard route to the views at all; since 2026-09-06 both carry the Mac spelling, ctrl-1, ctrl-2 and ctrl-3, which is safe because only one preset is ever live. Every other row a preset governs is already a bare shared key. ui/ViewState.qml is the validator: it resolves a stored name that is not in Settings.PRESETS to the first of them, default, so an unknown name, which would match no overlay row, never reaches the generated module.

A chord claimed twice inside one preset fails the build. That is SettingsKeys.html's "Conflicts fail the build", and until this change it was not true: a second mac ctrl-1 claiming viewGrid emitted two rows into lookupPreset's if-chain, exited 0, and let the first silently win. preset_conflict keys on the preset with the modifier state and the Qt key, never the printed chord, because that is what the if-chain matches, and it names the preset, the chord and both actions. Two presets may hold the same chord, because only one is ever live. The tool takes the table to read as its second argument, so tests/keymap-gen.sh proves the refusal against a broken copy in its own probe directory rather than by editing the table this repo ships.

A preset overlay must never hold a chord the [[sheet]] table draws. The sheet is one static array with no preset of its own, so a cap it draws for a Mac-only chord would be wrong for half its readers the moment the toggle moved. tests/js/keymap.js resolves the whole sheet under both presets and fails if any row answers differently.

A mirrored pair shows the same number of spellings on the sheet. sheetFor adds a second spelling while the cap fits SHEET_CAP_BUDGET, which gave next tab ] beside prev tab [ / ctrl-pageup. The generator's SHEET_MIRRORS (cursor, page, history, tab, tab move, zoom, text size and extend pairs) now shows a second spelling on both members or on neither; Windows Page Up loses pageup beside ctrl-u because ctrl-d is not bound there. Pairs that are not mirrors by data, undo and redo, copy and cut, browse in and parent, are left alone. tests/js/keymap.js asserts the pairs across every preset and both frontends.

The tool emits JavaScript only. Plan 6 adds the Rust output together with the terminal key type that consumes it; a generated module with no caller is dead code, so the second output waits for its consumer.

Testing

The cohesion capture port moves two ceilings, each re-derived with wc -l: ui/Ipc.qml 985 to 992 and ui/PickerWindow.qml 812 to 819, for the cohesionControls call each window makes into ui/js/ControlReader.js, the one reader the cohesion and button look captures take every control's box and state from; the reader stays off the first frame: ui/IpcControls.qml imports it and each window's cohesionControls builds that file on its first call (Qt.createComponent(...).createObject(null), collected after the call), so neither ui/Ipc.qml nor ui/PickerWindow.qml imports the unit and tests/startup-units.txt does not list it. The pinned bytes are 1628899, as startup-objects measures them with the reader off the first frame (169 units); a mutant that imports js/ControlReader.js at the top of ui/Ipc.qml again turns startup-objects red.

The port re-derives both tests/staticgates-unqualified.tsv profiles with Qt 6.11.2: 1,469 allowances with Quickshell present and 2,219 with it absent. The absent-module census uses qmllint --bare with the real Qt modules and the Quickshell stubs, excluding the installed Quickshell module; it pins 532 rows absent from the inherited pin, removes stale reads and names the current declared ids in Glass and MediaSession and the unresolved Hyprland singleton. Every allowance names its current read with an applicable reason and the compiler's duplicate count, and the present profile keeps its exact row set after the picker's three lines per profile move from 712 to 713, 713 to 714 and 714 to 715. The startup-objects census proves the 169-unit, 1,628,899-byte list, and an eager ControlReader.js import in a marked source copy fails the explicit cold-unit assertion.

The capture port keeps the stage's later suites and native cases. The sweep's PDF column capture follows the continuous stack from c5196e133: it wheels to the second decoded page and requires a positive scroll offset, instead of asking the removed columnChevronCentre reader. tests/cohesion-reader.sh reads both windows offscreen, including the stage's TabBar, PathEntry and PickerFilter types in place of the stale TabButton, PathField and Framed names. ui/js/ControlReader.js also reads a control's own name, which is how the filter calls itself "Filter". The filter draws hover and press from its existing pointer handlers rather than exported flags, so the reader observes those handlers when a control has no direct flag; the picker reader test requires both capabilities. Native ui:cap_b72 and ui:cap_cohesion include their picker captures, ui:cap_columns14 takes the current Columns view at text size 14, ui:cap_archives_format_live and ui:cap_archives_format_kanagawa take the archive Format cards, and ui:cap_unlock records unlock failure and success for the controller to inspect.

  • tests/menu-settle.sh has a second leg, tests/menu-open-cost.qml, that opens the real pane's row menu by the m key and by a click point, in two processes so the key and the click path each take the cold first open, and pins MenuFit.reads (ui/js/MenuFit.js, what the card's fit reads) to one read per rows build. The card's width binding reads every row's wantedWidth, so it re-ran once per row built and cost 209 reads cold and 114 warm for 3 to 4 builds; ContextMenu.setEntries now holds it while the rows build (rowsBuilding) and it reads once when they stand (4 and 3 reads). Headless, that was about 2 ms of a 13 ms cold open and 1 to 2 of a 10 ms warm one.
  • tests/picker-040.sh is 0.3.10's Picker040 acceptance suite for path entry and collision review, expected red until then and named in run-all's not-run list.
  • tests/xwstate.sh is a headless run-all suite: it drives two shipped windows offscreen through tests/xwstate-control.qml, a test-only IPC seam copied into a sandbox. It proves the rec

Download now (ZIP)

  • Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.

    Of the AGENTS.md files we read, the 28,693 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.