722 lines
25 KiB
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
|