Windows speed, memory and a right-click menu - #1
Conversation
cmux terminates the foreground process group when AppKit activates an executable reached through the macOS command-line symlink. Restart interactive cmux launches once in their own process group before AppKit starts. Preserve arguments, prevent recursive restarts with an environment marker, and leave non-cmux and non-interactive launches unchanged.
`cargo xtask bundle` from a fresh clone failed with `rustc 1.94.0 is not supported by the following package: xtask@0.10.1 requires rustc 1.97` when the machine's `rustup default` was older than the workspace MSRV. The message blames xtask only because `.cargo/config.toml` aliases `cargo xtask` to `cargo run -p xtask`, so it is the first package in the build graph; cargo rejects the whole workspace before anything compiles. CI pins 1.97 but nothing provisioned it for builders. rust-toolchain.toml pins the same version, with the clippy and rustfmt that `make lint` needs, so rustup fetches it on the first build and local lints match the gate. Verified from this commit: `cargo xtask --help` (the command that failed) runs, `cargo xtask lint` is green, and `cargo xtask bundle` produces target/bundle/disktree-0.10.1-aarch64-macos.zip.
The README has always said the mouse's side buttons retrace the visited directories, and on_mouse_down had the two MouseButton::Navigate arms for it, but nothing ever delivered buttons 8 and 9: the mosaic registered listeners per button, for Left and Middle only. The arms were dead code. Replaced the two registrations with on_any_mouse_down, so every button reaches on_mouse_down and the Navigate arms work. One listener rather than four, and the per-button form would have needed a new registration for each button the platform names. Guarded the arms on Screen::Explore, matching the alt-arrow path: on the review screen the marked list is not somewhere history should move. The help screen and the hover card on `<` and `>` named only the keys, so the in-app bindings were narrower than the README's. Both now mention the side buttons.
Elevated scans of a whole NTFS drive read the master file table in large parallel reads instead of walking directories; any failure falls back to the walk. The walk also allocates once per entry, tallies progress per directory and folds hardlink de-dup into the parallel aggregate pass.
# Conflicts: # crates/disktree-app/src/tests.rs
# Conflicts: # crates/disktree-app/src/tests.rs
# Conflicts: # crates/disktree-app/src/tests.rs
# Conflicts: # README.md # crates/disktree-app/src/views.rs # crates/disktree-core/src/windows.rs
Integrate Windows scanning, drive picker and navigation fixes
A WSL disk image grew 1.1 GB with no journal entry. Files last seen open and the 1,024 largest are measured again on every reuse.
current_file and sizes_by_id both asked for FILE_STANDARD_INFO. sizes_by_id read it into the struct, whose flags are Rust bools a kernel byte need not be valid for; both now read it as words.
Every read of the body reopens the handle whose header was checked, not the path, so all reads see the same file.
A cache is opened relative to its directory with NtCreateFile, and a link in its place is not followed.
The partial is made and renamed relative to the open directory, so a link planted above it cannot redirect either; a failed save deletes it.
An elevated process keeps its caches in admin, made with an owner and ACL only Administrators and SYSTEM hold, and never reads the user's.
An elevated scan reads a cache only when its directory and the very handle it reads are owned by Administrators or SYSTEM under a protected ACL that grants no one else write access. Anything else is a whole scan.
README describes the admin cache directory and its checks; AGENTS lists the rule as an invariant and names where it is enforced.
The tree is no longer a boxed node per entry (120 B and a heap block for its name) but flat lists: a 32 B entry per file or folder, a 56 B record per directory, names in text segments. The walk places each directory's ordered run into its thread's fixed segment as the directory finishes, charging a hardlinked file at its first listed name, so no whole-tree aggregate pass remains. The file table reader builds the same tree in parallel and hands it over as is: no node tree beside the flat one, and the saving thread writes the tree the app holds instead of building a second. The walk cache is read and written as a stream straight into and out of that tree. The app reads it through a Copy `Node` view; a metric switch reorders runs in place. Measured on this machine (i7-12800HX), base e594324 vs this, interleaved, first pair dropped, medians: - home walk C:\Users\shish (3.67 M files, 551 k dirs), 6 pairs: cold peak commit 1061 -> 402 MB, held private 757 -> 304 MB, wall 9256 -> 9604 ms (+3.8%), CPU 83.7 -> 81.5 s; warm peak 850 -> 261 MB, held 693 -> 253 MB, wall 1025 -> 580 ms, CPU 1055 -> 594 ms. - elevated C:\ tree from the real 4.87 M-entry table (lab, 6 pairs): cold peak 1820 -> 1072 MiB, held 845 -> 337 MiB, hand-over 452 -> 360 ms; warm peak 1073 -> 284 MiB, held 784 -> 284 MiB, load to tree 290 -> 76 ms, CPU 1400 -> 483 ms.
Store only the 256-record pages touched by planned reads. Keep original record numbers for links and journal refreshes. Cover gaps, shared pages, and refreshes into absent pages. Six alternating real-table pairs, first discarded: peak 1837 to 1616 MiB; node plus flat build 1094 to 1046 ms and CPU 2796 to 2641 ms. Tree-ready phase 444 to 476 ms; live elevated whole-scan confirmation remains required.
Keep direct live/directory flags and full sizes, times, sequences and name counts. Store only hidden, evicted and reparse facts consumed by traversal. Preserve ordinary-tag precedence over name hints and extensions. Seven interleaved real-table trios, first discarded: versus sparse40, peak 1698875392 to 1646981120 bytes, build wall 1178.5 to 1135.5 ms, CPU 2789.5 to 2820.5 ms. Tree and tag-precedence checks pass; elevated confirmation remains required.
Adapt the tag-precedence assertion to the resident Tree API after rebase onto 5673a24. Seven matched real-table pairs, first discarded: peak 1101.0 to 834.3 MiB; tree-ready wall 378.5 to 393.5 ms and CPU 1547 to 1476 ms. Full decode/build wall 746 to 724 ms, CPU 2297 to 2046 ms.
An entry keeps its name's length in 16 bits, so a checksummed walk cache holding a longer name below the root loaded it as an empty name. The reader now refuses such a record before taking its bytes, and the next scan walks again. No file system names a file that long; this only closes the loader to a forged or damaged file.
A whole read stops at 1024 levels, but a kept tree came back without that bound, and a journal that moves a deep folder into another could take the patched tree past it: a 50,000-level kept tree overflowed the stack in the filter. The patch, which every resumed scan goes through, now checks the finished tree's depth from the root and asks for a whole read past it. Also drops a trailing blank line rustfmt refused.
A Windows file id keeps the record's reuse count above bit 48, so every id missed the bitmap and went into the sharded hash set: about 60 MiB for a home folder's 3.7 M files, held to the end of a cold walk. A walk that stays on one NTFS volume and grafts nothing kept from an earlier scan meets only current ids, where one record is one file, so it keys the bitmap by record. A widening walk, another file system, a share or a walk that follows links keeps whole keys. Ported from Fable's f398c1d, with the NTFS check added. Cold walk of C:\Users\shish, launch probe, 6 interleaved pairs, first dropped, medians: peak commit 406 -> 349 MiB; private held after the scan 358 -> 292 MiB; wall 83 -> 81 s, process CPU 164 -> 165 s (both noisy); warm unchanged at 252 MiB.
A run's name offsets, a segment's entry counts and the directories a builder hands out are 32-bit, and were narrowed with `as`: past 4 GiB of names in one run, or 4 G directories, they wrapped into wrong names or reused numbers. Builder::place now refuses such a run, and a refused reserve takes nothing. The file table build then stops and the walk measures instead, as the old build did; a walk shows such a directory unreadable, its entries unknown; a kept tree too large for one segment is not saved. No real volume comes near these limits.
A relisted directory's removed subfolders kept their parent link and their entries, although a dropped directory has neither (tree.rs). They now get both cleared, beneath them too, while their files are marked for the alias refresh as before. Also wraps two comment lines to 80 columns.
With hardlinks counted once, a file weighs under one name and nothing under the others. When the folder holding the charged name went out of view (hidden, or made a cloud placeholder) while the file itself did not change, a resumed scan dropped that name and left the others at nothing, so the totals above them came up short until the next whole read. The patch now asks for a whole read in that case. Not a regression: the old patch did the same.
The depth check a resume makes walked every entry a level at a time, and an elevated warm launch took about 20 ms longer for it (5 pairs, warm2 194 -> 213 ms). It now climbs each directory's parent links once, keeping the levels found, which is a pass over the directories only. It also refuses a live directory hanging from nothing, or parents that loop, which the old walk passed over.
A cold NTFS folder walk encoded and wrote its ~150 MB snapshot before handing the tree over, so the treemap waited out the save. The walk now keeps the tree on a thread of its own that shares the Arc, the way the file-table reader already did; both share one save slot in scan.rs, which a scan waits on before it reads a kept tree and which scan::wait_for_cache joins. A cancelled walk still keeps nothing.
One right-click menu, drawn by disktree on every platform. On Windows it lists Explorer's rows below disktree's own: the shell builds them into a Win32 menu that is never shown, and shell_menu.rs reads it into rows with their labels, state, icons and submenus, without Delete and Cut. Submenus open as flyouts by pointer or arrow keys; a pick runs the shell command by its id. The rows load after the menu first draws and are read again over the next seconds, since Send to and Defender add theirs late. The native popup, its window-procedure swap and the release-to-open listener are gone.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Formal verification. PR-changed functions: 0/170 verified (0 proven, 0 may-equivalent, 0 distinguished) · 170 not verified (25 unsupported, 145 over cap).
Not verified on this run: parse\_args (unsupported), run (unsupported), file (unsupported), find (unsupported), find\_matches\_whole\_components\_only (unsupported), refresh (unsupported), tree (unsupported), apply\_filter (unsupported), begin\_removal (unsupported), breadcrumbs (unsupported), and 15 more; 145 changed function(s) beyond the cap and 0 skipped when the time budget ran out.
Graphify review — findings
Adds an elevated Windows path that reads a whole NTFS drive straight from the master file table instead of walking it, and resumes later scans from the last snapshot plus the change journal. It reads only caches kept under an Administrators-only ACL and falls back to a full scan otherwise. The tree becomes flat, with arena names and per-run totals and ordering, and a hardlinked file is charged under the first name listed. Removal now refuses each profile directory under FOLDERID_UserProfiles, and the app gains a volume picker (v), esc to stop a scan, single-folder refresh with a folder watch, and Explorer's rows in the right-click menu.
Worth a look
- Windows-only icon check has no OS guard —
.github/workflows/ci.yml:102· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1310 functions depend on the 1310 functions this change touches.
Health — this change adds coupling hotspots:
- new:
refuse()— 10 callers, 10 callees - new:
remove_permanently()— 10 callers, 5 callees - new:
scan_blocking()— 3 callers, 16 callees - new:
guard_key()— 12 callers, 3 callees - new:
classify_where()— 4 callers, 8 callees - new:
selection_section()— 1 callers, 21 callees - new:
encode()— 2 callers, 8 callees - new:
node_card()— 2 callers, 7 callees - …and 50 more — each is listed as a finding
Verification — 1310 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1310 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify parse\_args.
The verifier did not have enough to check parse\_args, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed (() -> Result<Args>) → ((mut args: implIterator<Item=std::ffi::OsString>) -> Result<Args>) — a single call cannot feed both versions
Could not verify: Could not verify run.
The verifier did not have enough to check run, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported return type: Result<()>
Could not verify: Could not verify file.
The verifier did not have enough to check file, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed ((name: &str, bytes: u64) -> Node) → ((name: &str, bytes: u64) -> Draft) — a single call cannot feed both versions
Could not verify: Could not verify find.
The verifier did not have enough to check find, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed ((root_path: &Path, root: &Node, path: &Path) -> Option<&Node>) → ((root_path: &Path, root: Node<'a>, path: &Path) -> Option<Node<'a>>) — a single call cannot feed both versions
Could not verify: Could not verify find\_matches\_whole\_components\_only.
The verifier did not have enough to check find\_matches\_whole\_components\_only, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unit-returning functions have no value to compare
Could not verify: Could not verify refresh.
The verifier did not have enough to check refresh, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify tree.
The verifier did not have enough to check tree, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed (() -> Node) → (() -> Tree) — a single call cannot feed both versions
Could not verify: Could not verify apply\_filter.
The verifier did not have enough to check apply\_filter, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify begin\_removal.
The verifier did not have enough to check begin\_removal, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify breadcrumbs.
The verifier did not have enough to check breadcrumbs, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify can\_start\_over.
The verifier did not have enough to check can\_start\_over, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify crumbs\_for\_path.
The verifier did not have enough to check crumbs\_for\_path, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify current.
The verifier did not have enough to check current, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify dispatch\_key.
The verifier did not have enough to check dispatch\_key, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify enter.
The verifier did not have enough to check enter, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify node\_at.
The verifier did not have enough to check node\_at, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify on\_explore\_key.
The verifier did not have enough to check on\_explore\_key, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify on\_mouse\_down.
The verifier did not have enough to check on\_mouse\_down, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify poll\_scan\_once.
The verifier did not have enough to check poll\_scan\_once, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify prepare.
The verifier did not have enough to check prepare, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify ranked\_siblings.
The verifier did not have enough to check ranked\_siblings, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify refresh\_insights.
The verifier did not have enough to check refresh\_insights, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify render.
The verifier did not have enough to check render, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify reveal\_target.
The verifier did not have enough to check reveal\_target, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
Could not verify: Could not verify set\_root.
The verifier did not have enough to check set\_root, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
· 20 grounded finding(s) anchored inline below; 27 more finding(s) on lines outside this diff (see the check run); 11 additional anchorable finding(s) not shown (cap).
| @@ -73,7 +83,31 @@ fn main() -> Result<()> { | |||
| } | |||
|
|
|||
| fn run() -> Result<()> { | |||
There was a problem hiding this comment.
run()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
|
||
| /// Enter must reach the picker even when its dialog owns keyboard focus. | ||
| #[gpui_kit::test] | ||
| fn enter_in_the_focused_volume_picker_scans_the_selected_root( |
There was a problem hiding this comment.
enter_in_the_focused_volume_picker_scans_the_selected_root()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| /// Escape stops a first scan. What the walk found so far is not shown as a | ||
| /// tree, a late result is ignored, and `r` starts over. | ||
| #[gpui_kit::test] | ||
| fn escape_cancels_the_first_scan_and_r_starts_it_again( |
There was a problem hiding this comment.
escape_cancels_the_first_scan_and_r_starts_it_again()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| /// The mouse's back and forward buttons retrace the same history as | ||
| /// alt-arrows and the header buttons, and only on the explore screen. | ||
| #[gpui_kit::test] | ||
| fn mouse_side_buttons_go_back_and_forward(cx: &mut TestAppContext) { |
There was a problem hiding this comment.
mouse_side_buttons_go_back_and_forward()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
| #[gpui_kit::test] | ||
| fn a_right_click_selects_the_tile_and_copies_its_path(cx: &mut TestAppContext) { |
There was a problem hiding this comment.
a_right_click_selects_the_tile_and_copies_its_path()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| /// Records land in `infos` as they are parsed, while other reads are | ||
| /// still on the disk: gathered into it after the last read, they cost a | ||
| /// tenth of a second on the way to the tree. | ||
| fn read_records( |
There was a problem hiding this comment.
read_records()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
| /// [`parse_record`] for a record whose update sequence is already undone. | ||
| fn parse_fixed( |
There was a problem hiding this comment.
parse_fixed()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
| #[test] | ||
| fn reparse_tag_precedence_preserves_directory_and_link_kinds() { |
There was a problem hiding this comment.
reparse_tag_precedence_preserves_directory_and_link_kinds()
fans out to 9 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| /// The last scan's tree brought up to date; `None` when a whole read is | ||
| /// needed instead. With it, where the next scan picks up, when that is | ||
| /// worth writing down: see [`RESAVE_JOURNAL`]. | ||
| pub(super) fn resume( |
There was a problem hiding this comment.
resume()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
| fn scan_blocking(root: &Path, context: &Arc<WalkContext>) -> io::Result<Node> { | ||
| fn scan_blocking( |
There was a problem hiding this comment.
scan_blocking()
fans out to 16 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Brings the fork's main up to date with upstream main (158f9cc) plus:
Checks:
cargo xtask lintandcargo xtask testpass on Windows; the menu, terminal and folder refresh were exercised in the running app.