pmacs/tests/fixtures/pmacs-magit/init.lua

722 lines
25 KiB
Lua

-- pmacs-magit/init.lua --- Foldable section view (T M8.5).
--
-- The magit-class entry of M8's three universality-proof packages.
-- Validates the *selective rendering* shape: a buffer holds the
-- full rendered text; a separate visible buffer is the projection
-- the user navigates, rebuilt on each fold operation. The fold
-- state lives in a Lua table per handle, not in any rope.
--
-- This v0.1 covers the read-only foldable-section primitive.
-- Git status integration (M8.6) and gestures (M8.7) build on it.
--
-- Public surface:
--
-- local magit = require("pmacs-magit")
-- magit.open {
-- id = "root",
-- title = "Demo",
-- children = {
-- { id = "a", title = "Section A", body = "line1\nline2" },
-- { id = "b", title = "Section B", body = "line3" },
-- },
-- }
--
-- -- inside a magit-section buffer:
-- -- TAB -> toggle fold of section under cursor
-- -- M-x pmacs-magit.fold-all
-- -- M-x pmacs-magit.unfold-all
--
-- Architecture (T M8.5 Plan A --- two-buffer projection):
--
-- * source buffer: canonical rope, held in the handle. Its rope is
-- the full rendered text (every section, header + body, regardless
-- of fold state). Folding never rewrites this buffer. pmacs v0.1
-- has no hidden-buffer API, so the source buffer is protected by a
-- read-only intercept rather than relying on a naming convention.
--
-- * visible buffer: what the user sees. Rebuilt
-- on every fold operation as the projection of (flat) through
-- (fold_state). The user navigates this buffer directly;
-- C-n / C-p / etc. naturally skip folded content because it
-- isn't in the visible buffer at all.
--
-- * fold_state: Lua table mapping section_id to "collapsed" or
-- "expanded" (default). Lives in the handle; survives any
-- repaint of the visible buffer (which is the M8.5 acceptance
-- bullet "folding state survives buffer redraw and view
-- repaint").
--
-- * intercept on the visible buffer rejects user edits. The
-- package's own paint operations bypass the intercept via the
-- painting flag (same shape as dired-class, with the same
-- pcall guard --- M8.4 audit finding 2).
--
-- * cursor reseat: every repaint captures the section under
-- cursor first, repaints, then moves the cursor to the same
-- section's new header line. Without this the engine's stale
-- byte-offset behavior (documented in
-- builtin/commands/default.lua:308) leaves the cursor at an
-- arbitrary spot after fold-all / unfold-all / a body-line
-- toggle.
local fold = require("pmacs-magit.fold")
local status = require("pmacs-magit.status")
local gestures = require("pmacs-magit.gestures")
local M = {}
-- ---------------------------------------------------------------------------
-- Per-buffer state
-- ---------------------------------------------------------------------------
local handles = {}
local function cleanup_handle(handle)
-- Flip the alive flag first so the M8.6 polling loop exits at its
-- next wake-up rather than running one more refresh against a
-- dead handle.
handle.alive = false
if handle.intercept then
pmacs.buffer.remove_intercept(handle.intercept)
handle.intercept = nil
end
if handle.source_intercept then
pmacs.buffer.remove_intercept(handle.source_intercept)
handle.source_intercept = nil
end
if handle.source then
if pmacs.buffer.kill then
pcall(pmacs.buffer.kill, handle.source)
end
handle.source = nil
end
-- M8.7 commit-message buffer (if one's open). Killing it leaves
-- whatever was there as scratch text in the user's mental model;
-- since the magit handle this buffer was attached to is going
-- away, there's nothing to do with the message anyway.
if handle.commit_session then
if pmacs.buffer.kill then
pcall(pmacs.buffer.kill, handle.commit_session.buffer)
end
handle.commit_session = nil
end
end
local function find_handle(visible_buf)
-- Same shape as dired-class: probe :is_valid() so a removed
-- magit buffer doesn't keep its handle alive forever.
-- (M8.4 finding 3.)
local live = {}
local found = nil
for _, h in ipairs(handles) do
local ok, valid = pcall(h.visible.is_valid, h.visible)
if ok and valid then
live[#live + 1] = h
if h.visible == visible_buf then
found = h
end
else
cleanup_handle(h)
end
end
handles = live
return found
end
local function active_handle()
return find_handle(pmacs.window.buffer())
end
-- Locate the magit handle whose `*magit-commit*` buffer is currently
-- active. Used by the `commit-submit` / `commit-cancel` commands,
-- which are bound on the commit buffer (so `active_handle()` returns
-- nil --- the active buffer isn't a magit *visible* buffer). Walks
-- the handle list since the commit buffer's identity isn't part of
-- the keymap-stack binding context.
local function commit_session_handle()
local active = pmacs.window.buffer()
for _, h in ipairs(handles) do
if h.commit_session and h.commit_session.buffer == active then
return h
end
end
return nil
end
-- ---------------------------------------------------------------------------
-- Painting
-- ---------------------------------------------------------------------------
--
-- Paint a string into a buffer with the painting-flag bypass. The
-- intercept attached to the visible buffer rejects all user edits
-- and lets package writes pass; the flag is the discriminator.
-- The full op runs inside pcall so the flag is guaranteed to clear
-- even if `:replace` (or `:len`) raises --- same shape as
-- dired-class's `paint`, M8.4 audit finding 2.
local function paint_buffer(handle, buf, text)
handle.painting = true
local ok, err = pcall(function()
buf:replace(0, buf:len(), text)
end)
handle.painting = false
if not ok then error(err) end
end
-- Return the cursor's current line if and only if the active window
-- is the visible buffer of `handle`. Otherwise nil --- a repaint
-- triggered while the user is in some other buffer must not perturb
-- their cursor there.
local function visible_cursor_line(handle)
if pmacs.window.buffer() == handle.visible then
return pmacs.editor.cursor_line()
end
return nil
end
-- Move the cursor in the active window to `target_line` (0-indexed)
-- using only the public `move_up` / `move_down` primitives. After a
-- wholesale `:replace`, the engine leaves the cursor at a stale
-- byte offset (documented in builtin/commands/default.lua:308); we
-- can't rely on the post-replace line being any particular value.
-- The most robust shape is: walk up to bottom-out at line 0 (the
-- move_up no-op signals BOB), then walk down to target.
local function move_cursor_to_line(target_line)
-- Walk up until move_up no longer changes the line (we've hit
-- the start of the buffer). Cap with a generous step counter so
-- a buggy engine can't loop forever.
local guard = 0
local prev = pmacs.editor.cursor_line()
while guard < 100000 do
pmacs.editor.move_up()
local now = pmacs.editor.cursor_line()
if now == prev then break end
prev = now
guard = guard + 1
end
-- Now at line 0. Walk down target_line steps.
for _ = 1, target_line do
pmacs.editor.move_down()
end
end
local function repaint_visible(handle)
-- Capture the section the cursor is on before we repaint, so we
-- can move back to that section's header in the new projection.
-- A nil here just means the user isn't currently in this view; we
-- skip cursor reseat.
local cursor_line_before = visible_cursor_line(handle)
local section_id_before = nil
if cursor_line_before ~= nil and handle.projection then
section_id_before = fold.section_at(handle.projection, cursor_line_before)
end
local proj = fold.render_visible(handle.flat, handle.fold_state)
handle.projection = proj
paint_buffer(handle, handle.visible, proj.text)
-- Build a section-id -> first-visible-line lookup from the new
-- projection. fold_targets entries are dense across visible
-- lines (header + body lines all map back to a section); we
-- want the smallest line for each section id, which is its
-- header line under the renderer's contract.
local section_lines = {}
for line, sid in pairs(proj.fold_targets) do
if section_lines[sid] == nil or line < section_lines[sid] then
section_lines[sid] = line
end
end
-- Reseat cursor on the same section if possible. If the section
-- got hidden (its parent collapsed), walk up the parent chain
-- until we find an ancestor whose header is in the new
-- projection. handle.parent_of is built once at open() time
-- from the flat parse.
if section_id_before ~= nil and visible_cursor_line(handle) ~= nil then
local target_id = section_id_before
while target_id ~= nil and section_lines[target_id] == nil do
target_id = handle.parent_of[target_id]
end
if target_id ~= nil then
local target_line = section_lines[target_id]
if target_line ~= nil then
move_cursor_to_line(target_line)
end
end
end
end
-- ---------------------------------------------------------------------------
-- Live update of an existing magit buffer
-- ---------------------------------------------------------------------------
--
-- M8.5 ships `M.open(spec)` for one-shot section views. M8.6 needs
-- to *update* the spec while the buffer is open: each git refresh
-- produces a new spec the package paints into the existing visible
-- (and source) buffer.
--
-- update_spec preserves fold_state across the swap: section IDs are
-- stable across refreshes (e.g., always `"working"`, `"staged"`,
-- ..., never `"working-3"`-style IDs that drift with content), so
-- a section the user collapsed before the refresh stays collapsed
-- after. Cursor reseat happens through repaint_visible's existing
-- machinery --- if the user was on Section X before refresh, they
-- end up on Section X's new header line after.
local function update_spec(handle, new_spec)
local flat = fold.parse(new_spec)
if #flat == 0 then return end
handle.flat = flat
local sr = fold.render_source(flat)
handle.source_text = sr.text
handle.source_line_index = sr.line_index
-- Rebuild parent_of: structurally stable for magit-status today,
-- but we don't *rely* on that --- a future caller of update_spec
-- might legitimately swap the section tree, and the cursor-reseat
-- code reads parent_of expecting it to match `flat`.
local parent_of = {}
for _, s in ipairs(flat) do
parent_of[s.id] = s.parent_id
end
handle.parent_of = parent_of
-- Repaint the source buffer with the new full text. This is the
-- one buffer write per refresh that the painting flag has to
-- bypass; without it, the source-buffer's read-only intercept
-- would reject our own update.
paint_buffer(handle, handle.source, sr.text)
-- Repaint the visible projection using the preserved fold_state.
repaint_visible(handle)
end
-- ---------------------------------------------------------------------------
-- Intercept body for the visible buffer
-- ---------------------------------------------------------------------------
--
-- M8.5 makes the section view read-only. The visible buffer's
-- rope is a projection that the package owns; user edits would
-- desync the projection from the source + fold state. Reject
-- every kind of user edit; let our paint passes through via the
-- painting flag.
local function make_readonly_intercept(handle, name)
return function(_op)
if handle.painting then return nil end
error("pmacs-magit: this buffer is a " .. name .. "; direct edits " ..
"are not supported. Use TAB to fold/unfold sections, or " ..
"the M-x pmacs-magit.* commands.")
end
end
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------
local OWNED_COMMANDS = {}
local function define_owned(spec)
pmacs.command.define(spec)
OWNED_COMMANDS[#OWNED_COMMANDS + 1] = spec.name
end
define_owned {
name = "pmacs-magit.toggle-fold",
description = "Toggle fold state of the section under cursor.",
fn = function()
local h = active_handle()
if not h then return end
local line = pmacs.editor.cursor_line()
local id = fold.section_at(h.projection, line)
if not id then return end -- cursor past end-of-buffer
fold.toggle(h.fold_state, id)
repaint_visible(h)
end,
}
define_owned {
name = "pmacs-magit.fold-all",
description = "Collapse every section in the active magit buffer.",
fn = function()
local h = active_handle()
if not h then return end
for _, s in ipairs(h.flat) do
if #s.child_ids > 0 or #s.body_lines > 0 then
h.fold_state[s.id] = "collapsed"
end
end
repaint_visible(h)
end,
}
define_owned {
name = "pmacs-magit.unfold-all",
description = "Expand every section in the active magit buffer.",
fn = function()
local h = active_handle()
if not h then return end
h.fold_state = {}
repaint_visible(h)
end,
}
-- ---------------------------------------------------------------------------
-- Gesture commands (T M8.7)
-- ---------------------------------------------------------------------------
--
-- Each gesture funnels through the gestures module, which knows how
-- to resolve "section / item under cursor" and runs the
-- corresponding `git` command via status.run_git. Multi-step
-- gestures (commit, push, branch-create, branch-switch) compose
-- with `pmacs.minibuffer.read` for user input; the on_accept
-- callback then schedules a fresh pmacs.async coroutine for the
-- git work.
--
-- Single-step gestures (stage, unstage) need a pmacs.async wrapper
-- because run_git yields via pmacs.workers.sleep:await(); the
-- command body is invoked from key dispatch, which is synchronous.
local function run_async(fn)
pmacs.async(fn)
end
define_owned {
name = "pmacs-magit.stage",
description = "Stage the working-tree-change item under cursor.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
run_async(function() gestures.stage(h, update_spec) end)
end,
}
define_owned {
name = "pmacs-magit.unstage",
description = "Unstage the staged item under cursor.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
run_async(function() gestures.unstage(h, update_spec) end)
end,
}
define_owned {
name = "pmacs-magit.commit",
description = "Open a commit-message buffer for `git commit`.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
gestures.commit(h, update_spec)
end,
}
define_owned {
name = "pmacs-magit.commit-submit",
description = "Finish the active commit-message buffer and run git commit.",
fn = function()
local h = commit_session_handle()
if not h then return end
gestures.commit_submit(h, update_spec)
end,
}
define_owned {
name = "pmacs-magit.commit-cancel",
description = "Abandon the active commit-message buffer without committing.",
fn = function()
local h = commit_session_handle()
if not h then return end
gestures.commit_cancel(h)
end,
}
define_owned {
name = "pmacs-magit.push",
description = "Prompt for a remote and run git push.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
gestures.push(h, update_spec)
end,
}
define_owned {
name = "pmacs-magit.branch-create",
description = "Prompt for a branch name and run git checkout -b.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
gestures.branch_create(h, update_spec)
end,
}
define_owned {
name = "pmacs-magit.branch-switch",
description = "Prompt with branch candidates and run git checkout.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
gestures.branch_switch(h, update_spec)
end,
}
define_owned {
name = "pmacs-magit.refresh-status",
description = "Re-fetch git status and refresh the active magit buffer.",
fn = function()
local h = active_handle()
if not h or not h.repo_root then return end
-- Schedule an immediate refresh, separate from the polling loop's
-- next tick. The polling loop uses h.refresh_pending to skip
-- overlapping work; we set it here too so a manual refresh while
-- a poll-driven one is in flight is a no-op rather than a queue.
if h.refresh_pending then return end
h.refresh_pending = true
pmacs.async(function()
local ok, err = pcall(function()
local result = status.fetch_sections(h.repo_root)
if h.alive then
update_spec(h, result.spec)
h.branches_snapshot =
(result.parsed.branches and result.parsed.branches.all) or {}
end
end)
h.refresh_pending = false
if not ok and pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status(
"pmacs-magit.refresh-status failed: " .. tostring(err)
)
end
end)
end,
}
-- ---------------------------------------------------------------------------
-- Public: open()
-- ---------------------------------------------------------------------------
-- Open a section view for `spec`. Returns the handle (mostly for
-- testing; user code typically just calls open and lets the
-- window switch handle the rest).
function M.open(spec)
local flat = fold.parse(spec)
if #flat == 0 then
error("pmacs-magit.open: section spec must contain at least one section")
end
local source_render = fold.render_source(flat)
-- Build the parent-of lookup from the flat parse. Used by the
-- cursor-reseat path: when the section under cursor gets
-- collapsed away by a parent fold, we walk up until we find an
-- ancestor still in the visible projection.
local parent_of = {}
for _, s in ipairs(flat) do
parent_of[s.id] = s.parent_id
end
local source = pmacs.buffer.create(" *magit-source:" .. flat[1].id .. "*")
local visible = pmacs.buffer.create("*magit:" .. flat[1].id .. "*")
local handle = {
flat = flat,
fold_state = {},
source = source,
visible = visible,
-- Source rope line index, stable across fold operations.
source_line_index = source_render.line_index,
parent_of = parent_of,
-- projection populated by repaint_visible below.
projection = nil,
painting = false,
}
handles[#handles + 1] = handle
handle.source_intercept = pmacs.buffer.add_intercept(
source, make_readonly_intercept(handle, "magit source view")
)
paint_buffer(handle, source, source_render.text)
handle.intercept = pmacs.buffer.add_intercept(
visible, make_readonly_intercept(handle, "section view")
)
pmacs.window.switch_buffer(visible)
repaint_visible(handle)
pmacs.keymap.bind {
scope = "buffer", buffer = visible, sequence = "Tab",
command = "pmacs-magit.toggle-fold",
}
return handle
end
-- ---------------------------------------------------------------------------
-- Public: open_status() --- magit-class entry for git status (T M8.6)
-- ---------------------------------------------------------------------------
--
-- Open a magit-status buffer for a Git repo. Renders the canonical
-- 5-section view (working tree, staged, recent commits, branches,
-- stashes) by invoking `git` via `pmacs.process.spawn`, parsing
-- output, and feeding the result through the M8.5 fold module.
--
-- The buffer auto-refreshes every 250 ms (well under the M8.6
-- acceptance bullet's 500 ms latency budget). The polling loop
-- terminates when the visible buffer is removed (`handle.alive`
-- flipped by cleanup_handle) or when the package is unloaded.
--
-- Refresh failures (transient git errors, missing repo, etc.) are
-- captured via pcall and surfaced through pmacs.editor.set_status
-- when available; the polling loop continues so a recoverable
-- error doesn't kill the watcher.
--
-- Returns the handle. `pmacs-magit.refresh-status` is bound to "g"
-- on the buffer so users can force-refresh manually (the canonical
-- magit gesture).
--
-- Caller invariants:
-- * `repo_root` must be an absolute path to a git repository's
-- working tree (or a subdirectory thereof; git resolves the
-- repo root itself). Validation happens at first refresh ---
-- a non-repo path produces a refresh failure with the git
-- stderr surfaced; we don't pre-validate to avoid duplicating
-- git's own logic.
-- * Must be called inside a `pmacs.async` body, because the
-- initial fetch runs synchronously to ensure the buffer
-- populates before open_status returns.
function M.open_status(repo_root)
if type(repo_root) ~= "string" or repo_root == "" then
error("pmacs-magit.open_status: repo_root must be a non-empty string")
end
-- Initial spec: a single "loading" section so M.open's "at least
-- one section" check passes and the user sees something
-- immediately. Replaced by the first refresh.
local handle = M.open {
{
id = "_magit_status_loading",
title = "(loading magit status...)",
body = nil,
},
}
handle.repo_root = repo_root
handle.refresh_pending = false
handle.alive = true
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "g",
command = "pmacs-magit.refresh-status",
}
-- M8.7 gestures: stage / unstage / commit / push / branch.
-- "b" is a prefix: "b c" creates, "b b" switches.
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "s",
command = "pmacs-magit.stage",
}
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "u",
command = "pmacs-magit.unstage",
}
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "c",
command = "pmacs-magit.commit",
}
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "P",
command = "pmacs-magit.push",
}
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "b c",
command = "pmacs-magit.branch-create",
}
pmacs.keymap.bind {
scope = "buffer", buffer = handle.visible, sequence = "b b",
command = "pmacs-magit.branch-switch",
}
-- Initial fetch: synchronous via the same pcall pattern as the
-- polling loop. We're already inside pmacs.async (callers are
-- required to be).
do
handle.refresh_pending = true
local ok, err = pcall(function()
local result = status.fetch_sections(repo_root)
update_spec(handle, result.spec)
handle.branches_snapshot =
(result.parsed.branches and result.parsed.branches.all) or {}
end)
handle.refresh_pending = false
if not ok and pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status(
"pmacs-magit: initial status fetch failed: " .. tostring(err)
)
end
end
-- Polling loop. One coroutine; refresh runs inline so the next
-- sleep starts only after the previous refresh finishes ---
-- there's no overlap, no queue, no need to debounce.
pmacs.async(function()
while handle.alive do
pmacs.workers.sleep(250):await()
if not handle.alive then break end
-- Skip if a manual refresh is in flight (the refresh-status
-- command sets refresh_pending). Keeps things serialized.
if not handle.refresh_pending then
handle.refresh_pending = true
local ok, err = pcall(function()
local result = status.fetch_sections(repo_root)
if handle.alive then
update_spec(handle, result.spec)
handle.branches_snapshot =
(result.parsed.branches and result.parsed.branches.all) or {}
end
end)
handle.refresh_pending = false
if not ok and pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status(
"pmacs-magit: refresh failed: " .. tostring(err)
)
end
end
end
end)
return handle
end
-- ---------------------------------------------------------------------------
-- Cleanup on unload
-- ---------------------------------------------------------------------------
pmacs.packages.on_unload(function()
for _, h in ipairs(handles) do
cleanup_handle(h)
end
handles = {}
for _, name in ipairs(OWNED_COMMANDS) do
pmacs.command.unregister(name)
end
OWNED_COMMANDS = {}
end)
-- ---------------------------------------------------------------------------
-- Test seam
-- ---------------------------------------------------------------------------
--
-- The seam name is loud-prefixed per the M8.4 audit finding 7
-- discussion: the v0.1 audit lint can't enforce field-access
-- privacy, so the convention is to make accidental external use
-- *obvious* at the call site. External packages reaching into
-- `__pmacs_magit_test_seam_DO_NOT_USE` are unambiguously off the
-- supported path.
M.__pmacs_magit_test_seam_DO_NOT_USE = {
active_handle = active_handle,
fold = fold,
status = status,
gestures = gestures,
repaint_visible = repaint_visible,
paint_buffer = paint_buffer,
update_spec = update_spec,
}
return M