Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 38 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,28 @@ plugin list:
```lua
return {
"jwarykowski/nvim-shepherd",
cmd = { "Shepherd", "ShepherdAdd", "ShepherdList", "ShepherdCapture" },
cmd = {
"Shepherd",
"ShepherdAdd",
"ShepherdList",
"ShepherdCapture",
"ShepherdStats",
"ShepherdBoards",
"ShepherdBoardsArchived",
},
keys = {
{ "<leader>T", "<cmd>Shepherd<cr>", desc = "shepherd board" },
{ "<leader>tg", "<cmd>Shepherd!<cr>", desc = "shepherd global view (all boards)" },
{ "<leader>ta", "<cmd>ShepherdAdd<cr>", desc = "shepherd quick-add" },
{ "<leader>tl", "<cmd>ShepherdList<cr>", desc = "shepherd list / pick" },
{ "<leader>tc", "<cmd>ShepherdCapture<cr>", desc = "shepherd capture line" },
{ "<leader>tc", ":ShepherdCapture<cr>", mode = "x", desc = "shepherd capture selection" },
{ "<leader>ts", "<cmd>ShepherdStats<cr>", desc = "shepherd stats" },
{ "<leader>tb", "<cmd>ShepherdBoards<cr>", desc = "shepherd boards" },
},
opts = {
-- per-repo project board (own file under ~/.config/shepherd/projects/)
project = function()
-- per-repo board (own file under ~/.config/shepherd/boards/)
board = function()
return vim.fn.fnamemodify(vim.fn.getcwd(), ":t")
end,
},
Expand All @@ -53,15 +63,27 @@ return {
exits (`q`).
- `:Shepherd work` — open with an explicit filter, overriding the configured
one for that view.
- `:Shepherd!` — open the read-only global view across all project boards
- `:Shepherd!` — open the read-only global view across all boards
(`shepherd --all`).
- `:ShepherdAdd` — prompt for a todo, then `shepherd add` it. An open board
reloads and shows it within ~2s.
- `:ShepherdAdd deploy api @work !h due:tomorrow` — add directly, with the same
quick-add tokens the board accepts.
- `:ShepherdList` — pick an item (`vim.ui.select`), then mark it done/undone or
remove it. Uses whatever `vim.ui.select` UI you have (dressing, snacks,
telescope-ui-select), or the built-in menu.
- `:ShepherdList` — pick an item (`vim.ui.select`), then act on it: toggle
done/undone, **edit** (a pre-filled quick-add line — text, `@category`,
`!priority`, `due:`, `defer:`, `link:`, `note:`), set a **status**, add a
**subtask**, **rm**, or **open link** when it has one. Subtasks show indented
under their parent and take the same actions. Uses whatever `vim.ui.select` UI
you have (dressing, snacks, telescope-ui-select), or the built-in menu.
- `:ShepherdList work` — pick within a filtered view. `:ShepherdList!` picks
across all boards (read-only — items show their `[board]`; only open-link is
offered).
- `:ShepherdStats` — open shepherd's stats charts in the floating terminal.
- `:ShepherdBoards` — pick a board (shows `open/total`, `*` marks current),
then **switch** the session to it, **rename**, **archive**, or **delete** it
(delete shows a dry-run preview and asks to confirm first). Switching overrides
the configured `board` until you switch again or restart.
- `:ShepherdBoardsArchived` — pick an archived board to unarchive.
- `:ShepherdCapture` — turn the current line into a todo; in visual mode
(`:'<,'>ShepherdCapture`) the selection. Strips a leading comment marker and
`TODO:`/`FIXME:`, then opens the add prompt pre-filled so you can tweak it.
Expand Down Expand Up @@ -91,8 +113,8 @@ Native statusline:
vim.o.statusline = "%{v:lua.require'shepherd'.status()}"
```

The count covers the configured project's board (all todos on it, independent
of `config.filter`); with no `project` set, the default board. A refresh
The count covers the configured board (all todos on it, independent
of `config.filter`); with no `board` set, the default board. A refresh
fires the `User ShepherdStatusUpdate` autocmd — hook it if your statusline
needs a manual redraw:

Expand All @@ -111,18 +133,19 @@ Defaults:
require("shepherd").setup({
cmd = "shepherd", -- binary name / path
filter = nil, -- string | fun():string | nil — passed as --filter
project = nil, -- string | fun():string | nil — passed as --project
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
})
```

- `filter` — a string, or a function returning one (evaluated on each open, so
it can track the current project). `nil`/empty means no filter.
- `project` — a shepherd project board name (string or function, e.g. derive it
- `board` — a shepherd board name (string or function, e.g. derive it
from the cwd). Scopes everything — board, add, list/pick, statusline counts —
to `~/.config/shepherd/projects/<name>.md`. `nil`/empty uses the default
board. `:Shepherd!` ignores it and shows all boards.
to `~/.config/shepherd/boards/<name>.md`. `nil`/empty uses the default
board. `:Shepherd!` ignores it and shows all boards. `:ShepherdBoards` →
*switch* overrides it for the session.
- `float` — fractions of the editor size, and the window border.
- `status.icon` — prefix for `status()`. With an icon it renders `<icon> 3`;
empty renders `3 todo`.
Expand All @@ -143,4 +166,5 @@ make check # lint + test

CI (`.github/workflows/pull-request.yml`) runs lint plus the suite on Neovim
stable and nightly. Tests cover the pure helpers (`tally`, `format_status`, `label`,
`clean`, `build_cmd`/filter resolution) via the `_internal` table.
`flatten`, `edit_seed`, `clean`, `build_cmd`/filter resolution) via the
`_internal` table.
100 changes: 100 additions & 0 deletions lua/shepherd/board.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
-- Board management: a board switcher and CRUD, driven by the shepherd CLI's
-- `boards --json` reads and `board <sub>` verbs. Board verbs name their
-- target explicitly, so they run with { no_board = true } to bypass scoping.
local sh = require("shepherd")

local M = {}

-- fetch decodes `boards --json [args]` and hands the board array to cb.
local function fetch(args, cb)
local cmd = { sh.binary(), "boards", "--json" }
for _, a in ipairs(args or {}) do
cmd[#cmd + 1] = a
end
vim.system(cmd, { text = true }, function(r)
vim.schedule(function()
if r.code ~= 0 then
vim.notify("shepherd: " .. ((r.stderr or ""):gsub("%s+$", "")), vim.log.levels.ERROR)
return
end
local ok, boards = pcall(vim.json.decode, r.stdout)
if not ok then
vim.notify("shepherd: could not parse `boards --json`", vim.log.levels.ERROR)
return
end
cb(boards or {})
end)
end)
end

local function plabel(b)
return string.format("%s %s (%d/%d)", b.current and "*" or " ", b.name, b.open, b.total)
end

-- confirm_delete previews the removal with --dry-run, then requires an explicit
-- "yes" before the destructive --force delete.
local function confirm_delete(name)
vim.system({ sh.binary(), "board", "delete", name, "--dry-run" }, { text = true }, function(r)
vim.schedule(function()
local preview = (r.stdout or ""):gsub("%s+$", "")
vim.ui.select({ "no", "yes" }, { prompt = "delete '" .. name .. "'? " .. preview }, function(ans)
if ans == "yes" then
sh._run({ "board", "delete", name, "--force" }, "deleted " .. name, { no_board = true })
end
end)
end)
end)
end

-- switch lists boards; selecting one offers switch/rename/archive/delete.
function M.switch()
fetch({}, function(boards)
if #boards == 0 then
vim.notify("shepherd: no boards")
return
end
vim.ui.select(boards, { prompt = "board", format_item = plabel }, function(b)
if not b then
return
end
vim.ui.select({ "switch", "rename", "archive", "delete" }, { prompt = b.name }, function(act)
if act == "switch" then
sh.set_active_board(b.name)
sh.refresh()
vim.notify("shepherd: board " .. b.name)
elseif act == "rename" then
vim.ui.input({ prompt = "rename to: ", default = b.name }, function(v)
if v and v ~= "" and v ~= b.name then
sh._run(
{ "board", "rename", b.name, v },
"renamed " .. b.name .. " -> " .. v,
{ no_board = true }
)
end
end)
elseif act == "archive" then
sh._run({ "board", "archive", b.name }, "archived " .. b.name, { no_board = true })
elseif act == "delete" then
confirm_delete(b.name)
end
end)
end)
end)
end

-- archived lists archived boards; selecting one unarchives it.
function M.archived()
fetch({ "--archived" }, function(boards)
if #boards == 0 then
vim.notify("shepherd: no archived boards")
return
end
vim.ui.select(boards, { prompt = "unarchive", format_item = plabel }, function(b)
if b then
sh._run({ "board", "unarchive", b.name }, "unarchived " .. b.name, { no_board = true })
end
end)
end)
end

return M
Loading
Loading