From 703942d19c7ea598f55d36c6c9e0ec1c4345d640 Mon Sep 17 00:00:00 2001 From: "Andrey A. Ugolnik" Date: Mon, 14 Sep 2026 13:04:19 +0200 Subject: [PATCH] feat(issues): tag issues with what they belong to A global backlog holds the work of an engine and of the modules shared by several games, and the only way to say which was which was to write the name into the heading by hand. Tags are the one frontmatter field that is a list, kept flat as "tags: age,gui" so the file stays plain key: value. The field is written into every new card even when empty, because the card is where fields are edited and one that is never shown is one nobody fills in. They open the description in the list, dimmed, rather than take a column of their own: a column would cost the description its width for something most issues carry one or two of. Ordering by description still follows the description - otherwise the tags standing in front would quietly order the column by themselves. No ordering by tags: an issue belongs to several groups at once, so "the first one alphabetically" would say less than it seems. Filtering by them is the next step, together with filtering by status, type and priority. --- README.md | 20 ++++++++++++++++++++ lua/scratch/issue.lua | 26 +++++++++++++++++++++++-- lua/scratch/list.lua | 44 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a3b1bf5..613f3d8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ and a closed issue keeps a checkbox-style mark.* has a list of its own, remembering the row you left it on. - Type, priority and status are changed straight from the list; the list is ordered by any of its columns, and priority is colour-coded. +- Any number of tags per issue, or none — they say what it belongs to, and + stand in front of its description in the list. - Nothing of the plugin's own ends up in the buffer list, and a file that belongs to neither notes nor issues is never left inside the window. - Notes and issues are written whenever they leave the screen, so nothing is @@ -235,6 +237,7 @@ no counter and the directory sorts chronologically on its own. type: bug priority: high status: open +tags: parser,io --- # Parser drops the last line of a file @@ -247,6 +250,22 @@ Free-form markdown below the title. - `type` — `bug`, `feature`, `refactor` or `task` - `priority` — `low`, `normal`, `high` or `critical` - `status` — `open` or `done` +- `tags` — any number of them, comma-separated, or none at all + +Tags say what an issue belongs to — a module, a subsystem, a platform — and +are written by hand in the card, since their values are yours rather than a +fixed set. They open the description in the list, dimmed: + +```text + Type Priority Created Description + x BUG HIGH 2026-09-11 [age, gui] guiText: no size without one + TASK NORMAL 2026-09-11 pugixml 1.16 +``` + +The list is not ordered by them: a tag names a group, and an issue can be in +several at once, so ordering by "the first one alphabetically" would say less +than it seems. Ordering by description follows the description, not the tags +standing in front of it. Only these fields and the first `#` heading are read; everything else in the file is left alone. Missing fields fall back to `task`, `normal` and `open`, @@ -303,6 +322,7 @@ thing to follow instead of two competing ones. | `ScratchIssueLow` | priority `low` | `Comment` | | `ScratchIssueDate` | the date column | `Comment` | | `ScratchIssueDone` | a closed issue, whole row | `Comment` | +| `ScratchIssueTags` | the tags before a description | `Comment` | Priority `normal` is deliberately left plain. All groups are defined with `default = true`, so any definition of your own wins: diff --git a/lua/scratch/issue.lua b/lua/scratch/issue.lua index ceec292..38bba9d 100644 --- a/lua/scratch/issue.lua +++ b/lua/scratch/issue.lua @@ -34,9 +34,25 @@ local defaults = { ---@field type string: bug|feature|refactor|task ---@field priority string: low|normal|high|critical ---@field status string: open|done +---@field tags string[]: what the issue belongs to, empty when it says nothing ---@field title string|nil: nil until the issue is given a heading ---@field updated number: file mtime, not a stored field +--- Split a frontmatter list: "age, gui" gives { "age", "gui" }. The one field +--- that is not a single value, kept flat so the file stays plain key: value. +---@param value string +---@return string[] +local function split_tags(value) + local tags = {} + for tag in value:gmatch("[^,]+") do + tag = vim.trim(tag) + if tag ~= "" then + table.insert(tags, tag) + end + end + return tags +end + --- todo-comments keywords mapped onto issue types, so a comment in the code --- can seed an issue. Keys follow that plugin's default keyword set. local keyword_types = { @@ -84,6 +100,7 @@ function M.parse(path) local issue = vim.tbl_extend("force", {}, defaults) issue.path = path issue.id = vim.fn.fnamemodify(path, ":t:r") + issue.tags = {} -- Left nil when the file carries no heading: an issue waiting to be named -- is a fact about the store, and how to show it is the list's business issue.title = nil @@ -100,7 +117,9 @@ function M.parse(path) break end local key, value = lines[i]:match("^(%w+):%s*(.-)%s*$") - if key and defaults[key] then + if key == "tags" then + issue.tags = split_tags(value) + elseif key and defaults[key] then issue[key] = value end end @@ -166,7 +185,7 @@ end --- Write a new issue ---@param scope string ----@param fields table: type, priority, title, body +---@param fields table: type, priority, tags, title, body ---@return string path function M.create(scope, fields) local dir = M.dir(scope) @@ -189,6 +208,9 @@ function M.create(scope, fields) "type: " .. (fields.type or defaults.type), "priority: " .. (fields.priority or defaults.priority), "status: " .. defaults.status, + -- Written even when empty: the card is where fields are edited by + -- hand, and a field that is never shown is a field nobody knows about + "tags: " .. table.concat(fields.tags or {}, ","), "---", "", "# " .. (fields.title or ""), diff --git a/lua/scratch/list.lua b/lua/scratch/list.lua index efc735a..dabc014 100644 --- a/lua/scratch/list.lua +++ b/lua/scratch/list.lua @@ -45,12 +45,12 @@ local function cell_date(entry, sort) return entry.id:sub(1, 10) end ---- An issue still waiting for its heading shows the time it was made, so that ---- two of them are told apart while they are both unnamed. The time only: the ---- date column beside it already carries the day. +--- What an issue is called: its heading, or - while it has none - the time it +--- was made, so that two unnamed ones are told apart. The time only: the date +--- column beside it already carries the day. ---@param entry scratch.Issue ---@return string -local function cell_title(entry) +local function name_of(entry) if entry.title then return entry.title end @@ -65,6 +65,22 @@ local function cell_title(entry) return "(untitled) " .. (time:gsub("%-", ":", 2)) end +--- Tags as they stand in front of the description, empty when there are none +---@param entry scratch.Issue +---@return string +local function tag_prefix(entry) + if entry.tags == nil or #entry.tags == 0 then + return "" + end + return "[" .. table.concat(entry.tags, ", ") .. "] " +end + +---@param entry scratch.Issue +---@return string +local function cell_title(entry) + return tag_prefix(entry) .. name_of(entry) +end + --- One colour axis, and it is priority: type is already legible as a word, --- while HIGH and LOW read the same until they differ in colour. Everything --- unimportant is dimmed instead of coloured, so the eye has one thing to @@ -84,6 +100,7 @@ local highlight_links = { ScratchIssueLow = "Comment", ScratchIssueDate = "Comment", ScratchIssueDone = "Comment", + ScratchIssueTags = "Comment", } ---@param entry scratch.Issue @@ -170,8 +187,10 @@ local function by_updated(a, b) return a.id > b.id end +--- By what the issue is called, not by what its row starts with: the tags in +--- front of the description would otherwise quietly order the column by them local function by_title(a, b) - local left, right = cell_title(a), cell_title(b) + local left, right = name_of(a), name_of(b) if left ~= right then return left < right end @@ -315,6 +334,21 @@ local function row_marks(entry, spans, line, row) }) end end + + -- The tags open the description rather than hold a column of their own, + -- and they are dimmed so the eye runs along the descriptions and takes + -- them in beside it. + local tags = #tag_prefix(entry) + local from = spans[#columns].from + if tags > 0 and from < #line then + table.insert(marks, { + row = row, + from = from, + to = math.min(from + tags, #line), + group = "ScratchIssueTags", + }) + end + return marks end