1342 lines
77 KiB
Markdown
1342 lines
77 KiB
Markdown
# Dired — framing
|
||
|
||
**Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162;
|
||
Stage 1 IN REVIEW as PR #165, review round 1 addressed.**
|
||
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; rev 6 records what Stage
|
||
1's implementation falsified (§0, S1-1…S1-9); rev 7 adds what its first
|
||
review round found (§0, S1-10…S1-12). 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_unload` unregistration, `DuplicateName`, per-package
|
||
`require` scoping. Verified: `install_local` appears only in the shared
|
||
`editor_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_ceiling`
|
||
is a `lines < 1500` lint. 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).** `RET` must not use bare `find_or_open`.
|
||
`window_panel.rs:373-376` documents 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." A `RET` in a
|
||
panel-displayed dired would swallow the panel. Visits now route through
|
||
`pmacs.window.display_file` (new Q#DR10), which also dedups by
|
||
*normalized* path. §2 gains the window-primitive ground truth it was
|
||
missing entirely, and the `dired` command gains the `display` opt 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 any
|
||
`pmacs.async` coroutine, where `Handle: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 Rust `CompletionSource::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 to `find_or_open`.
|
||
- **F4 (design gap).** Q#DR5(a) as specced did not cover dired's own hard
|
||
case. `apply_resource_op`'s rebind uses `find_by_path`
|
||
(`buffer_registry.rs:168-174`) — **exact `Path` equality, first match
|
||
only** — so a *directory* rename strands every buffer beneath it, and
|
||
`R` on 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 wrapper `find_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.rename` has 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 through `rename`. Tolerant
|
||
mode now carries `readlink` failures 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/../tmp` would mint three buffers), and
|
||
found-by-name must verify **dired ownership** before painting into a
|
||
buffer through `bypass_intercept`. Both folded into Q#DR2.
|
||
- **Minors.** Arc number dropped; `C-x C-r` attributed to `recentf.lua:85`
|
||
rather than the default keymap; the tolerant opt must **validate**
|
||
unknown keys (`supersede_key`, `fs.lua:73-83`, silently ignores them, so
|
||
a typo'd `tolerant` would 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 failing `metadata.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 `await` or attach `on_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 drain** `AsyncRuntime::tick`
|
||
(`async_runtime.rs:991`) as the seam, unconditional on success — plus a
|
||
fact that makes the implementation non-obvious: **rename settles as an
|
||
undifferentiated `ReplyKind::FsUnit`**, the same reply chmod and remove
|
||
produce (`:1022-1025` maps `Sleep | FsUnit` alike to `JobResult::Unit`;
|
||
there is no `Rename` variant). The drain therefore cannot key on the
|
||
reply — it must key on the pending job's own `JobKind::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 `errors` row shape cannot always carry a name. A per-entry
|
||
`readdir` iterator error (`fs.rs:215-218`) has no filename — the entry
|
||
never materialized, and the error is wrapped with the *parent* path. §8
|
||
makes `name` optional for that arm; the footer counts it without naming
|
||
it.
|
||
- **R2-3.** §2's `pmacs.window` inventory listed five exports; there are
|
||
**eight** — also `display_target` (`:425`), `panel` (`:440`), and
|
||
`set_params` (`:544`). The omission was the relevant one:
|
||
`display_target` is "the non-side window a visit from a panel should
|
||
address", i.e. the mechanism behind `display_file`'s panel-safety. §9's
|
||
loosest sentence — directory descent in a panel-displayed dired, left as
|
||
"(or `display`, when dired was itself panel-displayed)" — is now
|
||
specified, with the dedication question answered.
|
||
- **R2-4.** The Lua mirror of `normalize_buffer_path` is 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`, `~` with `HOME` unset, root's trailing slash), dired's
|
||
name-dedup and `display_file`'s `find_buffer_for_path` dedup 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_file` routes through
|
||
`resolve_target_buffer` (`editor_core.rs:885-898`), which on
|
||
`ErrorKind::NotFound` **creates** 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 `Files` source
|
||
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_candidates` sets `selected = Some(0)` **whenever the
|
||
candidate list is non-empty** (`minibuffer.rs:372-377`), and
|
||
`resolve_accepted_value` (`:564-574`) returns the **selected candidate**
|
||
in preference to the typed contents. So typed text reaches `on_accept`
|
||
**only 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 by `tests/find_file_acceptance.rs`:
|
||
- the deeper-path case works (`sub/inner.txt` matches 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_text`
|
||
pins 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-x` and
|
||
`switch-buffer` too, and so is deliberately **not** made in Stage 0. It
|
||
joins the hierarchical-completion deferral in §13.
|
||
- **S0-4. Accepting on empty input opens the first-sorted candidate.**
|
||
A consequence of S0-1 with an empty needle: `fuzzy_score` returns
|
||
`Some(0)` for every entry (`minibuffer.rs:637-640`) and
|
||
`filter_and_sort` breaks 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-x` and `switch-buffer` share 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.** `accept`
|
||
pushes the resolved value into the history bucket **before** `on_accept`
|
||
joins it onto the root, so a `C-p` recall 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::open` on a directory succeeds; the read returns EISDIR), so the
|
||
command's `pcall` turns it into a status message instead of letting the
|
||
error escape mid-dispatch.
|
||
`find_file_accepting_a_directory_reports_instead_of_raising` pins it
|
||
through the real accept path and fails when the `pcall` is 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 calls `load_file` with the **raw** one (`:847`), so
|
||
a `~/…` path deduplicates against an already-open buffer (dedup goes
|
||
through the normalizing `find_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's `apply_resource_op`
|
||
correction.
|
||
|
||
### Stage 1 implementation notes (rev 5 → rev 6)
|
||
|
||
Implementing Stage 1 (PR #165) falsified four things the approved text
|
||
asserted and settled five it left open. Recorded here rather than only
|
||
in the code, per the rev-4 precedent.
|
||
|
||
- **S1-1. The normalizer is EXPOSED, not mirrored — so B2 is partly
|
||
false, in the direction Q#DR2 preferred.** Q#DR2 made the mirror
|
||
conditional (`Stage 1 may still mirror if exposure turns out to drag
|
||
in EditorCore borrow plumbing it does not otherwise need`).
|
||
`normalize_buffer_path` is a **free function** (`editor_core.rs`), so
|
||
exposure drags in nothing: it is now `pub` and reachable as
|
||
`pmacs.path.canonicalize`. Consequences, all deliberate: B2 ("tolerant
|
||
`read_dir` is the only Rust change Stage 1 needs") is false by one
|
||
small binding; acceptance 3b degenerates to the round-trip form the
|
||
framing described; and the Stage 2 mirror-removal follow-up **is not
|
||
owed** — there is no second canonical form to remove. The parity
|
||
acceptance is still carried, now as "the Lua binding and the Rust
|
||
function agree over one shared edge list", which is exactly the claim
|
||
a future re-mirroring would break.
|
||
- **S1-2. R2-3's dedication claim is falsified by the substrate.** It
|
||
read "a dedicated dired panel stays dedicated across descent and the
|
||
new dired buffer inherits it". `display_buffer` never replaces the
|
||
buffer in a slot dedicated to another one: it discards every
|
||
side-specific parameter and falls back to the document window (Q#BP3
|
||
2.iii), and the exact-window arm errors outright. Dired therefore does
|
||
**not** try to unpin the user's panel — which is also what Emacs's
|
||
`display-buffer` does with a dedicated window. Acceptance 3c is split:
|
||
a non-dedicated panel keeps the descent, and a dedicated one keeps its
|
||
buffer *and* its pin while the new directory appears in the document
|
||
window.
|
||
- **S1-3. Acceptance 3c cannot pin the descent ROUTING, and the test now
|
||
says so.** Dired holds the focus in its own panel, so a raw
|
||
`switch_buffer` lands in that same window and every 3c assertion holds
|
||
either way — the mutation is *vacuous* against it. Dedication is the
|
||
only thing that distinguishes `display { side = … }` from the raw
|
||
switch, so the dedicated-panel test is the discriminating pin. Found
|
||
by running the bite rather than by reading the test; the vacuity is
|
||
documented at the assertion instead of being left to be believed.
|
||
- **S1-4. Dired is the first builtin to bind a mode-scoped key, and one
|
||
pre-existing lib test assumed none existed.**
|
||
`describe_key_identifies_every_default_binding` iterated *every*
|
||
binding in the stack and asserted `pmacs.describe.key` resolves it
|
||
context-free, which held only while the modes table was empty. It now
|
||
sets the effective context per binding — and explicitly *clears* the
|
||
mode for a global one, because a mode left over from a previous
|
||
iteration legitimately shadows a global chord of the same name
|
||
(dired's `RET` shadows `edit.newline-and-indent`, which is the point
|
||
of the mode).
|
||
- **S1-5. `C-x d` deliberately takes NO completion source.** It is the
|
||
direct consequence of S0-1/S0-4: with a `files` source, RET on an
|
||
empty field opens whatever sorts first (the minibuffer selects
|
||
candidate 0 whenever the list is non-empty, and a selected candidate
|
||
shadows typed text), and RET-on-the-directory-you-are-in is exactly
|
||
the gesture `C-x d` exists for. The field is **prefilled** with the
|
||
current directory instead — Emacs's own shape here — and free text
|
||
always reaches `on_accept` because `CompletionSource::None` bypasses
|
||
candidate resolution entirely. Directory-name completion is what dired
|
||
itself replaces.
|
||
- **S1-6. Ownership is the handle table ALONE**, narrower than Q#DR2's
|
||
"present in dired's handle table, or `major_mode(buf) == "dired"`". A
|
||
foreign buffer that carries the mode *is* the case the check exists to
|
||
refuse, and a builtin's handle table cannot be lost the way a
|
||
reloadable package's can. Acceptance 4 sets the mode on the foreign
|
||
buffer to pin the stronger reading.
|
||
- **S1-7. The mark column ships in Stage 1, rendered blank.** Q#DR4 is a
|
||
Stage 2 decision, but reserving the two columns now means Stage 2 does
|
||
not move every offset and Stage 3's column-classifying intercept can
|
||
be written against constants that did not shift under it. The
|
||
constants are computed from the widths (the fixture hardcoded
|
||
`NAME_START = 39` and paid for it in every wdired test) and exported
|
||
as `pmacs.dired._layout` so acceptance cannot drift from them.
|
||
- **S1-8. A symlinked directory needs a probe, because kinds are
|
||
lstat-based.** Both `read_dir` and `stat` report a link as
|
||
`"symlink"`, so nothing in the entry says whether it points at a
|
||
directory. `RET` on a symlink therefore *tries* to list the target
|
||
(one extra syscall, on symlink lines only) and descends if that
|
||
succeeds, else visits it as a file. Q#DR10 specified only the
|
||
dir/file arms; this is the third.
|
||
- **S1-9. Interactive origin does not survive the await.** Every listing
|
||
is worker-dispatched, so the work after the first `:await()` resumes
|
||
inside `tick_async`, where `InteractiveCommandOrigin` is empty and
|
||
`pmacs.window.*` falls back to the **ambient** active frontend. Single
|
||
frontend: correct. Multi-frontend: a dired opened from peer B while A
|
||
is ambient would display for A. Not fixable from Lua (the display
|
||
surface takes no frontend argument) and named here rather than
|
||
discovered later.
|
||
|
||
### Stage 1 review round 1 (rev 6 → rev 7)
|
||
|
||
Three findings changed behavior; the rest were naming and comments. Each
|
||
fix is bite-verified against the test that names it.
|
||
|
||
- **S1-10. An ambient re-seat is not safe after an await.** `dired.revert`
|
||
painted its own buffer by name (safe) and then re-seated through
|
||
`pmacs.editor.move_to_line`, which moves whatever window is
|
||
**active** — so a user who switched buffers while the re-read was in
|
||
flight had an unrelated buffer's cursor moved to a line index
|
||
meaningful only in the dired listing. This is the buffer-level instance
|
||
of the hazard S1-9 named at the frontend level, and it generalizes: in
|
||
this codebase, *painting takes a buffer and seating takes the world*.
|
||
Any post-await cursor operation needs an active-buffer guard;
|
||
`open_directory` is exempt only because it displays the buffer first.
|
||
- **S1-11. The rendered columns are a contract, so precision yields to
|
||
width.** `%10d` overflowed at 10 GB (VM images, core dumps), widening
|
||
the size field and shifting mtime and name right on that line alone.
|
||
Cosmetically harmless today, but `_layout` is exported and Stage 3's
|
||
column-classifying intercept is planned against it, so a
|
||
contract-violating line now is a Stage 3 trap. `fmt_size` took
|
||
`fmt_mtime`'s shape: exact bytes while they fit, else a fixed-width
|
||
magnitude. Not the deferred human-readable column (§13) — the exact
|
||
count still renders right up to the point where it cannot.
|
||
- **S1-12. `open_directory`'s "changed nothing on failure" invariant is
|
||
reusable as a PROBE.** S1-8's symlink descent originally listed the
|
||
target to learn its kind and then opened it — two full listings of the
|
||
same directory. Because a failed open touches no editor state
|
||
(acceptance 15), the open itself is the probe: try the descent, fall
|
||
back to `display_file`. One read. The comment that claimed "one
|
||
syscall" for a full `read_dir` is corrected rather than left as a
|
||
cost claim nobody would re-check.
|
||
|
||
Also, on the tolerant channel (Q#DR6): a `readdir` iterator may keep
|
||
yielding errors without terminating, and **cancellation is not a backstop
|
||
for a dired listing** — it carries no supersede key, so nothing cancels
|
||
it. A consecutive-error cap now fails the listing the way an unopenable
|
||
directory fails, rather than accumulating error rows on a worker thread.
|
||
It is deliberately untested: faking a failing iterator would need the
|
||
walk generic over it, a refactor with no other consumer.
|
||
|
||
## 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`) does
|
||
`File::open` (which succeeds on a directory) then `read_to_end` →
|
||
EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s
|
||
create-a-`[new file]` arm never fires and the error escapes.
|
||
**That is the same mechanism Stage 0 pinned** in
|
||
`find_file_accepting_a_directory_reports_instead_of_raising` — where
|
||
the `pcall` turns 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 at `resolve_target_buffer`. This
|
||
framing does **not** claim the CLI path; it supplies the buffer a
|
||
directory should resolve *to*, and Journey Stage 1 should route
|
||
`pmacs .` into it rather than inventing a second directory surface.
|
||
- **Step 4, "understand the visible interface"** — marginally, via the
|
||
`dired` major 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, `RET` to visit (files open through
|
||
`display_file`, directories descend), sort modes, revert, quit,
|
||
`C-x d` / `C-x C-j`, a `dired` major mode with mode-scoped keys, cursor
|
||
preservation across refresh — plus the one Rust change Stage 1 needs, a
|
||
**per-entry-tolerant `read_dir`** (Q#DR6).
|
||
- **Stage 2 — marks and operations.** `m`/`u`/`U`/`t`, deletion flags
|
||
`d`/`x`, immediate `D`, `R` rename, `C` copy, `+` 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 binds
|
||
`RET` and `Backspace` **buffer-locally** at open (`:381-388`), paints by
|
||
wholesale `buf:replace` behind a `painting` passthrough flag
|
||
(`:294-302`), and keys per-buffer handles by linear scan over
|
||
`BufferIdLua.__eq` with 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`/`\xNN` filename
|
||
escaping with an **exact inverse** so a no-op commit cannot fire a
|
||
spurious rename (`:140-211`), field-by-field external-change detection
|
||
including `mtime_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 after
|
||
`set_init_complete`. One (`m8_2:1245`) is a source-line-count lint.
|
||
`install_local` appears only in the shared `editor_with_dired()` harness
|
||
as a setup precondition and in doc comments; `on_unload`,
|
||
`DuplicateName`, and per-package `require` scoping are asserted nowhere.
|
||
- **Two of its own stated limitations are now false.**
|
||
- `open-line` on a non-directory errors with "requires the
|
||
buffer-from-file API (not yet exposed)" (`:948-961`).
|
||
`pmacs.buffer.from_file` (`mod.rs:3054`) and `find_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`) and
|
||
`move_to_line` (`:12526`) landed with editops (#111).
|
||
- **A real defect in its model:** navigation mutates `handle.path` and
|
||
repaints, but the buffer was named `*dired:<path>*` at creation and
|
||
**there is no `pmacs.buffer.set_name`** — the `pmacs.buffer` table
|
||
exports exactly `create`, `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_name` exists Rust-side, unexposed.) So after one `RET` the
|
||
buffer name names a directory it is no longer showing. Q#DR2 answers this.
|
||
|
||
### The filesystem surface
|
||
|
||
- `pmacs.fs` is exactly five worker-dispatched ops — `read_dir`, `stat`,
|
||
`rename`, `chmod`, `remove` (the complete `_dispatch_fs_*` set) — plus a
|
||
Lua-side polling `fs.watch` (`builtin/runtime/fs.lua:226`). **No
|
||
`mkdir`, no `copy`, no symlink-create, no recursive remove.**
|
||
- **`read_dir` is all-or-nothing, and this is the load-bearing gap.**
|
||
`read_dir_blocking` (`src/fs.rs:201`) returns
|
||
`Result<Vec<FsDirEntry>, FsError>`. **Five** per-entry conditions fail the
|
||
**entire listing**: a per-entry `readdir` error, a failed
|
||
`symlink_metadata`, a failed `read_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:
|
||
1. a directory readable but not searchable (`r` without `x`) — `readdir`
|
||
succeeds, every child `lstat` fails;
|
||
2. a file unlinked between `readdir` and `lstat` — ENOENT, i.e. a plain
|
||
refresh of a busy directory (`/tmp`, a build tree) can just fail;
|
||
3. 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`).
|
||
- `read_dir` already takes `(path, opts)` and the opts parser
|
||
(`supersede_key`, `fs.lua:73-83`) reads only `opts.supersede` and
|
||
**silently ignores unknown keys** — signature-natural for Q#DR6's opt,
|
||
but a typo'd `tolerant` would degrade to fatal mode unnoticed.
|
||
- `chmod` **follows symlinks** (`src/fs.rs:370`; `fs.lua:104`) while
|
||
`read_dir`/`stat` use `lstat`. The fixture rejects symlink perms edits
|
||
at intercept time for exactly this reason (`init.lua:629-638`) — that
|
||
decision carries over unchanged.
|
||
- **`pmacs.fs.rename` has zero production callers** — only its own
|
||
definition (`fs.lua:126`), `m8_1`/`m8_3` acceptance, and the fixture.
|
||
- `pmacs.fs.rename` does **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 calls `reg.borrow().find_by_path(&from)` (`:3248`), which
|
||
is exact `Path` equality 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
|
||
lookup `find_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_op` is also **synchronous and blocking
|
||
on the main thread**, unlike every `pmacs.fs` op. Q#DR5.
|
||
|
||
### Windows, panels, and how anything gets displayed
|
||
|
||
- `pmacs.window` exports **eight** functions: `display`
|
||
(`window_panel.rs:356`), `display_file` (`:379`), `display_target`
|
||
(`:425`), `panel` (`:440`), `quit` (`:453`), `params` (`:499`),
|
||
`set_params` (`:544`), and `resize` (`:591`) — alongside the pre-arc
|
||
`switch_buffer`. **`display_target` is "the non-side window a visit from
|
||
a panel should address"**, i.e. the mechanism that makes `display_file`
|
||
panel-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_open` is 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_file` is the Q#BP11b answer — a side-effect-free dedup
|
||
via the **normalizing** `find_buffer_for_path` *before* 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-only `add_intercept`
|
||
(`:101-104`), `set_round_trip_input` (`:106`), a buffer-local keymap, a
|
||
line→item map, `q`-restores-previous, and the `display = "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 active
|
||
`dispatch_idle` reports false, so optimistic-apply stays off and `d`
|
||
reaches 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_only` buffer 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 `source` **function** is re-called on every keystroke
|
||
(`recompute_candidates`, `minibuffer.rs:361`) but invoked as
|
||
`f.call(())` — **zero arguments** (`:591`). `pmacs.minibuffer.contents()`
|
||
exists, but the callback runs synchronously from Rust dispatch, outside
|
||
any `pmacs.async` coroutine, and `Handle: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 as
|
||
`source = "files"` with `source_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:497` is the **only** `set_major_mode` caller
|
||
in `builtin/`, firing on `buffer.after-load`; a `buffer.create`d dired
|
||
buffer has no path and fires no `after-load`, so nothing contends.
|
||
- `find_or_open` on a directory reaches `file_io::load_file` → EISDIR.
|
||
Dired must dispatch on `entry.kind` itself.
|
||
- **Keybinding space.** `C-x d`, `C-x C-j`, `C-x C-f`, and `C-x C-q` are
|
||
unbound **repo-wide**. `C-x C-r` is bound to recent-files in
|
||
`recentf.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-k` is 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:
|
||
|
||
1. **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.
|
||
2. **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-local `RET`/`Backspace` binds
|
||
`dired_ret_and_backspace_keybindings_navigate` drives;
|
||
buffer-per-directory (Q#DR2) replaces the in-place repaint that
|
||
`dired_parent_command_navigates_up_one_level` asserts; and the whole
|
||
`M._test` seam is package-shaped.
|
||
3. **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, default `false`, 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.parents` for `create_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.overwrite` defaults false and the op refuses an
|
||
existing target otherwise.
|
||
- `pmacs.fs.remove_dir_all(path)` — separate from `remove` rather 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`'s
|
||
`find_by_path` (`buffer_registry.rs:168-174`) is exact `Path` equality,
|
||
first match only — which strands every buffer beneath a renamed
|
||
*directory*, and `R` on 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 wrapper
|
||
`find_buffer_for_path` (`:864-867`) already exists;
|
||
`apply_resource_op` bypasses 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
|
||
to `await` or attach `on_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 reply `chmod` and
|
||
`remove` produce; there is no `Rename` variant, and the drain arm maps
|
||
`Sleep | FsUnit` alike to `JobResult::Unit` (`async_runtime.rs:1022-1025`).
|
||
So the drain cannot key on the reply; it must key on the **pending job's
|
||
own `JobKind::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 to
|
||
`async_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:
|
||
|
||
```lua
|
||
{ 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 standard `display = "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.read` with `source = "files"` and
|
||
`source_root` set to the current buffer's directory, accepting free text
|
||
(`resolve_accepted_value`, `minibuffer.rs:564`) into
|
||
`pmacs.window.display_file`. **Completion is flat and does not
|
||
descend**: a function source cannot list a directory (it is called with
|
||
zero arguments and cannot `await`, F3), and the Rust `Files` source 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 the `Files` source per
|
||
keystroke. **A nonexistent path creates a `[new file]` buffer** rather
|
||
than erroring: `display_file` routes through `resolve_target_buffer`
|
||
(`editor_core.rs:885-898`), which on `ErrorKind::NotFound` creates 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's `RET` opens 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`; the `dired` major mode and mode keymap;
|
||
buffer-per-directory with canonical naming and the ownership check;
|
||
read-only intercept + round-trip input; visit routing through
|
||
`display_file`; parent / sort / revert / quit; `C-x d` (with the
|
||
`display` opt) / `C-x C-j`; cursor preservation across repaint; the
|
||
`dired.kill-when-opening` config key; **and the tolerant `read_dir` opt
|
||
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 matching
|
||
`apply_resource_op` raw-lookup fix (Q#DR5), pinned by a **no-await**
|
||
rename and by a directory rename that must not strand the buffers
|
||
beneath it; a `y_or_n` confirm helper; and removal of the Lua
|
||
canonicalization mirror if Stage 1 shipped one (Q#DR2).
|
||
- **Stage 3 — wdired.** `C-x C-q` mode 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 under `tests/fixtures/` because the
|
||
**harness routing** (46/47 tests reaching dired through `install_local`
|
||
+ `require`) *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 no `pmacs.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 of `normalize_buffer_path` is 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` (default `false`) mirrors
|
||
Emacs 28's opt-out. (§4)
|
||
- **Q#DR3** Read-only via `add_intercept` + `set_round_trip_input`, with
|
||
dired's own paints using `bypass_intercept`; dired owns its buffer and
|
||
does **not** extend `pmacs.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; `*` and `D` are 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** (fixing
|
||
`apply_resource_op`'s raw `find_by_path` in the same change), and in the
|
||
**main-thread completion drain** `AsyncRuntime::tick` — never in the
|
||
take/await path, which a fire-and-forget rename never reaches. Because
|
||
rename settles as an undifferentiated `ReplyKind::FsUnit`, the drain
|
||
keys on the pending job's `JobKind::FsRename` and the job retains
|
||
from/to. `R` on a directory is supported in v1; `C` refuses directory
|
||
sources. Destructive ops confirm via a new `y_or_n` helper. (§7)
|
||
- **Q#DR6** `read_dir` becomes per-entry tolerant behind an **opt**
|
||
(`{ tolerant = true }`), carrying per-entry `readdir`/`lstat`/`readlink`
|
||
failures **and non-UTF-8 symlink targets** in an `errors` channel;
|
||
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 the `display`
|
||
opt) / `C-x C-j`; mode-scoped in-buffer keys per the §9 table; wdired on
|
||
`C-x C-q` with `C-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 via `display_target`), never bare `find_or_open`, which
|
||
switches the active window before hooks and would let a `RET` replace
|
||
the panel dired is displayed in. **Directory descent instead reuses
|
||
dired's own window** — `switch_buffer` in 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 `Files` source rooted
|
||
at the current buffer's directory, plus free-text accept. Function
|
||
sources cannot descend (zero-argument call, no `await` in 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_dir` is the *only* Rust change Stage 1 needs —
|
||
`display_file`, mode keymaps, `move_to_line`, and
|
||
`set_round_trip_input` all already exist. FALSIFIABLE during
|
||
implementation; the most likely miss is **scroll** preservation, since
|
||
the daemon owns `view_top` and "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_input` keeps 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: pressing `d` must flag, never insert.
|
||
- **B4** Buffer-per-directory does not produce clutter users complain
|
||
about (Emacs parity), and `dired.kill-when-opening` is 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::Files` per 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-x` and `switch-buffer` accept semantics too, so it needs its
|
||
own reasoning and gates. **It would also close the empty-input case**
|
||
(S0-4): `fuzzy_score` returns `Some(0)` for an empty needle
|
||
(`minibuffer.rs:637-640`) and `filter_and_sort` breaks 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::accept` pushes 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 recalling `sub/inner.txt` with `C-p` under a
|
||
*different* root resolves against the new root, and can silently create
|
||
a `[new file]` buffer somewhere else. Emacs's `file-name-history` stores
|
||
absolute paths. Lua cannot fix this — the push happens before
|
||
`on_accept` runs — so it belongs with the other Rust-side minibuffer
|
||
deferrals here.
|
||
- **Load through the normalized path** (S0-3) — `get_or_load_buffer`
|
||
computes 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's `apply_resource_op` fix.
|
||
- **Non-UTF-8 filenames** — needs byte-preserving `pmacs.fs` paths
|
||
(`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-o` visit-in-other-window — the GPU has no splits (GPU
|
||
structural parity, roadmap Arc 8).
|
||
- `!` / `&` shell command on marked files; `Q` query-replace across marked
|
||
files; `A` search across marked files.
|
||
- `i` insert-subdirectory (in-buffer recursive listing) and
|
||
`dired-hide-details`.
|
||
- Owner and group columns — no uid/gid → name primitive exists.
|
||
- Human-readable sizes; sort by extension; reverse-sort toggle.
|
||
- `%m` / `%d` regex mark family; `dired-omit-mode`.
|
||
- Recursive copy (`C` on a directory), which v1 refuses.
|
||
- Auto-revert on external change — `pmacs.fs.watch` exists 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
|
||
|
||
1. **Listing shape.** `C-x d` on a temp directory renders a header line
|
||
plus one line per entry, with kind char, perms, size, mtime, and name;
|
||
a symlink renders `l` with ` -> target`; the entry count matches
|
||
`read_dir`.
|
||
2. **Visit dispatches on kind, through the panel-safe primitive
|
||
(Q#DR10).** `RET` on a subdirectory line opens that directory's dired
|
||
buffer; `RET` on a **file** line opens the file bound to its path (the
|
||
fixture's "not yet exposed" error is gone). `RET` on the header does
|
||
nothing. **The panel case is the real assertion**: with dired opened
|
||
`display = "panel"`, a `RET` on a file line leaves the dired panel
|
||
alive and puts the file in the document window — **falsified by
|
||
swapping `display_file` for `find_or_open`**, which must make the panel
|
||
disappear.
|
||
3. **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 d` on `/tmp`, `/tmp/`, and `/tmp/../tmp` (with a real temp
|
||
dir) yields **one** buffer, not three. With
|
||
`dired.kill-when-opening = true`, the departed buffer is gone.
|
||
3b. **Canonicalization parity (R2-4).** One shared edge-case list —
|
||
`//tmp`, a trailing slash, `~` with `HOME` set and unset, `.`/`..`
|
||
segments including a `..` that would escape root, a relative path —
|
||
driven through **both** dired's canonicalizer and the Rust
|
||
`normalize_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 opened
|
||
`display = "panel"`, `RET` on 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.
|
||
4. **Ownership check (Q#DR2, F7).** A foreign `pmacs.buffer.create`
|
||
buffer named exactly `*dired:<path>*` is **not** adopted: `C-x d` on
|
||
that path leaves the foreign buffer's contents byte-identical and
|
||
opens dired elsewhere.
|
||
5. **Read-only (Q#DR3).** A `buffer.self-insert` into a dired buffer is
|
||
rejected by the intercept and leaves the text byte-identical; dired's
|
||
own repaint succeeds through `bypass_intercept`. `set_round_trip_input`
|
||
is set, pinned **through the real dispatch path** so a semantic
|
||
frontend's `d` reaches the binding rather than optimistic-applying —
|
||
falsified by reverting the `set_round_trip_input` call, not by a
|
||
direct-call assertion.
|
||
6. **Mode keymap (Q#DR8).** The keys resolve through `scope = "mode"`
|
||
with no per-buffer binding: a *second* dired buffer, created without
|
||
any `keymap.bind` call of its own, still responds to `g` and `^`.
|
||
`pmacs.buffer.major_mode(buf)` is `"dired"`, and the mode shows in the
|
||
statusline.
|
||
7. **Cursor preservation (Q#DR9).** With the cursor on entry `k`, `g`
|
||
re-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.
|
||
8. **Sort.** `s` cycles 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.
|
||
9. **Tolerant listing (Q#DR6).** In a directory containing a child whose
|
||
`lstat` fails, `{ tolerant = true }` returns the surviving entries plus
|
||
one `errors` row 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.
|
||
10. **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 the `read_link`/target arm**, which
|
||
must take the whole listing down.
|
||
11. **Unknown opts rejected (minor c).** `read_dir(path, { tolerat = true })`
|
||
errors naming the unknown key rather than silently listing in fatal
|
||
mode.
|
||
12. **Non-UTF-8 names stay fatal, and say so.** A directory containing a
|
||
non-UTF-8 *name* reports the structured `NonUtf8Path` error with the
|
||
offending bytes; dired surfaces it as a status message and creates no
|
||
buffer.
|
||
13. **`dired-jump`.** From a file buffer, `C-x C-j` opens 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.
|
||
14. **Quit.** `q` restores the previously active buffer; in a side window
|
||
(`display = "panel"`) it routes through `pmacs.window.quit`, matching
|
||
`listview.quit`'s Q#BP11b split.
|
||
15. **Failure leaves nothing behind.** `C-x d` on a nonexistent or
|
||
unreadable directory creates no buffer, switches no window, and
|
||
reports the reason — the fixture's
|
||
`dired_open_failure_leaves_editor_unchanged` invariant.
|
||
16. **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.
|
||
17. **The fixture still passes.** `m8_2_acceptance` 15/15 and
|
||
`m8_3_acceptance` 32/32 unchanged, proving the `read_dir` opt 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`.
|