diff --git a/README.md b/README.md index 7b1d7cc..357a19a 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,10 @@ Run `:checkhealth shepherd` to verify the binary is found. `require("shepherd").status()` returns a short string — the open count, plus an overdue suffix — or `""` when there's nothing open or the count hasn't loaded -yet. Counts refresh after any add/done/rm you make and on `FocusGained`; call -`require("shepherd").refresh()` to force it. +yet. Counts refresh live on every board change — yours, the board TUI's, or +another process's — plus on `FocusGained`; call +`require("shepherd").refresh()` to force it. Set `watch = false` (see +[configuration](#configuration)) to drop back to `FocusGained` only. lualine: @@ -140,6 +142,7 @@ require("shepherd").setup({ board = nil, -- string | fun():string | nil — passed as --board float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for status(); e.g. a nerd-font glyph + watch = true, -- stream `shepherd watch` to keep the statusline live }) ``` @@ -153,6 +156,12 @@ require("shepherd").setup({ - `float` — fractions of the editor size, and the window border. - `status.icon` — prefix for `status()`. With an icon it renders ` 3`; empty renders `3 todo`. +- `watch` — streams `shepherd watch` in the background and refreshes the + statusline counts on every board change (from the TUI or another process), not + just on `FocusGained`. Re-scopes to the active board on switch, and stops on + exit. One extra process per Neovim, polling the board file once a second; set + it to `false` to skip that. Needs shepherd ≥ 0.16 (the `watch` verb); with no + `shepherd` on `$PATH` it simply doesn't start. ## development diff --git a/lua/shepherd/init.lua b/lua/shepherd/init.lua index 9728bb9..8ae80fe 100644 --- a/lua/shepherd/init.lua +++ b/lua/shepherd/init.lua @@ -6,6 +6,7 @@ local defaults = { board = nil, -- string | fun():string | nil float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for M.status(), e.g. a nerd-font glyph + watch = true, -- stream `shepherd watch` to keep the statusline live } local config = vim.tbl_deep_extend("force", {}, defaults) @@ -14,6 +15,10 @@ local config = vim.tbl_deep_extend("force", {}, defaults) -- it wins over config.board; "default" means the unscoped default board. local active_board = nil +-- watch state, forward-declared so set_active_board (below) can restart the +-- stream; defined near M.status. +local watch_job, watch_start, watch_stop + -- binary returns the configured shepherd command (used by the health check). function M.binary() return config.cmd @@ -23,6 +28,9 @@ end -- config.board until changed again. function M.set_active_board(name) active_board = name + if watch_job then + watch_start() -- re-scope the stream to the new board + end end -- with_board appends "--board " to cmd when one is in effect: the @@ -300,6 +308,57 @@ function M.refresh() end) end +-- watch streams `shepherd watch` (NDJSON, board-scoped) and refreshes counts on +-- every change, so edits from the board TUI or another process update the +-- statusline without waiting for FocusGained. Opt-in via config.watch. +-- watch_job/watch_start/watch_stop are forward-declared near the top. +function watch_stop() + if watch_job then + pcall(function() + watch_job:kill(15) + end) + watch_job = nil + end +end + +function watch_start() + watch_stop() + -- on by default, so a missing binary must stay a checkhealth problem rather + -- than an ENOENT thrown out of setup() on every startup + if vim.fn.executable(config.cmd) ~= 1 then + return + end + local cmd = with_board({ config.cmd, "watch" }) + local job + job = vim.system(cmd, { + text = true, + stdout = function(_, data) + if data and data ~= "" then + vim.schedule(M.refresh) + end + end, + }, function(r) + -- a restart's kill lands after the replacement is live, so only the + -- current job may clear the handle — and only its death is worth + -- reporting: a killed predecessor is expected, a live stream dying + -- (no `watch` verb on an older binary) would otherwise fail silently + if watch_job ~= job then + return + end + watch_job = nil + if r.code ~= 0 then + local why = (r.stderr or ""):gsub("%s+$", "") + vim.schedule(function() + vim.notify( + "shepherd: watch stopped — " .. (why ~= "" and why or "exit " .. r.code), + vim.log.levels.ERROR + ) + end) + end + end) + watch_job = job +end + -- status returns a statusline string: "" when empty/loading, else the open -- count with the configured icon and an overdue suffix. function M.status() @@ -440,12 +499,18 @@ function M.setup(opts) end, { desc = "unarchive an archived board" }) -- keep statusline counts fresh across external edits / other tabs + local group = vim.api.nvim_create_augroup("shepherd", { clear = true }) vim.api.nvim_create_autocmd("FocusGained", { - group = vim.api.nvim_create_augroup("shepherd", { clear = true }), + group = group, callback = function() M.refresh() end, }) + + if config.watch then + watch_start() + vim.api.nvim_create_autocmd("VimLeavePre", { group = group, callback = watch_stop }) + end end -- _run exposes the CLI runner to the board-management submodule