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
- Very specific: line 449
Only rename, mkdir, bulkrename and settags answer slow.
- Very specific: line 526
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 …
- Very specific: line 777
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 …
≈ “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.
Files
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
- 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.qmlowns the viewport-sized held window and callsui/Backend.qml'swindow(start, count)request only when that window drifts. - The QML model is an integer count, never a list.
ui/Pane.qmlbinds itsListViewtomodel: root.total, so it instantiates and recycles only the delegates the viewport needs. ARepeaterover 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 throughui/StableModel.qml, aDelegateModelover 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"). listanswers with the first screenful, unasked. The window is rows from 0, unless aselectnames a row at or pastfirst:Listing::first_startinsrc/backend/listing.rsthen starts it at that row.ui/Backend.qmlsends onelistrequest and emits itslistedand initialrowsreplies;ui/PaneSwap.qmlconsumes 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.- Prewarm stays disabled until it is both safe and faster. The
flea --prewarmproducer 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. Productionui/WindowBody.qmlintentionally calls onlypane.open(start), andtools/flea-first-paintpreserves 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. - Vulkan where the loader can deliver it, lazy multimedia later.
src/gui.rssetsQSG_RHI_BACKEND=vulkanwhen the user did not choose a renderer andsrc/vulkan.rshas 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/drmconnectors whosestatusisconnected. Flea setsVK_DRIVER_FILESandVK_ICD_FILENAMESto the ICD whoselibrary_pathbelongs 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 tovkEnumeratePhysicalDevicesand 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 explicitQSG_RHI_BACKEND=vulkannow 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 stubqs, 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 explicitVK_DRIVER_FILESorVK_ICD_FILENAMESis the operator's, and an exported-but-empty one is absent, the same ruleQSG_RHI_BACKENDfollows. 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 ownVK_DRIVER_FILESis 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, andsrc/open.rsandsrc/terminal.rsdrop 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) withnvidia-utils610.57.04 installed, the NVIDIA ICD madevkCreateInstancetake 0.63 to 1.1 s and found no device: it runsnvidia-modprobetwice, 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-modprobereturns 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 stubqs, 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.rsruns before the probe and beforeqsin every renderer arm, because the OpenGL retry inherits the shell's filter and zink draws GL through Vulkan. It setsVK_LOADER_DRIVERS_DISABLEto the manifest file names whoseICD.library_pathfile 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 whosenvidia-drmis not loaded keeps its driver. Nothing is excluded when the operator setVK_DRIVER_FILES,VK_ICD_FILENAMES,VK_ADD_DRIVER_FILES,VK_LOADER_DRIVERS_SELECTorVK_LOADER_DRIVERS_DISABLE, whenvulkan/loader_settings.d/vk_loader_settings.jsonexists 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.sois excluded when no present display device is hasvk-class (Gen7 and Gen8 Intel), and kept beside one, where the probe'shasvk_onlyand 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 throughbackend/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/shareand/usr/share.vulkan::icd_libraryreadsICD.library_paththroughjsondoc, for the pin as well, because a first-match text scan read a decoy key placed before it.src/jsonmembers.rsfinds both keys the way the loader's cJSON does, without regard to case, and sees an exact repeat, whichjsondoc::parsefolds 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 makesICD\u0000xa repeat ofICD, 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,vkCreateInstancethen 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 stubqs, 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 markerFLEA_VK_DISABLEholds the exact filter written: a launch inheriting a filter its marker names, a new window or the OpenGL retry, decides again, andsrc/open.rsandsrc/terminal.rsdrop 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'sVK_DRIVER_FILESalready loads only the display GPU's ICD. A loader that cannot deliver a Vulkan device is givenopenglbeforeqsstarts at all, because Quickshell handsQRhi::createaQVulkanInstanceit 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, andsrc/gui.rsprints 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 isdlopenplusvkCreateInstanceplusvkEnumeratePhysicalDevices, 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 explicitQSG_RHI_BACKENDis never replaced, and an exported-but-empty one is absent rather than a choice, the same rulepaths::has_display()applies. What the probe proves is exactly what it asks the loader for: an instance carryingVK_KHR_surfaceand the session's own surface extension,VK_KHR_wayland_surfacehere andVK_KHR_xcb_surfacewhere onlyDISPLAYis set, plus a physical device. Those three names are Qt's own:libQt6Gui.so.6,libQt6XcbQpa.so.6andlibQt6WaylandClient.so.6here 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, andvkCreateInstanceanswersVK_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 stubqs, 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.jsis the decision and the argv, driven bytests/js/renderer.js; the signal reaching that decision is driven bytests/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,qswarnsNo QVulkanInstance set for QQuickWindowand exits 255 without raising anything; the SIGSEGV is issue #14's own report on a QEMU Virtio GPU.bodyLoader.Window.windowis null whileui/boot/shell.qmlloads and holds theQQuickWindowonce it exists, and that sameConnectionswas measured receivingsceneGraphInitialized, the signal Qt raises at the phasesceneGraphErrorreplaces. What no test reaches is the positive arm, a Vulkan failure leavingQVulkanInstancevalid and failing atQRhi::create, which no environment variable here was able to produce.ui/boot/shell.qmlno longer carries the//@ pragma DefaultEnv QSG_RHI_BACKEND=vulkanline, sosrc/gui.rsis the only thing that chooses a renderer for a launch, the one OpenGL relaunch inui/boot/shell.qmlaside, and a directqs -p ui/bootlaunch bypasses it entirely:tools/flea-metrics-gate,tests/ui.sh,tests/drag.shand theREADME.mddev loop each stateQSG_RHI_BACKENDfor 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_PAINTand the gtk3 trade of "The first window", so anything that TIMES a launch goes through$FLEA_BIN --guiinstead:tools/flea-field-benchalways did, andtools/flea-first-paintwas moved onto it when this landed.tools/flea-metrics-gatestarts nothing and times nothing, it reads the token seam over IPC from a Flea the operator started, and the four directqslaunches intests/ui.shassert 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.qmlis the only file that imports QtMultimedia, andui/PreviewPdf.qmlthe only one that imports QtQuick.Pdf, each reached through aLoaderbuilt by the first press of play or the first PDF open. Compiling is a separate step:ui/QuickLookWarm.qmlcallsQt.createComponentfor 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 resolvesimport QtMultimediaand a rest on a PDF resolvesQtQuick.Pdfwith 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 offscreenqs(tests/quicklook-firstkind.sh, five runs, a fresh process each), PSS from/proc/self/smaps_rollupafter 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 explicitQSG_RHI_BACKENDis never touched. The environment is the only setter either way:ui/boot/shell.qmlcarries noDefaultEnvpragma for it, so whatsrc/gui.rsputs in theqsenvironment is what Qt reads. - A hidden view is not a free view.
visible: falsedoes 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 withvisible: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 behindLoaders 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 needsPane.qmlsplit first, because twoLoaders inline exceed the 400-line hard cap, and that split broketests/ui.shto 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 whyfocus:is toggled explicitly besidevisible:. A hidden view that can steal a keystroke is a hidden view that is fully alive. - The media comparison measured
stratafully on 2026-09-03, and two of Flea's figures moved. Measuringstrata'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 thannemo'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: itsLoadergating 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 broketests/ui.shtwice. 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:
| When | No lever | Pinned | Saving |
| first large directory on screen | 4999 | 4949 | 50 KiB, nothing |
| second large directory on screen | 9882 | 4957 | 4925 KiB, 4.81 MiB, 6 of 6 pairs disjoint |
| back on a 10-entry directory | 1633 | 1453 | 180 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-portalowns the bus nameorg.freedesktop.impl.portal.desktop.flea, exportsFileChooserat/org/freedesktop/portal/desktopand aRequestobject at each request's own handle, turns one call into one JSON request, runsflea --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>isgui::pick: it refuses withoutFLEA_PICKERor a reply path, refuses without a display, resolves the UI with the samepaths::ui_dir()the window uses, andexecsqs -p <ui>/boot/picker.qmlwith the same renderer choice--guimakes. One code path chooses Vulkan for both front doors.ui/PickerWindow.qmlis the window, loaded by file: URL fromui/boot/picker.qml; see "The first window". It instantiates the sameBackend, draws the sameRowbehind a check box, reads the sameThemeand the samePlaces.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.htmldraws that row as the name, a 70 px size and an 80 px date, soui/PickerList.qmlhandsRowPicker.HIDDEN_COLSand the chooser never inherits Mode or Kind fromViewState, 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:
fleavalidated by a signature is OPR's package, andcheckupdatesis asked. pacman-contrib is a hard dependency ofomarchy, 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-binasks the AUR's RPC v5infoendpoint once:curl -q --silent --fail --globoff --max-time 10 --max-filesize 1M,-qfirst so the user's~/.curlrcis never read,--globoffbecausearg[]is otherwise curl's own glob syntax, and a body over 1 MiB is refused in Rust too.src/jsondoc.rsparses it. The release workflow pushes a version to the AUR only after its makepkg proof.flea-git, afleano signature validated (a localmakepkg -sibuild), any other owner name, and a binary no package owns are allunchecked: 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.rsdispatches on argv, and this is every flag it matches:--backendruns 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-terminalwith the same identity start the card's program outside or inside a terminal, answering0,2,6for a file changed since the ask,7for a noexec volume or8for 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,--youleftmeforstratais the undocumented second spelling of--default off,--picker [off]claims or releases the desktop's file chooser alone,--pick <reply>opens one chooser window fortools/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, whichgui.rsstarts andtui/warm.rsstarts with "-" for the list, see "The first window",--figure-helperruns the sandboxed quickjs-ng figure renderer on stdio, see "Markdown figures",--versionprints the version,--print-targetresolves--select's pair for the tests, and anything else opens the window, on--select's parent directory when one is given, unless explicit--tuirequests the terminal interface,--guibeing the explicit spelling of the window a bare invocation already means, see "Modes".paths.rsresolves the UI directory and whether a display is available.gui.rsexecsqsagainst the resolved UI directory.thp.rstheprctl(PR_SET_THP_DISABLE)declaration behinddisable()andenable().icdexclude.rsdisables the Vulkan drivers of GPU vendors the box does not hold, before the probe andqs; see rule 5.icdmanifests.rsthe manifest inventory behindicdexclude.rsand the probe cache's key: which ICD manifests the loader would read and what each one's library names.vkprobecache.rsanswers the launcher's Vulkan probe from a fingerprinted cache, probing only on a miss; the key lives invkprobekey.rsand its library search invkprobelibs.rs.open.rshands one file togio openand waits for it, see "Opening a file".terminal.rshands one directory toxdg-terminal-exec --dir=and does not wait, see "Opening a file"; itsdetachis the set of guards every program Flea starts and does not wait for carries.tearoff.rsnames the three tab tear-off variables and removes them from a child's environment, fordetachandopen.rs.unlock.rsflea --unlock <device>: opens the same unlock dialog a locked rail row opens, through its ownexec_unlockbesidegui::qs_command.figurehelper.rsflea --figure-helper: maths and diagrams through quickjs-ng under the thumbnail jail's flags and caps, see "Markdown figures".update.rsflea --update [check]: the install kind, the source's answer, and Omarchy's updater, see "Updates".defaults.rsclaims or releases the OS-level default: the desktop-entry install check, theinode/directoryMIME default viaxdg-mime, and reporting each half, see "Modes".hyprkeys.rsadds or removes the additive, markered block in Omarchy's~/.config/hypr/bindings.luathat binds the two file-manager chords to Flea, see "Modes".userfile.rsresolves$HOME,$XDG_CONFIG_HOMEand$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.rsthe one error type, naming the failing operation and input.json.rsthe wire's JSON: read one named field out of one line, escape one string into one.jsondoc.rsone whole JSON document in and out, which the one-line scanner above deliberately is not.jsonstring.rsone JSON string in and one RustStringout: the escapes and the surrogate pairs.jsonmembers.rsone member of a JSON object found as the Vulkan loader's cJSON finds it: any case, every repeat seen.uischema.rsthe shippedui.jsonshape and the rule each key is measured against.uistate.rstheui.jsonmerges: a file onto the defaults, one caller patch, and 0.1.3'sview.json.uimigrate.rsthe one-time changes a storedui.jsonis owed, applied on read and recorded by itsstateVersionstamp, see "The state file".uistatecli.rsisflea --ui-state, the window's path to read or patchui.json(the TUI writes through its ownuistore::Store), and the shelf switch that follows a patch.uistore.rswhereui.jsonlives and the one locked, atomic way it is rewritten, see "The state file".backend/mod.rsis 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.rsthe arena-backedListing.backend/aliases.rsresolves a MIME alias to its canonical name, see "MIME aliases".backend/scan.rsphase 1: readdir plusfstatatfor typeless entries only.backend/sort.rsparses the sort key, holds the name order, and holds the one sentence a key it does not know answers with.backend/meta.rsphase 2: stat a row range for awindow, or every row for a size or date sort.backend/metasort.rsthe size and date orders: sorts an index over the metadata pass, then gathers the spans behind it, see "Two-phase listing".backend/owner.rsresolves a uid to its login name from/etc/passwdalone, for the meta reply.backend/mime.rsresolves a file name to a MIME type from the shared globs2 database.backend/icons.rsresolves a MIME type to a freedesktop icon name from generic-icons.backend/md5.rscomputes the thumbnail cache's filename digest, see "Thumbnail cache".backend/thumbspec.rsparses the.thumbnailerfiles on the search path into a table of validated specs, see "Thumbnailer specs".backend/thumbargv.rsturns one spec plus one input into the exact argv a child is exec'd with, see "Thumbnailer specs".backend/thumbcache.rsnames and reads entries in the shared thumbnail cache, see "Thumbnail cache".backend/thumbwrite.rscreates the temp, stamps the PNG and writes the fail marker.backend/sandbox.rswraps 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.rsruns one argv under a deadline and says whether it succeeded, failed or never started, which is the whole of what decides afail/marker, see "Thumbnail pool".backend/thumbs.rsthe bounded, cancellable thumbnail pool, see "Thumbnail pool".backend/proto.rsthe wire types, the request dispatch and the one-line responses.backend/rows.rsserialises one window of rows and its per-response Kind dictionary.backend/rowguard.rsstamps 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.rsthe thumbnail request policy: cache lookup, queueing, cancel and result reporting, see "Thumbnail requests".backend/run.rsthe 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 onerecv, becausestdhas noselect.backend/dirsizeworker.rsruns 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.rstheEventthose sources arrive as, and the forwarding threads that join them onto the loop's one channel. It came out ofrun.rsat 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.rsthe one inotify watch on the directory the current listing came from, its reader thread and thechangedline it answers with, see "The open directory is watched".backend/copyfile.rsthe copy primitives every transfer is built from: streaming, preserving symlinks and refusing to overwrite.backend/copyread.rsthe 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.rsthe 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.rsthat journal's versioned wire form and its read-time checks, both directions.backend/undostage.rsthat journal's file text, in and out:readparses the file once (a document, a newer writer's file, or garbage) andStagedrenders each entry once, so trimming to the cap sums piece sizes and a write is a join.backend/undorebase.rsisRebaseMap, one walk of the document for a whole batch of moves.heap.rspins 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.rsre-exportsprewarm, nothing else.launcher/prewarm.rswrites the listing and first screenful before the UI starts.ui/boot/shell.qmlowns the pragmas, the window, its geometry readers and the scene-graph error path; it holds nothing else, see "The first window".ui/WindowBody.qmlowns 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.qmlowns the OpenGL retry argv, loaded by file: URL only once the scene graph has failed.ui/Backend.qmlis the only QML component that talks to the Rust child, and carriesthumbandthumbcancelout andthumbedin alongsidelist,windowandsort.ui/ViewState.qmlreadsui.jsononce at startup with a blockingFileViewand writes nothing itself: every change, the header menu's columns and all three settings sections alike, goes back out throughflea --ui-stateas a patch naming that change alone, see "The state file". It also watches the file and applies another window's settled preferences live throughUiState.applyExternal, owning the apply/prune settler whose exit and collected text join the landed/whole way before anything is parsed.ui/js/UiState.jsisViewState'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 rebuildsViewStateruns 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, sotests/js/uistate.jscan redden on a mutation of the rule that only a zero exit proves a save.ui/Theme.qmlowns the singleton palette, type and spacing tokens from the Omarchy theme plus the user override.ui/Pane.qmlowns 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.qmlis 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 amadeline will open an editor on, and the watched re-read's debt, timer and cursor anchor, see "The open directory is watched".ui/PaneSwap.qmlis where a listing'slistedandrowsreplies land, and holds the old rows on screen until they do, see "The listing swap";ui/js/Swap.jsmakes its decisions.ui/Header.qmlrenders the column header band and its rule, and owns nothing else: it was lifted out ofPane.qmlat the 400-line hard cap and has no behaviour.ui/Row.qmlrenders one row delegate: the icon slot, which a thumbnail replaces in place, the PlainText name and the semantic colours.ui/Opener.qmlis the only component that runs Flea's own opening modes, by runningflea --open,flea --terminaland a one-lineshthat pipes intowl-copy, which is whywl-clipboardis adependsentry, see "Opening a file".ui/ContextMenu.qmlis the one pane-owned right-click popup and its single Open action.ui/StatusBar.qmlrenders the path, row counts and transient messages.ui/TabBar.qmlis the window's tab strip, hidden with no height until a second tab exists.ui/ChromeBar.qmlrenders the top chrome, and owns the path bar: the same strip typed into rather than drawn, opened by:,Ctrl+Lor 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 inTheme.color.accent(the error role inui/RenameField.qmlwhile the pane holds an error), the frame isTheme.color.mutedwhile 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 keepsTheme.fileRowHeightwhile 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.qmlowns 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, astests/rename-frame.shpins 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:CardScrollcarriesbleed, room inside its clip that the caller's margins give back, on every dialog that holds aFlea.DialogButtonorFlea.CheckBox(all of them: Collide, Trash, menu action, open with, convert, permissions, network and the save picker).CardScroll.reveal()keepsbleedYof 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.shpins all of it from the real window at every text stop: the content positions on whole pixels after the card snap, intests/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, sobleedandTheme.focusClearancestay: 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.shcase_clickreads 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 withinchrome_ink_toleranceof 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,PermissionsDialogand thePreviewsurface) takes its size fromTheme.cardSpan(want, room)(the want rounded up, inside the room) and its origin fromTheme.cardOrigin(room, span), set asxandyand neveranchors.centerIn; NetworkDialog and Preview add theirrisetoy.tests/permissions-adv.shreads 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.shreads every opened card's mapped rectangle in its even 1000 x 650 window (a subject with nocardItemfails unless it lists why it is no card), andtests/js/permissions.jspins that each card's own bindings, children excluded, use the helpers and nevercenterIn. 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.FixedHeightplus 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 theminusglyph at stroke 3. ui/PathJump.qmlis 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 andrecently-used.xbelin one ranked dropdown under the field, and Enter opens the cursor row.ui/JumpPath.qmldraws one row's path.ui/js/Jump.jsranks 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.jsportsbackend/fuzzy.rs, whose exact scorestests/js/jump.jsand the Rust testthe_exact_scores_the_jump_port_mirrorsshare, value for value. The history is read once and kept, and aFileViewthat never loads it re-reads it only after the file changes.backend/jump.rsanswers 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 throughexisting()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.shdrives all of this through the real bar, offscreen.keys.tomlis the one key table, andtools/flea-keymap-genturns it intoKeymap.js.ui/js/Keymap.jsis the generated key-to-action lookup and imports no QML.ui/js/Format.jsis the pure size, date, permission and name-elision formatter.ui/js/Errors.jsturns a backend failure into the one sentence the status bar shows, and imports no QML; it was lifted out ofPane.qmlat the hard cap and has no behaviour of its own.ui/js/Thumbs.jsis the pure row-to-path map and the visible-row request plan, and imports no QML so./tests/js.shcan redden on a mutation.ui/js/Tabs.jsis 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.jsis the dd pair's arm-and-fire policy, split out ofFocus.jsat its cap.ui/js/Collide.jsis 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.qmlholds the transfer waiting on the answer andui/CollideConfirm.qmldraws the card;backend/collide.rsanswers the question and applies the choice, see "Write operations and the undo journal".ui/js/TextSize.jsis 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.qmlstoresdisplay.textSizeas{"mode":"system"}or{"mode":N}, which issrc/uischema.rs's own rule for that key and the only stored shape, and owns the writers,ui/Theme.qmlderives its whole token ladder from the size in force, andkeys.tomlaliasestextSizeUp,textSizeDownandtextSizeResetonto 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.qmlreads it once fromhyprctl monitors -jand the panel shows it read-only, because the board rules that Flea does not step or cycle it.ui/js/Settings.jsis 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, whichmasterStatederives frommenu.hiddenrather than storing beside it, the Shortcuts group's singlekeyHintsrow, which is the one check that is not a menu action and so is never inMENU_GROUPS, and the row list each section draws. Pure, sotests/js/settings.jsdrives every control without a window.ui/SettingsPanel.qmlpaints whatrows()returns and owns the panel's two-sided keyboard,ui/SettingsRail.qmlthe section rail andui/SettingsRow.qmlone row of the pane.ui/SettingsPane.qmlbuilds 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.qmlpins 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.jsis the About section's rows, the Updates group among them;ui/js/Settings.jsroutesrows("about")to it.ui/UpdateCheck.qmlis the updater singleton: the check and launch processes and the six hour poll, see "Updates".ui/js/Update.jsis its state and every word the row, the note and the footer use.ui/DefaultClaim.qmlis the Make Flea the default singleton: the handler read, the entry probe, theflea --default [off]run and the portal restart after it.ui/js/MakeDefault.jsis its state, the row, the note and what a press runs; see "Make Flea the default, in Settings > About".ui/js/MenuRefresh.jsis where the menu's keyboard cursor lands when a provider refresh rebuilds the rows under it, andui/MenuEdgeFade.qmlis one edge of a menu that scrolls; both came out ofui/js/Menu.jsandui/ContextMenu.qmlwhole, see "File budget".ui/js/Menu.jsapplyHiddenis the only consumer of the stored hidden set, and it runs at the end oflistingEntries, so a switched-off action leaves every menu that carried it at once.openandtoggleHiddenare refused there as well as drawn locked in the panel, so a hand-edited state file cannot empty the menu.ui/js/PreviewKeys.jsis what the preview overlay does with a key, and the 5 s seek step only it reads, split out ofFocus.jsat its cap the second time it reached one.ui/js/RailKeys.jsis what the rail does with a key, split out ofFocus.jsat its cap the third time it reached one;Focus.handleKeycalls it directly, as it already calledPreviewKeys. Enter orlon a row (#181) armsSidebar.focusOnOpenand moves focus into the folder only when its open lands throughopenFrom, so a mount, or a share whosegio infois slow or never answers, leaves focus on the rail rather than in the folder it came from.ui/js/Menu.jsis what either menu holds and where its frame sits: the submenu test, the edge clamp, the listing and header row lists, andopenAtCursor, the keyboard's own entrance, which came out ofui/Pane.qmlwhen PR 34'sopenTerminalwould have pushed it past its cap.ui/js/Scale.jsis the interface scale's step, clamp and sentence;ui/ViewState.qmlholds it for the window andui/Theme.qmlmultiplies 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.jsis what a typed path line means: the tilde, the relative name, thefile://URI, the interior.and.., and what Tab makes of one directory's names. Pure, sotests/js/pathbar.jsdrives all of it; the field itself isui/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 | |
| Pss | 76,308 | 78,064 | +1,756 KiB, 1.71 MiB |
| Pss_Anon | 37,400 | 39,052 | +1,652 KiB |
| USS (Private_Clean + Private_Dirty) | 54,988 | 56,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:
| gain | commit | objects at rest | verdict |
| clipMark/clipPx props, List markForRow | b0f33b6 | 0, empty clipboard early-returns | keep |
| clipLoader Loader, inactive unless marked | b0f33b6 | +1 shell, builds nothing | keep, already deferred |
| nameBudget/elidedName, elideMiddle | b0f33b6 | 0, fast-path string scan | keep |
| cellInk/dimOpacity folding cellColor, clipCut | 89115b4 | 0, fewer evaluations | keep, the fold |
| eight dimOpacity opacities | 89115b4 | 0 | keep |
| date RecentDates.isRecent call | 04485bf1, e24 short-circuits on the switch | 0, never entered when off | keep |
| Scroll.contentWidth delegate width | a783cc5 | 0 | keep |
| dirSizes wantsSizes gate | 9e3e199 | 0 | keep |
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.shhas a second leg,tests/menu-open-cost.qml, that opens the real pane's row menu by themkey and by a click point, in two processes so the key and the click path each take the cold first open, and pinsMenuFit.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'swantedWidth, so it re-ran once per row built and cost 209 reads cold and 114 warm for 3 to 4 builds;ContextMenu.setEntriesnow 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.shis 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.shis a headless run-all suite: it drives two shipped windows offscreen throughtests/xwstate-control.qml, a test-only IPC seam copied into a sandbox. It proves the rec
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
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.

