69 KiB
Dired — framing
Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.
Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings;
rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's
implementation falsified in the approved text (§0); rev 5 adds the
coherence impact statement now required of every framing
(CLAUDE.md, COHERENCE.md §20) — see §0.5. Deliberately
unnumbered: the roadmap's Arc 8 is GPU
structural parity but docs/lean4-mode-framing.md also claims Arc 8, so
the arc space is already forked in uncommitted work. (Rev 2 also cited
docs/dap-debugging-framing.md as part of that fork — wrong: its Arc 7
matches the roadmap's Arc 7 = Debugging. R2-5.) Numbering this one
would mint a third claim; it is ranked when the roadmap is next
reconciled. Not on docs/roadmap-2026-07.md and not in
docs/side-quest-backlog.md — this framing proposes the work as well as
its design.
0. Revision history
Round 1 (rev 1 → rev 2)
- F1 (load-bearing). §3's rationale for freezing the M8 fixture was
false. Rev 1 claimed the 47 tests pin the package system —
install_local,on_unloadunregistration,DuplicateName, per-packagerequirescoping. Verified:install_localappears only in the sharededitor_with_dired()harness and in doc comments, never in an assertion;on_unload,DuplicateName, and require-scoping are asserted nowhere in either file;dired_source_size_under_audit_ceilingis alines < 1500lint. Exactly one of 47 (dired_package_reload_is_safe_after_init_complete,m8_2_acceptance.rs:1190) exercises package mechanics; the other 45 assert dired/wdired behavior. §3 rewritten on honest grounds; the "shrink the fixture" follow-up repositioned from if drift appears to scheduled after Stage 3, because it is now known to be cheap. - F2 (load-bearing).
RETmust not use barefind_or_open.window_panel.rs:373-376documents why:find_or_open"switches the ACTIVE window in both branches before firing hooks, so a visit to a previously unopened file would replace a focused panel." ARETin a panel-displayed dired would swallow the panel. Visits now route throughpmacs.window.display_file(new Q#DR10), which also dedups by normalized path. §2 gains the window-primitive ground truth it was missing entirely, and thediredcommand gains thedisplayopt that acceptance 11 was already testing without. - F3 (load-bearing). Stage 0's completion mechanism does not work as
described. A function source is re-called per keystroke but invoked as
f.call(())— zero arguments (src/minibuffer.rs:591) — and the callback runs synchronously from Rust dispatch, outside anypmacs.asynccoroutine, whereHandle:await()raises (async.lua:76-79). There is no synchronous directory listing in Lua, so a function source cannot descend into directories. Stage 0 rewritten around the existing RustCompletionSource::Files(minibuffer.rs:589) plus free-text accept; hierarchical completion is a named deferral (new Q#DR11). Rev 1's "a file you have never opened is unreachable" is corrected to undiscoverable and uncompleted — recentf's prompt already passes free text tofind_or_open. - F4 (design gap). Q#DR5(a) as specced did not cover dired's own hard
case.
apply_resource_op's rebind usesfind_by_path(buffer_registry.rs:168-174) — exactPathequality, first match only — so a directory rename strands every buffer beneath it, andRon a directory line is an ordinary dired operation. Worse, that arm looks up with the raw path (mod.rs:3248) while stored paths are normalized on write (EditorCore::set_buffer_path,editor_core.rs:819) and the normalizing wrapperfind_buffer_for_path(:864-867) exists and is bypassed — so a non-normalized old path silently fails to match today. Q#DR5 widened to include rebind semantics, with new evidence:pmacs.fs.renamehas zero production callers, so changing the primitive's contract breaks nobody. - F5 (spec fix). Non-UTF-8 symlink targets are fatal too
(
fs.rs:227), and differ in kind from names: the entry's own name is fine and nothing needs to pass a target back throughrename. Tolerant mode now carriesreadlinkfailures and target-encoding failures in the per-entry channel; only names stay fatal. - F6 / F7 (Q#DR2 gaps). Name-keyed dedup needs a canonical path form
(
/tmp,/tmp/,/tmp/../tmpwould mint three buffers), and found-by-name must verify dired ownership before painting into a buffer throughbypass_intercept. Both folded into Q#DR2. - Minors. Arc number dropped;
C-x C-rattributed torecentf.lua:85rather than the default keymap; the tolerant opt must validate unknown keys (supersede_key,fs.lua:73-83, silently ignores them, so a typo'dtolerantwould degrade to fatal mode unnoticed); acceptance 13 carries the fixture's macOS ignore gate (m8_2_acceptance.rs:211-213); and §8 footnotes one per-entry failure that is already tolerated — a failingmetadata.modified()yields mtime 0 rather than an error (fs.rs:463-476).
Round 2 (rev 2 → rev 3)
- R2-1. Q#DR5's "at reply-settle time" named no seam, and the obvious
one is wrong. The fs ops are fire-and-forget-capable — nothing obliges a
caller to
awaitor attachon_complete— so a rebind implemented where results are consumed (_take_result,mod.rs:6758) misses any rename whose handle is never taken: the rename lands on disk and the buffer is never rebound. The trap survives one layer down, and an acceptance that awaits would pass while the fire-and-forget path stayed broken (the pin-through-the-real-path class again). §7 now names the main-thread completion drainAsyncRuntime::tick(async_runtime.rs:991) as the seam, unconditional on success — plus a fact that makes the implementation non-obvious: rename settles as an undifferentiatedReplyKind::FsUnit, the same reply chmod and remove produce (:1022-1025mapsSleep | FsUnitalike toJobResult::Unit; there is noRenamevariant). The drain therefore cannot key on the reply — it must key on the pending job's ownJobKind::FsRename, and the job must retain from/to so the paths exist at settle. Stage 2's acceptance includes a no-await rename. - R2-2. The
errorsrow shape cannot always carry a name. A per-entryreaddiriterator error (fs.rs:215-218) has no filename — the entry never materialized, and the error is wrapped with the parent path. §8 makesnameoptional for that arm; the footer counts it without naming it. - R2-3. §2's
pmacs.windowinventory listed five exports; there are eight — alsodisplay_target(:425),panel(:440), andset_params(:544). The omission was the relevant one:display_targetis "the non-side window a visit from a panel should address", i.e. the mechanism behinddisplay_file's panel-safety. §9's loosest sentence — directory descent in a panel-displayed dired, left as "(ordisplay, when dired was itself panel-displayed)" — is now specified, with the dedication question answered. - R2-4. The Lua mirror of
normalize_buffer_pathis a second implementation of a canonical form — the tab-width-constants class in miniature. If the mirror and the Rust normalizer disagree on an edge (//tmp,~withHOMEunset, root's trailing slash), dired's name-dedup anddisplay_file'sfind_buffer_for_pathdedup diverge silently: two buffers, no error. §4 now carries a parity obligation, and records that the "binding whose only caller is dired" argument undercounts — Q#DR5's fix touches the same normalizer, so a Lua-exposed canonicalize has at least two consumers by Stage 2. - R2-5. Header nit: only Lean 4 forks the arc numbering; DAP's Arc 7 matches the roadmap. Conclusion unchanged, evidence corrected.
- R2-6. Stage 0 was "recommended first" with no acceptance and no
ruling on nonexistent paths.
display_fileroutes throughresolve_target_buffer(editor_core.rs:885-898), which onErrorKind::NotFoundcreates the buffer, binds the path, and sets status"[new file]"— Emacs parity, now stated rather than inherited. §14 gains four Stage 0 acceptance items.
Stage 0 implementation notes (rev 3 → rev 4)
Implementing Stage 0 falsified one thing the approved text asserted, and the correction belongs here rather than only in the code.
-
S0-1. Flat completion and free-text accept do not compose the way Q#DR11 described. Rev 3 said Stage 0 is "the flat Rust
Filessource rooted at the current buffer's directory, plus free-text accept", as though the two were independent and always both available. They are not:recompute_candidatessetsselected = Some(0)whenever the candidate list is non-empty (minibuffer.rs:372-377), andresolve_accepted_value(:564-574) returns the selected candidate in preference to the typed contents. So typed text reacheson_acceptonly when the input filters every candidate away — which, since candidates are bare basenames and the filter is a case-insensitive subsequence match, means when the input contains a/. Consequences, all now pinned bytests/find_file_acceptance.rs:- the deeper-path case works (
sub/inner.txtmatches no basename, so it arrives verbatim) — which is what acceptance 0b actually tests; - the new-file case works only for names containing a separator, so acceptance 0c uses one;
- and there is a genuine hole: typing a new bare name that is a
subsequence of an existing entry opens the existing file instead of
creating the new one.
find_file_selected_candidate_shadows_typed_textpins that as a decision rather than an accident.
Closing the hole needs a Rust change to accept semantics — prefer typed text over the selection when the two differ and the user has not explicitly moved the selection — which would change
M-xandswitch-buffertoo, and so is deliberately not made in Stage 0. It joins the hierarchical-completion deferral in §13. - the deeper-path case works (
-
S0-4. Accepting on empty input opens the first-sorted candidate. A consequence of S0-1 with an empty needle:
fuzzy_scorereturnsSome(0)for every entry (minibuffer.rs:637-640) andfilter_and_sortbreaks the resulting tie lexicographically (:678), so an immediate RET opens whatever sorts first — dotfiles lead, and a directory can lead, in which case the open fails and reports (S0-6).M-xandswitch-buffershare the mechanism, so this is inherited rather than introduced; it is documented at the command and listed in §13 beside the accept-semantics fix that would close it. -
S0-5. Minibuffer history stores the pre-join value.
acceptpushes the resolved value into the history bucket beforeon_acceptjoins it onto the root, so aC-precall of a root-relative entry under a different root resolves somewhere else. Rust-side, so Stage 0 cannot fix it; §13. -
S0-6. The failure arm is a real path and is pinned. Accepting a directory candidate reaches
display_file, whose load fails (File::openon a directory succeeds; the read returns EISDIR), so the command'spcallturns it into a status message instead of letting the error escape mid-dispatch.find_file_accepting_a_directory_reports_instead_of_raisingpins it through the real accept path and fails when thepcallis removed. -
S0-2. The prompt field must start empty. Emacs prefills find-file's field with the directory. Here any prefill contains a
/, which by S0-1 filters every candidate away and silently disables completion — so the root is named in the prompt string instead, and the empty field is pinned by acceptance 0d. -
S0-3. A leading
~must be expanded before the path reaches the core.get_or_load_buffer(editor_core.rs:842-856) computes a normalized path but callsload_filewith the raw one (:847), so a~/…path deduplicates against an already-open buffer (dedup goes through the normalizingfind_buffer_for_path) yet fails to load a file that is not open yet. Stage 0 expands the tilde in Lua, which makes both halves agree without changing core load semantics for the CLI, LSP, and bootstrap callers. Using the normalized path for the load is the better fix and is now a named deferral (§13) — it is the same normalize-before-lookup family as Q#DR5'sapply_resource_opcorrection.
0.5. Coherence impact (COHERENCE.md §20)
Required of every framing since #163. This arc was scouted and approved before that rule existed; the statement is added here rather than backfilled silently.
Section served: §20 Priority 1 — protect the golden product journey, which already names this work: "a find-file surface (in flight, PR #162)" and "directory-argument handling". Secondary: §5 (unify discoverability) and §14 (coherent workbench primitives).
Journey steps touched (§2).
- Step 7, "find a symbol or file" — the file half, which had no
surface at all. Stage 0 (
C-x C-f, merged as #162) covers opening a known path; Stages 1–3 cover browsing, which is the half a user reaches for when they do not already know the path. - Step 3, "open a real project" — partially, and the boundary
matters. §2's ground truth grades the journey broken at step 3
because
pmacs .exits 1:load_file(src/file_io.rs:81-87) doesFile::open(which succeeds on a directory) thenread_to_end→ EISDIR, which is notNotFound, soresolve_target_buffer's create-a-[new file]arm never fires and the error escapes. That is the same mechanism Stage 0 pinned infind_file_accepting_a_directory_reports_instead_of_raising— where thepcallturns it into a status message instead. Dired Stage 1 is what makes a directory open into something rather than merely fail politely. Boundary with the adjacent arc: §20's arc-cut list puts CLI directory-argument handling in "Journey Stage 1", noted as riding alongside this arc. The two meet atresolve_target_buffer. This framing does not claim the CLI path; it supplies the buffer a directory should resolve to, and Journey Stage 1 should routepmacs .into it rather than inventing a second directory surface. - Step 4, "understand the visible interface" — marginally, via the
diredmajor mode showing in the statusline (Q#DR8). - Steps 1–2, 5–6, 8–12: untouched.
Interaction islands added (§6): none — deliberately. §6 grades this
area "weak, and growing by one island per modal feature", with every
modal surface funnelling through EditorInstance::dispatch_key's
precedence machine. Dired adds no Rust-level interception: its keys are
an ordinary mode-scoped keymap through the existing pmacs.keymap
registry (Q#DR8), so they are introspectable by describe.key and
rebindable like any other binding. Stage 3's wdired is a major-mode
swap, not a modal layer — which is the reason Q#DR3 chose a mode swap
over an edit-mode flag. Two existing islands are consumed (the
minibuffer prompt for C-x d, and Stage 0's), neither added by this
arc. This arc therefore moves §6's count sideways, not up.
Config registry adoption (§11): yes. dired.kill-when-opening is
defined through pmacs.config with a type, default, and
mutability = "live" (Q#DR2), not a bare Lua global — matching the
#127 adopters. Sort mode is deliberately not a setting in Stage 1: it
is per-buffer session state, and promoting it would need the
buffer-local scope plus a persistence story the registry does not have
yet (its own named deferral).
Background-work attribution (§9): inherited debt, not fixed here.
Every listing runs as a pmacs.fs.read_dir worker job, and those jobs
carry no owner or purpose — §9's gap. A dired refresh will therefore
show up in the activity planes exactly as anonymously as every other fs
job does today. Stage 1 does not fix that and does not make it worse;
when §20's "worker identity" arc lands, dired's jobs are ordinary
consumers of it. Naming it here so the debt is visible rather than
silently compounded (§1.3).
Net. One journey step goes from no surface to a surface; one more moves from fails toward resolves; no island added; one setting enters the registry; one attribution gap inherited and named.
1. Problem and what ships
Two separate facts collide here, and the second is why this is worth more than "a file browser would be nice".
Fact one: a complete dired already exists, and ships to nobody.
tests/fixtures/pmacs-dired/init.lua is 1,384 lines of Lua implementing
the read-only directory view (T M8.2) and the wdired editable
rename/chmod layer (T M8.3), pinned by 47 acceptance tests (15 in
tests/m8_2_acceptance.rs, 32 in tests/m8_3_acceptance.rs). It was
built as one of M8's three "universality proof" packages — the evidence
that a buffer can be a projection of external state. It lives under
tests/fixtures/, and rg pmacs-dired outside tests/ returns zero
hits. Nothing installs it; no user can reach it.
Fact two: pmacs has no discoverable way to open a file by path. There
is no find-file command and no C-x C-f binding. The complete list of
builtin command names contains nothing matching file or open;
pmacs.buffer.find_or_open (src/lua_bindings/mod.rs:3104) is a Lua API
with no interactive caller of its own. A file enters a session via the
CLI (pmacs FILE, pmacs --gpu FILE), an LSP jump, a project-search
visit, or C-x C-r recent-files — whose prompt does pass free text
through to find_or_open (recentf.lua:74-80), so an arbitrary path is
technically reachable, but only by typing it blind into a prompt labelled
"Recent file:" with no completion and no discoverability.
editor.switch-buffer (builtin/commands/default.lua:594) completes over
already-open buffers and reports no buffer: <name> for anything else.
So dired is not a convenience rider on an existing file surface. Dired is the file surface. That reframes both its value and its risk: it is the first thing a new user needs, and the last place we can afford a listing that refuses to render.
What ships, staged (§10):
- Stage 1 — the dired view. A builtin
builtin/runtime/dired.lua: read-only listing, navigation,RETto visit (files open throughdisplay_file, directories descend), sort modes, revert, quit,C-x d/C-x C-j, adiredmajor mode with mode-scoped keys, cursor preservation across refresh — plus the one Rust change Stage 1 needs, a per-entry-tolerantread_dir(Q#DR6). - Stage 2 — marks and operations.
m/u/U/t, deletion flagsd/x, immediateD,Rrename,Ccopy,+mkdir — and the three filesystem primitives that do not exist yet, plus the rename/rebind fix. - Stage 3 — wdired. The editable layer, carrying over the fixture's hard-won commit logic.
find-file itself (C-x C-f) is separable and is Stage 0 (§10).
2. Ground truth (scouted 2026-07-25, main @ e745068; verified across review rounds 1 and 2; re-verified against main @ 0827dd1)
Base note. main moved from e745068 to 0827dd1 (Lean 4 Stage 1,
#160) between the scout and approval. The diff touches exactly one file
this framing cites — builtin/runtime/syntax.lua, which gained a
lean = "lean4" modeline alias — and nothing else in the ground truth
below. The only consequence is a line drift: set_major_mode is now
syntax.lua:497, not :492. Every other citation is unchanged.
The existing fixture
tests/fixtures/pmacs-dired/init.lua(1,384 lines) defines eight commands:open-line,parent,sort-name,sort-mtime,sort-size,wdired-edit,wdired-abandon,wdired-commit. It bindsRETandBackspacebuffer-locally at open (:381-388), paints by wholesalebuf:replacebehind apaintingpassthrough flag (:294-302), and keys per-buffer handles by linear scan overBufferIdLua.__eqwith a liveness compaction (:53-67).- Its wdired layer is the valuable part and is not naive: a
column-classifying
intercept_edit(:640-712), fixed-width perms with positional validation (:520-542),\\/\n/\r/\t/\xNNfilename escaping with an exact inverse so a no-op commit cannot fire a spurious rename (:140-211), field-by-field external-change detection includingmtime_nsec(:854-902), duplicate-final-name rejection before any syscall, and a two-phase rename through unique temp names so swaps and chains commit safely (:1176-1216). - What the 47 tests actually assert (F1). 45 assert dired/wdired
behavior: rendered listing shape, sort order, escaping round-trips,
intercept column rejection, on-disk chmod/rename effects, the two-phase
swap, external-change detection, partial-application reporting. One
(
m8_2:1190) exercises package mechanics — reload safety afterset_init_complete. One (m8_2:1245) is a source-line-count lint.install_localappears only in the sharededitor_with_dired()harness as a setup precondition and in doc comments;on_unload,DuplicateName, and per-packagerequirescoping are asserted nowhere. - Two of its own stated limitations are now false.
open-lineon a non-directory errors with "requires the buffer-from-file API (not yet exposed)" (:948-961).pmacs.buffer.from_file(mod.rs:3054) andfind_or_open(:3104) both exist and ship.- The test seam claims "the v0.1 buffer surface doesn't expose
move_to_byte yet, so tests can't reliably position the cursor"
(
:1355).pmacs.editor.goto_byte(mod.rs:12765) andmove_to_line(:12526) landed with editops (#111).
- A real defect in its model: navigation mutates
handle.pathand repaints, but the buffer was named*dired:<path>*at creation and there is nopmacs.buffer.set_name— thepmacs.buffertable exports exactlycreate,from_bytes,from_file,find_or_open,list,kill,remove,on_removed,major_mode,set_major_mode,set_round_trip_input,mark_create,add_intercept,remove_intercept,apply_resource_op, and the style-overlay family. (Buffer::set_nameexists Rust-side, unexposed.) So after oneRETthe buffer name names a directory it is no longer showing. Q#DR2 answers this.
The filesystem surface
pmacs.fsis exactly five worker-dispatched ops —read_dir,stat,rename,chmod,remove(the complete_dispatch_fs_*set) — plus a Lua-side pollingfs.watch(builtin/runtime/fs.lua:226). Nomkdir, nocopy, no symlink-create, no recursive remove.read_diris all-or-nothing, and this is the load-bearing gap.read_dir_blocking(src/fs.rs:201) returnsResult<Vec<FsDirEntry>, FsError>. Five per-entry conditions fail the entire listing: a per-entryreaddirerror, a failedsymlink_metadata, a failedread_link(:228-234), a non-UTF-8 symlink target (:227), and a non-UTF-8 name (:238). The module doc acknowledges the shape and says "dired-class will likely want a per-entry-tolerant wrapper but that's the package's job, not the primitive's" (:196-200) — that wrapper cannot be written in Lua. The primitive hands Lua one structured error and no partial vec; there is nothing to be tolerant with. Three concrete failure modes, all ordinary:- a directory readable but not searchable (
rwithoutx) —readdirsucceeds, every childlstatfails; - a file unlinked between
readdirandlstat— ENOENT, i.e. a plain refresh of a busy directory (/tmp, a build tree) can just fail; - any single non-UTF-8 filename or symlink target in the directory.
One per-entry failure is already tolerated: a failing
metadata.modified()yields mtime 0 rather than an error (:463-476).
- a directory readable but not searchable (
read_diralready takes(path, opts)and the opts parser (supersede_key,fs.lua:73-83) reads onlyopts.supersedeand silently ignores unknown keys — signature-natural for Q#DR6's opt, but a typo'dtolerantwould degrade to fatal mode unnoticed.chmodfollows symlinks (src/fs.rs:370;fs.lua:104) whileread_dir/statuselstat. The fixture rejects symlink perms edits at intercept time for exactly this reason (init.lua:629-638) — that decision carries over unchanged.pmacs.fs.renamehas zero production callers — only its own definition (fs.lua:126),m8_1/m8_3acceptance, and the fixture.pmacs.fs.renamedoes not rebind an open buffer's path.pmacs.buffer.apply_resource_op(mod.rs:3206) — the LSP workspace-edit applier — does, but by exact first match: its"rename"arm callsreg.borrow().find_by_path(&from)(:3248), which is exactPathequality over insertion order (buffer_registry.rs:168-174). It also uses the raw path while stored paths are normalized on write (EditorCore::set_buffer_path,editor_core.rs:819) and the normalizing lookupfind_buffer_for_path(:864-867) exists and is bypassed. So the model rev 1 proposed copying is itself subtly wrong, and has no prefix rebind anywhere.apply_resource_opis also synchronous and blocking on the main thread, unlike everypmacs.fsop. Q#DR5.
Windows, panels, and how anything gets displayed
pmacs.windowexports eight functions:display(window_panel.rs:356),display_file(:379),display_target(:425),panel(:440),quit(:453),params(:499),set_params(:544), andresize(:591) — alongside the pre-arcswitch_buffer.display_targetis "the non-side window a visit from a panel should address", i.e. the mechanism that makesdisplay_filepanel-safe; it is what Q#DR10 rests on, and it is the reason a file visit and a directory descent take different routes (§9).find_or_openis panel-hostile, by documented design.window_panel.rs:373-376:find_or_open"switches the ACTIVE window in both branches before firing hooks, so a visit to a previously unopened file would replace a focused panel before any display policy could help."display_fileis the Q#BP11b answer — a side-effect-free dedup via the normalizingfind_buffer_for_pathbefore any I/O, then destination resolution before the read, so a dedicated origin cannot force load-before-failure. LSP visits and compile already route through it.pmacs.listview(builtin/runtime/listview.lua) implements the disciplines a read-only panel needs: a read-onlyadd_intercept(:101-104),set_round_trip_input(:106), a buffer-local keymap, a line→item map,q-restores-previous, and thedisplay = "current" | "panel"opt-in (:132-142). But its keymap is a fixed set —RET/SPC/n/p/g/q(:76-88) — with no extension point, and its read-only intercept is installed once at panel creation and never removed (:101), which wdired requires. Three panels already depend on this module (references, outline, project-search).set_round_trip_input(mod.rs:3085) is what makes single-key bindings work on a semantic/GPU frontend: while a marked buffer is activedispatch_idlereports false, so optimistic-apply stays off anddreaches the binding instead of landing as a CRDT insert. The same gate (dispatch_idle_for,editor.rs:818) also disables optimistic apply for any focused side window (!window.is_side(), Q#BP14a), so a panel-displayed dired is covered twice.- There is no real
read_onlybuffer flag — a standing backlog item (docs/side-quest-backlog.md, cross-cutting substrate). The intercept idiom is what every generated buffer uses today.
Minibuffer completion
- A
sourcefunction is re-called on every keystroke (recompute_candidates,minibuffer.rs:361) but invoked asf.call(())— zero arguments (:591).pmacs.minibuffer.contents()exists, but the callback runs synchronously from Rust dispatch, outside anypmacs.asynccoroutine, andHandle:await()explicitly raises there (async.lua:76-79). There is no synchronous directory listing in Lua, so a function source cannot descend into directories. CompletionSource::Files { root }exists Rust-side (minibuffer.rs:589→list_directory(root)), reached from Lua assource = "files"withsource_root(mod.rs:13286). It is flat, single-directory, capped at 1024 candidates, and currently used by nothing outside a unit test.- Free text accepts:
resolve_accepted_value(minibuffer.rs:564) returns the raw typed string when no candidate is selected.
Buffers, modes, keys
- Mode-scoped keymaps exist since #129:
pmacs.keymap.bind { scope = "mode", mode = "<name>", … }(mod.rs:13432), resolving buffer-local → mode → global.syntax.lua:497is the onlyset_major_modecaller inbuiltin/, firing onbuffer.after-load; abuffer.created dired buffer has no path and fires noafter-load, so nothing contends. find_or_openon a directory reachesfile_io::load_file→ EISDIR. Dired must dispatch onentry.kinditself.- Keybinding space.
C-x d,C-x C-j,C-x C-f, andC-x C-qare unbound repo-wide.C-x C-ris bound to recent-files inrecentf.lua:85(not the default keymap).C-c <letter>is fully taken by LSP (lsp.lua:2248-2256);C-c @is the folding prefix (fold.lua:48-52);C-c C-kis bound buffer-locally by compile (compile.lua:231) and async, not globally.
3. Where dired lives (Q#DR1)
A builtin runtime module, builtin/runtime/dired.lua, written fresh.
The M8 fixture stays exactly where it is, frozen.
Builtin rather than a package, because a surface that is the primary way
to open a file cannot be gated on the user installing something, and
because builtin modules get the load-order and config-registry guarantees
a package does not (the pair.lua-before-lsp.lua precedent).
Why the fixture is not promoted. Rev 1 argued the 47 tests pin the package system and would be voided by re-pointing. That was false (F1) — 45 of them assert dired behavior, and behavior transfers. The honest argument is narrower and rests on three things:
- The harness route is itself the proof. 46 of 47 tests reach dired
through
install_local+require, and that routing — a third-party package, in its own environment, driving buffers, intercepts, marks, commands, and keymaps — is the M8 universality claim. A builtin loaded by the runtime demonstrates nothing about packages. Re-pointing the suites keeps every behavioral assertion and silently deletes the claim they were written to support. - The builtin diverges structurally, so the tests cannot transfer
verbatim anyway: the mark column (Q#DR4) shifts every column offset the
wdired tests hardcode through
_test.NAME_START; mode-scoped keys (Q#DR8) replace the buffer-localRET/Backspacebindsdired_ret_and_backspace_keybindings_navigatedrives; buffer-per-directory (Q#DR2) replaces the in-place repaint thatdired_parent_command_navigates_up_one_levelasserts; and the wholeM._testseam is package-shaped. - The behavior gets re-pinned regardless, by the builtin's own acceptance (§14), which is where those 45 assertions are owed a home.
The cost is real: roughly 900 lines of rendering, escaping, and commit logic will exist in two places. The mitigation is that the fixture is frozen — a proof artifact, not a maintained feature, already fully pinned.
Named follow-up, scheduled rather than conditional (F1 corollary). Because the fixture's package-system value concentrates in exactly one test plus the harness routing, shrinking it to the minimum payload that still proves universality is cheap — far cheaper than rev 1 implied. It is scheduled after Stage 3, when the builtin owns every behavior the 45 tests currently cover, not left to "if drift shows up".
The fixture remains a reference for the parts that were hard — escaping with an exact inverse, two-phase rename, external-change detection, the symlink rules — and those carry over as decided design, not as re-litigated questions.
4. Buffer model and navigation (Q#DR2)
One buffer per directory, found-or-created by canonical name; navigation opens the target's buffer rather than mutating the current one.
This is Emacs's actual behavior (dired-find-file on a directory yields a
dired buffer for that directory), and it answers the fixture's stale
buffer-name defect without adding a pmacs.buffer.set_name binding:
nothing is ever renamed, because a buffer's name always describes the
directory it was created for. It also makes C-x d on an
already-visited directory and C-x C-j dedup for free.
Canonicalization (F6). /tmp, /tmp/, and /tmp/../tmp are all
absolute and would otherwise mint three buffers. The rule is lexical
normalization before naming and before lookup: expand a leading ~,
absolutize, collapse . and .., strip a trailing slash except at root.
Symlinks are deliberately not resolved — Emacs parity, and resolving
them would make .. from a symlinked directory jump somewhere the user
did not navigate from. The core already has exactly this shape in
normalize_buffer_path (editor_core.rs:4790); it is not Lua-exposed,
so Stage 1 either mirrors it in Lua or exposes it.
A mirror is a second implementation of a canonical form, and needs a
parity pin (R2-4). This is the tab-width-constants class in miniature:
if the Lua mirror and the Rust normalizer disagree on an edge — //tmp
(POSIX gives a leading double slash implementation-defined meaning), ~
with HOME unset, root's trailing slash, a .. that would escape root —
then dired's name-dedup and display_file's find_buffer_for_path dedup
diverge silently: two buffers for one directory, no error anywhere.
So whichever route Stage 1 takes, it carries a parity acceptance that
drives both implementations over one shared edge-case list and asserts
identical output (acceptance 3b).
Rev 2's "avoids a binding whose only caller is dired" also undercounts:
Q#DR5's rename fix touches the same normalizer, so a Lua-exposed
pmacs.path.canonicalize (or equivalent) has at least two consumers by
Stage 2. Exposing it and deleting the mirror is therefore the better
end state; Stage 1 may still mirror if exposure turns out to drag in
EditorCore borrow plumbing it does not otherwise need, but the parity
acceptance is required either way, and the mirror is then a named
Stage 2 removal, not a permanent duplicate.
Ownership check (F7). pmacs.buffer.create takes any caller-chosen
name, so a foreign buffer named *dired:/tmp* would be "found" by name
and then painted into through bypass_intercept. Found-by-name must
confirm the buffer is dired-owned — present in dired's handle table, or
pmacs.buffer.major_mode(buf) == "dired" — and otherwise create a fresh
buffer under a disambiguated name rather than clobbering it.
Buffer name: *dired:<canonical absolute path>*.
The cost is buffer accumulation when walking a deep tree. Emacs users live with this; Emacs 28 added an opt-out, and we mirror it as a config key rather than a hardcoded policy:
dired.kill-when-opening(boolean, defaultfalse, live) — when true, descending or ascending kills the dired buffer being left.
5. Read-only discipline and the wdired seam (Q#DR3)
The dired buffer is read-only by the listview idiom — an add_intercept
that rejects every non-bypass edit, plus set_round_trip_input(buf, true) so a GPU session's optimistic-apply cannot swallow single-key
bindings — and dired's own paints use bypass_intercept = true.
Dired owns its buffer directly; it is not built on pmacs.listview.
Listview would have to grow a keymap extension point and a removable
read-only mode, and three shipped panels depend on it. Bending a module
into a shape its existing callers do not need is exactly the change class
that took CI red in #155: scoping pmacs.window.buffer()'s no-arg arm
"for consistency" made a total function partial and silently dropped
edits from six unpcall'd runtime callers. Dired reuses listview's
disciplines, not its code. Factoring the shared disciplines into a
common helper is a named follow-up, to be done once dired's real shape is
known rather than predicted.
Wdired (Stage 3) is a mode swap, not a flag. C-x C-q removes the
read-only intercept, installs the column-classifying one, and calls
set_major_mode(buf, "wdired"); commit and abandon restore both. Because
keys are mode-scoped (Q#DR8), the entire keymap changes with the mode —
m means "mark" in dired and means "type an m" in wdired, with no
per-key bookkeeping.
Wdired refuses to open on a partially-listed directory. If the listing carried any per-entry error (Q#DR6), rename and chmod are refused with that reason: a rename batch is planned against a snapshot, and you cannot safely plan against a directory you could not fully see.
6. Marks (Q#DR4)
Emacs's mark column is column 0, so every other column shifts right by
two (" " or "* "). The builtin computes its offsets from the
constants rather than inheriting the fixture's PERMS_START = 1 /
NAME_START = 39.
Marks are keyed by basename, never by line index. A sort, a revert,
or an external change reorders lines; a line-indexed mark set would
silently retarget onto a different file — the same class of defect as
keying kill-ring state by index rather than by stable id (the Arc 2
substrate rule). *buffer-list* keys its deletion marks by buffer id
(builtin/commands/default.lua:507) for the same reason.
Two mark characters, following Emacs: * (general mark, consumed by
operations) and D (deletion flag, consumed by x). A basename that
disappears between marking and executing is dropped from the batch and
reported, not silently skipped.
7. Operations and the missing primitives (Q#DR5)
Stage 2's operations need three filesystem primitives that do not exist, and one correctness fix.
New pmacs.fs ops (worker-dispatched, matching the existing five;
mutating ops take no supersede, per fs.lua:101-124):
pmacs.fs.mkdir(path, opts)—opts.parentsforcreate_dir_all.pmacs.fs.copy(from, to, opts)— regular files in v1; a directory source is refused rather than silently shallow-copied. Preserves mode bits;opts.overwritedefaults false and the op refuses an existing target otherwise.pmacs.fs.remove_dir_all(path)— separate fromremoverather than a flag on it, so a recursive delete can never be reached by a caller that meant the single-object op.
The rename rule — decided, with the semantics widened (F4). A rename
must rebind open buffers, and it must do so at the primitive:
pmacs.fs.rename has zero production callers, so changing its
contract breaks nobody, and leaving the trap armed guarantees the next
caller rediscovers it the expensive way. The rebind is:
-
prefix-aware, not exact-match.
apply_resource_op'sfind_by_path(buffer_registry.rs:168-174) is exactPathequality, first match only — which strands every buffer beneath a renamed directory, andRon a directory line is an ordinary dired operation. The reconcile rebinds the renamed path itself and every buffer whose path has it as a path-component prefix. -
normalize-before-lookup. Stored paths are normalized on write (
editor_core.rs:819) and the normalizing wrapperfind_buffer_for_path(:864-867) already exists;apply_resource_opbypasses it with a raw lookup (mod.rs:3248), which is a latent miss today. The new path goes through the wrapper.apply_resource_op's own raw lookup is fixed in the same change — it is the same bug, one call site away. -
in the main-thread completion drain,
AsyncRuntime::tick(async_runtime.rs:991), unconditionally on success — not where results are consumed. This is the load-bearing half of the decision (R2-1). The fs ops are fire-and-forget-capable: nothing obliges a caller toawaitor attachon_complete, so a rebind hung off_take_result(mod.rs:6758) would miss every rename whose handle is never taken — the rename lands on disk, the buffer is never rebound, and the trap survives one layer below where we thought we fixed it. An acceptance that awaits the rename would pass throughout, so Stage 2's acceptance includes a no-await rename and bites against the drain.Two facts make this non-obvious to implement. Rename settles as an undifferentiated
ReplyKind::FsUnit— the same replychmodandremoveproduce; there is noRenamevariant, and the drain arm mapsSleep | FsUnitalike toJobResult::Unit(async_runtime.rs:1022-1025). So the drain cannot key on the reply; it must key on the pending job's ownJobKind::FsRename. And the from/to paths live only in the dispatch call today, so the pending job must retain them for the drain to have anything to rebind with. Both are additive toasync_runtime.rs; neither changes the wire or the worker contract.
v1 supports R on a directory — that is precisely what prefix-aware
rebinding buys, and refusing it while C refuses directory sources for a
different reason (no recursive copy primitive) would be an arbitrary
asymmetry. Stage 2's acceptance pins the directory case explicitly.
Confirmation. Destructive operations (x, D, recursive delete,
overwriting copy) prompt. There is no y_or_n helper — a named
autosave-arc deferral — so Stage 2 adds one rather than repeating
autosave.lua:219's two-element minibuffer.read at four call sites.
8. Tolerant listing — the Stage 1 Rust change (Q#DR6)
read_dir grows a per-entry error channel behind an opt. The Lua
result under { tolerant = true } becomes:
{ entries = { <entry>, ... }, errors = { { name = "..." | nil, message = "..." }, ... } }
with per-entry failures recorded and enumeration continuing. Errors on
the parent read_dir itself stay fatal — a directory you cannot open
has no partial answer. Dired renders a footer line (N entries unreadable) and refuses wdired (§5).
name is optional (R2-2). A per-entry readdir iterator error
(fs.rs:215-218) carries no filename — the entry never materialized, so
there is nothing to name, and the error is wrapped with the parent
path. That arm reports name = nil; the footer counts it without naming
it. Every other per-entry arm has an entry in hand and names it.
What moves into the per-entry channel (F5): per-entry readdir
errors, symlink_metadata failures, read_link failures
(fs.rs:228-234), and non-UTF-8 symlink targets (:227). A
non-UTF-8 target differs in kind from a non-UTF-8 name: the entry's own
name is fine, the listing renders it with the target shown as unknown,
and nothing needs to pass the target back through rename. As it stands
today, one weird symlink in /tmp kills the entire listing — the exact
failure class this section exists to fix.
Non-UTF-8 names stay fatal, and are a named deferral. Rendering them
tolerantly is not a listing problem but a path representation problem:
FsDirEntry.name is String, every pmacs.fs op takes a String path,
and src/fs.rs:152-155 names byte-preserving paths as post-v0.1 work
that widens the whole surface. Doing it properly changes the type of
every path in the API; doing it improperly hands dired a name it cannot
pass back to rename. Stage 1 reports the directory as unlistable with
the offending bytes named, which FsError::NonUtf8Path already carries.
Why an opt rather than a shape change. read_dir already takes
(path, opts), so it is signature-natural; it keeps the change additive
for third-party packages; and it leaves the frozen fixture's bare-array
consumption (init.lua:312) untouched, which matters because a proof
artifact that must be edited to accommodate new work is not frozen.
The opts parser must validate (minor c). supersede_key
(fs.lua:73-83) reads only opts.supersede and silently ignores every
other key, so a typo'd tolerant would degrade to fatal mode with no
signal. The tolerant change adds unknown-key rejection to the read ops'
opts parsing.
Already tolerated, for the record: a failing metadata.modified()
yields mtime 0 rather than an error (fs.rs:463-476), so the per-entry
channel is not the first such concession — it is the first explicit one.
9. Keybindings, display, and the major mode (Q#DR7, Q#DR8, Q#DR10)
Global (both unbound repo-wide):
C-x d→dired— prompt for a directory, defaulting to the current buffer's directory. Takes the standarddisplay = "current" | "panel"opt (Q#BP11b), defaulting to"current"in Stages 1–2 like every other adopter.C-x C-j→dired-jump— dired on the current buffer's file's directory, cursor seated on that file.
Visit routing (Q#DR10, F2). A RET on a file line goes through
pmacs.window.display_file(path, { select = true }), never bare
find_or_open. find_or_open switches the active window in both
branches before firing hooks (window_panel.rs:373-376), so a RET in a
panel-displayed dired would replace the panel with the visited file —
the panel swallows itself. display_file is the Q#BP11b answer: it dedups
side-effect-free through the normalizing find_buffer_for_path
before any I/O, resolves the destination before the read, and is what LSP
visits and compile already use. Underneath, its panel-safety comes from
display_target (window_panel.rs:425) — "the non-side window a visit
from a panel should address".
Directory descent routes differently, and deliberately (R2-3). A
RET on a directory line replaces the dired buffer in the window
dired already occupies: switch_buffer when dired is in a document
window, and pmacs.window.display(buf, { side = <same side>, select = true }) when dired is panel-displayed. This is Emacs behavior — walking
a tree in a side window keeps the side window — and it is the opposite
routing from a file visit for a principled reason: a file is not a
dired buffer and belongs in the document area (hence display_target),
while the next directory is the same kind of thing as the current one
and belongs in the same slot. Dedication is a property of the slot, not
the buffer, so a dedicated dired panel stays dedicated across descent
and the new dired buffer inherits it; Stage 1's acceptance pins that
rather than assuming it, since it is a Layout/WindowParams behavior
this framing does not otherwise touch.
Mode-scoped on dired (Q#DR8: scope = "mode", mode = "dired",
bound once at load rather than per buffer — dired is the first real
consumer of #129's mode keymaps beyond language detection):
| Key | Command | Stage |
|---|---|---|
RET, f |
visit (dir → descend, file → display_file) |
1 |
^ |
parent directory | 1 |
n / p, <down> / <up> |
move by line | 1 |
g |
revert (re-read, preserve cursor and marks) | 1 |
q |
quit (restore previous buffer / window.quit in a side window) |
1 |
s |
cycle sort mode (name → mtime → size) | 1 |
m / u / U / t |
mark / unmark / unmark-all / toggle | 2 |
d / x |
flag for deletion / execute flagged | 2 |
D |
delete now (confirms) | 2 |
R / C / + |
rename / copy / mkdir | 2 |
w |
copy filename to the kill ring | 2 |
C-x C-q |
toggle wdired | 3 |
Mode-scoped on wdired: C-c C-c commit, C-c C-k abandon —
matching compile's buffer-local C-c C-k idiom without colliding with
it, since dired buffers are never compilation buffers.
Everything follows the M-; / M-% / C-c @ precedent of shipping the
faithful Emacs default; users rebind through pmacs.keymap.
Cursor preservation (Q#DR9) is a Stage 1 requirement, not a nicety:
g, a sort, and every Stage 2 operation repaint wholesale, and a dired
that drops you to line 0 after each mark is unusable. The cursor is
re-seated by basename, falling back to the nearest surviving line
index when the file is gone — pmacs.editor.move_to_line
(mod.rs:12526) makes this exact rather than the move_down-in-a-loop
walk listview.lua:68-74 uses.
10. Staging and scope
- Stage 0 (separable, recommended first) —
find-file(Q#DR11).C-x C-f→pmacs.minibuffer.readwithsource = "files"andsource_rootset to the current buffer's directory, accepting free text (resolve_accepted_value,minibuffer.rs:564) intopmacs.window.display_file. Completion is flat and does not descend: a function source cannot list a directory (it is called with zero arguments and cannotawait, F3), and the RustFilessource is single-directory and 1024-capped. Typing a full path still works via free-text accept; typing a prefix completes only within the root. Hierarchical completion is a named Rust change (§13) — either pass the current input to custom sources, or re-root theFilessource per keystroke. A nonexistent path creates a[new file]buffer rather than erroring:display_fileroutes throughresolve_target_buffer(editor_core.rs:885-898), which onErrorKind::NotFoundcreates the buffer, binds the path, and sets that status. This is Emacs parity and is stated rather than inherited (R2-6). Stage 0 carries its own acceptance (§14) — it is small but no longer trivial to describe honestly, which is itself an argument for taking it as its own PR. It is not required by any later stage; Stage 1'sRETopens files directly. Say if you want it folded into Stage 1 instead; it is one branch either way. - Stage 1 — the dired view. Approval-critical.
builtin/runtime/dired.lua; thediredmajor mode and mode keymap; buffer-per-directory with canonical naming and the ownership check; read-only intercept + round-trip input; visit routing throughdisplay_file; parent / sort / revert / quit;C-x d(with thedisplayopt) /C-x C-j; cursor preservation across repaint; thedired.kill-when-openingconfig key; and the tolerantread_diropt plus its unknown-key validation (Q#DR6) — the only Rust in this stage. No wire change; no protocol bump. - Stage 2 — marks and operations. The mark column and basename-keyed
mark set;
m/u/U/t/d/x/D/R/C/+/w;pmacs.fs.mkdir/copy/remove_dir_all; the prefix-aware, normalized rename rebind in the completion drain and the matchingapply_resource_opraw-lookup fix (Q#DR5), pinned by a no-await rename and by a directory rename that must not strand the buffers beneath it; ay_or_nconfirm helper; and removal of the Lua canonicalization mirror if Stage 1 shipped one (Q#DR2). - Stage 3 — wdired.
C-x C-qmode swap; the column-classifying intercept over the mark-shifted layout; escape/unescape round-trip; duplicate-name and NUL/slash rejection pre-syscall; two-phase rename; field-by-field external-change detection; the symlink perms and symlink target rules; partial-application reporting. - After Stage 3 — shrink the M8 fixture to the minimum payload that still proves package universality (§3).
Stages 2 and 3 are sketched here and each gets its own detailed framing after the prior stage lands, per the folding-arc precedent. This framing asks approval for the architecture and Stage 1's detail.
11. Numbered decisions
- Q#DR1 Dired ships as
builtin/runtime/dired.lua, written fresh; the M8 fixture stays frozen undertests/fixtures/because the harness routing (46/47 tests reaching dired throughinstall_localrequire) is the universality proof and dies if re-pointed, and because the builtin diverges structurally. Its 45 behavioral assertions are re-pinned by §14. Shrinking the fixture is scheduled after Stage 3. (§3)
- Q#DR2 One buffer per directory,
*dired:<canonical abs path>*, found-or-created by name; navigation opens the target's buffer rather than renaming the current one (there is nopmacs.buffer.set_name). Names and lookups are lexically normalized (tilde, absolutize,./.., trailing slash) with symlinks deliberately unresolved; found-by-name verifies dired ownership before painting. A Lua mirror ofnormalize_buffer_pathis a second canonical form and requires a parity acceptance against the Rust normalizer; exposing the normalizer instead is the preferred end state, since Q#DR5 gives it a second consumer.dired.kill-when-opening(defaultfalse) mirrors Emacs 28's opt-out. (§4) - Q#DR3 Read-only via
add_intercept+set_round_trip_input, with dired's own paints usingbypass_intercept; dired owns its buffer and does not extendpmacs.listview; wdired is a major-mode swap, and refuses to open on a partially-listed directory. (§5) - Q#DR4 Mark column at column 0 shifts all offsets; marks are keyed
by basename, never line index;
*andDare the two mark characters; a vanished basename is dropped from a batch and reported. (§6) - Q#DR5 Stage 2 adds
pmacs.fs.mkdir/copy/remove_dir_all. Rename rebinding is fixed at the primitive (zero production callers to break), prefix-aware (a directory rename must not strand the buffers beneath it), normalize-before-lookup (fixingapply_resource_op's rawfind_by_pathin the same change), and in the main-thread completion drainAsyncRuntime::tick— never in the take/await path, which a fire-and-forget rename never reaches. Because rename settles as an undifferentiatedReplyKind::FsUnit, the drain keys on the pending job'sJobKind::FsRenameand the job retains from/to.Ron a directory is supported in v1;Crefuses directory sources. Destructive ops confirm via a newy_or_nhelper. (§7) - Q#DR6
read_dirbecomes per-entry tolerant behind an opt ({ tolerant = true }), carrying per-entryreaddir/lstat/readlinkfailures and non-UTF-8 symlink targets in anerrorschannel; parent-level failures stay fatal; non-UTF-8 names stay fatal, deferred to a byte-preserving path surface. The read ops' opts parsing gains unknown-key rejection. (§8) - Q#DR7 Emacs-parity bindings: global
C-x d(with thedisplayopt) /C-x C-j; mode-scoped in-buffer keys per the §9 table; wdired onC-x C-qwithC-c C-c/C-c C-k. (§9) - Q#DR8 Keys are mode-scoped (
scope = "mode", mode = "dired"), not buffer-local — bound once at load, and the wdired swap changes the whole keymap with the mode. Dired is #129's first non-detection consumer. (§9) - Q#DR9 Cursor is re-seated by basename after every repaint,
falling back to the nearest surviving index, via
pmacs.editor.move_to_line. (§9) - Q#DR10 File visits route through
pmacs.window.display_file(panel-safe viadisplay_target), never barefind_or_open, which switches the active window before hooks and would let aRETreplace the panel dired is displayed in. Directory descent instead reuses dired's own window —switch_bufferin a document window,display { side = <same side>, select = true }in a panel — because the next directory is the same kind of thing as the current one. Dedication is a slot property and follows across descent. (§9) - Q#DR11 Stage 0's completion is the flat Rust
Filessource rooted at the current buffer's directory, plus free-text accept. Function sources cannot descend (zero-argument call, noawaitin the dispatch context); hierarchical path completion is a named Rust deferral. Amended by S0-1: the two are not independent — a selected candidate shadows typed text, so free-text accept is reached only when the input filters every candidate away (in practice, when it contains a/). The prompt field therefore starts empty (S0-2), and a leading~is expanded Lua-side (S0-3). (§0, §10)
12. Bets
- B1 The fixture's hard parts — escaping with an exact inverse, two-phase rename, field-by-field external-change detection, the symlink rules — transfer to the builtin as decided design. FALSIFIABLE at Stage 3: if the mark-shifted layout or the mode swap forces a different commit model, the fixture stops being a reference and Stage 3 is re-framed from scratch.
- B2 Tolerant
read_diris the only Rust change Stage 1 needs —display_file, mode keymaps,move_to_line, andset_round_trip_inputall already exist. FALSIFIABLE during implementation; the most likely miss is scroll preservation, since the daemon ownsview_topand "viewport facts on the wire" is a standing backlog gap — if preserving scroll (not just cursor) across a repaint needs a new fact, Stage 1 grows. - B3 Mode-scoped single-key bindings survive both frontends, because
set_round_trip_inputkeeps optimistic-apply off — and, when dired sits in a panel, the!window.is_side()arm of the same gate (editor.rs:818) covers it a second time. FALSIFIABLE on a real GPU session: pressingdmust flag, never insert. - B4 Buffer-per-directory does not produce clutter users complain
about (Emacs parity), and
dired.kill-when-openingis a sufficient escape hatch. Falsifiable only by use. - B5 Flat, non-descending completion is acceptable for Stage 0 because free-text accept covers the full-path case. FALSIFIABLE immediately by use: if typing full paths blind is what people actually do, hierarchical completion stops being a deferral and becomes Stage 0's real scope.
13. Deferred (named)
- Hierarchical path completion — pass the current minibuffer input to
custom sources, or re-root
CompletionSource::Filesper keystroke (Q#DR11). - Typed text vs. a selected candidate on accept (S0-1) — prefer the
typed contents when they differ from the selection and the user has not
explicitly moved it. Closes Stage 0's "a new bare name that is a
subsequence of an existing entry opens the existing file" hole, but
changes
M-xandswitch-bufferaccept semantics too, so it needs its own reasoning and gates. It would also close the empty-input case (S0-4):fuzzy_scorereturnsSome(0)for an empty needle (minibuffer.rs:637-640) andfilter_and_sortbreaks ties lexicographically (:678), so accepting immediately opens the first-sorted entry — dotfiles first, and possibly a directory, which then fails and reports. Inherited from the shared minibuffer, not introduced by find-file, and recorded as decided rather than overlooked. - Minibuffer history stores the pre-join value (S0-5) —
Minibuffer::acceptpushes the resolved value (a bare basename, or a root-relative path) into the history bucket Rust-side, before Lua joins it onto the root. So recallingsub/inner.txtwithC-punder a different root resolves against the new root, and can silently create a[new file]buffer somewhere else. Emacs'sfile-name-historystores absolute paths. Lua cannot fix this — the push happens beforeon_acceptruns — so it belongs with the other Rust-side minibuffer deferrals here. - Load through the normalized path (S0-3) —
get_or_load_buffercomputes a normalized path and then loads from the raw one (editor_core.rs:842-856), so tilde paths dedup but do not load. Same normalize-before-lookup family as Q#DR5'sapply_resource_opfix. - Non-UTF-8 filenames — needs byte-preserving
pmacs.fspaths (src/fs.rs:152-155); widens every path in the API. (Non-UTF-8 symlink targets are handled in Stage 1, §8.) - Shrinking the M8 fixture — scheduled after Stage 3 (§3).
- Factoring the shared panel disciplines out of dired and
pmacs.listview(§5). o/C-ovisit-in-other-window — the GPU has no splits (GPU structural parity, roadmap Arc 8).!/&shell command on marked files;Qquery-replace across marked files;Asearch across marked files.iinsert-subdirectory (in-buffer recursive listing) anddired-hide-details.- Owner and group columns — no uid/gid → name primitive exists.
- Human-readable sizes; sort by extension; reverse-sort toggle.
%m/%dregex mark family;dired-omit-mode.- Recursive copy (
Con a directory), which v1 refuses. - Auto-revert on external change —
pmacs.fs.watchexists and polls (fs.lua:226), so this is wiring plus a policy decision about polling a directory the user is not looking at. - Dired buffers in the desktop session — covered by Arc 3's standing "non-file buffers in the desktop" deferral.
- Remote / Tramp-style paths.
- Symlink creation and symlink-target editing (the fixture rejects target
edits at commit;
init.lua:821-840).
14. Acceptance
Stage 0 — find-file (R2-6)
0a. Flat completion within the root. With the current buffer in a
temp directory, C-x C-f offers that directory's entries as
candidates and does not offer entries of a subdirectory —
documenting the flat-source limitation as intended behavior rather
than leaving it unpinned.
0b. Free-text accept of a deeper path. Typing a full path below the
root and accepting opens that file, with no candidate selected
(resolve_accepted_value's raw-input path).
0c. Nonexistent path creates. Accepting a path that does not exist
yields a buffer bound to it, unmodified and empty, with the
[new file] status — not an error.
0d. No-path origin. From a buffer with no backing path, the prompt
roots at the process cwd rather than erroring or offering nothing.
Stage 1 — the dired view
- Listing shape.
C-x don a temp directory renders a header line plus one line per entry, with kind char, perms, size, mtime, and name; a symlink renderslwith-> target; the entry count matchesread_dir. - Visit dispatches on kind, through the panel-safe primitive
(Q#DR10).
RETon a subdirectory line opens that directory's dired buffer;RETon a file line opens the file bound to its path (the fixture's "not yet exposed" error is gone).RETon the header does nothing. The panel case is the real assertion: with dired openeddisplay = "panel", aRETon a file line leaves the dired panel alive and puts the file in the document window — falsified by swappingdisplay_fileforfind_or_open, which must make the panel disappear. - Buffer-per-directory and canonicalization (Q#DR2). Descending
twice then ascending twice yields the same buffer ids as the first
visit; every dired buffer's name matches the directory it displays;
and
C-x don/tmp,/tmp/, and/tmp/../tmp(with a real temp dir) yields one buffer, not three. Withdired.kill-when-opening = true, the departed buffer is gone. 3b. Canonicalization parity (R2-4). One shared edge-case list —//tmp, a trailing slash,~withHOMEset and unset,./..segments including a..that would escape root, a relative path — driven through both dired's canonicalizer and the Rustnormalize_buffer_path, asserting identical output. If Stage 1 exposes the normalizer instead of mirroring it, this degenerates to a round-trip test and the mirror-removal follow-up is dropped. 3c. Panel descent (Q#DR10, R2-3). With dired openeddisplay = "panel",RETon a directory line leaves dired in the same side window showing the new directory — the panel is neither replaced by a document window nor duplicated — and a dedicated panel is still dedicated afterward. - Ownership check (Q#DR2, F7). A foreign
pmacs.buffer.createbuffer named exactly*dired:<path>*is not adopted:C-x don that path leaves the foreign buffer's contents byte-identical and opens dired elsewhere. - Read-only (Q#DR3). A
buffer.self-insertinto a dired buffer is rejected by the intercept and leaves the text byte-identical; dired's own repaint succeeds throughbypass_intercept.set_round_trip_inputis set, pinned through the real dispatch path so a semantic frontend'sdreaches the binding rather than optimistic-applying — falsified by reverting theset_round_trip_inputcall, not by a direct-call assertion. - Mode keymap (Q#DR8). The keys resolve through
scope = "mode"with no per-buffer binding: a second dired buffer, created without anykeymap.bindcall of its own, still responds togand^.pmacs.buffer.major_mode(buf)is"dired", and the mode shows in the statusline. - Cursor preservation (Q#DR9). With the cursor on entry
k,gre-seats on the same basename after an external file was added above it (so the line index changed); when that basename is deleted externally, the cursor lands on the nearest surviving line, not line 0. - Sort.
scycles name → mtime → size → name; mtime sorts newest first and size largest first, each with a stable name tiebreak; the cursor stays on its basename across the reorder. - Tolerant listing (Q#DR6). In a directory containing a child whose
lstatfails,{ tolerant = true }returns the surviving entries plus oneerrorsrow naming the child; dired renders every readable entry plus the unreadable-count footer; and the default (non-opt) call still returns a bare array — both forms called in one test, so the fixture's contract cannot regress unnoticed. A failure on the parent directory itself is still fatal. - Tolerant symlink targets (Q#DR6, F5). A directory containing a
symlink whose target is non-UTF-8 lists successfully under
{ tolerant = true }, with that entry present and its target reported unknown — falsified by reverting theread_link/target arm, which must take the whole listing down. - Unknown opts rejected (minor c).
read_dir(path, { tolerat = true })errors naming the unknown key rather than silently listing in fatal mode. - Non-UTF-8 names stay fatal, and say so. A directory containing a
non-UTF-8 name reports the structured
NonUtf8Patherror with the offending bytes; dired surfaces it as a status message and creates no buffer. dired-jump. From a file buffer,C-x C-jopens dired on that file's directory with the cursor on that file's line. From a buffer with no path, it reports that and creates nothing.- Quit.
qrestores the previously active buffer; in a side window (display = "panel") it routes throughpmacs.window.quit, matchinglistview.quit's Q#BP11b split. - Failure leaves nothing behind.
C-x don a nonexistent or unreadable directory creates no buffer, switches no window, and reports the reason — the fixture'sdired_open_failure_leaves_editor_unchangedinvariant. - Scale. A 10,000-entry directory renders within the fixture's
established 200 ms budget, on the builtin path — carrying the same
cfg_attr(target_os = "macos", ignore)gate the fixture's version uses (m8_2_acceptance.rs:211-213), since hosted macOS debug runners do not consistently satisfy it. - The fixture still passes.
m8_2_acceptance15/15 andm8_3_acceptance32/32 unchanged, proving theread_diropt is additive.
Every behavioral claim above is bite-verified with scripts/bite.
15. Gates (Stage 1)
cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings as its own step; cargo test --lib; cargo test --lib --features crdt; tests/dired_acceptance.rs (default + CRDT);
tests/m8_1_acceptance.rs, tests/m8_2_acceptance.rs, and
tests/m8_3_acceptance.rs (the additivity proof — m8_1 because it
exercises pmacs.fs.rename and read_dir directly); cargo test --test m4_acceptance -- --skip basedpyright; PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu; the workspace sweep with an isolated XDG_CONFIG_HOME
(the real ~/.config/pmacs/init.lua on this desktop calls
install_local, which races every editor the sweep builds and leaks a
status message into frame-comparing suites); git diff --check.
16. Branch and PR plan
Branch dired, worktree ../pmacs-dired-arc — not ../pmacs-dired,
which would read as the fixture. Based on canonical githubsucks/main
@ 0827dd1 (Lean 4 Stage 1 #160), which is one merge ahead of the
scout's e745068; see §2's base note for why that movement does not
disturb the ground truth.
The shared checkout is not the place to cut this. It currently has
lean4-stage1 checked out with in-progress foreign work
(src/highlight.rs modified), and this framing is untracked in it. Per
the §5 ops rule, the branch is cut as a sibling worktree off main
and the framing is committed there as the branch's first commit, rather
than by switching the shared checkout. The framing does not travel until
that commit is pushed.
Stage 1 implements on the same branch and opens as the first dired PR.
Stages 2 and 3 are separate branches and PRs off the main that results
from the prior stage, each with its own detailed framing. If Stage 0
(find-file) is taken separately it goes first, on its own branch
find-file, and Stage 1 rebases onto the resulting main.