Merge pull request #165 from levineuwirth/dired-stage1

feat(dired): the directory view (Stage 1)
This commit is contained in:
Levi Neuwirth 2026-07-25 21:29:58 +00:00 committed by GitHub
commit c8ec8f3718
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 3418 additions and 130 deletions

View File

@ -119,9 +119,10 @@ detailed in §1.1§1.3: **substrate without surface**, **the silence
asymmetry**, and **per-arc coherence debt**. asymmetry**, and **per-arc coherence debt**.
Coherence-shaped work already in flight at audit time: find-file / Coherence-shaped work already in flight at audit time: find-file /
dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its
panel Stage 1 (merged #155), multi-root LSP affinity (PR #161), the Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155),
config registry foundation (merged #127). multi-root LSP affinity (merged #161), the config registry foundation
(merged #127).
--- ---
@ -191,9 +192,13 @@ working, unreachable capability:
is Lua-bound; no builtin command opens `*lsp*` (§2, §9). is Lua-bound; no builtin command opens `*lsp*` (§2, §9).
- **Interactive file opening.** `pmacs.buffer.find_or_open` - **Interactive file opening.** `pmacs.buffer.find_or_open`
(`src/lua_bindings/mod.rs:3103`) had no interactive caller at audit (`src/lua_bindings/mod.rs:3103`) had no interactive caller at audit
time; a complete 1,384-line dired exists as a frozen test fixture time; a complete 1,384-line dired existed only as a frozen test
(`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired
0 (PR #162). Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the
browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture
stays frozen — its `install_local` + `require` routing *is* the M8
package-universality proof (Q#DR1) — and shrinking it is scheduled
after Stage 3.
The strategic consequence: **most coherence gaps in pmacs are doors, The strategic consequence: **most coherence gaps in pmacs are doors,
not engines** — deliberately deferred surface, not design error. That is not engines** — deliberately deferred surface, not design error. That is
@ -358,11 +363,11 @@ Full verdict table:
|---|---|---|---| |---|---|---|---|
| 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | | 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime |
| 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | | 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on |
| 3 | Open real project | **Missing** | `pmacs .` exits 1 (above). No directory handling anywhere | | 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` 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. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface |
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) | | 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) |
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config | | 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
| 7 | Find symbol / file | **File: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI | | 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* | | 8 | Open terminal | **Works but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | | 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) |
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first | | 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
@ -455,7 +460,8 @@ level is the one missing. Audited level-by-level:
**Beginner** (should see: files, buffers, search, diagnostics, terminal, **Beginner** (should see: files, buffers, search, diagnostics, terminal,
build actions, menus, missing-tool guidance): build actions, menus, missing-tool guidance):
- files ✗ (no find-file at audit; PR #162 in flight) · buffers ✓ (`C-x - files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses;
neither is advertised anywhere but the keymap) · buffers ✓ (`C-x
b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search
is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but
M-x-only · build ✓ but M-x-only with empty prompt · menus △ M-x-only · build ✓ but M-x-only with empty prompt · menus △
@ -1177,7 +1183,11 @@ Primitive-by-primitive against the list above:
hierarchy, package dependency graph, worker trees, git status) will hierarchy, package dependency graph, worker trees, git status) will
each need it; building it once *before* dired's directory view and each need it; building it once *before* dired's directory view and
the workers tree harden their own conventions is exactly this the workers tree harden their own conventions is exactly this
section's point. section's point. Dired Stage 1 (PR #165) landed **without** inventing
one: its listing is flat (Emacs parity), and the recursive
in-buffer case — `i` insert-subdirectory — is a named deferral in
`docs/dired-framing.md` §13, which is where a shared tree primitive
would land.
- **Structured table / inspector / diff view** ✗ — none. (`describe.*` - **Structured table / inspector / diff view** ✗ — none. (`describe.*`
tables are the inspector's data model without a view; the tables are the inspector's data model without a view; the
wire-declared `ResourceOffer` family was reserved for diff/blame wire-declared `ResourceOffer` family was reserved for diff/blame
@ -1392,8 +1402,10 @@ missing runtime entity — a real arc).
Establish the end-to-end workflow; treat regressions as release Establish the end-to-end workflow; treat regressions as release
blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually
cheap:** directory-argument handling; a find-file surface (in flight, cheap:** directory-argument handling (the remaining half of step 3 —
PR #162); surfacing the LSP spawn failure with guidance (§1.2); a dired Stage 1 landed the buffer it should resolve to); a find-file
surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the
LSP spawn failure with guidance (§1.2); a
compile keybinding + `cargo build`/`test` default from the existing compile keybinding + `cargo build`/`test` default from the existing
`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The `ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The
journey acceptance suite (§19) is the ratchet that keeps it fixed. journey acceptance suite (§19) is the ratchet that keeps it fixed.

895
builtin/runtime/dired.lua Normal file
View File

@ -0,0 +1,895 @@
-- dired.lua --- the directory view (dired arc Stage 1).
--
-- Dired is not a convenience rider on an existing file surface: until
-- Stage 0 (`C-x C-f`, #162) there was no way to open a file by path at
-- all, and browsing is the half a user reaches for when they do NOT
-- already know the path. So this is a primary surface, and the one
-- thing it may never do is refuse to render a listing --- hence the
-- per-entry-tolerant `read_dir` opt it drives (Q#DR6), the only Rust
-- this stage needed besides exposing the path normalizer.
--
-- Framing: docs/dired-framing.md (Q#DR1-DR10). Stage 1 is the view:
-- listing, navigation, sort, revert, quit. Marks and operations are
-- Stage 2; the editable wdired layer is Stage 3.
--
-- Public surface:
--
-- pmacs.dired.open(path [, opts]) -- awaits; run inside pmacs.async
-- opts.display = "current" | "panel" (Q#BP11b, default "current")
-- opts.select_name = "<basename>" -- seat the cursor on it
--
-- M-x dired / C-x d -- prompt for a directory
-- M-x dired-jump / C-x C-j -- dired on this file's directory
--
-- In a dired buffer (mode-scoped keys, Q#DR8):
-- RET, f visit (directory -> descend, file -> display_file)
-- ^ parent directory
-- n / p move by line (<down> / <up> too)
-- g revert (re-read, preserving the cursor's entry)
-- q quit (restore the previous buffer, or window.quit in a panel)
-- s cycle sort mode (name -> mtime -> size)
--
-- Three structural decisions worth knowing before editing this file:
--
-- 1. ONE BUFFER PER DIRECTORY, named `*dired:<canonical path>*`
-- (Q#DR2). Navigation *opens the target's buffer*; it never mutates
-- the current one. That is Emacs behavior, and it is also the only
-- way to keep the name honest --- there is no
-- `pmacs.buffer.set_name`, so the M8.2 fixture's in-place repaint
-- leaves a buffer named after a directory it no longer shows.
--
-- 2. THE CANONICAL FORM IS THE CORE'S, not a copy of it
-- (`pmacs.path.canonicalize` is `normalize_buffer_path` itself).
-- Dired's name-dedup and `display_file`'s `find_buffer_for_path`
-- dedup have to agree; two implementations that disagree on `//tmp`
-- or a `..` at root would mint two buffers for one directory with no
-- error anywhere.
--
-- 3. EVERY LISTING IS ASYNC. `pmacs.fs.read_dir` is worker-dispatched,
-- so each command spawns a coroutine and the work after the first
-- `:await()` resumes on a later tick --- outside interactive
-- dispatch. Three consequences:
--
-- * Errors MUST be `pcall`ed and reported here, and that is
-- load-bearing rather than tidy. An uncaught raise inside a
-- `pmacs.async` coroutine reaches `step()`, which reports through
-- `pmacs.error` --- a channel that **is never defined in
-- production** (`COHERENCE.md` §1.1) --- and so falls through to a
-- bare `error()` inside `pmacs._async.tick()`, whose result
-- `EditorState::tick_async` discards with `let _ =`. The failure
-- would not reach the status line, the `*errors*` buffer, or a log:
-- it would reach nowhere, and dired would look like it silently did
-- nothing.
-- * Reporting therefore goes through `pmacs.editor.set_status`, which
-- exists and which the acceptance suite observes --- the corollary
-- COHERENCE draws from that dead channel: report through a surface
-- a test can see, or the guard is indistinguishable from the
-- silence it was meant to fix.
-- * `pmacs.window.*` calls made after the await act for the *ambient*
-- active frontend, since interactive origin does not survive the
-- tick boundary; and `pmacs.editor.move_to_line` acts on the
-- ambient *buffer*, which is why every post-await re-seat is
-- guarded (see `seat_cursor`).
-- Emacs 28's dired-kill-when-opening-new-dired-buffer, as a setting
-- rather than a hardcoded policy: buffer-per-directory accumulates
-- buffers when walking a deep tree, and Emacs users differ on whether
-- that is a feature.
pmacs.config.define {
name = "dired.kill-when-opening",
description = "Kill the dired buffer being left when descending or ascending.",
type = "boolean",
default = false,
mutability = "live",
}
-- ---------------------------------------------------------------------------
-- Layout
-- ---------------------------------------------------------------------------
--
-- The mark column is column 0 (Q#DR4), so every other column sits two
-- bytes right of the M8.2 fixture's offsets. Stage 1 always renders it
-- blank: filling it in is Stage 2's job, but reserving it now means
-- Stage 2 does not have to move every column, and Stage 3's
-- column-classifying intercept can be written against constants that
-- did not shift under it. Offsets are computed from the widths for the
-- same reason --- the fixture hardcoded `NAME_START = 39` and paid for
-- it in every wdired test.
local MARK_BYTES = 2
local KIND_BYTES = 1
local PERMS_BYTES = 9
local SIZE_BYTES = 10
local MTIME_BYTES = 16
local MARK_START = 0
local KIND_START = MARK_START + MARK_BYTES -- 2
local PERMS_START = KIND_START + KIND_BYTES -- 3
local PERMS_END = PERMS_START + PERMS_BYTES -- 12 (exclusive)
local SIZE_START = PERMS_END + 1 -- 13
local MTIME_START = SIZE_START + SIZE_BYTES + 1 -- 24
local NAME_START = MTIME_START + MTIME_BYTES + 1 -- 41
local BLANK_MARK = string.rep(" ", MARK_BYTES)
local SORT_MODES = { "name", "mtime", "size" }
-- ---------------------------------------------------------------------------
-- Per-buffer state
-- ---------------------------------------------------------------------------
--
-- handles: array of { buf, path, entries, errors, sort_mode, prev }.
--
-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table
-- key: two BufferIdLua values for the same buffer are distinct
-- userdata, so a `handles[buf]` lookup would miss. The scan is over a
-- handful of dired buffers. Dead buffers are compacted out first, so a
-- command in a removed dired buffer sees "not in dired" rather than
-- operating on dead state (the M8.2 fixture's `find_handle` lesson).
local handles = {}
local function live_handles()
local live = {}
for _, h in ipairs(handles) do
local ok, valid = pcall(h.buf.is_valid, h.buf)
if ok and valid then live[#live + 1] = h end
end
handles = live
return live
end
local function handle_for_buffer(buf)
if buf == nil then return nil end
for _, h in ipairs(live_handles()) do
if h.buf == buf then return h end
end
return nil
end
local function handle_for_path(path)
for _, h in ipairs(live_handles()) do
if h.path == path then return h end
end
return nil
end
local function active_handle()
return handle_for_buffer(pmacs.window.buffer())
end
-- ---------------------------------------------------------------------------
-- Paths and names
-- ---------------------------------------------------------------------------
local canonicalize = pmacs.path.canonicalize
local function join_path(dir, name)
if dir:sub(-1) == "/" then return dir .. name end
return dir .. "/" .. name
end
-- Parent of a canonical directory, through the same normalizer: `..`
-- against the root folds away, so `/` is its own parent and no separate
-- root special case can drift out of agreement with the canonical form.
local function parent_path(path)
return canonicalize(join_path(path, ".."))
end
local function basename(path)
return path:match("([^/]+)/*$")
end
local function dirname(path)
local dir = path:match("^(.*)/[^/]*$")
if dir == nil then return nil end
if dir == "" then return "/" end
return dir
end
local function buffer_name(path)
return "*dired:" .. path .. "*"
end
local function buffer_named(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, described = pcall(pmacs.describe.buffer, id)
if ok and described and described.name == name then return id end
end
return nil
end
-- The directory a prompt or a jump should start from: the active
-- buffer's own directory, else the process cwd (which the normalizer
-- yields for a bare "." because it absolutizes against it).
local function current_directory()
local buf = pmacs.window.buffer()
if buf ~= nil then
local ok, path = pcall(function() return buf:path() end)
if ok and path then
local dir = dirname(path)
if dir then return canonicalize(dir) end
end
local h = handle_for_buffer(buf)
if h then return h.path end
end
return canonicalize(".")
end
-- ---------------------------------------------------------------------------
-- Failure reporting
-- ---------------------------------------------------------------------------
-- `Handle:await()` raises structured tables (R45), so `tostring` on a
-- failure yields "table: 0x...". Every user-visible dired failure goes
-- through here.
local function failure_message(err)
if type(err) == "table" then
return tostring(err.message or err.tag or "error")
end
return tostring(err)
end
local function report(where, err)
pmacs.editor.set_status(where .. ": " .. failure_message(err))
end
-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------
-- `rwxr-xr-x`, without the leading kind char (rendered separately so a
-- symlink shows `l` and a directory `d`). Arithmetic rather than bit
-- ops: this file has to run on LuaJIT (5.1) as well as Lua 5.4.
--
-- The nine basic bits only: setuid / setgid / sticky are deliberately
-- not surfaced as Emacs's `s` / `t`, matching the M8.3 fixture's
-- `parse_perm_string`, which edits exactly these nine. Rendering a bit
-- Stage 3 could not accept back would be worse than omitting it.
local function fmt_perms(mode)
local function tri(bits)
local r = (bits >= 4) and "r" or "-"
local w = ((bits % 4) >= 2) and "w" or "-"
local x = ((bits % 2) >= 1) and "x" or "-"
return r .. w .. x
end
return tri(math.floor(mode / 64) % 8)
.. tri(math.floor(mode / 8) % 8)
.. tri(mode % 8)
end
local function kind_char(kind)
if kind == "dir" then return "d"
elseif kind == "symlink" then return "l"
elseif kind == "file" then return "-"
else return "?" -- device, fifo, socket
end
end
-- Exact bytes while they fit the column; a magnitude past that.
--
-- `%10d` holds ten digits, so a file of 10 GB or more (VM images, core
-- dumps --- ordinary things) widens the field and shifts mtime and name
-- right on that line alone. That is only cosmetic today, but
-- `_layout.NAME_START` is exported as a contract and Stage 3's
-- column-classifying intercept is planned against these constants, so a
-- line that violates them now is a Stage 3 trap. Same discipline as
-- `fmt_mtime`: the width is the invariant, and precision yields to it.
--
-- This is NOT the deferred human-readable size column (§13): the exact
-- byte count is still what a listing shows, right up to the point where
-- it cannot be shown at all.
local SIZE_UNITS = { "K", "M", "G", "T", "P", "E" }
local function fmt_size(n)
local exact = string.format("%" .. SIZE_BYTES .. "d", n)
if #exact <= SIZE_BYTES then return exact end
local value, unit = n, SIZE_UNITS[#SIZE_UNITS]
for _, suffix in ipairs(SIZE_UNITS) do
value = value / 1024
unit = suffix
if value < 1024 then break end
end
local scaled = string.format("%.1f%s", value, unit)
if #scaled > SIZE_BYTES then scaled = scaled:sub(1, SIZE_BYTES) end
return string.rep(" ", SIZE_BYTES - #scaled) .. scaled
end
local function fmt_mtime(secs)
-- Explicit format string, so the width is fixed and the result does
-- not move with LC_TIME. A pre-epoch mtime is legal and `os.date`'s
-- behavior on a negative time is platform-dependent, so a
-- non-conforming result degrades to a fixed-width placeholder rather
-- than shifting every column right of it.
local ok, formatted = pcall(os.date, "%Y-%m-%d %H:%M", secs)
if ok and type(formatted) == "string" and #formatted == MTIME_BYTES then
return formatted
end
return string.rep("?", MTIME_BYTES)
end
-- POSIX permits any byte but `/` and NUL in a filename, including `\n`.
-- Rendering one verbatim would break the one-line-per-entry invariant
-- that cursor-line -> entry resolution rests on (and that Stage 3's
-- intercept will rest on harder), so control bytes are escaped. The
-- backslash goes first, which is what makes the encoding invertible ---
-- Stage 3 needs the exact inverse so a no-op commit cannot fire a
-- spurious rename. Carried over from the M8.2 fixture as decided
-- design, not re-litigated.
local function escape_displayable(s)
if s == nil then return "" end
s = s:gsub("\\", "\\\\")
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
-- NUL is deliberately absent from the class: the kernel forbids it in
-- a filename, so the fixture's `%z` (removed from Lua 5.2's pattern
-- syntax) was covering a case that cannot occur.
s = s:gsub("[\1-\8\11\12\14-\31]", function(ch)
return string.format("\\x%02X", string.byte(ch))
end)
return s
end
local function render_entry(entry)
local target = ""
if entry.symlink_target then
target = " -> " .. escape_displayable(entry.symlink_target)
elseif entry.kind == "symlink" then
-- A tolerant listing keeps a symlink whose target could not be
-- represented (non-UTF-8) or read; say so rather than rendering a
-- bare `l` line that looks like a complete entry.
target = " -> ?"
end
return string.format(
"%s%s%s %s %s %s%s",
BLANK_MARK, kind_char(entry.kind), fmt_perms(entry.mode),
fmt_size(entry.size), fmt_mtime(entry.mtime),
escape_displayable(entry.name), target)
end
-- Header (line 0) + one line per entry + the unreadable-count footer.
-- The footer exists because a tolerant listing that silently dropped
-- entries is worse than one that failed: the user has to know the view
-- is incomplete (and Stage 3's wdired refuses to open on one).
local function render_text(handle)
local lines = { handle.path .. ":" }
for _, entry in ipairs(handle.entries) do
lines[#lines + 1] = render_entry(entry)
end
local unreadable = #handle.errors
if unreadable > 0 then
lines[#lines + 1] = string.format("%d entries unreadable", unreadable)
end
return table.concat(lines, "\n")
end
-- Dired's own writes are the only ones that reach the buffer: the
-- read-only intercept rejects everything else, and this bypasses it.
local function paint(handle)
local text = render_text(handle)
handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
end
-- ---------------------------------------------------------------------------
-- Cursor
-- ---------------------------------------------------------------------------
--
-- Entry i renders on line i (line 0 is the header), so the entry under
-- the cursor is `entries[cursor_line()]`.
local function entry_at_cursor(handle)
local line = pmacs.editor.cursor_line()
if line < 1 then return nil end
return handle.entries[line], line
end
local function index_of_name(handle, name)
if name == nil then return nil end
for i, entry in ipairs(handle.entries) do
if entry.name == name then return i end
end
return nil
end
-- Re-seat by BASENAME (Q#DR9), falling back to the nearest surviving
-- line. Every repaint is wholesale, so without this a revert, a sort,
-- or any Stage 2 operation would drop the cursor to the header.
--
-- `move_to_line` is AMBIENT --- it moves the active window's cursor, not
-- `handle.buf`'s --- so every caller that can run after an `:await()`
-- has to check that dired is still the active buffer first. Painting is
-- safe either way (it names the buffer); seating is not. Callers that
-- activate the buffer themselves (an open, which displays first) are
-- unconditionally in the right place.
local function seat_cursor(handle, name, fallback_line)
local count = #handle.entries
if count == 0 then
pmacs.editor.move_to_line(0)
return
end
local target = index_of_name(handle, name)
if target == nil then
target = math.max(1, math.min(fallback_line or 1, count))
end
pmacs.editor.move_to_line(target)
end
-- ---------------------------------------------------------------------------
-- Sorting
-- ---------------------------------------------------------------------------
local function sort_entries(entries, mode)
if mode == "name" then
table.sort(entries, function(a, b) return a.name < b.name end)
elseif mode == "mtime" then
-- Newest first, name as a stable tiebreak so a directory of
-- same-second files renders deterministically.
table.sort(entries, function(a, b)
if a.mtime ~= b.mtime then return a.mtime > b.mtime end
return a.name < b.name
end)
elseif mode == "size" then
table.sort(entries, function(a, b)
if a.size ~= b.size then return a.size > b.size end
return a.name < b.name
end)
else
error("dired: unknown sort mode: " .. tostring(mode))
end
end
local function next_sort_mode(mode)
for i, candidate in ipairs(SORT_MODES) do
if candidate == mode then
return SORT_MODES[(i % #SORT_MODES) + 1]
end
end
return SORT_MODES[1]
end
-- ---------------------------------------------------------------------------
-- Reading
-- ---------------------------------------------------------------------------
-- Read and sort one directory without touching editor state, so a
-- failure happens before any side effect is committed (acceptance 15).
-- Must run inside `pmacs.async`.
--
-- Always tolerant (Q#DR6): a plain refresh of a busy directory must not
-- fail because one child was unlinked between `readdir` and `lstat`.
-- Parent-level failures and non-UTF-8 *names* still raise.
local function read_listing(path, sort_mode)
local listing = pmacs.fs.read_dir(path, { tolerant = true }):await()
local entries = listing.entries
sort_entries(entries, sort_mode)
return entries, listing.errors
end
-- ---------------------------------------------------------------------------
-- Buffer ownership
-- ---------------------------------------------------------------------------
-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
local NAME_VARIANT_LIMIT = 99
-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign
-- buffer may already be called `*dired:/tmp*`. Painting into it through
-- `bypass_intercept` would clobber a user's data, so found-by-name is
-- NOT adoption: ownership means "this buffer is in dired's own handle
-- table" (F7).
--
-- That is deliberately narrower than the framing's "in the handle table
-- OR major_mode == dired": a foreign buffer that also carries the mode
-- is precisely the case the check exists to refuse, and a builtin's
-- handle table cannot be lost the way a reloadable package's can.
local function claim_handle(path)
local existing = handle_for_path(path)
if existing then return existing end
local name = buffer_name(path)
if buffer_named(name) then
local unique = nil
for i = 2, NAME_VARIANT_LIMIT do
local candidate = string.format("%s<%d>", name, i)
if buffer_named(candidate) == nil then
unique = candidate
break
end
end
if unique == nil then
error(string.format("dired: %s is taken and no free variant remains", name))
end
name = unique
end
local buf = pmacs.buffer.create(name)
-- Read-only by the listview idiom (Q#DR3): every non-bypass edit is
-- rejected, and the intercept lives as long as the buffer.
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
end)
-- Q#DR3/Q#P6: while this buffer is active a semantic frontend must
-- round-trip keys, or optimistic apply would swallow the single-key
-- bindings (`g` would insert a `g` into a CRDT mirror instead of
-- reverting) and bypass the intercept entirely.
pmacs.buffer.set_round_trip_input(buf, true)
-- Q#DR8: the mode is what carries the keymap, and dired is #129's
-- first consumer of mode-scoped keys outside language detection.
pmacs.buffer.set_major_mode(buf, "dired")
local handle = {
buf = buf,
path = path,
entries = {},
errors = {},
sort_mode = SORT_MODES[1],
prev = nil,
}
handles[#handles + 1] = handle
return handle
end
-- ---------------------------------------------------------------------------
-- Display
-- ---------------------------------------------------------------------------
local function drop_handle(handle)
for i, candidate in ipairs(handles) do
if candidate == handle then
table.remove(handles, i)
return
end
end
end
-- Kill the dired buffer being left, when the user asked for it.
-- Deliberately after the new buffer is displayed: `pmacs.buffer.kill`
-- redirects windows showing the doomed buffer, and doing that first
-- would fight the display we are about to perform.
local function kill_departed(departed, arriving)
if departed == nil or departed == arriving then return end
if not pmacs.config.get("dired.kill-when-opening") then return end
local ok, err = pcall(pmacs.buffer.kill, departed.buf)
if ok then
drop_handle(departed)
else
-- A buffer that could not be killed keeps its handle: dropping it
-- would leave a live dired buffer no command recognizes.
report("dired", err)
end
end
-- Where a dired buffer goes.
--
-- A fresh `dired` takes the standard adopter opt (Q#BP11b): omitted or
-- "current" is the raw switch every other adopter defaults to in
-- Stages 1-2, "panel" is the bottom side window.
--
-- Navigation (`departed ~= nil`) instead reuses the window dired
-- already occupies, which is the opposite routing from a file visit and
-- deliberately so (Q#DR10): the next directory is the same kind of
-- thing as the current one and belongs in the same slot, while a file
-- is not a dired buffer and belongs in the document area.
local function display(handle, opts, departed)
local side = nil
if departed ~= nil then
-- Dired's own window, not the request's: walking a tree in a side
-- window keeps the side window.
local params = pmacs.window.params()
side = params and params.side
elseif opts and opts.display == "panel" then
side = "bottom"
end
if side ~= nil then
-- A side slot DEDICATED to another buffer refuses the replacement
-- and this falls back to the document window (Q#BP3 2.iii). That is
-- both the substrate's documented policy and Emacs's, so dired does
-- not try to unpin the user's panel.
pmacs.window.display(handle.buf, { side = side, select = true })
else
pmacs.window.switch_buffer(handle.buf)
end
end
-- ---------------------------------------------------------------------------
-- Public: open a directory
-- ---------------------------------------------------------------------------
pmacs.dired = pmacs.dired or {}
local OPEN_OPTS = { display = true, select_name = true }
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
-- window it occupies when this is a navigation rather than a fresh
-- open. Awaits, so it must run inside `pmacs.async`; raises on a read
-- failure, having changed nothing. Returns the buffer.
local function open_directory(path, opts, departed)
if type(path) ~= "string" then
error("pmacs.dired.open: path must be a string, got " .. type(path))
end
opts = opts or {}
-- Validated up front, before the read and before any buffer exists,
-- so a bad opt leaves nothing to roll back (the
-- `parse_adopter_placement` discipline).
for key in pairs(opts) do
if not OPEN_OPTS[key] then
error(string.format("pmacs.dired.open: unknown opts key %q", tostring(key)))
end
end
local wanted = opts.display
if wanted ~= nil and wanted ~= "current" and wanted ~= "panel" then
error(string.format('pmacs.dired.open: unknown display %q (expected "current" or "panel")',
tostring(wanted)))
end
local canonical = canonicalize(path)
-- Read first: a failure must leave no buffer, no window change, and
-- no handle behind.
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
local entries, errors = read_listing(canonical, sort_mode)
local handle = claim_handle(canonical)
handle.entries = entries
handle.errors = errors
handle.sort_mode = sort_mode
-- `q` returns to the buffer you came from, never to another dired
-- buffer (which would trap `q` walking back down the tree); on a
-- descent the arriving buffer inherits the departing one's origin.
if departed ~= nil then
handle.prev = departed.prev
else
local active = pmacs.window.buffer()
if active ~= nil and handle_for_buffer(active) == nil then
handle.prev = active
end
end
paint(handle)
display(handle, opts, departed)
-- Seating happens after the display: `switch_buffer` zeroes the
-- window cursor, so an earlier seat would be discarded.
seat_cursor(handle, opts.select_name, 1)
kill_departed(departed, handle)
return handle.buf
end
function pmacs.dired.open(path, opts)
return open_directory(path, opts, nil)
end
-- Every interactive entry point funnels through here: spawn the
-- coroutine the await needs, and turn a failure into a status message
-- rather than an uncaught raise inside `pmacs.async` (which would land
-- in *errors* and leave the user with a silent no-op).
local function open_async(path, opts, departed, where)
pmacs.async(function()
local ok, err = pcall(open_directory, path, opts, departed)
if not ok then report(where or "dired", err) end
end)
end
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------
pmacs.command.define {
name = "dired",
description = "Open a directory listing (dired).",
fn = function()
local root = current_directory()
-- No completion source, deliberately. `source = "files"` would make
-- RET-on-empty open whatever sorts first (the minibuffer selects
-- candidate 0 whenever the list is non-empty, and a selected
-- candidate shadows typed text --- S0-1/S0-4), and RET-on-the-
-- default-directory is exactly the gesture `C-x d` exists for. The
-- field is prefilled instead, which is Emacs's own shape here.
pmacs.minibuffer.read {
prompt = "Dired: ",
initial = root,
history = "dired",
on_accept = function(value)
if value == nil or value == "" then return end
open_async(value, nil, nil, "dired")
end,
}
end,
}
pmacs.command.define {
name = "dired-jump",
description = "Open dired on the current file's directory, cursor on that file.",
fn = function()
local buf = pmacs.window.buffer()
local path = nil
if buf ~= nil then
local ok, value = pcall(function() return buf:path() end)
if ok then path = value end
end
if path == nil then
pmacs.editor.set_status("dired-jump: this buffer has no file")
return
end
local dir = dirname(path)
if dir == nil then
pmacs.editor.set_status("dired-jump: cannot find the directory of " .. path)
return
end
open_async(dir, { select_name = basename(path) }, nil, "dired-jump")
end,
}
pmacs.command.define {
name = "dired.visit",
description = "Visit the entry under the cursor (descend a directory, open a file).",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry = entry_at_cursor(handle)
-- The header and the unreadable-count footer are not entries.
if entry == nil then return end
local target = join_path(handle.path, entry.name)
if entry.kind == "dir" then
open_async(target, nil, handle, "dired")
return
end
if entry.kind == "symlink" then
-- `read_dir` and `stat` are both lstat-based, so nothing in the
-- entry says whether the link points at a directory --- the only
-- way to find out is to try to list it. A symlinked directory is
-- an ordinary thing to walk into, so try the descent and fall back
-- to a file visit.
--
-- `open_directory` is the try: it reads before touching any editor
-- state and raises having changed nothing (acceptance 15), so its
-- failure IS the "not a directory" answer. An explicit probe
-- followed by the real open would list the whole directory TWICE
-- --- opendir plus one lstat per child, each time.
pmacs.async(function()
local descended = pcall(open_directory, target, nil, handle)
if descended then return end
local visited, err = pcall(pmacs.window.display_file, target, { select = true })
if not visited then report("dired", err) end
end)
return
end
-- Q#DR10: `display_file`, never `find_or_open`, which switches the
-- active window in both branches before firing hooks --- in a
-- panel-displayed dired that would replace the panel with the
-- visited file, i.e. the panel swallows itself.
local ok, err = pcall(pmacs.window.display_file, target, { select = true })
if not ok then report("dired", err) end
end,
}
pmacs.command.define {
name = "dired.parent",
description = "Open the parent directory.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local parent = parent_path(handle.path)
if parent == handle.path then
pmacs.editor.set_status("dired: already at the filesystem root")
return
end
-- Seat on the directory we came from, the way Emacs's `^` does.
open_async(parent, { select_name = basename(handle.path) }, handle, "dired")
end,
}
pmacs.command.define {
name = "dired.revert",
description = "Re-read the directory, keeping the cursor on its entry.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
pmacs.async(function()
local ok, entries, errors = pcall(read_listing, handle.path, handle.sort_mode)
if not ok then
-- On failure `entries` carries the raised value, not a listing.
report("dired", entries)
return
end
if not handle.buf:is_valid() then return end
handle.entries = entries
handle.errors = errors
paint(handle)
-- The re-read settles a tick or more later, and the user may have
-- left (a buffer switch, or `q`) in the meantime. The paint names
-- its buffer and is safe; seating is ambient, so a stale seat here
-- would move an unrelated buffer's cursor to a line index that
-- only means something in this listing.
if pmacs.window.buffer() == handle.buf then
seat_cursor(handle, name, line)
end
end)
end,
}
pmacs.command.define {
name = "dired.sort-cycle",
description = "Cycle the sort mode: name -> mtime -> size.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
-- A pure reorder of the entries already in hand: sort is a display
-- decision, not a reason to re-read the directory.
handle.sort_mode = next_sort_mode(handle.sort_mode)
sort_entries(handle.entries, handle.sort_mode)
paint(handle)
seat_cursor(handle, name, line)
pmacs.editor.set_status("dired: sorted by " .. handle.sort_mode)
end,
}
pmacs.command.define {
name = "dired.quit",
description = "Leave dired, restoring the previous buffer.",
fn = function()
local handle = active_handle()
if handle == nil then return end
-- Q#BP11b, matching `listview.quit`: `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when
-- dired really is in a side window.
local params = pmacs.window.params()
if params and params.side and params.quit_action then
pmacs.window.quit()
return
end
local target = handle.prev
if not (target and target:is_valid()) then
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")
end
pmacs.window.switch_buffer(target)
end,
}
-- ---------------------------------------------------------------------------
-- Keys
-- ---------------------------------------------------------------------------
-- Global: both sequences are unbound repo-wide, and both are the Emacs
-- defaults.
pmacs.keymap.bind { scope = "global", sequence = "C-x d", command = "dired" }
pmacs.keymap.bind { scope = "global", sequence = "C-x C-j", command = "dired-jump" }
-- In-buffer keys are MODE-scoped (Q#DR8), bound once here rather than
-- per buffer: a second dired buffer needs no `keymap.bind` of its own,
-- and Stage 3's wdired swap changes the whole keymap with the mode
-- instead of unbinding key by key.
local function bind(sequence, command)
pmacs.keymap.bind { scope = "mode", mode = "dired", sequence = sequence, command = command }
end
bind("RET", "dired.visit")
bind("f", "dired.visit")
bind("^", "dired.parent")
bind("n", "cursor.down")
bind("<down>", "cursor.down")
bind("p", "cursor.up")
bind("<up>", "cursor.up")
bind("g", "dired.revert")
bind("q", "dired.quit")
bind("s", "dired.sort-cycle")
-- ---------------------------------------------------------------------------
-- Test seam
-- ---------------------------------------------------------------------------
--
-- The layout constants, so acceptance can assert column positions
-- without hardcoding the numbers this file computes.
pmacs.dired._layout = {
MARK_START = MARK_START,
KIND_START = KIND_START,
PERMS_START = PERMS_START,
PERMS_END = PERMS_END,
SIZE_START = SIZE_START,
MTIME_START = MTIME_START,
NAME_START = NAME_START,
}

View File

@ -12,7 +12,10 @@
-- `symlink_target` is present only on symlink entries. -- `symlink_target` is present only on symlink entries.
-- `opts` may contain `supersede = "<key>"` to chain into the M3 -- `opts` may contain `supersede = "<key>"` to chain into the M3
-- supersede semantics (a later read_dir under the same key -- supersede semantics (a later read_dir under the same key
-- cancels the earlier one). -- cancels the earlier one), and `tolerant = true` to swap the
-- all-or-nothing listing for `{ entries = ..., errors = ... }`
-- (see fs.read_dir's own comment below). Any other key is an
-- error rather than being silently ignored.
-- --
-- Order: entries are returned in *filesystem iteration order*, -- Order: entries are returned in *filesystem iteration order*,
-- which is whatever the kernel's `readdir` syscall returns. On -- which is whatever the kernel's `readdir` syscall returns. On
@ -69,24 +72,61 @@ end
local fs = {} local fs = {}
-- Shared opts.supersede extractor; raises on misshapen opts. -- Shared read-op opts parser; raises on misshapen opts.
local function supersede_key(opts, where) --
if opts == nil then return nil end -- Unknown keys are REJECTED, not ignored. The earlier version read
-- `opts.supersede` and silently dropped everything else, which means a
-- typo'd `tolerant` would degrade to the fatal contract with no signal
-- at all --- exactly the failure the tolerant opt exists to prevent
-- (dired framing §8, minor c). `allowed` is the per-op whitelist.
local function read_opts(opts, where, allowed)
if opts == nil then return nil, false end
if type(opts) ~= "table" then if type(opts) ~= "table" then
error(where .. ": opts must be a table or nil, got " .. type(opts)) error(where .. ": opts must be a table or nil, got " .. type(opts))
end end
local k = opts.supersede for key in pairs(opts) do
if k ~= nil and type(k) ~= "string" then if not allowed[key] then
local names = {}
for name in pairs(allowed) do names[#names + 1] = name end
table.sort(names)
error(string.format("%s: unknown opts key %q (expected one of: %s)",
where, tostring(key), table.concat(names, ", ")))
end
end
local key = opts.supersede
if key ~= nil and type(key) ~= "string" then
error(where .. ": opts.supersede must be a string") error(where .. ": opts.supersede must be a string")
end end
return k local tolerant = opts.tolerant
if tolerant ~= nil and type(tolerant) ~= "boolean" then
error(where .. ": opts.tolerant must be a boolean")
end
return key, tolerant == true
end end
local READ_DIR_OPTS = { supersede = true, tolerant = true }
local STAT_OPTS = { supersede = true }
-- Two result shapes, chosen by `opts.tolerant` (dired Q#DR6):
--
-- read_dir(path) -> { <entry>, ... }
-- read_dir(path, { tolerant = true }) -> { entries = { <entry>, ... },
-- errors = { { name = ...?,
-- message = ... }, ... } }
--
-- The bare array is the M8.1 contract and stays exactly as it was, so
-- an existing consumer (the frozen M8.2 dired fixture consumes it with
-- `ipairs`) is unaffected. Under the opt, a per-entry `readdir` /
-- `lstat` / `readlink` failure and a non-UTF-8 symlink *target* become
-- `errors` rows instead of failing the whole listing; a failure on the
-- parent directory, and a non-UTF-8 entry *name*, stay fatal. An
-- `errors` row has no `name` when the entry never materialized.
function fs.read_dir(path, opts) function fs.read_dir(path, opts)
if type(path) ~= "string" then if type(path) ~= "string" then
error("pmacs.fs.read_dir: path must be a string, got " .. type(path)) error("pmacs.fs.read_dir: path must be a string, got " .. type(path))
end end
local id = async_mod._dispatch_fs_read_dir(path, supersede_key(opts, "pmacs.fs.read_dir")) local key, tolerant = read_opts(opts, "pmacs.fs.read_dir", READ_DIR_OPTS)
local id = async_mod._dispatch_fs_read_dir(path, key, tolerant)
return build_handle(id) return build_handle(id)
end end
@ -94,7 +134,8 @@ function fs.stat(path, opts)
if type(path) ~= "string" then if type(path) ~= "string" then
error("pmacs.fs.stat: path must be a string, got " .. type(path)) error("pmacs.fs.stat: path must be a string, got " .. type(path))
end end
local id = async_mod._dispatch_fs_stat(path, supersede_key(opts, "pmacs.fs.stat")) local key = read_opts(opts, "pmacs.fs.stat", STAT_OPTS)
local id = async_mod._dispatch_fs_stat(path, key)
return build_handle(id) return build_handle(id)
end end

View File

@ -14,10 +14,11 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror, machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone. or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot: - Canonical base at this snapshot:
`githubsucks/main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2 `githubsucks/main` @ `8c86d34` (the dired framing #164 atop find-file
landed-doc refresh #150, folding Stage 2 #149, the ledger refresh #147, web #162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap blank-slab fix
grammars HTML+CSS #146, and the LaTeX Stage 1 #144 / inline-math framing #159, bottom-panel Stage 1 #155, the inline-math re-scout #154, the vterm
#145 pair; protocol v20). PTY-flake fix #153, and the GPU initial-target doc refresh #152; protocol
v20).
- On the transfer source, `origin/main` named a release mirror at - On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names `d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by the canonical URL. This difference is why all recovery begins by
@ -51,7 +52,7 @@ git worktree list
git status --short --branch git status --short --branch
``` ```
The `git log` command must expose `0dd16a5` or a newer intentional main. The `git log` command must expose `8c86d34` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration. If it does not, stop and repair the remote/fetch configuration.
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161) ## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
@ -188,44 +189,153 @@ If it does not, stop and repair the remote/fetch configuration.
suites**; `git diff --check` clean. The sweep needs an isolated suites**; `git diff --check` clean. The sweep needs an isolated
`XDG_CONFIG_HOME` and `-- --skip basedpyright`. `XDG_CONFIG_HOME` and `-- --skip basedpyright`.
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next ## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165)
- Approved framing: `docs/dired-framing.md` (revision 5), landing as its - Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
own docs PR off `githubsucks/main` @ `2af1ab3`, branch approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
`githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
repo's `-framing`-branch convention (`vterm-framing`, (wdired) each get their own detailed framing after the prior stage lands.
`gpu-initial-target-framing`, `tab-width-parity-framing`).
- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`, - **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`,
2026-07-25, one review round, 12/12 CI green). Durable facts moved to 2026-07-25, one review round, 12/12 CI green). Durable facts moved to
`docs/agent-handoff.md` §1 per rule 3 below. `docs/agent-handoff.md` §1 per rule 3 below.
- **Stage 1 (the dired view) is next and unstarted.** Branch `dired` - **Stage 1 branch: `githubsucks/dired-stage1`**, worktree
(worktree `../pmacs-dired-arc`) carries the framing commits only and is `../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
based on the now-superseded `0827dd1`; **rebase it onto the `main` framing merge #164). **A fresh cut, not a rebase:** the older `dired`
resulting from the framing PR before implementing**, or cut a fresh branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
branch — its framing commits become redundant once the docs PR lands. superseded `0827dd1` and carried only the framing content #164 already
- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the put on `main`, so merging it would have reconciled two histories of one
`dired` major mode + mode keymap; buffer-per-directory with lexical document. It is left untouched and carries nothing unmerged.
canonicalization and the ownership check; read-only intercept + - **Stage 1 implemented; no wire change (protocol stays v20).** What
`set_round_trip_input`; visit routing through `window.display_file`; landed on the branch:
parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`; - `builtin/runtime/dired.lua`: one buffer per directory named
cursor preservation by basename; the `dired.kill-when-opening` config `*dired:<canonical path>*` with the handle-table ownership check;
key; **and the tolerant `read_dir` opt** — the only Rust in the stage. read-only intercept + `set_round_trip_input`; the `dired` major mode
- The one Rust change is load-bearing and is why Stage 1 is not and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire basename cursor re-seating across every wholesale repaint;
listing** on any of five per-entry conditions, and the tolerant wrapper `display_file` for file visits and same-window reuse for directory
its own module doc delegates to package authors **cannot be written in descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
Lua** — the primitive returns one error and no partial vec. Loaded after `window.lua`.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` - `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
§20 Priority 1, which names this work explicitly; journey steps 7 and and one walk that either fails on a per-entry condition or records it
(partially) 3; **adds no interaction island** — keys are a mode-scoped (Q#DR6). `src/async_runtime.rs` carries the listing in
keymap, and wdired is a mode swap; adopts `pmacs.config` for `ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
`dired.kill-when-opening`; inherits §9's worker-attribution gap for its keys the Lua result **shape** on `errors.is_some()`, so the bare array
`read_dir` jobs without worsening it. the frozen M8.2 fixture consumes with `ipairs` is untouched;
`builtin/runtime/fs.lua` validates read-op opts and **rejects unknown
keys** (a typo'd `tolerant` used to degrade silently to fatal).
- `src/editor_core.rs` + `src/lua_bindings/mod.rs`:
`normalize_buffer_path` is `pub` and exposed as
`pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua
mirror exists and Stage 2 owes no mirror removal. This makes B2
("tolerant `read_dir` is the only Rust change") false by one small
binding, deliberately.
- `tests/dired_acceptance.rs`: 22 tests over framing items 116,
dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate.
- **The framing claim the substrate falsified (S1-2):** R2-3 expected a
dedicated dired panel to carry its dedication across a descent.
`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.
Dired does not unpin the user's panel; both arms are pinned.
- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the
descent *routing*. Dired holds focus in its own panel, so a raw
`switch_buffer` lands in the same window and every 3c assertion holds
either way. Dedication is the only discriminator, so the
dedicated-panel test is the real pin — and the vacuity is documented at
the assertion rather than relabelled.
- **The pre-existing test dired's first mode-scoped binding broke
(S1-4):** `describe_key_identifies_every_default_binding` asserted every
binding in the stack resolves through `describe.key` 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 global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`).
- Durable substrate facts, independent of this arc:
- `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed
buffer before removal, so `kill-when-opening` kills **after** the
replacement is displayed.
- Interactive origin does **not** survive an await: work resumed in
`tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*`
acts for the *ambient* active frontend (S1-9).
- Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an
entry says whether a symlink points at a directory; `RET` probes by
trying to list it (S1-8).
- A path-backed buffer's *name* is its full path, not its basename —
worth knowing before writing any name assertion.
- `C-x d` takes **no** completion source on purpose (S1-5): with one,
RET on an empty field opens whatever sorts first, and
RET-on-where-you-are is the gesture the binding exists for. The field
is prefilled instead.
- **Bite verification:** 15 claims, each mutated in place and required to
fail the test that names it. `dired.lua` is new, so `scripts/bite`'s
file swap does not apply; every mutation was applied and reverted with
`git checkout --`. One came back VACUOUS and is recorded above.
- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three
behavioral fixes, each bite-verified: `dired.revert`'s re-seat is
guarded on the active buffer (an ambient `move_to_line` after an await
moved an unrelated buffer's cursor — the buffer-level instance of
S1-9); `fmt_size` keeps the column width past ten digits, because
`_layout` is a contract Stage 3 is planned against; and the symlink
descent dropped its probe, since `open_directory`'s
changed-nothing-on-failure invariant *is* the probe (it was listing the
target directory twice). Plus a consecutive-`readdir`-error cap, because
**nothing cancels a dired listing** — it carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly relied
on. Naming/comment findings taken as-is.
- Durable process lesson, hit twice now: a mutation-bite helper restores
with `git checkout --`, which reverts to **HEAD** — so a fix must be
committed *before* it is bitten. Round 1's fixes were briefly wiped by
exactly that.
- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP
affinity #161) and again at `b889873` (GPU terminal input #166), both
merged rather than rebased per the #135/#137 precedent so the review
anchors stay addressable. Each conflict was a single doc hunk resolved
as the union: this lane owns COHERENCE's journey step 7 file half, #161
owns the in-flight list, #166 owns step 8's GPU-terminal addendum.
Three things worth carrying:
- **A conflicting PR silently stops running CI.** GitHub builds
`pull_request` runs against the merge ref, which does not exist while
the PR conflicts, so no run is created and nothing reports a
failure — the checks list simply stays as it was. Three pushes to
this branch produced no CI at all before the cause was found. Watch
`mergeable` on a long-lived lane, not just the check list.
- #161's own COHERENCE finding **falsified a claim in this lane's
module doc**: `pmacs.error` is never defined in production, so an
uncaught raise inside a `pmacs.async` coroutine does not reach
`*errors*` as the comment said. It reaches a bare `error()` inside
`pmacs._async.tick()`, whose result `tick_async` discards with
`let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` +
`set_status` load-bearing rather than tidy, and the comment now says
so.
- **A lane in review against a fast-moving `main` needs its gates rerun
per integration, not per push.** Main advanced twice inside this
review round, and the second time landed while the first
integration's sweep was still running. The numbers below describe the
twice-merged tree.
- Verification on the twice-merged tree (`main` @ `b889873`):
`cargo fmt --check` clean; strict workspace Clippy clean; **1,832
default + 2,009 CRDT** library tests; dired acceptance **25 default +
25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and
vterm Stage 3 5 (both suites main added, green under this lane's
`mod.rs` and `editor.rs` changes); M4 121; required GPU 155;
**isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93
suites, zero failures**; `git diff --check` clean. The sweep needs the
isolated config for the reason recorded in the bottom-panel lane
below.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20
Priority 1, which names this work explicitly; journey step 7's file half
goes from no surface to a surface; **adds no interaction island** — keys
are a mode-scoped keymap, and wdired will be a mode swap; adopts
`pmacs.config` for `dired.kill-when-opening`; inherits §9's
worker-attribution gap for its `read_dir` jobs without worsening it. The
audited claims this changes are updated in `COHERENCE.md` itself, per its
§25.
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut - **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there, 1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
not here. The two meet at `resolve_target_buffer`; dired supplies the not here — Stage 1 does **not** fix it. The two meet at
buffer a directory should resolve *to*, and `pmacs .` should route into `resolve_target_buffer`; dired supplies the buffer a directory should
it rather than growing a second directory surface. resolve *to*, and `pmacs .` should route into it rather than growing a
second directory surface.
## GPU terminal input lane — IN REVIEW ## GPU terminal input lane — IN REVIEW

View File

@ -70,6 +70,11 @@ commands, read `docs/active-work.md` immediately after this file.
against an open buffer yet fails to load one that is not open — against an open buffer yet fails to load one that is not open —
find-file expands the tilde Lua-side. Loading through the normalized find-file expands the tilde Lua-side. Loading through the normalized
path is a named deferral. path is a named deferral.
- **Stage 1 (the directory view) is IN REVIEW as PR #165** — the
builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and
`pmacs.path.canonicalize`. Its branch state, substrate facts, and
verification live in `docs/active-work.md`; this section absorbs them
when it merges.
- **GPU initial target LANDED — #148** - **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path

View File

@ -1,11 +1,14 @@
# Dired — framing # Dired — framing
**Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.** **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 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 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 implementation falsified in the approved text (§0); rev 5 adds the
**coherence impact** statement now required of every framing **coherence impact** statement now required of every framing
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5. Deliberately (`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 unnumbered: the roadmap's Arc 8 is GPU
structural parity but `docs/lean4-mode-framing.md` also claims Arc 8, so 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 the arc space is already forked in uncommitted work. (Rev 2 also cited
@ -203,6 +206,135 @@ the correction belongs here rather than only in the code.
same normalize-before-lookup family as Q#DR5's `apply_resource_op` same normalize-before-lookup family as Q#DR5's `apply_resource_op`
correction. 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) ## 0.5. Coherence impact (`COHERENCE.md` §20)
Required of every framing since #163. This arc was scouted and approved Required of every framing since #163. This arc was scouted and approved

View File

@ -71,8 +71,8 @@ use crossbeam::channel as cb_channel;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::fs::{ use crate::fs::{
FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking, FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking,
stat_blocking, remove_blocking, rename_blocking, stat_blocking,
}; };
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry}; use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle}; use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
@ -220,9 +220,10 @@ enum ReplyKind {
/// T M4.1. /// T M4.1.
Parse { duration_ms: u64 }, Parse { duration_ms: u64 },
/// `dispatch_fs_read_dir` completed; payload is the directory /// `dispatch_fs_read_dir` completed; payload is the directory
/// listing. The Vec is `Serialize` so it crosses the bus /// listing. The listing is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1. /// directly --- no side handoff like parse trees need. T M8.1; its
ReadDir(Vec<FsDirEntry>), /// per-entry error channel is dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` completed; payload is the per-path /// `dispatch_fs_stat` completed; payload is the per-path
/// metadata. T M8.1. /// metadata. T M8.1.
Stat(FsDirEntry), Stat(FsDirEntry),
@ -266,10 +267,11 @@ pub enum JobResult {
duration_ms: u64, duration_ms: u64,
}, },
/// `dispatch_fs_read_dir` produced a directory listing. The /// `dispatch_fs_read_dir` produced a directory listing. The
/// Lua boundary in [`crate::lua_bindings`] turns the Vec into a /// Lua boundary in [`crate::lua_bindings`] turns the entries into
/// per-entry table when `_take_result` consumes the result. /// per-entry tables when `_take_result` consumes the result, and
/// T M8.1. /// keys the result *shape* on whether the listing carries a
ReadDir(Vec<FsDirEntry>), /// per-entry error channel. T M8.1 / dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` produced metadata for a single path. The /// `dispatch_fs_stat` produced metadata for a single path. The
/// Lua boundary turns the [`FsDirEntry`] into the same table /// Lua boundary turns the [`FsDirEntry`] into the same table
/// shape `read_dir` entries use. T M8.1. /// shape `read_dir` entries use. T M8.1.
@ -832,11 +834,21 @@ impl AsyncRuntime {
/// `lstat`-style metadata. Polls cancel every batch of /// `lstat`-style metadata. Polls cancel every batch of
/// entries; supersede follows the same rule as the other /// entries; supersede follows the same rule as the other
/// dispatchers. T M8.1. /// dispatchers. T M8.1.
pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId { ///
/// `tolerance` selects the per-entry contract (dired Q#DR6):
/// [`ReadDirTolerance::Fatal`] is the original all-or-nothing
/// listing, [`ReadDirTolerance::PerEntry`] carries per-entry
/// failures alongside the entries that survived.
pub fn dispatch_fs_read_dir(
&self,
path: PathBuf,
tolerance: ReadDirTolerance,
supersede: Option<&str>,
) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None); let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let bus = self.workers.clone(); let bus = self.workers.clone();
self.pool.dispatch(move |_pool| { self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path); let kind = run_fs_read_dir(&cancel, &path, tolerance);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind }); let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
}); });
id id
@ -1038,8 +1050,8 @@ impl AsyncRuntime {
ReplyKind::Parse { duration_ms } => { ReplyKind::Parse { duration_ms } => {
PendingState::Complete(JobResult::Parse { duration_ms }) PendingState::Complete(JobResult::Parse { duration_ms })
} }
ReplyKind::ReadDir(entries) => { ReplyKind::ReadDir(listing) => {
PendingState::Complete(JobResult::ReadDir(entries)) PendingState::Complete(JobResult::ReadDir(listing))
} }
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)), ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)), ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)),
@ -1295,9 +1307,13 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind {
/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`]; /// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`];
/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the /// [`FsError::Io`] becomes [`ReplyKind::Error`] with the
/// human-readable message attached. /// human-readable message attached.
fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind { fn run_fs_read_dir(
match read_dir_blocking(path, cancel) { cancel: &CancellationToken,
Ok(entries) => ReplyKind::ReadDir(entries), path: &Path,
tolerance: ReadDirTolerance,
) -> ReplyKind {
match read_dir_blocking(path, cancel, tolerance) {
Ok(listing) => ReplyKind::ReadDir(listing),
Err(FsError::Cancelled) => ReplyKind::Cancelled, Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => { Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string()) ReplyKind::Error(e.to_string())

View File

@ -525,6 +525,17 @@ impl EditorState {
include_str!("../builtin/runtime/window.lua"), include_str!("../builtin/runtime/window.lua"),
) )
.expect("load window builtin chunk"); .expect("load window builtin chunk");
// Dired Stage 1: the directory view. Loaded AFTER window.lua,
// whose `window.panel-height` setting a `display = "panel"`
// listing resolves, and after the pre-runtime tables it drives
// (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` /
// `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above).
lua_host
.eval(
Some("@pmacs/builtin/runtime/dired.lua"),
include_str!("../builtin/runtime/dired.lua"),
)
.expect("load dired builtin chunk");
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT: // Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
// compile.lua must load AFTER lsp.lua. It takes over // compile.lua must load AFTER lsp.lua. It takes over
// `M-g n` / `M-g p` for the unified error dispatchers, and // `M-g n` / `M-g p` for the unified error dispatchers, and
@ -5706,17 +5717,25 @@ mod tests {
// ---- T M2.11 acceptance -------------------------------------------------- // ---- T M2.11 acceptance --------------------------------------------------
/// Every chord in the default global keymap must round-trip through /// Every chord in the default keymap must round-trip through
/// `pmacs.describe.key`: returning a non-nil table whose `command` /// `pmacs.describe.key`: returning a non-nil table whose `command`
/// matches the binding the keymap stack stores. /// matches the binding the keymap stack stores.
///
/// `describe.key` resolves against the **effective context**
/// (buffer-local → mode → global), so a mode-scoped default is
/// asserted with a buffer that carries that mode rather than
/// context-free. Dired is the first builtin to bind mode-scoped keys
/// (#129's first non-detection consumer), and without the mode in
/// place its `n` / `p` / `g` correctly resolve to nothing.
#[test] #[test]
fn describe_key_identifies_every_default_binding() { fn describe_key_identifies_every_default_binding() {
use crate::keymap_stack::Scope;
let s = EditorState::new(); let s = EditorState::new();
let kms = s.lua_host.keymaps().borrow(); let kms = s.lua_host.keymaps().borrow();
let bindings: Vec<(String, String)> = kms let bindings: Vec<(Scope, String, String)> = kms
.iter_all() .iter_all()
.into_iter() .into_iter()
.map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command)) .map(|(scope, seq, b)| (scope, crate::key::display_sequence(&seq), b.command))
.collect(); .collect();
drop(kms); drop(kms);
// Sanity floor: the default keymap binds at least the M1 surface. // Sanity floor: the default keymap binds at least the M1 surface.
@ -5725,18 +5744,50 @@ mod tests {
"default keymap unexpectedly small: {} bindings", "default keymap unexpectedly small: {} bindings",
bindings.len() bindings.len()
); );
let modes: usize = bindings
.iter()
.filter(|(scope, _, _)| matches!(scope, Scope::Mode(_)))
.count();
assert!(
modes >= 1,
"a mode-scoped default is expected since dired Stage 1; \
found none, so the mode arm below asserts nothing"
);
for (seq, expected_command) in &bindings { for (scope, seq, expected_command) in &bindings {
let mode = match scope {
Scope::Mode(name) => Some(name.clone()),
// No buffer-scoped defaults exist; a future one would
// need its own buffer context here.
Scope::Buffer(_) => continue,
Scope::Global => None,
};
// Set the context explicitly on EVERY iteration, including
// the global one: a mode left over from a previous iteration
// legitimately shadows a global binding of the same chord
// (dired's mode-scoped `RET` shadows
// `edit.newline-and-indent`, which is the point of the
// mode), so a leaked mode would make this assert the wrong
// thing.
let context = match &mode {
Some(name) => {
format!("pmacs.buffer.set_major_mode(pmacs.window.buffer(), {name:?}); ")
}
None => "pmacs.buffer.set_major_mode(pmacs.window.buffer(), nil); ".to_owned(),
};
let script = format!( let script = format!(
"local r = pmacs.describe.key({seq:?}); \ "{context}local r = pmacs.describe.key({seq:?}); \
if r == nil then return 'nil' else return r.command end" if r == nil then return 'nil' else return r.command end"
); );
let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| { let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| {
panic!("describe.key({seq}) raised: {e}"); panic!("describe.key({seq}) raised: {e}");
}); });
assert_eq!( assert_eq!(
&got, expected_command, &got,
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}" expected_command,
"describe.key for {seq:?} (scope {}) returned {got:?}, \
expected {expected_command:?}",
scope.render()
); );
} }
} }

View File

@ -4787,7 +4787,14 @@ fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
/// path's on-disk identity. Every step is best-effort — if `$HOME` /// path's on-disk identity. Every step is best-effort — if `$HOME`
/// or the cwd is unavailable the path is returned as far as it could /// or the cwd is unavailable the path is returned as far as it could
/// be resolved rather than panicking. /// be resolved rather than panicking.
fn normalize_buffer_path(path: PathBuf) -> PathBuf { ///
/// Public because dired needs the *same* canonical form the buffer
/// registry keys on (Q#DR2): its buffer-per-directory naming and
/// `find_buffer_for_path`'s dedup have to agree, and a Lua-side mirror
/// of this function would be a second implementation of a canonical
/// form — the tab-width-constants class in miniature. `pmacs.path
/// .canonicalize` is this function, not a copy of it.
pub fn normalize_buffer_path(path: PathBuf) -> PathBuf {
let path = expand_tilde(path); let path = expand_tilde(path);
let abs = if path.is_absolute() { let abs = if path.is_absolute() {
path path

331
src/fs.rs
View File

@ -42,6 +42,28 @@ use crate::worker::CancellationToken;
/// directories. /// directories.
const READDIR_CANCEL_POLL_EVERY: usize = 32; const READDIR_CANCEL_POLL_EVERY: usize = 32;
/// How many *consecutive* `readdir` iterator errors a tolerant listing
/// records before giving up and failing (dired Q#DR6).
///
/// [`std::fs::ReadDir`] is not obliged to terminate after yielding an
/// `Err`: a directory pulled out from under a stalled network mount can
/// keep producing them. Tolerant mode records-and-continues, so without
/// a bound that is an unbounded error vector on a worker thread.
///
/// Cancellation is **not** an adequate backstop here, which is the
/// reason this constant exists rather than a comment saying it is: a
/// dired listing carries no supersede key and nothing cancels it, so the
/// only thing that would stop the loop is the directory itself. A
/// directory whose iterator produces nothing but errors has no partial
/// answer worth rendering, so the listing fails with the last error the
/// way an unopenable directory does.
///
/// Deliberately untested: forcing a real `readdir` to yield errors
/// repeatedly is not portable, and faking it would need the walk to be
/// generic over its iterator — a refactor with no other consumer. The
/// counter resets on any entry that materializes.
const READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 1024;
/// One directory entry as returned by [`read_dir_blocking`]. /// One directory entry as returned by [`read_dir_blocking`].
/// ///
/// The shape is what `dired` / `magit-class` / `outline-class` /// The shape is what `dired` / `magit-class` / `outline-class`
@ -116,6 +138,59 @@ impl FsEntryKind {
} }
} }
/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6).
///
/// The M8.1 primitive was all-or-nothing: five per-entry conditions
/// failed the *entire* listing, which makes a plain refresh of a busy
/// directory (`/tmp`, a build tree) fail outright. The module doc used
/// to say a per-entry-tolerant wrapper was "the package's job" --- it
/// cannot be: the primitive hands Lua one structured error and no
/// partial vec, so there is nothing to be tolerant *with*.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReadDirTolerance {
/// Any per-entry failure fails the whole listing. The original
/// M8.1 contract, and still the default at every Lua call site
/// that does not opt in.
Fatal,
/// Per-entry failures are recorded in [`FsDirListing::errors`] and
/// enumeration continues. A failure on the *parent* `read_dir`
/// stays fatal (a directory you cannot open has no partial
/// answer), and so does a non-UTF-8 entry **name** --- see
/// [`FsError::NonUtf8Path`].
PerEntry,
}
/// One per-entry failure recorded by a tolerant [`read_dir_blocking`].
///
/// `name` is optional because a per-entry `readdir` *iterator* error
/// has no filename to report: the entry never materialized, and the
/// underlying error is about the parent directory. Every other arm has
/// an entry in hand and names it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirEntryError {
/// Basename of the entry that failed, when one is known.
pub name: Option<String>,
/// Rendered failure, already formatted for display.
pub message: String,
}
/// What [`read_dir_blocking`] returns: the entries it could read, plus
/// the per-entry failures when the caller asked to tolerate them.
///
/// `errors` is `None` under [`ReadDirTolerance::Fatal`] and `Some`
/// (possibly empty) under [`ReadDirTolerance::PerEntry`]. The
/// distinction is load-bearing at the Lua boundary: it is what selects
/// the bare-array result shape the M8.1 surface promises from the
/// `{ entries = …, errors = … }` shape the tolerant opt returns, so the
/// conversion never has to look the job back up.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirListing {
/// One entry per readable child, in filesystem iteration order.
pub entries: Vec<FsDirEntry>,
/// Per-entry failures; `None` in [`ReadDirTolerance::Fatal`] mode.
pub errors: Option<Vec<FsDirEntryError>>,
}
/// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] / /// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] /
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`]. /// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
/// ///
@ -192,50 +267,100 @@ pub enum FsError {
/// `to_string_lossy` would have mangled dired/wdired round-trips). /// `to_string_lossy` would have mangled dired/wdired round-trips).
/// ///
/// Errors on the *parent* `read_dir` call surface as /// Errors on the *parent* `read_dir` call surface as
/// [`FsError::Io`]. Errors on individual entries (a single broken /// [`FsError::Io`] regardless of `tolerance`: a directory you cannot
/// symlink, a permission-denied stat) currently propagate the same /// open has no partial answer.
/// way --- the cleanest behavior at this primitive layer is "fail ///
/// fast and let the caller decide whether a partial listing is /// Errors on individual entries (a permission-denied `lstat`, a child
/// acceptable"; dired-class will likely want a per-entry-tolerant /// unlinked between `readdir` and `lstat`, a `readlink` failure, a
/// wrapper but that's the package's job, not the primitive's. /// non-UTF-8 symlink target) are governed by `tolerance`. Under
/// [`ReadDirTolerance::Fatal`] they fail the whole listing, which is
/// the M8.1 contract every existing caller relies on; under
/// [`ReadDirTolerance::PerEntry`] they land in
/// [`FsDirListing::errors`] and enumeration continues (dired Q#DR6).
///
/// A non-UTF-8 entry **name** is fatal in both modes. That is not a
/// listing problem but a path-representation one: [`FsDirEntry::name`]
/// is a `String` and every `pmacs.fs` op takes a `String` path, so a
/// tolerantly-rendered non-UTF-8 name would be a name the caller could
/// not pass back through `rename`. Byte-preserving paths are the named
/// deferral (see [`FsError::NonUtf8Path`]). A non-UTF-8 *target*
/// differs in kind --- the entry's own name is fine and nothing needs
/// to round-trip the target --- so it joins the per-entry channel.
pub fn read_dir_blocking( pub fn read_dir_blocking(
path: &Path, path: &Path,
cancel: &CancellationToken, cancel: &CancellationToken,
) -> Result<Vec<FsDirEntry>, FsError> { tolerance: ReadDirTolerance,
) -> Result<FsDirListing, FsError> {
let iter = std::fs::read_dir(path).map_err(|source| FsError::Io { let iter = std::fs::read_dir(path).map_err(|source| FsError::Io {
path: path.display().to_string(), path: path.display().to_string(),
source, source,
})?; })?;
let mut out: Vec<FsDirEntry> = Vec::new(); let mut out: Vec<FsDirEntry> = Vec::new();
let mut errors: Option<Vec<FsDirEntryError>> =
matches!(tolerance, ReadDirTolerance::PerEntry).then(Vec::new);
let parent_str = path.display().to_string(); let parent_str = path.display().to_string();
let mut consecutive_entry_errors = 0usize;
for (i, entry_result) in iter.enumerate() { for (i, entry_result) in iter.enumerate() {
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() { if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
return Err(FsError::Cancelled); return Err(FsError::Cancelled);
} }
let entry = entry_result.map_err(|source| FsError::Io { let entry = match entry_result {
Ok(entry) => entry,
Err(source) => {
// R2-2: the entry never materialized, so there is no
// name to report and the error names the parent.
let error = FsError::Io {
path: parent_str.clone(), path: parent_str.clone(),
source, source,
})?;
let entry_path = entry.path();
let metadata = std::fs::symlink_metadata(&entry_path).map_err(|source| FsError::Io {
path: entry_path.display().to_string(),
source,
})?;
let kind = classify(&metadata);
let symlink_target = if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &parent_str)?),
Err(source) => {
return Err(FsError::Io {
path: entry_path.display().to_string(),
source,
});
}
}
} else {
None
}; };
consecutive_entry_errors += 1;
if consecutive_entry_errors > READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS {
return Err(error);
}
record_entry_error(&mut errors, None, error)?;
continue;
}
};
consecutive_entry_errors = 0;
let entry_path = entry.path();
// Resolved first so a later per-entry failure can name it.
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?; let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let metadata = match std::fs::symlink_metadata(&entry_path) {
Ok(metadata) => metadata,
Err(source) => {
record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
},
)?;
continue;
}
};
let kind = classify(&metadata);
let mut symlink_target = None;
if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
// A target we cannot represent leaves the entry in the
// listing with its target unknown, not the entry out of
// it: one weird symlink in `/tmp` used to take the
// whole directory down.
Ok(target) => match path_to_utf8_string(target.as_os_str(), &parent_str) {
Ok(target) => symlink_target = Some(target),
Err(error) => record_entry_error(&mut errors, Some(&name), error)?,
},
Err(source) => record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
},
)?,
}
}
out.push(FsDirEntry { out.push(FsDirEntry {
name, name,
kind, kind,
@ -246,7 +371,33 @@ pub fn read_dir_blocking(
symlink_target, symlink_target,
}); });
} }
Ok(out) Ok(FsDirListing {
entries: out,
errors,
})
}
/// Route one per-entry failure: append it to the tolerant channel, or
/// propagate it when the caller asked for the fatal contract.
///
/// `errors.is_none()` *is* [`ReadDirTolerance::Fatal`] --- keeping the
/// mode in the accumulator rather than passing it separately makes the
/// two impossible to disagree.
fn record_entry_error(
errors: &mut Option<Vec<FsDirEntryError>>,
name: Option<&str>,
error: FsError,
) -> Result<(), FsError> {
match errors {
Some(list) => {
list.push(FsDirEntryError {
name: name.map(ToOwned::to_owned),
message: error.to_string(),
});
Ok(())
}
None => Err(error),
}
} }
/// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns /// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns
@ -495,12 +646,23 @@ mod tests {
CancellationToken::new() CancellationToken::new()
} }
/// The fatal-mode shorthand every pre-Q#DR6 test used.
fn read_dir_fatal(path: &Path, cancel: &CancellationToken) -> Result<Vec<FsDirEntry>, FsError> {
read_dir_blocking(path, cancel, ReadDirTolerance::Fatal).map(|listing| {
assert!(
listing.errors.is_none(),
"fatal mode must not open a per-entry channel"
);
listing.entries
})
}
#[test] #[test]
fn read_dir_returns_entries_with_lstat_metadata() { fn read_dir_returns_entries_with_lstat_metadata() {
let td = tempfile::tempdir().expect("tempdir"); let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"hello").expect("write"); std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
std::fs::create_dir(td.path().join("subdir")).expect("mkdir"); std::fs::create_dir(td.path().join("subdir")).expect("mkdir");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir"); let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable(); names.sort_unstable();
assert_eq!(names, vec!["a.txt", "subdir"]); assert_eq!(names, vec!["a.txt", "subdir"]);
@ -516,7 +678,7 @@ mod tests {
let td = tempfile::tempdir().expect("tempdir"); let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write"); std::fs::write(td.path().join("real.txt"), b"x").expect("write");
symlink("real.txt", td.path().join("link")).expect("symlink"); symlink("real.txt", td.path().join("link")).expect("symlink");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir"); let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
let link = entries.iter().find(|e| e.name == "link").unwrap(); let link = entries.iter().find(|e| e.name == "link").unwrap();
assert_eq!(link.kind, FsEntryKind::Symlink); assert_eq!(link.kind, FsEntryKind::Symlink);
assert_eq!(link.symlink_target.as_deref(), Some("real.txt")); assert_eq!(link.symlink_target.as_deref(), Some("real.txt"));
@ -535,7 +697,7 @@ mod tests {
} }
let cancel = token(); let cancel = token();
cancel.cancel(); cancel.cancel();
let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel"); let err = read_dir_fatal(td.path(), &cancel).expect_err("must observe cancel");
assert!(matches!(err, FsError::Cancelled), "got {err:?}"); assert!(matches!(err, FsError::Cancelled), "got {err:?}");
} }
@ -627,7 +789,7 @@ mod tests {
fn read_dir_on_missing_path_reports_io_error() { fn read_dir_on_missing_path_reports_io_error() {
let td = tempfile::tempdir().expect("tempdir"); let td = tempfile::tempdir().expect("tempdir");
let missing = td.path().join("does-not-exist"); let missing = td.path().join("does-not-exist");
let err = read_dir_blocking(&missing, &token()).expect_err("must error"); let err = read_dir_fatal(&missing, &token()).expect_err("must error");
match err { match err {
FsError::Io { path, .. } => { FsError::Io { path, .. } => {
assert!( assert!(
@ -640,6 +802,109 @@ mod tests {
} }
} }
#[test]
fn read_dir_tolerant_opens_an_empty_error_channel_on_a_clean_directory() {
// `Some(vec![])` rather than `None` is the whole shape
// contract: the Lua boundary keys the bare-array-vs-table
// result on `errors.is_some()`, so a clean tolerant listing
// must still carry the channel.
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir");
assert_eq!(listing.entries.len(), 1);
assert_eq!(listing.errors.as_deref(), Some(&[][..]));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_tolerant_keeps_an_entry_whose_symlink_target_is_not_utf8() {
use std::os::unix::ffi::OsStrExt;
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
// A legal Unix symlink target that is not representable as a
// Rust `String`. Before Q#DR6 this single entry took the whole
// listing down.
symlink(
std::ffi::OsStr::from_bytes(b"tgt-\xff"),
td.path().join("weird"),
)
.expect("symlink");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir must survive a non-UTF-8 target");
let weird = listing
.entries
.iter()
.find(|e| e.name == "weird")
.expect("the entry itself must be listed");
assert_eq!(weird.kind, FsEntryKind::Symlink);
assert!(
weird.symlink_target.is_none(),
"an unrepresentable target reports as unknown"
);
assert!(
listing.entries.iter().any(|e| e.name == "real.txt"),
"the readable sibling must survive too"
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(errors[0].name.as_deref(), Some("weird"));
// The same directory under the fatal contract still fails
// whole-listing --- the opt is what changes behavior, not the
// walk.
let err = read_dir_fatal(td.path(), &token()).expect_err("fatal mode must still fail");
assert!(
matches!(err, FsError::NonUtf8Path { .. }),
"expected NonUtf8Path, got {err:?}"
);
}
#[test]
fn read_dir_tolerant_records_a_failed_lstat_and_lists_nothing_else_wrong() {
use std::os::unix::fs::PermissionsExt;
// Failure mode 1 from the framing: a directory readable but not
// searchable. `readdir` yields the names; every child `lstat`
// fails with EACCES.
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("no-search");
std::fs::create_dir(&dir).expect("mkdir");
std::fs::write(dir.join("child"), b"x").expect("write child");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400");
let searchable = std::fs::symlink_metadata(dir.join("child")).is_ok();
if searchable {
// Running as root (or on a filesystem that ignores the
// bits): the premise cannot be established, so assert
// nothing rather than pass vacuously.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
eprintln!("lstat still succeeds without search permission; skipping");
return;
}
let tolerant = read_dir_blocking(&dir, &token(), ReadDirTolerance::PerEntry);
let fatal = read_dir_fatal(&dir, &token());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
let listing = tolerant.expect("tolerant read_dir must not fail the listing");
assert!(
listing.entries.is_empty(),
"the unreadable child cannot be described: {:?}",
listing.entries
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(
errors[0].name.as_deref(),
Some("child"),
"an lstat failure has an entry in hand and must name it"
);
let err = fatal.expect_err("fatal mode must still fail the whole listing");
assert!(matches!(err, FsError::Io { .. }), "got {err:?}");
}
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
#[test] #[test]
fn read_dir_on_non_utf8_entry_name_reports_structured_error() { fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
@ -650,7 +915,7 @@ mod tests {
// Rust `String`. // Rust `String`.
let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name"); let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name");
std::fs::write(td.path().join(bad_name), b"").expect("write entry"); std::fs::write(td.path().join(bad_name), b"").expect("write entry");
let err = read_dir_blocking(td.path(), &token()).expect_err("must error on non-UTF-8"); let err = read_dir_fatal(td.path(), &token()).expect_err("must error on non-UTF-8");
match err { match err {
FsError::NonUtf8Path { parent, bytes } => { FsError::NonUtf8Path { parent, bytes } => {
assert!( assert!(

View File

@ -2439,6 +2439,7 @@ pub fn install(
)?; )?;
pmacs.set("instance", install_instance_module(lua, registry)?)?; pmacs.set("instance", install_instance_module(lua, registry)?)?;
pmacs.set("ansi", install_ansi_module(lua)?)?; pmacs.set("ansi", install_ansi_module(lua)?)?;
pmacs.set("path", install_path_module(lua)?)?;
pmacs.set("packages", install_packages_module(lua)?)?; pmacs.set("packages", install_packages_module(lua)?)?;
pmacs.set("state", install_state_module(lua)?)?; pmacs.set("state", install_state_module(lua)?)?;
pmacs.set("session", install_session_module(lua)?)?; pmacs.set("session", install_session_module(lua)?)?;
@ -3555,6 +3556,46 @@ impl UserData for AnsiParserLua {
} }
} }
/// Build the `pmacs.path.*` table: pure path arithmetic, no
/// filesystem access and no editor state.
///
/// `canonicalize(path)` is [`crate::editor_core::normalize_buffer_path`]
/// itself — the function the buffer registry's path keys already go
/// through on write and that `find_buffer_for_path` looks up with. It
/// expands a leading `~`, absolutizes against the process cwd, folds
/// `.` / `..` lexically, and drops redundant separators (so a trailing
/// slash disappears everywhere except at root). Symlinks are
/// deliberately **not** resolved: dired's `..` must return where the
/// user navigated from, and a not-yet-created "[new file]" path has
/// nothing to resolve.
///
/// Exposed rather than mirrored in Lua because dired keys one buffer per
/// directory on this form (Q#DR2). Two implementations that disagree on
/// an edge (`//tmp`, `~` with `HOME` unset, a `..` that would escape
/// root) would mint two buffers for one directory with no error
/// anywhere.
///
/// The result crosses the boundary through `to_string_lossy`, so a
/// non-UTF-8 `$HOME` (or a non-UTF-8 argument) can yield a Lua string
/// that no longer names the `PathBuf` the registry keys on. That is the
/// same limit `pmacs.fs` already documents — byte-preserving paths are
/// post-v0.1 work that widens every path in the API — and it is recorded
/// here so this binding is not read as an exception to it.
fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
let path = lua.create_table()?;
path.set(
"canonicalize",
lua.create_function(|_, raw: String| {
Ok(
crate::editor_core::normalize_buffer_path(std::path::PathBuf::from(raw))
.to_string_lossy()
.into_owned(),
)
})?,
)?;
Ok(path)
}
/// Build the `pmacs.ansi.*` table. The only entry today is /// Build the `pmacs.ansi.*` table. The only entry today is
/// `parser()`; future additions (e.g. an event-table-validator /// `parser()`; future additions (e.g. an event-table-validator
/// helper) live alongside it. /// helper) live alongside it.
@ -6479,6 +6520,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
Ok(t) Ok(t)
} }
/// Convert a settled `read_dir` listing to its Lua result value.
///
/// The shape is chosen by the listing itself (dired Q#DR6): a fatal-mode
/// listing carries no error channel and stays the **bare array** the
/// M8.1 surface documents --- the frozen M8.2 fixture consumes it with
/// `ipairs` --- while a tolerant listing becomes
/// `{ entries = { … }, errors = { { name = …?, message = … }, … } }`.
/// Keying on the payload rather than on the job keeps the additive
/// promise checkable in one place.
fn fs_dir_listing_to_lua(lua: &Lua, listing: crate::fs::FsDirListing) -> mlua::Result<mlua::Value> {
let entries = lua.create_table_with_capacity(listing.entries.len(), 0)?;
for (i, entry) in listing.entries.iter().enumerate() {
entries.set(i + 1, fs_dir_entry_to_lua(lua, entry)?)?;
}
let Some(errors) = listing.errors else {
return Ok(mlua::Value::Table(entries));
};
let rows = lua.create_table_with_capacity(errors.len(), 0)?;
for (i, error) in errors.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 2)?;
// `name` is absent for a per-entry `readdir` iterator error:
// the entry never materialized, so there is nothing to name.
if let Some(name) = &error.name {
row.set("name", name.as_str())?;
}
row.set("message", error.message.as_str())?;
rows.set(i + 1, row)?;
}
let out = lua.create_table_with_capacity(0, 2)?;
out.set("entries", entries)?;
out.set("errors", rows)?;
Ok(mlua::Value::Table(out))
}
fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result<mlua::Value> { fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result<mlua::Value> {
match payload { match payload {
StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))), StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))),
@ -6570,9 +6645,23 @@ pub fn install_async(
let rt = runtime.clone(); let rt = runtime.clone();
async_mod.set( async_mod.set(
"_dispatch_fs_read_dir", "_dispatch_fs_read_dir",
lua.create_function(move |_, (path, key): (String, Option<String>)| { lua.create_function(
Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref())) move |_, (path, key, tolerant): (String, Option<String>, Option<bool>)| {
})?, // dired Q#DR6: the tolerance is decided at dispatch
// and travels in the settled payload, so the result
// conversion below never has to look the job back up.
let tolerance = if tolerant == Some(true) {
crate::fs::ReadDirTolerance::PerEntry
} else {
crate::fs::ReadDirTolerance::Fatal
};
Ok(rt.dispatch_fs_read_dir(
std::path::PathBuf::from(path),
tolerance,
key.as_deref(),
))
},
)?,
)?; )?;
} }
@ -6777,16 +6866,15 @@ pub fn install_async(
i64::try_from(duration_ms).unwrap_or(i64::MAX), i64::try_from(duration_ms).unwrap_or(i64::MAX),
)); ));
} }
Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => { Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => {
// Lua surface for fs.read_dir settle: // Lua surface for fs.read_dir settle:
// status "ok", value = array of per-entry // status "ok", value = array of per-entry
// tables. T M8.1. // tables (T M8.1), or the
// `{ entries = …, errors = … }` table when the
// caller opted into per-entry tolerance
// (dired Q#DR6).
out.push_back(mlua::Value::String(lua.create_string("ok")?)); out.push_back(mlua::Value::String(lua.create_string("ok")?));
let t = lua.create_table_with_capacity(entries.len(), 0)?; out.push_back(fs_dir_listing_to_lua(lua, listing)?);
for (i, entry) in entries.into_iter().enumerate() {
t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?;
}
out.push_back(mlua::Value::Table(t));
} }
Some(JobOutcome::Complete(JobResult::Stat(entry))) => { Some(JobOutcome::Complete(JobResult::Stat(entry))) => {
// Lua surface for fs.stat settle: status // Lua surface for fs.stat settle: status
@ -6933,9 +7021,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
"ok", "ok",
mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)), mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)),
), ),
JobOutcome::Complete(JobResult::ReadDir(entries)) => ( JobOutcome::Complete(JobResult::ReadDir(listing)) => (
"ok", "ok",
mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)), mlua::Value::Integer(i64::try_from(listing.entries.len()).unwrap_or(i64::MAX)),
), ),
JobOutcome::Complete(JobResult::Stat(entry)) => { JobOutcome::Complete(JobResult::Stat(entry)) => {
("ok", mlua::Value::String(lua.create_string(&entry.name)?)) ("ok", mlua::Value::String(lua.create_string(&entry.name)?))

View File

@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String {
JobOutcome::Complete(JobResult::Parse { duration_ms }) => { JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
format!("ok (parse {duration_ms}ms)") format!("ok (parse {duration_ms}ms)")
} }
JobOutcome::Complete(JobResult::ReadDir(entries)) => { JobOutcome::Complete(JobResult::ReadDir(listing)) => {
format!("ok ({} entries)", entries.len()) // Per-entry failures (dired Q#DR6) are counted here too: a
// tolerant listing that dropped half a directory is not the
// same observable outcome as a clean one.
match listing.errors.as_deref() {
Some(errors @ [_, ..]) => format!(
"ok ({} entries, {} unreadable)",
listing.entries.len(),
errors.len()
),
_ => format!("ok ({} entries)", listing.entries.len()),
}
} }
JobOutcome::Complete(JobResult::Stat(entry)) => { JobOutcome::Complete(JobResult::Stat(entry)) => {
format!("ok (stat {:?})", entry.name) format!("ok (stat {:?})", entry.name)

1656
tests/dired_acceptance.rs Normal file

File diff suppressed because it is too large Load Diff