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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`,
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 24 additions & 2 deletions lua/scratch/issue.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 ""),
Expand Down
44 changes: 39 additions & 5 deletions lua/scratch/list.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -84,6 +100,7 @@ local highlight_links = {
ScratchIssueLow = "Comment",
ScratchIssueDate = "Comment",
ScratchIssueDone = "Comment",
ScratchIssueTags = "Comment",
}

---@param entry scratch.Issue
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down