Merge pull request #165 from levineuwirth/dired-stage1
feat(dired): the directory view (Stage 1)
This commit is contained in:
commit
c8ec8f3718
36
COHERENCE.md
36
COHERENCE.md
|
|
@ -119,9 +119,10 @@ detailed in §1.1–§1.3: **substrate without surface**, **the silence
|
|||
asymmetry**, and **per-arc coherence debt**.
|
||||
|
||||
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
|
||||
panel Stage 1 (merged #155), multi-root LSP affinity (PR #161), the
|
||||
config registry foundation (merged #127).
|
||||
dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its
|
||||
Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155),
|
||||
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).
|
||||
- **Interactive file opening.** `pmacs.buffer.find_or_open`
|
||||
(`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
|
||||
(`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage
|
||||
0 (PR #162).
|
||||
time; a complete 1,384-line dired existed only as a frozen test
|
||||
fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired
|
||||
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,
|
||||
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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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.* |
|
||||
| 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 |
|
||||
|
|
@ -455,7 +460,8 @@ level is the one missing. Audited level-by-level:
|
|||
**Beginner** (should see: files, buffers, search, diagnostics, terminal,
|
||||
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
|
||||
is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but
|
||||
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
|
||||
each need it; building it once *before* dired's directory view and
|
||||
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.*`
|
||||
tables are the inspector's data model without a view; the
|
||||
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
|
||||
blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually
|
||||
cheap:** directory-argument handling; a find-file surface (in flight,
|
||||
PR #162); surfacing the LSP spawn failure with guidance (§1.2); a
|
||||
cheap:** directory-argument handling (the remaining half of step 3 —
|
||||
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
|
||||
`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The
|
||||
journey acceptance suite (§19) is the ratchet that keeps it fixed.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -12,7 +12,10 @@
|
|||
-- `symlink_target` is present only on symlink entries.
|
||||
-- `opts` may contain `supersede = "<key>"` to chain into the M3
|
||||
-- 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*,
|
||||
-- which is whatever the kernel's `readdir` syscall returns. On
|
||||
|
|
@ -69,24 +72,61 @@ end
|
|||
|
||||
local fs = {}
|
||||
|
||||
-- Shared opts.supersede extractor; raises on misshapen opts.
|
||||
local function supersede_key(opts, where)
|
||||
if opts == nil then return nil end
|
||||
-- Shared read-op opts parser; raises on misshapen opts.
|
||||
--
|
||||
-- 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
|
||||
error(where .. ": opts must be a table or nil, got " .. type(opts))
|
||||
end
|
||||
local k = opts.supersede
|
||||
if k ~= nil and type(k) ~= "string" then
|
||||
for key in pairs(opts) do
|
||||
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")
|
||||
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
|
||||
|
||||
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)
|
||||
if type(path) ~= "string" then
|
||||
error("pmacs.fs.read_dir: path must be a string, got " .. type(path))
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
@ -94,7 +134,8 @@ function fs.stat(path, opts)
|
|||
if type(path) ~= "string" then
|
||||
error("pmacs.fs.stat: path must be a string, got " .. type(path))
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ backlog.
|
|||
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||
or something else, and therefore has no authority by name alone.
|
||||
- Canonical base at this snapshot:
|
||||
`githubsucks/main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2
|
||||
landed-doc refresh #150, folding Stage 2 #149, the ledger refresh #147, web
|
||||
grammars HTML+CSS #146, and the LaTeX Stage 1 #144 / inline-math framing
|
||||
#145 pair; protocol v20).
|
||||
`githubsucks/main` @ `8c86d34` (the dired framing #164 atop find-file
|
||||
#162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap blank-slab fix
|
||||
#159, bottom-panel Stage 1 #155, the inline-math re-scout #154, the vterm
|
||||
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
|
||||
`d3fa632` and lagged badly. On the current destination, `origin` names
|
||||
the canonical URL. This difference is why all recovery begins by
|
||||
|
|
@ -51,7 +52,7 @@ git worktree list
|
|||
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.
|
||||
|
||||
## 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
|
||||
`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
|
||||
own docs PR off `githubsucks/main` @ `2af1ab3`, branch
|
||||
`githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The
|
||||
repo's `-framing`-branch convention (`vterm-framing`,
|
||||
`gpu-initial-target-framing`, `tab-width-parity-framing`).
|
||||
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
|
||||
approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
|
||||
implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
|
||||
(wdired) each get their own detailed framing after the prior stage lands.
|
||||
- **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
|
||||
`docs/agent-handoff.md` §1 per rule 3 below.
|
||||
- **Stage 1 (the dired view) is next and unstarted.** Branch `dired`
|
||||
(worktree `../pmacs-dired-arc`) carries the framing commits only and is
|
||||
based on the now-superseded `0827dd1`; **rebase it onto the `main`
|
||||
resulting from the framing PR before implementing**, or cut a fresh
|
||||
branch — its framing commits become redundant once the docs PR lands.
|
||||
- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the
|
||||
`dired` major mode + mode keymap; buffer-per-directory with lexical
|
||||
canonicalization and the ownership check; read-only intercept +
|
||||
`set_round_trip_input`; visit routing through `window.display_file`;
|
||||
parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`;
|
||||
cursor preservation by basename; the `dired.kill-when-opening` config
|
||||
key; **and the tolerant `read_dir` opt** — the only Rust in the stage.
|
||||
- The one Rust change is load-bearing and is why Stage 1 is not
|
||||
pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire
|
||||
listing** on any of five per-entry conditions, and the tolerant wrapper
|
||||
its own module doc delegates to package authors **cannot be written in
|
||||
Lua** — the primitive returns one error and no partial vec.
|
||||
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md`
|
||||
§20 Priority 1, which names this work explicitly; journey steps 7 and
|
||||
(partially) 3; **adds no interaction island** — keys are a mode-scoped
|
||||
keymap, and wdired is 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.
|
||||
- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree
|
||||
`../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
|
||||
framing merge #164). **A fresh cut, not a rebase:** the older `dired`
|
||||
branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
|
||||
superseded `0827dd1` and carried only the framing content #164 already
|
||||
put on `main`, so merging it would have reconciled two histories of one
|
||||
document. It is left untouched and carries nothing unmerged.
|
||||
- **Stage 1 implemented; no wire change (protocol stays v20).** What
|
||||
landed on the branch:
|
||||
- `builtin/runtime/dired.lua`: one buffer per directory named
|
||||
`*dired:<canonical path>*` with the handle-table ownership check;
|
||||
read-only intercept + `set_round_trip_input`; the `dired` major mode
|
||||
and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
|
||||
basename cursor re-seating across every wholesale repaint;
|
||||
`display_file` for file visits and same-window reuse for directory
|
||||
descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
|
||||
Loaded after `window.lua`.
|
||||
- `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
|
||||
and one walk that either fails on a per-entry condition or records it
|
||||
(Q#DR6). `src/async_runtime.rs` carries the listing in
|
||||
`ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
|
||||
keys the Lua result **shape** on `errors.is_some()`, so the bare array
|
||||
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 1–16,
|
||||
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
|
||||
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
|
||||
not here. The two meet at `resolve_target_buffer`; dired supplies the
|
||||
buffer a directory should resolve *to*, and `pmacs .` should route into
|
||||
it rather than growing a second directory surface.
|
||||
not here — Stage 1 does **not** fix it. The two meet at
|
||||
`resolve_target_buffer`; dired supplies the buffer a directory should
|
||||
resolve *to*, and `pmacs .` should route into it rather than growing a
|
||||
second directory surface.
|
||||
|
||||
## GPU terminal input lane — IN REVIEW
|
||||
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
find-file expands the tilde Lua-side. Loading through the normalized
|
||||
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**
|
||||
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
|
||||
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# 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 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's
|
||||
implementation falsified in the approved text (§0); rev 5 adds the
|
||||
**coherence impact** statement now required of every framing
|
||||
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5. Deliberately
|
||||
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5; rev 6 records what Stage
|
||||
1's implementation falsified (§0, S1-1…S1-9); rev 7 adds what its first
|
||||
review round found (§0, S1-10…S1-12). Deliberately
|
||||
unnumbered: the roadmap's Arc 8 is GPU
|
||||
structural parity but `docs/lean4-mode-framing.md` also claims Arc 8, so
|
||||
the arc space is already forked in uncommitted work. (Rev 2 also cited
|
||||
|
|
@ -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`
|
||||
correction.
|
||||
|
||||
### Stage 1 implementation notes (rev 5 → rev 6)
|
||||
|
||||
Implementing Stage 1 (PR #165) falsified four things the approved text
|
||||
asserted and settled five it left open. Recorded here rather than only
|
||||
in the code, per the rev-4 precedent.
|
||||
|
||||
- **S1-1. The normalizer is EXPOSED, not mirrored — so B2 is partly
|
||||
false, in the direction Q#DR2 preferred.** Q#DR2 made the mirror
|
||||
conditional (`Stage 1 may still mirror if exposure turns out to drag
|
||||
in EditorCore borrow plumbing it does not otherwise need`).
|
||||
`normalize_buffer_path` is a **free function** (`editor_core.rs`), so
|
||||
exposure drags in nothing: it is now `pub` and reachable as
|
||||
`pmacs.path.canonicalize`. Consequences, all deliberate: B2 ("tolerant
|
||||
`read_dir` is the only Rust change Stage 1 needs") is false by one
|
||||
small binding; acceptance 3b degenerates to the round-trip form the
|
||||
framing described; and the Stage 2 mirror-removal follow-up **is not
|
||||
owed** — there is no second canonical form to remove. The parity
|
||||
acceptance is still carried, now as "the Lua binding and the Rust
|
||||
function agree over one shared edge list", which is exactly the claim
|
||||
a future re-mirroring would break.
|
||||
- **S1-2. R2-3's dedication claim is falsified by the substrate.** It
|
||||
read "a dedicated dired panel stays dedicated across descent and the
|
||||
new dired buffer inherits it". `display_buffer` never replaces the
|
||||
buffer in a slot dedicated to another one: it discards every
|
||||
side-specific parameter and falls back to the document window (Q#BP3
|
||||
2.iii), and the exact-window arm errors outright. Dired therefore does
|
||||
**not** try to unpin the user's panel — which is also what Emacs's
|
||||
`display-buffer` does with a dedicated window. Acceptance 3c is split:
|
||||
a non-dedicated panel keeps the descent, and a dedicated one keeps its
|
||||
buffer *and* its pin while the new directory appears in the document
|
||||
window.
|
||||
- **S1-3. Acceptance 3c cannot pin the descent ROUTING, and the test now
|
||||
says so.** Dired holds the focus in its own panel, so a raw
|
||||
`switch_buffer` lands in that same window and every 3c assertion holds
|
||||
either way — the mutation is *vacuous* against it. Dedication is the
|
||||
only thing that distinguishes `display { side = … }` from the raw
|
||||
switch, so the dedicated-panel test is the discriminating pin. Found
|
||||
by running the bite rather than by reading the test; the vacuity is
|
||||
documented at the assertion instead of being left to be believed.
|
||||
- **S1-4. Dired is the first builtin to bind a mode-scoped key, and one
|
||||
pre-existing lib test assumed none existed.**
|
||||
`describe_key_identifies_every_default_binding` iterated *every*
|
||||
binding in the stack and asserted `pmacs.describe.key` resolves it
|
||||
context-free, which held only while the modes table was empty. It now
|
||||
sets the effective context per binding — and explicitly *clears* the
|
||||
mode for a global one, because a mode left over from a previous
|
||||
iteration legitimately shadows a global chord of the same name
|
||||
(dired's `RET` shadows `edit.newline-and-indent`, which is the point
|
||||
of the mode).
|
||||
- **S1-5. `C-x d` deliberately takes NO completion source.** It is the
|
||||
direct consequence of S0-1/S0-4: with a `files` source, RET on an
|
||||
empty field opens whatever sorts first (the minibuffer selects
|
||||
candidate 0 whenever the list is non-empty, and a selected candidate
|
||||
shadows typed text), and RET-on-the-directory-you-are-in is exactly
|
||||
the gesture `C-x d` exists for. The field is **prefilled** with the
|
||||
current directory instead — Emacs's own shape here — and free text
|
||||
always reaches `on_accept` because `CompletionSource::None` bypasses
|
||||
candidate resolution entirely. Directory-name completion is what dired
|
||||
itself replaces.
|
||||
- **S1-6. Ownership is the handle table ALONE**, narrower than Q#DR2's
|
||||
"present in dired's handle table, or `major_mode(buf) == "dired"`". A
|
||||
foreign buffer that carries the mode *is* the case the check exists to
|
||||
refuse, and a builtin's handle table cannot be lost the way a
|
||||
reloadable package's can. Acceptance 4 sets the mode on the foreign
|
||||
buffer to pin the stronger reading.
|
||||
- **S1-7. The mark column ships in Stage 1, rendered blank.** Q#DR4 is a
|
||||
Stage 2 decision, but reserving the two columns now means Stage 2 does
|
||||
not move every offset and Stage 3's column-classifying intercept can
|
||||
be written against constants that did not shift under it. The
|
||||
constants are computed from the widths (the fixture hardcoded
|
||||
`NAME_START = 39` and paid for it in every wdired test) and exported
|
||||
as `pmacs.dired._layout` so acceptance cannot drift from them.
|
||||
- **S1-8. A symlinked directory needs a probe, because kinds are
|
||||
lstat-based.** Both `read_dir` and `stat` report a link as
|
||||
`"symlink"`, so nothing in the entry says whether it points at a
|
||||
directory. `RET` on a symlink therefore *tries* to list the target
|
||||
(one extra syscall, on symlink lines only) and descends if that
|
||||
succeeds, else visits it as a file. Q#DR10 specified only the
|
||||
dir/file arms; this is the third.
|
||||
- **S1-9. Interactive origin does not survive the await.** Every listing
|
||||
is worker-dispatched, so the work after the first `:await()` resumes
|
||||
inside `tick_async`, where `InteractiveCommandOrigin` is empty and
|
||||
`pmacs.window.*` falls back to the **ambient** active frontend. Single
|
||||
frontend: correct. Multi-frontend: a dired opened from peer B while A
|
||||
is ambient would display for A. Not fixable from Lua (the display
|
||||
surface takes no frontend argument) and named here rather than
|
||||
discovered later.
|
||||
|
||||
### Stage 1 review round 1 (rev 6 → rev 7)
|
||||
|
||||
Three findings changed behavior; the rest were naming and comments. Each
|
||||
fix is bite-verified against the test that names it.
|
||||
|
||||
- **S1-10. An ambient re-seat is not safe after an await.** `dired.revert`
|
||||
painted its own buffer by name (safe) and then re-seated through
|
||||
`pmacs.editor.move_to_line`, which moves whatever window is
|
||||
**active** — so a user who switched buffers while the re-read was in
|
||||
flight had an unrelated buffer's cursor moved to a line index
|
||||
meaningful only in the dired listing. This is the buffer-level instance
|
||||
of the hazard S1-9 named at the frontend level, and it generalizes: in
|
||||
this codebase, *painting takes a buffer and seating takes the world*.
|
||||
Any post-await cursor operation needs an active-buffer guard;
|
||||
`open_directory` is exempt only because it displays the buffer first.
|
||||
- **S1-11. The rendered columns are a contract, so precision yields to
|
||||
width.** `%10d` overflowed at 10 GB (VM images, core dumps), widening
|
||||
the size field and shifting mtime and name right on that line alone.
|
||||
Cosmetically harmless today, but `_layout` is exported and Stage 3's
|
||||
column-classifying intercept is planned against it, so a
|
||||
contract-violating line now is a Stage 3 trap. `fmt_size` took
|
||||
`fmt_mtime`'s shape: exact bytes while they fit, else a fixed-width
|
||||
magnitude. Not the deferred human-readable column (§13) — the exact
|
||||
count still renders right up to the point where it cannot.
|
||||
- **S1-12. `open_directory`'s "changed nothing on failure" invariant is
|
||||
reusable as a PROBE.** S1-8's symlink descent originally listed the
|
||||
target to learn its kind and then opened it — two full listings of the
|
||||
same directory. Because a failed open touches no editor state
|
||||
(acceptance 15), the open itself is the probe: try the descent, fall
|
||||
back to `display_file`. One read. The comment that claimed "one
|
||||
syscall" for a full `read_dir` is corrected rather than left as a
|
||||
cost claim nobody would re-check.
|
||||
|
||||
Also, on the tolerant channel (Q#DR6): a `readdir` iterator may keep
|
||||
yielding errors without terminating, and **cancellation is not a backstop
|
||||
for a dired listing** — it carries no supersede key, so nothing cancels
|
||||
it. A consecutive-error cap now fails the listing the way an unopenable
|
||||
directory fails, rather than accumulating error rows on a worker thread.
|
||||
It is deliberately untested: faking a failing iterator would need the
|
||||
walk generic over it, a refactor with no other consumer.
|
||||
|
||||
## 0.5. Coherence impact (`COHERENCE.md` §20)
|
||||
|
||||
Required of every framing since #163. This arc was scouted and approved
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ use crossbeam::channel as cb_channel;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fs::{
|
||||
FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking,
|
||||
stat_blocking,
|
||||
FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking,
|
||||
remove_blocking, rename_blocking, stat_blocking,
|
||||
};
|
||||
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
|
||||
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
|
||||
|
|
@ -220,9 +220,10 @@ enum ReplyKind {
|
|||
/// T M4.1.
|
||||
Parse { duration_ms: u64 },
|
||||
/// `dispatch_fs_read_dir` completed; payload is the directory
|
||||
/// listing. The Vec is `Serialize` so it crosses the bus
|
||||
/// directly --- no side handoff like parse trees need. T M8.1.
|
||||
ReadDir(Vec<FsDirEntry>),
|
||||
/// listing. The listing is `Serialize` so it crosses the bus
|
||||
/// directly --- no side handoff like parse trees need. T M8.1; its
|
||||
/// per-entry error channel is dired Q#DR6.
|
||||
ReadDir(FsDirListing),
|
||||
/// `dispatch_fs_stat` completed; payload is the per-path
|
||||
/// metadata. T M8.1.
|
||||
Stat(FsDirEntry),
|
||||
|
|
@ -266,10 +267,11 @@ pub enum JobResult {
|
|||
duration_ms: u64,
|
||||
},
|
||||
/// `dispatch_fs_read_dir` produced a directory listing. The
|
||||
/// Lua boundary in [`crate::lua_bindings`] turns the Vec into a
|
||||
/// per-entry table when `_take_result` consumes the result.
|
||||
/// T M8.1.
|
||||
ReadDir(Vec<FsDirEntry>),
|
||||
/// Lua boundary in [`crate::lua_bindings`] turns the entries into
|
||||
/// per-entry tables when `_take_result` consumes the result, and
|
||||
/// keys the result *shape* on whether the listing carries a
|
||||
/// per-entry error channel. T M8.1 / dired Q#DR6.
|
||||
ReadDir(FsDirListing),
|
||||
/// `dispatch_fs_stat` produced metadata for a single path. The
|
||||
/// Lua boundary turns the [`FsDirEntry`] into the same table
|
||||
/// shape `read_dir` entries use. T M8.1.
|
||||
|
|
@ -832,11 +834,21 @@ impl AsyncRuntime {
|
|||
/// `lstat`-style metadata. Polls cancel every batch of
|
||||
/// entries; supersede follows the same rule as the other
|
||||
/// 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 bus = self.workers.clone();
|
||||
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 });
|
||||
});
|
||||
id
|
||||
|
|
@ -1038,8 +1050,8 @@ impl AsyncRuntime {
|
|||
ReplyKind::Parse { duration_ms } => {
|
||||
PendingState::Complete(JobResult::Parse { duration_ms })
|
||||
}
|
||||
ReplyKind::ReadDir(entries) => {
|
||||
PendingState::Complete(JobResult::ReadDir(entries))
|
||||
ReplyKind::ReadDir(listing) => {
|
||||
PendingState::Complete(JobResult::ReadDir(listing))
|
||||
}
|
||||
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
|
||||
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::Io`] becomes [`ReplyKind::Error`] with the
|
||||
/// human-readable message attached.
|
||||
fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind {
|
||||
match read_dir_blocking(path, cancel) {
|
||||
Ok(entries) => ReplyKind::ReadDir(entries),
|
||||
fn run_fs_read_dir(
|
||||
cancel: &CancellationToken,
|
||||
path: &Path,
|
||||
tolerance: ReadDirTolerance,
|
||||
) -> ReplyKind {
|
||||
match read_dir_blocking(path, cancel, tolerance) {
|
||||
Ok(listing) => ReplyKind::ReadDir(listing),
|
||||
Err(FsError::Cancelled) => ReplyKind::Cancelled,
|
||||
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
|
||||
ReplyKind::Error(e.to_string())
|
||||
|
|
|
|||
|
|
@ -525,6 +525,17 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/window.lua"),
|
||||
)
|
||||
.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.lua must load AFTER lsp.lua. It takes over
|
||||
// `M-g n` / `M-g p` for the unified error dispatchers, and
|
||||
|
|
@ -5706,17 +5717,25 @@ mod tests {
|
|||
|
||||
// ---- 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`
|
||||
/// 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]
|
||||
fn describe_key_identifies_every_default_binding() {
|
||||
use crate::keymap_stack::Scope;
|
||||
let s = EditorState::new();
|
||||
let kms = s.lua_host.keymaps().borrow();
|
||||
let bindings: Vec<(String, String)> = kms
|
||||
let bindings: Vec<(Scope, String, String)> = kms
|
||||
.iter_all()
|
||||
.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();
|
||||
drop(kms);
|
||||
// Sanity floor: the default keymap binds at least the M1 surface.
|
||||
|
|
@ -5725,18 +5744,50 @@ mod tests {
|
|||
"default keymap unexpectedly small: {} bindings",
|
||||
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!(
|
||||
"local r = pmacs.describe.key({seq:?}); \
|
||||
"{context}local r = pmacs.describe.key({seq:?}); \
|
||||
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| {
|
||||
panic!("describe.key({seq}) raised: {e}");
|
||||
});
|
||||
assert_eq!(
|
||||
&got, expected_command,
|
||||
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}"
|
||||
&got,
|
||||
expected_command,
|
||||
"describe.key for {seq:?} (scope {}) returned {got:?}, \
|
||||
expected {expected_command:?}",
|
||||
scope.render()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
/// or the cwd is unavailable the path is returned as far as it could
|
||||
/// 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 abs = if path.is_absolute() {
|
||||
path
|
||||
|
|
|
|||
329
src/fs.rs
329
src/fs.rs
|
|
@ -42,6 +42,28 @@ use crate::worker::CancellationToken;
|
|||
/// directories.
|
||||
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`].
|
||||
///
|
||||
/// 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`] /
|
||||
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
|
||||
///
|
||||
|
|
@ -192,50 +267,100 @@ pub enum FsError {
|
|||
/// `to_string_lossy` would have mangled dired/wdired round-trips).
|
||||
///
|
||||
/// Errors on the *parent* `read_dir` call surface as
|
||||
/// [`FsError::Io`]. Errors on individual entries (a single broken
|
||||
/// symlink, a permission-denied stat) currently propagate the same
|
||||
/// way --- the cleanest behavior at this primitive layer is "fail
|
||||
/// fast and let the caller decide whether a partial listing is
|
||||
/// acceptable"; dired-class will likely want a per-entry-tolerant
|
||||
/// wrapper but that's the package's job, not the primitive's.
|
||||
/// [`FsError::Io`] regardless of `tolerance`: a directory you cannot
|
||||
/// open has no partial answer.
|
||||
///
|
||||
/// Errors on individual entries (a permission-denied `lstat`, a child
|
||||
/// unlinked between `readdir` and `lstat`, a `readlink` failure, a
|
||||
/// 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(
|
||||
path: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<FsDirEntry>, FsError> {
|
||||
tolerance: ReadDirTolerance,
|
||||
) -> Result<FsDirListing, FsError> {
|
||||
let iter = std::fs::read_dir(path).map_err(|source| FsError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
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 mut consecutive_entry_errors = 0usize;
|
||||
for (i, entry_result) in iter.enumerate() {
|
||||
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
|
||||
return Err(FsError::Cancelled);
|
||||
}
|
||||
let entry = entry_result.map_err(|source| FsError::Io {
|
||||
path: parent_str.clone(),
|
||||
source,
|
||||
})?;
|
||||
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(),
|
||||
source,
|
||||
};
|
||||
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();
|
||||
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 {
|
||||
// Resolved first so a later per-entry failure can name it.
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
|
||||
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 {
|
||||
name,
|
||||
kind,
|
||||
|
|
@ -246,7 +371,33 @@ pub fn read_dir_blocking(
|
|||
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
|
||||
|
|
@ -495,12 +646,23 @@ mod tests {
|
|||
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]
|
||||
fn read_dir_returns_entries_with_lstat_metadata() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
|
||||
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();
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, vec!["a.txt", "subdir"]);
|
||||
|
|
@ -516,7 +678,7 @@ mod tests {
|
|||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
|
||||
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();
|
||||
assert_eq!(link.kind, FsEntryKind::Symlink);
|
||||
assert_eq!(link.symlink_target.as_deref(), Some("real.txt"));
|
||||
|
|
@ -535,7 +697,7 @@ mod tests {
|
|||
}
|
||||
let cancel = token();
|
||||
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:?}");
|
||||
}
|
||||
|
||||
|
|
@ -627,7 +789,7 @@ mod tests {
|
|||
fn read_dir_on_missing_path_reports_io_error() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
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 {
|
||||
FsError::Io { path, .. } => {
|
||||
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"))]
|
||||
#[test]
|
||||
fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
|
||||
|
|
@ -650,7 +915,7 @@ mod tests {
|
|||
// Rust `String`.
|
||||
let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name");
|
||||
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 {
|
||||
FsError::NonUtf8Path { parent, bytes } => {
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -2439,6 +2439,7 @@ pub fn install(
|
|||
)?;
|
||||
pmacs.set("instance", install_instance_module(lua, registry)?)?;
|
||||
pmacs.set("ansi", install_ansi_module(lua)?)?;
|
||||
pmacs.set("path", install_path_module(lua)?)?;
|
||||
pmacs.set("packages", install_packages_module(lua)?)?;
|
||||
pmacs.set("state", install_state_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
|
||||
/// `parser()`; future additions (e.g. an event-table-validator
|
||||
/// helper) live alongside it.
|
||||
|
|
@ -6479,6 +6520,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
|
|||
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> {
|
||||
match payload {
|
||||
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();
|
||||
async_mod.set(
|
||||
"_dispatch_fs_read_dir",
|
||||
lua.create_function(move |_, (path, key): (String, Option<String>)| {
|
||||
Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref()))
|
||||
})?,
|
||||
lua.create_function(
|
||||
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),
|
||||
));
|
||||
}
|
||||
Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => {
|
||||
Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => {
|
||||
// Lua surface for fs.read_dir settle:
|
||||
// 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")?));
|
||||
let t = lua.create_table_with_capacity(entries.len(), 0)?;
|
||||
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));
|
||||
out.push_back(fs_dir_listing_to_lua(lua, listing)?);
|
||||
}
|
||||
Some(JobOutcome::Complete(JobResult::Stat(entry))) => {
|
||||
// Lua surface for fs.stat settle: status
|
||||
|
|
@ -6933,9 +7021,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
|
|||
"ok",
|
||||
mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)),
|
||||
),
|
||||
JobOutcome::Complete(JobResult::ReadDir(entries)) => (
|
||||
JobOutcome::Complete(JobResult::ReadDir(listing)) => (
|
||||
"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)) => {
|
||||
("ok", mlua::Value::String(lua.create_string(&entry.name)?))
|
||||
|
|
|
|||
|
|
@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String {
|
|||
JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
|
||||
format!("ok (parse {duration_ms}ms)")
|
||||
}
|
||||
JobOutcome::Complete(JobResult::ReadDir(entries)) => {
|
||||
format!("ok ({} entries)", entries.len())
|
||||
JobOutcome::Complete(JobResult::ReadDir(listing)) => {
|
||||
// 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)) => {
|
||||
format!("ok (stat {:?})", entry.name)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue