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