-
Notifications
You must be signed in to change notification settings - Fork 4
Metadata
The metadata system adds simple @tag(value)
items to todos while exposing a robust interface for user configuration.
⚠️ The following information applies to release 0.9+
To define custom metadata, simply add a new field to config.metadata
table:
-- adds a @due tag
opts = {
metadata = {
due = {
-- Customize the style and functionality here
},
},
}
This won't accomplish much, yet, other than checkmate now being able to recognize and parse a @due()
tag.
Let's give it some basic functionality. First, how can we toggle the insertion/removal of this tag?
- Provide a keymap for this specific metadata that will add/remove it using the
key
option - Use commands or public API to manage it programatically
Use the key
option:
-- opts.metadata
due = {
key = "<leader>Td"
},
Or, manage the metadata programatically:
require("checkmate").add_metadata("due")
require("checkmate").remove_metadata("due")
require("checkmate").toggle_metadata("due")
or, via user commands:
:Checkmate metadata add due
, :Checkmate metadata toggle due
, etc.
Next, let's define a default value for when this tag is added to the todo.
The 'default value' is the value inserted into the metadata tag when it is added to the todo item.
Use the get_value
option, passing a string or a function that returns a string.
For a metadata that deals with static values, can simply use a default string:
-- opts.metadata
priority = {
get_value = "medium"
}
If you want a calculated default value:
-- opts.metadata
due = {
get_value = function()
-- tomorrow's date (a very naive implementation)
local t = os.date("*t")
t.day = t.day + 1
local tomorrow = os.time(t)
return os.date("%m/%d/%y", tomorrow)
end
}
Note, the get_value
function also accepts a context parameter that can provide relevant information about the todo item along with helper functions.
This could allow you to to calculate an @elapsed time from a @started and @done tag:
-- opts.metadata
-- assuming you have a 'started' and 'done' (which are defaults)
elapsed = {
get_value = function(context)
local _, started_value = context.todo.get_metadata("started")
local _, done_value = context.todo.get_metadata("done")
-- Ensure your @started and @done tags store a consistent strptime format
if started_value and done_value then
local started_ts = vim.fn.strptime("%m/%d/%y %H:%M", started_value)
local done_ts = vim.fn.strptime("%m/%d/%y %H:%M", done_value)
-- convert to days
return string.format("%.1f d", (done_ts - started_ts) / 86400)
end
return ""
}
A metadata's style can be configured as a table or by passing a function that returns a table, which must be a highlight definition, see vim.api.keyset.highlight
.
Style the @done tag green:
-- opts.metadata
done = {
style = { fg = "#96de7a" } -- green
}
Style the @priority tag based on its current value:
done = {
style = function(context)
local value = context.value:lower()
if value == "high" then
return { fg = "#ff5555", bold = true }
elseif value == "medium" then
return { fg = "#ffb86c" }
elseif value == "low" then
return { fg = "#8be9fd" }
else -- fallback
return { fg = "#8be9fd" }
end
end,
}
The context parameter provides info about the todo item, metadata, and exposes some helpers.
The metadata system supports completion for values, allowing you to define a set of choices that you can select from. This is particularly useful for tags with predefined options.
Define a simple array of choices:
-- opts.metadata
status = {
choices = { "backlog", "in-progress", "blocked", "review", "done" },
key = "<leader>Ts",
}
You can then use :Checkmate metadata select_value
cmd (or use keymap) to open a picker UI and select from these values.
You can also use a function to generate choices dynamically, supporting both sync and async functions.
Return the choices synchronously:
-- opts.metadata
assignee = {
choices = function(context)
-- get git contributors
local contributors = vim.fn.systemlist("git log --format='%an' | sort -u")
return contributors
end,
}
The context parameter provides info about the todo item, metadata, and exposes some helpers.
Or, even more powerfully, use an async pattern for fetching choices 🤘
Fetch git remote branches (your implementation may vary):
-- opts.metadata
branch = {
key = "<leader>Tb",
choices = function(context, callback)
vim.system(
{ "git", "branch", "-r", "--format=%(refname:short)" },
{ text = true },
vim.schedule_wrap(function(res)
local items = {}
if res.code == 0 then
items = vim.tbl_filter(function(b)
return not b:match("^origin/HEAD")
end, vim.split(res.stdout, "\n", { trimempty = true }))
items = vim.tbl_map(function(b)
return b:gsub("^origin/", "")
end, items)
end
callback(items)
end)
)
end
}
Note, that we pass the results through the callback function, though this may not be necessary in all cases (such as this one) in which the :wait()
runs synchronously.
React to metadata changes with on_add
, on_remove
, and on_change
callbacks. This enables workflows where metadata can trigger todo state changes or other actions.
- on_add: triggered when metadata is first added to a todo
- on_change: triggered when an existing metadata's value is changed (does not fire on initial add or removal)
- on_remove: triggered when metadata is removed from a todo
⚠️ The lifecycle hooks currently only fire for programmatic changes, i.e. metadata changed by the API—not by the user modifying the buffer directly.
Auto-check on adding @done:
-- opts.metadata
done = {
get_value = function()
return os.date("%m/%d/%y %H:%M")
end,
on_add = function(todo_item)
require("checkmate").set_todo_item(todo_item, "checked")
end,
on_remove = function(todo_item)
require("checkmate").set_todo_item(todo_item, "unchecked")
end,
}
Control where the cursor moves after inserting metadata with jump_to_on_insert
and select_on_insert
.
-- opts.metadata
estimate = {
get_value = "0h",
jump_to_on_insert = "value", -- Jump cursor to the value
select_on_insert = true, -- Select the value for easy replacement
}
This is particularly useful for tags where you always want to immediately edit the default value.
When the cursor is within a todo, you can quickly cycle through each metadata with:
- Forward:
:Checkmate metadata jump_next
orrequire("checkmate").jump_next_metadata
- Backward:
:Checkmate metadata jump_previous
orrequire("checkmate").jump_previous_metadata
Control the order in which metadata appears using sort_order
:
-- opts.metadata
priority = {
sort_order = 10, -- Appears first
},
status = {
sort_order = 20, -- Appears second
},
due = {
sort_order = 30, -- Appears third
}
checkmate.nvim
provides an implementation for the following pickers:
A preferred picker can be designated in the plugin opts ui.picker
.
You can use your own picker implementation by passing a function for ui.picker
:
snacks.nvim:
opts = {
ui = {
picker = function(items, opts)
---@type snacks.picker.ui_select
require("snacks").picker.select(items, {
-- ... plugin-specific opts
}, function(item)
opts.on_choice(item) -- << IMPORTANT!
end)
end
}
}
fzf-lua:
opts = {
ui = {
picker = function(items, opts)
require("fzf-lua").fzf_exec(items, {
actions = {
["default"] = function(selected)
opts.on_choice(selected[1])
end,
},
winopts = {
on_close = function()
opts.on_choice(nil)
end,
},
})
end,
},
}
Just make sure you call the opts.on_choice
callback with the selected choice.
With
fzf-lua
, can also register it as the UI forvim.ui.select
withrequire("fzf-lua").register_ui_select()
The following fields are available in the checkmate.MetadataContext
table:
-
name
- tag name -
value
- current value -
todo
- associated todo -
buffer
- buffer number
The todo
table (see checkmate.Todo
) provides access to some exposed todo item data and helper functions, as well as the entire internal representation via _todo_item
for advanced use cases.
- Consistent date formats: When using dates in metadata, stick to a consistent format across all tags for easier parsing and comparison.
Create a metadata tag/value based on the todo's text (first line).
E.g., if "bug" string is in the todo text, use @type(bug) with associated styling when @type is inserted
-- opts.metadata
type = {
get_value = function(context)
local text = context.todo.text:lower()
if text:match("bug") or text:match("fix") then
return "bug"
elseif text:match("feature") or text:match("implement") then
return "feature"
elseif text:match("refactor") then
return "refactor"
elseif text:match("doc") then
return "documentation"
else
return "task"
end
end,
choices = { "bug", "feature", "refactor", "documentation", "task", "chore" },
style = function(context)
local colors = {
bug = { fg = "#ff5555", bold = true },
feature = { fg = "#50fa7b" },
refactor = { fg = "#ff79c6" },
documentation = { fg = "#f1fa8c" },
task = { fg = "#8be9fd" },
chore = { fg = "#6272a4" }
}
return colors[context.value] or { fg = "#f8f8f2" }
end,
}
--- opts.metadata
issue = {
choices = function(context, callback)
vim.system({
"curl",
"-sS",
"https://api.github.com/repos/bngarren/checkmate.nvim/issues?state=open",
}, { text = true }, function(out)
if out.code ~= 0 then
callback({})
return
end
local ok, issues = pcall(vim.json.decode, out.stdout)
if not ok or type(issues) ~= "table" then
callback({})
return
end
local result = vim.tbl_map(function(issue)
return string.format("#%d %s", issue.number, issue.title)
end, issues)
callback(result)
end)
end,
}
Since we use
vim.system()
's callback parameter here, we must return the items via thechoices
function's callback rather than directly.
A basic example of how you can choose a filename from your current project. This is a coarse implementation of async behavior using vim.defer_fn
to yield back to the event loop.
-- opts.metadata
file = {
key = "<leader>Tmf",
choices = function(_, cb)
local function scan_dir_async(path, items, on_done)
local ignore = { ".git" }
local it = vim.uv.fs_scandir(path)
if not it then
return on_done()
end
local function step()
local name, t = vim.uv.fs_scandir_next(it)
if not name then
return on_done()
end
local full = path .. "/" .. name
if t == "file" then
local short = vim.fn.fnamemodify(full, ":.")
table.insert(items, short)
elseif t == "directory" and not vim.tbl_contains(ignore, name) then
scan_dir_async(full, items, function()
vim.defer_fn(step, 0)
end)
return
end
vim.defer_fn(step, 0)
end
step()
end
local project_root = vim.fs.root(0, ".git") or vim.loop.cwd()
local items = {}
scan_dir_async(project_root, items, function()
cb(items)
end)
end,
}