diff --git a/CHANGELOG.md b/CHANGELOG.md index da70314..e118105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,126 @@ this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] — v1.5.0 +### Added + +- **One-time tag repair** — + `:lua require("taskwarrior.repair_tags").run()` repairs the damage the + hyphenated-tag bug left behind. Tasks added before the fix had the + literal `+foo-bar` text filed into their *description* with no tag + created, so they stay unfindable by `+foo-bar` even after the parser + fix. This strips those tokens out and adds the real tags. Every + proposed change is shown in a preview tab and confirmed before anything + is written; the default scope is `status:pending`, and a `+tag` written + inside quotes is treated as a mention and left alone. Intentionally not + a `:Tw*` command — it is run once, not routinely. +- **`:TwContext [name|none]`** — set or show the Taskwarrior context + (work, home, …). Task buffers honor the active context's read filter + and refresh when it changes. Taskwarrior 3.x applies contexts to + reports but *not* to `export`, which is how the plugin reads + everything, so the render path injects the read filter itself; the + injected tokens land in the rendered header, keeping the save path on + the same effective filter. `uuid:`-targeted filters are never + context-narrowed. Completion lists defined contexts plus `none`. +- **`:TwTable [filter]`** — vit-style column view: one row per task with + aligned columns, sorted by urgency (or `custom_urgency`). `` opens + the task under the cursor in an editable `:Tw` buffer, `r` refreshes, + `q` closes; the view itself is read-only. Columns come from the new + `table_columns` option — a field name or + `{ field, label, width, align, format }`. Omitting `width` on one + column lets it absorb the leftover window width; over-long cells + ellipsize instead of overflowing. Unrecognised field names are read + straight off the exported task, so UDA columns need no extra wiring. +- **Capture annotations** — lines below the first in the quick-capture + window become annotations on the created task. `` or `` + opens an annotation line; `` still submits everything. New + `capture_annotations` option (default `true`) opts out; + `capture_height` sizes the form. The keys are configurable via + `capture_annotation_key` — `` only reaches Neovim from terminals + speaking the CSI-u / kitty keyboard protocol (and inside tmux only with + `set -g extended-keys on`), which is why the portable `` is bound + alongside it. +- **Capture field coloring** — the quick-capture buffer now runs the task + buffer's highlighter, so `project:`, `priority:`, `due:`, `+tags` and + UDAs are colored as you type. +- **`field_colors` option** — per-field highlight overrides keyed by + field name, mirroring `tag_colors`. Any `field:value` token not + otherwise styled now gets the new neutral `TaskField` highlight group, + so a UDA never renders as plain description text. Clock times + (`09:30`) and URL schemes are excluded from field matching. +- The quick-capture confirmation now echoes the first ~40 characters of + the stored description (plus an annotation count), so you can tell at a + glance that the right task went through and its fields parsed. + +- **Pickers for every finite choice.** Anything with a small known answer + set is now chosen from a list instead of typed from memory: sort order + (`ts`), grouping (`tg`), context (`tc`, new), + saved views (`tv`, new) and reports (`tr`, new). The + active value is marked in the list, and the context/report pickers show + each entry's filter so you can tell them apart. Built on `vim.ui.select`, + so fuzzy matching comes from whatever picker you already use + (dressing/telescope, snacks, fzf-lua) and it degrades to Neovim's + built-in list otherwise. Bare `:TwSort` now opens the picker instead of + erroring. New `context_key` / `view_key` / `report_key` options, each + disablable with `false`. + +### Fixed + +- **Sorting by a field only some tasks have** (`:TwSort priority-`, + `due+`, `project-`) raised `attempt to compare two boolean values` and + failed the whole render. The comparator compared two "is this side + missing?" booleans with `<`, which Lua rejects. Missing values now sort + last in both directions. +- **Priority sorted alphabetically rather than by importance**, so + `priority-` — which reads as "most important first", like `urgency-` + beside it — listed `L` above `H`. Priority is now ranked H > M > L. +- `:TwLoad` with no argument silently did nothing instead of offering the + saved-view picker: the command passes `""`, which is truthy in Lua, so + it fell through to the "load view named ''" path. +- **Hyphenated tag names** (`+ais-research-taste` and friends). + Taskwarrior 3's expression parser reads the hyphen in a bare `+tag` + token as a subtraction operator, which broke three separate paths: + filters failed with `Cannot subtract from a Boolean value` and rendered + a silently empty buffer; `task add … +foo-bar` put the tag in the + *description* text; and `modify +foo-bar` exited non-zero without + applying. Fixes: + - `shell_export` now routes parsed filter args through the same + `+t` → `tags.has:t` / `-t` → `tags.hasnt:t` rewrite `tw_export` + already used (virtual tags such as `+ACTIVE` pass through verbatim). + - Task creation and buffer saves emit a single `tags:a,b` replacement + instead of per-tag `+a` / `-b` deltas. + - New `taskmd.tw_change_tag(uuid, tag, remove)` merges a single-tag + delta into the full set and replaces; used by the `gm` tag picker and + `:TwInbox`. +- **Partial tag removal on save** — removing one tag from a task line in + the markdown buffer and saving now actually applies the removal. The + old per-tag `+t` delta args never did. +- `` in the quick-capture window only checked line 1 for unsubmitted + text, so annotation lines could be discarded without a prompt; the + cursor restore after "don't discard" also always jumped back to line 1. +- `task context none` exits non-zero when no context is set; clearing an + already-clear context is now treated as the no-op it is instead of + surfacing an error. + +- `:TwTable` sized its columns against `vim.o.columns`, which overcounts + by the width of the number/sign gutter — the widest rows wrapped past + the right edge. Widths now come from the window's actual text area + (`width - textoff`), and the view re-renders on window resize. +- The quick-capture window bound insert-mode `` for annotation lines, + shadowing Vim's built-in one-shot-normal-command. Removed; see + `capture_annotation_key`. + +### Changed + +- Task buffers now start at the top: the taskmd header comment is hidden + outright via extmark `conceal_lines` (Neovim 0.11+) instead of leaving a + blank concealed row, the spacer line beneath it is gone, and the cursor + opens on the first task rather than the header. On Neovim < 0.11 the + header still renders as one blank row (`conceal_lines` does not exist + there), but the spacer and cursor changes apply. +- With `confirm = false`, the apply summary notification now points at + `:TwUndo` — without the popup that notification is the only checkpoint, + so the revert path is named where you'll see it. + ### Removed — Python backend - `bin/taskmd` (the optional Python CLI) — removed. Taskwarrior.nvim has diff --git a/README.md b/README.md index 570eefe..fcb0b3f 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ These are bundled but require their own host plugins. | `:TwStart` / `:TwStop` | Start / stop active timer on task under cursor | | `:TwSave ` / `:TwLoad [name]` | Save / restore the current filter+sort+group as a named view | | `:TwReview` | Guided urgency walk through pending tasks | +| `:TwContext [name\|show\|define\|delete]` | Taskwarrior context: bare clears it, `` activates, `define`/`delete` manage them | | `:TwDelegate [copy\|copy-command]` | Delegate task(s) to Claude in a popup form | | `:TwDiffPreview [on\|off\|toggle]` | Toggle live virt-text diff preview | | `:TwBurndown` | Pending-task burndown chart | @@ -170,6 +171,7 @@ These are bundled but require their own host plugins. | `:TwSummary` | Per-project stats | | `:TwCalendar` | Tasks grouped by due date | | `:TwTags` | Tag-frequency view | +| `:TwTable [filter]` | Vit-style column table; columns configurable via `table_columns`. `` opens that task for editing | | `:TwProjectAdd [name]` / `:TwProjectRemove` / `:TwProjectList` | Auto-project mapping | | `:TwTutor [reset]` | Open the interactive tutorial; `reset` ends an active session and cleans up orphan temp dirs | | `:TwFeedback [last-error]` | Open the structured bug-report form; `last-error` pre-fills with the most recent ERROR captured by the plugin | @@ -188,9 +190,12 @@ These are bundled but require their own host plugins. | `ta` | Quick-capture (global, works from any buffer) | | `tt` | Open task buffer (global) | | `tf` | Change filter (in task buffer) | -| `ts` | Change sort (in task buffer) | -| `tg` | Change group (in task buffer) | +| `ts` | Pick sort order (in task buffer) | +| `tg` | Pick grouping (in task buffer) | | `tpa` | Register cwd as a project | +| `tc` | Pick the Taskwarrior context (global) | +| `tv` | Pick a saved view (global) | +| `tr` | Pick a named report (global) | ## Metadata syntax @@ -211,7 +216,10 @@ Tasks use Taskwarrior-native syntax after the description: ```lua require("taskwarrior").setup({ on_delete = "done", -- "done" or "delete" when lines are removed - confirm = true, -- show confirmation dialog before applying + confirm = true, -- show confirmation dialog before applying. + -- false = apply immediately on :w; the summary + -- notification then points at :TwUndo, which + -- reverts everything the save just did sort = "urgency-", -- default sort (field+ for asc, field- for desc) group = nil, -- default group field (nil to disable) wrap = true, -- line-wrap task buffers (false = one line per task) @@ -227,7 +235,28 @@ require("taskwarrior").setup({ icons = true, -- nerd font checkbox/header icons border_style = "rounded", -- "rounded" | "single" | "double" | "none" capture_width = nil, -- quick-capture width (nil = auto) - capture_height = 3, -- quick-capture height in lines + capture_height = 3, -- quick-capture height in lines; line 1 is the + -- task, lines below it become annotations + capture_annotations = true, -- false = ignore everything below line 1 + capture_annotation_key = nil,-- insert-mode key(s) opening an annotation + -- line. Default binds and . + -- only reaches Neovim from terminals + -- speaking CSI-u (in tmux you also need + -- `set -g extended-keys on`); works + -- almost everywhere, which is why both are + -- bound by default + table_columns = nil, -- :TwTable columns; nil = built-in defaults. + -- Entries are field names or tables: + -- { field, label, width, align, format }. + -- Omit width on one column to let it absorb + -- the leftover space. Unknown fields fall + -- through to the task, so UDAs just work: + -- { "id", { field = "utility", width = 5, + -- align = "right" }, + -- "description", "tags" } + field_colors = {}, -- per-field highlight override, e.g. + -- { utility = "DiagnosticInfo" }. Fields not + -- listed still get the neutral TaskField group auto_backup = true, -- copy ~/.task to stdpath("data")/taskwarrior.nvim/backups/ before apply auto_backup_keep = 10, -- number of recent backups to retain delegate = { @@ -282,6 +311,17 @@ Run `:checkhealth taskwarrior` to verify your setup (Neovim version, Taskwarrior By default (`auto_backup = true`), the plugin copies your Taskwarrior data directory to `stdpath("data")/taskwarrior.nvim/backups//` immediately before any apply. The ten newest backups are kept; older ones are pruned. Disable with `auto_backup = false` in `setup()`. +### One-time tag repair + +Taskwarrior 3 parses the hyphen in a bare `+foo-bar` token as a subtraction operator. Tasks added before this was fixed had the literal `+foo-bar` text filed into their **description** with no tag ever created — so they stay invisible to `+foo-bar` even after upgrading. If that happened to you, run the repair once: + +```vim +:lua require("taskwarrior.repair_tags").run() " pending tasks +:lua require("taskwarrior.repair_tags").run("status:completed") +``` + +It shows every proposed change in a preview tab and asks before writing anything. A `+tag` written inside quotes is treated as a mention and left alone. There is deliberately no `:Tw*` command for this — you run it once, not daily. + ## Help Run `:help taskwarrior.nvim` inside Neovim for the full reference, or read [`doc/taskwarrior.txt`](doc/taskwarrior.txt). `:checkhealth taskwarrior` verifies your setup. diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index f79fdc9..0257cec 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -129,7 +129,8 @@ Edit any line. `:w` to sync. That is the whole loop. *:TwSummary* *:TwCalendar* *:TwTags* -Visualisation commands. See |taskwarrior-views|. + *:TwTable* +Visualisation commands. See |taskwarrior-views| and |taskwarrior-table|. *:TwProjectAdd* *:TwProjectRemove* @@ -243,6 +244,44 @@ Visualisation commands. See |taskwarrior-views|. :TwSync Run `task sync` asynchronously with progress and error reporting. Offers a retry on failure. + *:TwContext* +:TwContext Clear the active context (same as `none`). +:TwContext {name} Activate that context. Task buffers refresh and + honor its read filter — TW 3.x does not apply + contexts to `export`, so the plugin injects the + filter itself. Filters targeting a specific + `uuid:` are never context-narrowed. +:TwContext show Show the active context and list defined ones. +:TwContext define [name] [filter] + Define a context, e.g. + :TwContext define work project:work + :TwContext define deep +focus or priority:H + Prompts for anything you leave out, then offers to + activate it. +:TwContext delete [name] + Delete a context definition (prompts with a picker + when the name is omitted). + + *taskwarrior-repair* +One-time tag repair ~ + +Taskwarrior 3 parses the hyphen in a bare `+foo-bar` token as a +subtraction operator, so tasks added before this was fixed had the +literal text filed into their DESCRIPTION and the tag was never created +— leaving them unfindable by `+foo-bar` even after upgrading. To move +those tokens back into real tags: > + + :lua require("taskwarrior.repair_tags").run() + :lua require("taskwarrior.repair_tags").run("status:completed") +< +Every proposed change is shown in a preview tab and confirmed before +anything is written. The default scope is `status:pending`; pass a +filter to widen it. A `+tag` written inside quotes is treated as a +mention (someone describing a tag) and left alone. + +There is deliberately no `:Tw` command for this — it is run once, not +routinely, and would otherwise sit in the command list forever. + *:TwFloat* :TwFloat [filter] Open the task buffer in a centered floating window instead of a split. `q` to dismiss. @@ -342,9 +381,34 @@ Buffer-local (inside a task buffer): ~ << Prepend to description (|:TwPrepend|). yt Duplicate task (|:TwDuplicate|). dD Purge task (irreversible, confirms). - tf Change filter interactively. - ts Change sort interactively. - tg Change grouping interactively. + tf Change filter (free text, completes). + ts Pick sort order. + tg Pick grouping. + +Global (work from any buffer): + + tt Open the task buffer. + ta Quick-capture a task. + tc Pick the Taskwarrior context. + tv Pick a saved view. + tr Pick a named report. + tpa Register cwd as a project. + tF Report a bug (|:TwFeedback|). + + *taskwarrior-pickers* +Anything with a small, known set of answers is chosen from a list rather +than typed: sort order, grouping, context, saved views and reports. The +active value is marked with `●`, and the context and report pickers show +each entry's filter alongside its name. + +These use |vim.ui.select|, so they render through whichever picker you +have configured — dressing.nvim/telescope, snacks, fzf-lua — and inherit +its fuzzy matching. With no such plugin installed you get Neovim's +built-in numbered list. + +The filter prompt is deliberately NOT a picker: a Taskwarrior filter is +open-ended, so it stays free text with completion over your real +projects, tags and fields. ============================================================================== 7. SYNTAX *taskwarrior-syntax* @@ -371,7 +435,8 @@ Full schema with defaults: > require("taskwarrior").setup({ on_delete = "done", -- or "delete" - confirm = true, + confirm = true, -- false = no popup: :w applies at once, + -- notifies a summary, :TwUndo reverts sort = "urgency-", group = nil, wrap = true, -- false = don't line-wrap task buffers @@ -382,12 +447,17 @@ Full schema with defaults: > sort_key = "ts", group_key = "tg", project_add_key = "tpa", + context_key = "tc", -- pick a context + view_key = "tv", -- pick a saved view + report_key = "tr", -- pick a named report filters = {}, projects = {}, icons = true, border_style = "rounded", capture_width = nil, - capture_height = 3, + capture_height = 3, -- line 1 = task, lines below it become + -- annotations ( opens one) + capture_annotations = true, -- false = ignore lines below line 1 capture_confirm_close = true, -- prompt before discarding text on group_separator = true, animation = true, @@ -407,6 +477,8 @@ Full schema with defaults: > }, -- Per-tag highlight overrides (see |taskwarrior-tag-colors|). tag_colors = {}, -- e.g. { ["+urgent"] = "ErrorMsg" } + field_colors = {}, -- e.g. { utility = "DiagnosticInfo" }; + -- unlisted fields get `TaskField` -- Urgency breakpoints for virtual text + views (|taskwarrior-urgency-colors|). urgency_colors = { { threshold = 8, hl = "TaskPriorityH" }, @@ -601,9 +673,34 @@ are supported. :TwSummary Per-project counts + horizontal bar chart. :TwCalendar Tasks grouped by due date. :TwTags Tag frequency. +:TwTable [filter] Vit-style column table of tasks. All read from `task export` — no external charting tools. + *taskwarrior-table* +:TwTable renders one row per task with aligned columns, sorted by urgency +(or `custom_urgency` when set). `` opens the task under the cursor in +an editable :Tw buffer, `r` refreshes, `q` closes. The view itself is +read-only. + +Columns come from the `table_columns` option. Each entry is a field name, +or a table: > + + table_columns = { + "id", + { field = "utility", label = "Util", width = 5, align = "right" }, + { field = "description" }, -- no width: absorbs leftover space + { field = "tags", label = "Count", width = 6, + format = function(value, task) return tostring(#(value or {})) end }, + } +< +`width` omitted on exactly one column makes it take the remaining window +width; cells longer than their column are ellipsized, never overflowed. +Field names not recognised as built-ins are read straight off the exported +task, so UDA columns need no extra wiring. `format(value, task)` renders +the cell yourself. Leave `table_columns` nil for the built-in defaults +(ID, urgency, priority, project, due, description, tags). + ============================================================================== 10. SAVED VIEWS *taskwarrior-saved-views* diff --git a/lua/taskwarrior/apply.lua b/lua/taskwarrior/apply.lua index ff5b040..2a4f74c 100644 --- a/lua/taskwarrior/apply.lua +++ b/lua/taskwarrior/apply.lua @@ -338,6 +338,14 @@ function M.do_apply_and_refresh(bufnr, tmpfile, on_delete, refresh_fn, opts) if first and first ~= "" then msg = msg .. "\n" .. first end vim.notify(msg, vim.log.levels.ERROR) elseif (summary.action_count or 0) > 0 then + -- Without the confirm popup the save applies silently, so the summary + -- notification is the user's only checkpoint — make the undo path + -- discoverable right there (tw fca82462). + local config = require("taskwarrior.config") + if not config.options.confirm then + local prefix = config.options.command_prefix or "Tw" + msg = msg .. (" — :%sUndo to revert"):format(prefix) + end vim.notify(msg) end diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 70ab765..e4dcf59 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -62,7 +62,19 @@ function M.set_buf_lines(bufnr, text) if lines[#lines] == "" then table.remove(lines) end - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + -- Render is not a user edit, so it must not be undoable. Otherwise `u` in a + -- freshly opened buffer reverts the population itself and leaves the empty + -- buffer behind — which the save path reads as "every task was deleted", + -- and duly offers to delete them all. Setting undolevels to -1 across the + -- change is the documented way to make it non-undoable (:h undolevels); + -- the user's own edits afterwards still undo normally. + vim.api.nvim_buf_call(bufnr, function() + local saved = vim.api.nvim_get_option_value("undolevels", { buf = bufnr }) + vim.api.nvim_set_option_value("undolevels", -1, { buf = bufnr }) + local ok, err = pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value("undolevels", saved, { buf = bufnr }) + if not ok then error(err) end + end) end -- --------------------------------------------------------------------------- @@ -76,6 +88,10 @@ local function render(filter, sort, group) if filter and filter ~= "" then for w in filter:gmatch("%S+") do table.insert(filter_args, w) end end + -- Honor the active Taskwarrior context (TW 3.x doesn't apply it to + -- `export`). The tokens land in the rendered header, so the save path + -- re-exports with the same effective filter — no false deletes. + vim.list_extend(filter_args, require("taskwarrior.context").filter_tokens(filter)) local tm = require("taskwarrior.taskmd") local ok, result = pcall(tm.render, { @@ -107,6 +123,8 @@ local function apply_custom_sort(bufnr) -- Export tasks to get full data for the custom function local filter = vim.b[bufnr].task_filter or "" local export_filter = filter ~= "" and filter or "status:pending" + local ctx = table.concat(require("taskwarrior.context").filter_tokens(filter), " ") + if ctx ~= "" then export_filter = export_filter .. " " .. ctx end local tasks = require("taskwarrior.taskmd").shell_export(export_filter) if not tasks then return end @@ -188,6 +206,22 @@ end -- --------------------------------------------------------------------------- local hl_ns = vim.api.nvim_create_namespace("taskwarrior_hl") + +-- Whether this Neovim understands extmark `conceal_lines` (0.11+), which +-- hides a line's screen row outright rather than blanking it. Probed once +-- against a throwaway buffer — the option is silently ignored on older +-- versions in some builds and errors in others, so feature-test rather +-- than version-compare. +local HAS_CONCEAL_LINES = (function() + local probe_buf = vim.api.nvim_create_buf(false, true) + local probe_ns = vim.api.nvim_create_namespace("taskwarrior_conceal_probe") + vim.api.nvim_buf_set_lines(probe_buf, 0, -1, false, { "probe" }) + local ok = pcall(vim.api.nvim_buf_set_extmark, probe_buf, probe_ns, 0, 0, { + conceal_lines = "", + }) + pcall(vim.api.nvim_buf_delete, probe_buf, { force = true }) + return ok +end)() local vt_ns = vim.api.nvim_create_namespace("taskwarrior_vt") -- Paint the checkbox visuals on a single line: a conceal extmark over the @@ -352,6 +386,8 @@ local function apply_virtual_text(bufnr) apply_empty_state(bufnr) local filter = vim.b[bufnr].task_filter or "" local export_filter = filter ~= "" and filter or "status:pending" + local ctx = table.concat(require("taskwarrior.context").filter_tokens(filter), " ") + if ctx ~= "" then export_filter = export_filter .. " " .. ctx end local tasks = require("taskwarrior.taskmd").shell_export(export_filter) if not tasks then return end @@ -596,6 +632,10 @@ local function define_highlights() -- Overdue right-align pill uses an inverted-fg style so it pops against -- normal right-align virt-text. vim.api.nvim_set_hl(0, "TaskOverdueBadge",{ fg = "#1e1e2e", bg = "#f38ba8", bold = true }) + -- Catch-all for any other `field:value` (UDAs, depends:, until:, …) so no + -- field renders as plain description text. Per-field overrides go through + -- config.field_colors. + vim.api.nvim_set_hl(0, "TaskField", { link = "TaskSubtle" }) end -- Highlight patterns: { lua pattern, highlight group, is_prefix_match } @@ -620,16 +660,24 @@ local function is_overdue(date_str) end -- Apply highlights to a single line -local function highlight_line(bufnr, line_nr, line) +local function highlight_line(bufnr, line_nr, line, has_tasks) -- Header comment line — concealed entirely. Users don't need to see the -- metadata, only -- the tasks themselves. Filter/sort/group are shown via statusline if the -- user configures it, and :TaskHelp lists the active settings. if line:match("^ 1 and line:sub(s - 1, s - 1) or "" + local value = line:sub(s + #name + 1, e) + -- Require a token boundary, reject numeric names ("12:30" is a clock + -- time) and `//` values (URL schemes) — neither is a Taskwarrior field. + if (prev == "" or prev:match("%s")) + and not name:match("^%d") + and not value:match("^//") then + local hl_group = "TaskField" + local override = field_colors[name] + if type(override) == "string" then + hl_group = override + elseif type(override) == "table" then + local gname = "TaskField_" .. name:gsub("[^%w]", "_") + pcall(vim.api.nvim_set_hl, 0, gname, override) + hl_group = gname + end + vim.api.nvim_buf_set_extmark(bufnr, hl_ns, line_nr, s - 1, { + end_col = e, hl_group = hl_group, + }) + end + pos = e + 1 + end + -- All other patterns for _, pat in ipairs(HL_PATTERNS) do pos = 1 @@ -704,7 +787,6 @@ local function highlight_line(bufnr, line_nr, line) -- Tags: +word, but only when the `+` is at start-of-line or preceded by a -- non-word character. Prevents "housing+food" from highlighting "+food". -- Honors config.tag_colors for per-tag overrides (e.g. +urgent → ErrorMsg). - local config = require("taskwarrior.config") local tag_colors = config.options.tag_colors or {} pos = 1 while true do @@ -736,8 +818,14 @@ end local function update_highlights(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, hl_ns, 0, -1) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + -- Whether the buffer holds any task at all decides if the header row can + -- be removed outright — see the header branch in highlight_line. + local has_tasks = false + for _, line in ipairs(lines) do + if uuid_from_line(line) or line:match("^%- %[") then has_tasks = true; break end + end for i, line in ipairs(lines) do - highlight_line(bufnr, i - 1, line) + highlight_line(bufnr, i - 1, line, has_tasks) end end @@ -763,6 +851,24 @@ end -- refresh_buf: re-render and re-highlight a task buffer -- --------------------------------------------------------------------------- +-- Park the cursor on the first task line. The header row above it is +-- concealed away entirely on Neovim 0.11+, so leaving the cursor on line 1 +-- would strand it on a row the user cannot see. +function M.cursor_to_first_task(win, bufnr) + win = win or 0 + bufnr = bufnr or vim.api.nvim_win_get_buf(win) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if uuid_from_line(line) or line:match("^%- %[") then + pcall(vim.api.nvim_win_set_cursor, win, { i, 6 }) + return i + end + end + -- No tasks (empty filter): sit on the first line that isn't the header. + pcall(vim.api.nvim_win_set_cursor, win, { math.min(2, #lines), 0 }) + return nil +end + function M.refresh_buf(bufnr) if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end local filter = vim.b[bufnr].task_filter or "" @@ -979,33 +1085,31 @@ function M.setup_buf_keymaps(bufnr) end, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: Change filter" }) end - -- Buffer-local sort key + -- Buffer-local sort key. Finite set → picker, not a free-text prompt. if config2.options.sort_key then vim.keymap.set("n", config2.options.sort_key, function() - local ok, input = pcall(vim.fn.input, { - prompt = "Sort: ", - default = vim.b[bufnr].task_sort or "urgency-", - completion = "customlist,v:lua.require'taskwarrior'._complete_sort", - }) - if not ok or input == nil then return end - vim.b[bufnr].task_sort = input - M.refresh_buf(bufnr) - vim.notify("taskwarrior.nvim: sort → " .. input) + require("taskwarrior.pick").select( + require("taskwarrior.choices").SORTS, + { prompt = "Sort by:", current = vim.b[bufnr].task_sort or "urgency-" }, + function(value) + vim.b[bufnr].task_sort = value + M.refresh_buf(bufnr) + vim.notify("taskwarrior.nvim: sort → " .. value) + end) end, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: Change sort" }) end - -- Buffer-local group key + -- Buffer-local group key. if config2.options.group_key then vim.keymap.set("n", config2.options.group_key, function() - local ok, input = pcall(vim.fn.input, { - prompt = "Group by (empty=none): ", - default = vim.b[bufnr].task_group or "", - completion = "customlist,v:lua.require'taskwarrior'._complete_group", - }) - if not ok or input == nil then return end - vim.b[bufnr].task_group = (input ~= "" and input ~= "none") and input or nil - M.refresh_buf(bufnr) - vim.notify("taskwarrior.nvim: group → " .. (input ~= "" and input or "(none)")) + require("taskwarrior.pick").select( + require("taskwarrior.choices").GROUPS, + { prompt = "Group by:", current = vim.b[bufnr].task_group or "none" }, + function(value) + vim.b[bufnr].task_group = (value ~= "none") and value or nil + M.refresh_buf(bufnr) + vim.notify("taskwarrior.nvim: group → " .. value) + end) end, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: Change grouping" }) end @@ -1117,6 +1221,37 @@ function M.setup_buf_autocmds(bufnr, on_write_fn) end, }) + -- Keep the cursor off the header line. The header row is concealed away + -- entirely (see highlight_line), so the first task is drawn on the top + -- screen row — which means `gg`, `:1`, or a restored cursor position puts + -- you on a line you cannot see while the first task LOOKS selected. + -- Typing there edits the header and earns a "header is read-only" + -- warning that appears to come from editing the first task. + -- + -- Visual mode is exempt: moving the cursor would silently reshape the + -- user's selection. The read-only guard below still covers that case. + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + buffer = bufnr, + group = group, + callback = function() + if not vim.api.nvim_buf_is_valid(bufnr) then return true end + if vim.fn.mode():match("[vV\22]") then return end + local pos = vim.api.nvim_win_get_cursor(0) + if pos[1] ~= 1 then return end + local first = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] or "" + if not first:match("^ comment), -- restore it on the next TextChanged event. The cached header lives on the -- buffer (vim.b) so :TaskFilter/:TaskSort/:TaskRefresh can update it when @@ -1293,6 +1428,7 @@ function M.open_task_buf(filter_str, on_write_fn, detect_project_fn) vim.wo[0].conceallevel = 3 vim.wo[0].concealcursor = "nvic" vim.wo[0].wrap = config.options.wrap ~= false + M.cursor_to_first_task(0, bufnr) -- Set name safely — wipe stale buffer with same name if needed local buf_name = "Tasks: " .. filter_str @@ -1362,6 +1498,7 @@ function M.open_float(filter_str) pcall(vim.api.nvim_win_close, win, true) end, { buffer = scratch, nowait = true, silent = true }) + M.cursor_to_first_task(win, scratch) apply_virtual_text(scratch) end diff --git a/lua/taskwarrior/capture.lua b/lua/taskwarrior/capture.lua index 89fad01..7b34a03 100644 --- a/lua/taskwarrior/capture.lua +++ b/lua/taskwarrior/capture.lua @@ -45,6 +45,14 @@ function M.open(refresh_fn) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "" }) vim.cmd("startinsert") + -- Color project:/+tag/due:/priority:/UDA fields as they're typed, using the + -- same highlighter the task buffer uses (tw 14d722e8). Re-runs on every + -- text change so the coloring tracks what you're typing. + local ok_buffer, tw_buffer = pcall(require, "taskwarrior.buffer") + if ok_buffer then + pcall(tw_buffer.setup_buf_syntax, buf) + end + -- Close is always deferred via vim.schedule: cmp's keymap solver (and other -- expr-mapping wrappers) can invoke our callbacks from inside a textlock -- context where nvim_win_close raises E565. Scheduling moves the close to @@ -65,8 +73,10 @@ function M.open(refresh_fn) local function close_with_confirm() vim.schedule(function() if config.options.capture_confirm_close ~= false then + -- Check every line, not just the first: annotation lines below it + -- are unsubmitted content too. local line = vim.api.nvim_buf_is_valid(buf) - and (vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "") + and table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") or "" if line:match("%S") then -- vim.fn.confirm requires a non-textlock context; we're already @@ -76,7 +86,9 @@ function M.open(refresh_fn) -- Restore insert mode at the line's end so typing resumes naturally. if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_current_win(win) - vim.api.nvim_win_set_cursor(win, { 1, #line }) + local last = vim.api.nvim_buf_line_count(buf) + local last_text = vim.api.nvim_buf_get_lines(buf, last - 1, last, false)[1] or "" + vim.api.nvim_win_set_cursor(win, { last, #last_text }) vim.cmd("startinsert!") end return @@ -87,8 +99,48 @@ function M.open(refresh_fn) end) end - local function submit(line) + -- Echo the start of what was actually stored so the user can confirm the + -- right task went through (tw 978fb9d1) — "added task" alone doesn't tell + -- you whether your fields parsed or the text was mangled. + local function snippet(text, max) + max = max or 40 + text = vim.trim(text or "") + if vim.fn.strchars(text) <= max then return text end + return vim.fn.strcharpart(text, 0, max) .. "…" + end + + -- Lines below the first become annotations on the new task (tw 14d722e8). + -- Set capture_annotations = false to ignore them instead. + local function annotate_extra_lines(uuid, extra) + if not uuid or uuid == "" or #extra == 0 then return 0 end + local done = 0 + for _, text in ipairs(extra) do + local result = command.mutate({ uuid, "annotate", "--", text }) + if result.ok then + done = done + 1 + else + vim.notify("taskwarrior.nvim: annotate failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + end + end + return done + end + + local function extra_lines() + if config.options.capture_annotations == false then return {} end + if not vim.api.nvim_buf_is_valid(buf) then return {} end + local all = vim.api.nvim_buf_get_lines(buf, 1, -1, false) + local out = {} + for _, l in ipairs(all) do + local t = vim.trim(l) + if t ~= "" then out[#out + 1] = t end + end + return out + end + + local function submit(line, extra) if not line or line == "" then return end + extra = extra or {} -- Greedy-parse the line so utility:20, project:X, +tag, due:tom etc. -- become real fields even when they appear in the middle of free-form @@ -107,7 +159,13 @@ function M.open(refresh_fn) if desc and desc ~= "" then local new_uuid, add_ok = tm.tw_add(desc, fields) if new_uuid and new_uuid ~= "" then - vim.notify("taskwarrior.nvim: added task") + local annotated = annotate_extra_lines(new_uuid, extra) + local msg = ('taskwarrior.nvim: added "%s"'):format(snippet(desc)) + if annotated > 0 then + msg = msg .. (" (+%d annotation%s)"):format( + annotated, annotated == 1 and "" or "s") + end + vim.notify(msg) elseif add_ok then vim.notify( "taskwarrior.nvim: added task, but Taskwarrior did not report its UUID; not retrying", @@ -126,7 +184,7 @@ function M.open(refresh_fn) -- their typed content. local result = command.mutate({ "add", "--", line }) if result.ok then - vim.notify("taskwarrior.nvim: added task (unparsed)") + vim.notify(('taskwarrior.nvim: added (unparsed) "%s"'):format(snippet(line))) refresh_fn() else vim.notify("taskwarrior.nvim: add failed", vim.log.levels.ERROR) @@ -147,14 +205,45 @@ function M.open(refresh_fn) return "" end local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "" + local extra = extra_lines() -- Defer close + submit to escape any active textlock (nvim-cmp, etc.). vim.schedule(function() close() - submit(line) + submit(line, extra) end) return "" end, { buffer = buf, expr = true }) + -- Open a new line below for an annotation without submitting — stays + -- "submit everything" from any line. + -- + -- Key choice matters more than it looks: only reaches Neovim from + -- terminals that speak the CSI-u / kitty keyboard protocol, and inside + -- tmux only with `set -g extended-keys on`. Everywhere else it arrives + -- indistinguishable from a plain and would silently submit. + -- is sent as ESC-prefixed CR by virtually every terminal, so it is the + -- portable default; both are bound by default and + -- capture_annotation_key overrides the whole set. + local function open_annotation_line() + local last = vim.api.nvim_buf_line_count(buf) + vim.api.nvim_buf_set_lines(buf, last, last, false, { "" }) + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_set_cursor(win, { last + 1, 0 }) + end + vim.cmd("startinsert!") + end + local anno_keys = config.options.capture_annotation_key + if anno_keys == nil then anno_keys = { "", "" } end + if type(anno_keys) == "string" then anno_keys = { anno_keys } end + for _, key in ipairs(anno_keys) do + if key ~= "" then + vim.keymap.set("i", key, open_annotation_line, { + buffer = buf, + desc = "taskwarrior.nvim: new annotation line", + }) + end + end + vim.keymap.set("i", "", close_with_confirm, { buffer = buf }) vim.keymap.set("n", "", close_with_confirm, { buffer = buf }) vim.keymap.set("n", "q", close_with_confirm, { buffer = buf }) diff --git a/lua/taskwarrior/choices.lua b/lua/taskwarrior/choices.lua new file mode 100644 index 0000000..1e4e29b --- /dev/null +++ b/lua/taskwarrior/choices.lua @@ -0,0 +1,52 @@ +-- taskwarrior/choices.lua — the finite option sets, in one place. +-- +-- Sort specs and group fields were previously spelled out three times each +-- (command completion, input() completion, and now the pickers), which is +-- how they drift. Everything that offers these choices reads them here. +-- +-- Each entry is { value, label } — `value` is what Taskwarrior/the buffer +-- variable wants, `label` is what a human should see in a picker. + +local M = {} + +M.SORTS = { + { value = "urgency-", label = "urgency ↓ (most urgent first)" }, + { value = "urgency+", label = "urgency ↑ (least urgent first)" }, + { value = "due+", label = "due ↑ (soonest first)" }, + { value = "due-", label = "due ↓ (latest first)" }, + { value = "priority-", label = "priority ↓ (H → L)" }, + { value = "priority+", label = "priority ↑ (L → H)" }, + { value = "project+", label = "project A→Z" }, + { value = "project-", label = "project Z→A" }, + { value = "description+", label = "description A→Z" }, +} + +M.GROUPS = { + { value = "none", label = "none (flat list)" }, + { value = "project", label = "project" }, + { value = "priority", label = "priority" }, + { value = "status", label = "status" }, + { value = "tag", label = "tag" }, +} + +-- Plain value lists, for cmdline completion. +local function values(set) + local out = {} + for _, entry in ipairs(set) do out[#out + 1] = entry.value end + return out +end + +function M.sort_values() return values(M.SORTS) end +function M.group_values() return values(M.GROUPS) end + +--- Filter a value list by a completion prefix. +function M.complete(set, arg_lead) + arg_lead = arg_lead or "" + local out = {} + for _, v in ipairs(values(set)) do + if v:sub(1, #arg_lead) == arg_lead then out[#out + 1] = v end + end + return out +end + +return M diff --git a/lua/taskwarrior/commands.lua b/lua/taskwarrior/commands.lua index f8afe7e..376718a 100644 --- a/lua/taskwarrior/commands.lua +++ b/lua/taskwarrior/commands.lua @@ -70,19 +70,24 @@ function M.setup(main, complete_filter) main.undo() end, { nargs = 0, desc = "Undo last save" }) + -- No argument opens the picker (previously this was an error), so both + -- `:TwSort due+` and a bare `:TwSort` are usable. register("Sort", function(cmd_opts) + if cmd_opts.args == "" then + local bufnr = vim.api.nvim_get_current_buf() + require("taskwarrior.pick").select( + require("taskwarrior.choices").SORTS, + { prompt = "Sort by:", current = vim.b[bufnr].task_sort or "urgency-" }, + function(value) main.sort(value) end) + return + end main.sort(cmd_opts.args) end, { - nargs = 1, - desc = "Change task sort order (e.g. due+, urgency-)", + nargs = "?", + desc = "Change task sort order (no argument opens a picker)", complete = function(arg_lead) - local fields = { "urgency-", "urgency+", "due+", "due-", "priority-", - "priority+", "project+", "project-", "description+" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.SORTS, arg_lead) end, }) @@ -90,14 +95,10 @@ function M.setup(main, complete_filter) main.group(cmd_opts.args) end, { nargs = "?", - desc = "Change task grouping (e.g. project, tag, none)", + desc = "Change task grouping (project, tag, none; see also tg)", complete = function(arg_lead) - local fields = { "project", "priority", "status", "tag", "none" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.GROUPS, arg_lead) end, }) @@ -201,6 +202,20 @@ function M.setup(main, complete_filter) views.tags() end, { nargs = 0, desc = "Show tag distribution" }) + -- NOTE: the one-time tag repair (taskwarrior.repair_tags) is deliberately + -- NOT registered here. It is run once, ever, to clean up damage from the + -- TW3 hyphen misparse; a permanent :Tw* slot would clutter the command + -- list for every user forever. Invoke it explicitly: + -- :lua require("taskwarrior.repair_tags").run() + + register("Table", function(o) + require("taskwarrior.table_view").open(o.args) + end, { + nargs = "*", + desc = "Show tasks as a configurable column table", + complete = function(arg_lead) return complete_filter(arg_lead) end, + }) + -- Structured feedback buffer. Default config: GitHub-issue + clipboard -- always available; HTTP "Send" only when feedback_endpoint is set. -- :Feedback open the empty form @@ -283,6 +298,48 @@ function M.setup(main, complete_filter) main.sync() end, { nargs = 0, desc = "Run `task sync` with progress and error handling" }) + -- Taskwarrior contexts (work, home, …). + -- :TwContext clear the active context + -- :TwContext activate it + -- :TwContext show active context + the defined ones + -- :TwContext define [name] [filter] create one (prompts for anything + -- not supplied) + -- :TwContext delete [name] remove a definition (prompts if omitted) + register("Context", function(o) + local ctx = require("taskwarrior.context") + local sub, rest = o.args:match("^(%S+)%s*(.*)$") + if sub == "define" then + local name, filter = rest:match("^(%S+)%s*(.*)$") + ctx.define(name, filter) + elseif sub == "delete" then + ctx.delete(rest) + else + main.context(o.args) + end + end, { + nargs = "*", + desc = "Taskwarrior context: bare = clear, = activate, show/define/delete", + complete = function(arg_lead, cmd_line) + -- After `define`/`delete`, complete context names rather than verbs. + local words = vim.split(cmd_line or "", "%s+") + local sub = words[2] + local names = require("taskwarrior.context").list() + local candidates + if sub == "delete" and #words > 2 then + candidates = names + elseif sub == "define" or (sub == "delete" and #words > 3) then + candidates = {} + else + candidates = vim.list_extend({ "none", "show", "define", "delete" }, names) + end + local out = {} + for _, n in ipairs(candidates) do + if n:sub(1, #arg_lead) == arg_lead then table.insert(out, n) end + end + return out + end, + }) + register("Float", function(o) require("taskwarrior.buffer").open_float(o.args) end, { diff --git a/lua/taskwarrior/config.lua b/lua/taskwarrior/config.lua index 6bfd837..c2dceab 100644 --- a/lua/taskwarrior/config.lua +++ b/lua/taskwarrior/config.lua @@ -31,6 +31,12 @@ M.defaults = { sort_key = "ts", -- buffer-local keybind to change sort (nil to disable) group_key = "tg", -- buffer-local keybind to change grouping (nil to disable) project_add_key = "tpa", -- global keybind to register cwd as a project (nil to disable) + -- Global keybinds that open a picker over a finite set. All use + -- vim.ui.select, so they inherit whatever picker (and fuzzy matching) + -- you already use. Set any to nil/false to disable. + context_key = "tc", -- pick the Taskwarrior context + view_key = "tv", -- pick a saved view + report_key = "tr", -- pick a named report filters = {}, -- named filter presets: { { key = "", filter = "filter_str", label = "label" }, ... } projects = {}, -- directory-to-project mapping: { ["/path/to/dir"] = "project_name", ... } -- Icon mode. Auto-detects nerd-font availability via `vim.g.have_nerd_font` @@ -43,11 +49,40 @@ M.defaults = { icons = true, border_style = "rounded", -- border style for floating windows: "rounded", "single", "double", "none" capture_width = nil, -- quick-capture window width (nil = auto: min(80, 60% of editor)) - capture_height = 3, -- quick-capture window height (lines visible; task is still 1 line) + -- Quick-capture window height. Line 1 is the task; every line below it + -- becomes an annotation on save ( opens one). Raise this for a + -- roomier capture form. + capture_height = 3, + -- Turn the capture window's extra lines into annotations. Set false to + -- ignore everything below line 1. + capture_annotations = true, + -- Insert-mode key(s) that open a new annotation line in the capture + -- window without submitting. String or list of strings; false/"" disables. + -- Default binds both and . + -- + -- only reaches Neovim from terminals speaking the CSI-u / kitty + -- keyboard protocol — and inside tmux only with `set -g extended-keys on` + -- (plus `set -as terminal-features '*:extkeys'`). Without that it arrives + -- as a plain and submits. (Alt+Enter) is sent as an + -- ESC-prefixed CR by virtually every terminal, which is why it's in the + -- default set. + capture_annotation_key = nil, -- When the quick-capture window has unsubmitted text and the user presses -- , ask before discarding instead of closing silently. Set to false -- to restore the pre-1.4 always-close behavior. capture_confirm_close = true, + -- Columns for the :TwTable view (vit-style). Each entry is a field name, + -- or a table { field, label, width, align, format }. `width` omitted on + -- exactly one column makes it absorb the leftover width (usually + -- description). `format = function(value, task) -> string` renders the + -- cell yourself. Unknown fields fall through to the exported task, so + -- UDA columns need no extra wiring: + -- table_columns = { + -- "id", { field = "utility", label = "Util", width = 5, align = "right" }, + -- { field = "description" }, "tags", + -- } + -- nil = the built-in default columns (see table_view.DEFAULT_COLUMNS). + table_columns = nil, group_separator = true, -- show separator lines between groups animation = true, -- enable open/transition animations clamp_cursor = true, -- clamp cursor before UUID comment (prevents invisible cursor movement) @@ -85,6 +120,13 @@ M.defaults = { -- nvim_set_hl (e.g. `{ fg = "#f38ba8", bold = true }`). -- tag_colors = { ["+urgent"] = "ErrorMsg", ["+someday"] = "Comment" } tag_colors = {}, + -- Per-field highlight overrides, keyed by field name (no colon). Applies + -- in task buffers AND the quick-capture window. Value is a highlight + -- group name or a table passed to nvim_set_hl. Any field not listed here + -- still gets the neutral `TaskField` group, so UDAs are never rendered + -- as plain description text. + -- field_colors = { utility = "DiagnosticInfo", depends = { fg = "#f38ba8" } } + field_colors = {}, -- Urgency color breakpoints used by task buffer virtual text and view -- renderers. Rows are evaluated top-to-bottom; the first row whose -- `threshold` is `<=` the urgency wins. The defaults reproduce the diff --git a/lua/taskwarrior/context.lua b/lua/taskwarrior/context.lua new file mode 100644 index 0000000..203b13b --- /dev/null +++ b/lua/taskwarrior/context.lua @@ -0,0 +1,221 @@ +-- taskwarrior/context.lua — Taskwarrior context support (tw c12b4cbd). +-- +-- `task context work` / `task context none` set a global context in the +-- user's .taskrc; reports honor it automatically. TW 3.x does NOT apply the +-- context to `export`, which is how this plugin reads everything — so the +-- render path injects the active context's read filter itself via +-- M.filter_tokens(). The injected tokens end up in the rendered taskmd +-- header, which keeps the save/apply path consistent with what was rendered +-- (no false external-delete conflicts when a context hides tasks). + +local M = {} +local command = require("taskwarrior.command") +local notify = require("taskwarrior.notify") + +-- Current context name, or nil when unset. +function M.current() + local result = command.read({ "_get", "rc.context" }) + if not result.ok then return nil end + local name = vim.trim(result.output or "") + -- Context names never contain whitespace — anything else is CLI chatter. + if name == "" or name:find("%s") then return nil end + return name +end + +-- All defined context names. +function M.list() + local result = command.read({ "_context" }) + if not result.ok then return {} end + local out = {} + for line in (result.output or ""):gmatch("[^\r\n]+") do + local v = vim.trim(line) + if v ~= "" and not v:find("%s") then out[#out + 1] = v end + end + return out +end + +-- Read filter of the named (default: active) context, or nil. +function M.read_filter(name) + name = name or M.current() + if not name then return nil end + local result = command.read({ "_get", "rc.context." .. name .. ".read" }) + local f = result.ok and vim.trim(result.output or "") or "" + if f == "" then + -- Contexts defined by TW <2.6 live in rc.context. without .read. + result = command.read({ "_get", "rc.context." .. name }) + f = result.ok and vim.trim(result.output or "") or "" + end + if f == "" then return nil end + return f +end + +-- Tokens to append to a render filter so task buffers honor the active +-- context. Parenthesized so `or`-filters compose with the rest. Skipped for +-- uuid-targeted filters: opening one specific task must never come up empty +-- because a context hides it. +function M.filter_tokens(existing_filter) + if type(existing_filter) == "string" and existing_filter:find("uuid[%.:=]") then + return {} + end + local f = M.read_filter() + if not f then return {} end + local tokens = { "(" } + for w in f:gmatch("%S+") do tokens[#tokens + 1] = w end + tokens[#tokens + 1] = ")" + return tokens +end + +local function refresh_all_task_buffers() + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.b[b].task_filter ~= nil then + pcall(function() require("taskwarrior.buffer").refresh_buf(b) end) + end + end +end + +-- Show the active context and the available ones. +function M.show() + local cur = M.current() + local names = M.list() + local lines = {} + if cur then + lines[1] = ("taskwarrior.nvim: context %s (%s)"):format(cur, M.read_filter(cur) or "?") + else + lines[1] = "taskwarrior.nvim: no context active" + end + if #names > 0 then + lines[#lines + 1] = "available: " .. table.concat(names, ", ") .. ", none" + else + lines[#lines + 1] = "none defined — create one with: task context define work project:work" + end + notify("view", table.concat(lines, "\n")) +end + +--- Fuzzy-pick a context to activate. Lists every defined context with its +--- read filter, marks the active one, and offers "none" to clear plus a +--- "define new" escape hatch so an empty context list is not a dead end. +function M.pick() + local names = M.list() + local current = M.current() + local entries = { { value = "none", label = "none (clear context)" } } + for _, name in ipairs(names) do + entries[#entries + 1] = { + value = name, + label = ("%-10s%s"):format(name, M.read_filter(name) or ""), + } + end + entries[#entries + 1] = { value = "\0define", label = "+ define a new context…" } + + require("taskwarrior.pick").select(entries, { + prompt = "Context:", + current = current or "none", + }, function(value) + if value == "\0define" then return M.define() end + M.set(value) + end) +end + +--- Define a context. `filter` is a Taskwarrior filter expression; when +--- omitted the user is prompted for it. Prompts for the name too when nil. +function M.define(name, filter) + local function do_define(n, f) + n, f = vim.trim(n or ""), vim.trim(f or "") + if n == "" or f == "" then return end + if n == "none" then + notify("error", "taskwarrior.nvim: 'none' is reserved — pick another name", + vim.log.levels.ERROR) + return + end + local args = { "context", "define", n } + -- The filter is one CLI argument per token, same as any other filter. + local parsed = command.parse_args(f) + if not parsed then + notify("error", "taskwarrior.nvim: unparseable context filter", + vim.log.levels.ERROR) + return + end + vim.list_extend(args, parsed) + local result = command.mutate(args) + if not result.ok then + notify("error", "taskwarrior.nvim: context define failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + return + end + notify("view", ("taskwarrior.nvim: context %s defined (%s)"):format(n, f)) + -- Offer to switch to it right away — defining one you don't then use is + -- almost never what you meant. + vim.ui.select({ "Activate now", "Leave inactive" }, { + prompt = ("Activate context %s?"):format(n), + }, function(choice) + if choice == "Activate now" then M.set(n) end + end) + end + + if name == nil or vim.trim(name) == "" then + vim.ui.input({ prompt = "Context name: " }, function(n) + if not n or vim.trim(n) == "" then return end + vim.ui.input({ prompt = "Filter (e.g. project:work or +work): " }, function(f) + do_define(n, f) + end) + end) + return + end + if filter == nil or vim.trim(filter) == "" then + vim.ui.input({ prompt = ("Filter for context %s: "):format(name) }, function(f) + do_define(name, f) + end) + return + end + do_define(name, filter) +end + +--- Delete a context definition (prompts to pick one when name is omitted). +function M.delete(name) + local function do_delete(n) + local result = command.mutate({ "context", "delete", n }) + if not result.ok then + notify("error", "taskwarrior.nvim: context delete failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + return + end + notify("view", ("taskwarrior.nvim: context %s deleted"):format(n)) + refresh_all_task_buffers() + end + + if name and vim.trim(name) ~= "" then return do_delete(vim.trim(name)) end + local names = M.list() + if #names == 0 then + notify("warn", "taskwarrior.nvim: no contexts defined", vim.log.levels.WARN) + return + end + vim.ui.select(names, { prompt = "Delete which context?" }, function(choice) + if choice then do_delete(choice) end + end) +end + +-- Set (or clear, with "none") the context, then refresh open task buffers. +-- Bare `:TwContext` clears — the common case is "get me out of this +-- context"; use `:TwContext show` for the read-only summary. +function M.set(name) + name = vim.trim(name or "") + if name == "" then name = "none" end + if name == "show" then return M.show() end + -- `task context none` exits 2 when no context was set ("Context not + -- unset.") — clearing an already-clear context is a no-op, not an error. + local ok_codes = name == "none" and { 0, 1, 2 } or nil + local result = command.mutate({ "context", name }, { ok_codes = ok_codes }) + if not result.ok then + notify("error", "taskwarrior.nvim: context failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + return + end + if name == "none" then + notify("view", "taskwarrior.nvim: context cleared") + else + notify("view", ("taskwarrior.nvim: context %s (%s)"):format( + name, M.read_filter(name) or "?")) + end + refresh_all_task_buffers() +end + +return M diff --git a/lua/taskwarrior/inbox.lua b/lua/taskwarrior/inbox.lua index c60d8ec..793e390 100644 --- a/lua/taskwarrior/inbox.lua +++ b/lua/taskwarrior/inbox.lua @@ -112,8 +112,11 @@ function M.run(hours) action = function(cb) vim.ui.input({ prompt = "Tag (no +): " }, function(v) if v and v ~= "" then - local result = command.mutate({ short, "modify", "+" .. v }) - return cb(result.ok, result.output) + -- `modify +v` breaks on hyphenated tags (TW3 parses the hyphen + -- as subtraction) — use the merge-and-replace helper instead. + local ok, out = require("taskwarrior.taskmd").tw_change_tag( + short, (v:gsub("^%+", ""))) + return cb(ok, out) end cb(nil, "") end) diff --git a/lua/taskwarrior/init.lua b/lua/taskwarrior/init.lua index 32d9404..b6e9432 100644 --- a/lua/taskwarrior/init.lua +++ b/lua/taskwarrior/init.lua @@ -181,6 +181,8 @@ function M.graph() require("taskwarrior.graph").open() end function M.inbox() require("taskwarrior.inbox").run() end function M.export(path) require("taskwarrior.export").write(path) end function M.sync() require("taskwarrior.sync").run() end +function M.context(name) require("taskwarrior.context").set(name) end +function M.table_view(f) require("taskwarrior.table_view").open(f) end -- Omnifunc bridge — capture buffer sets omnifunc to a v:lua expression that -- needs this method on the top-level require("taskwarrior") module. @@ -221,22 +223,13 @@ function M._complete_modify(_arg_lead, cmd_line, _cursor_pos) end function M._complete_sort(arg_lead, _cmd_line, _cursor_pos) - local fields = { "urgency-", "urgency+", "due+", "due-", "priority-", - "priority+", "project+", "project-", "description+" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.SORTS, arg_lead) end function M._complete_group(arg_lead, _cmd_line, _cursor_pos) - local fields = { "project", "priority", "status", "tag", "none" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.GROUPS, arg_lead) end M.api = {} @@ -293,6 +286,27 @@ function M.setup(opts) end, vim.tbl_extend("force", gopts, { desc = "taskwarrior.nvim: Register cwd as project" })) end + -- Finite-choice pickers. Each is a small known set, so it gets a picker + -- rather than a remembered-syntax prompt. + local function map_picker(key, fn, desc) + if key and key ~= false and key ~= "" then + vim.keymap.set("n", key, fn, + vim.tbl_extend("force", gopts, { desc = "taskwarrior.nvim: " .. desc })) + end + end + + map_picker(config.options.context_key, function() + require("taskwarrior.context").pick() + end, "Pick Taskwarrior context") + + map_picker(config.options.view_key, function() + M.view_load() + end, "Pick a saved view") + + map_picker(config.options.report_key, function() + M.report() + end, "Pick a named report") + -- Easy-feedback global keymap (issue #2 → v1.4.1). Default tF. -- Set feedback_key = false to disable. local feedback_cfg = config.options.feedback or {} diff --git a/lua/taskwarrior/modify.lua b/lua/taskwarrior/modify.lua index 91c94f2..4c2dc54 100644 --- a/lua/taskwarrior/modify.lua +++ b/lua/taskwarrior/modify.lua @@ -173,6 +173,19 @@ end -- user never has to remember Taskwarrior's attribute-value syntax. -- --------------------------------------------------------------------------- +-- Tag deltas can't go through `modify +t` / `modify -t` — TW3 mis-parses +-- hyphenated tag names (see taskmd.tw_change_tag). Route through the safe +-- merge-and-replace helper, keeping modify_field's notify/refresh contract. +local function modify_tag_delta(uuid, tag, remove) + local ok, out = require("taskwarrior.taskmd").tw_change_tag(uuid, tag, remove) + if ok then + notify("modify", "taskwarrior.nvim: " .. (remove and "-" or "+") .. tag) + refresh_all_task_buffers() + else + notify("error", "taskwarrior.nvim: modify failed\n" .. (out or ""), vim.log.levels.ERROR) + end +end + local function modify_field(uuid, spec) local parts, err = command.parse_args(spec) if not parts then @@ -295,10 +308,10 @@ function M.modify_tag() if not choice then return end if choice == "(custom…)" then vim.ui.input({ prompt = "Tag (no leading +): " }, function(v) - if v and v ~= "" then modify_field(uuid, "+" .. v) end + if v and v ~= "" then modify_tag_delta(uuid, (v:gsub("^%+", ""))) end end) else - modify_field(uuid, "+" .. choice) + modify_tag_delta(uuid, choice) end end) end diff --git a/lua/taskwarrior/pick.lua b/lua/taskwarrior/pick.lua new file mode 100644 index 0000000..baf7ebc --- /dev/null +++ b/lua/taskwarrior/pick.lua @@ -0,0 +1,52 @@ +-- taskwarrior/pick.lua — one way to choose from a finite set. +-- +-- Everything with a small, known set of answers (sort spec, grouping, +-- context, saved view, report) goes through here instead of a free-text +-- `vim.fn.input`. Typing `urgency-` from memory is not a feature. +-- +-- Built on `vim.ui.select`, deliberately: that routes through whatever +-- picker the user already configured — telescope/dressing, snacks, fzf-lua +-- — so fuzzy matching comes from their own tooling and matches the rest of +-- their editor. With no backend installed it degrades to Neovim's built-in +-- numbered list, which still works. + +local M = {} + +--- Choose from `entries` ({ value, label } tables, or plain strings). +--- opts.prompt — picker prompt. +--- opts.current — value to mark as active. +--- on_choice(value, entry) fires only on a real selection (never on cancel). +function M.select(entries, opts, on_choice) + opts = opts or {} + local items = {} + for _, e in ipairs(entries) do + if type(e) == "string" then + items[#items + 1] = { value = e, label = e } + else + items[#items + 1] = e + end + end + if #items == 0 then + require("taskwarrior.notify")("warn", + opts.empty_message or "taskwarrior.nvim: nothing to choose from", + vim.log.levels.WARN) + return + end + + vim.ui.select(items, { + prompt = opts.prompt or "Select:", + format_item = function(item) + local label = item.label or item.value + -- Mark the active entry so the picker doubles as "what is set now?". + if opts.current ~= nil and item.value == opts.current then + return "● " .. label + end + return " " .. label + end, + }, function(choice) + if not choice then return end + on_choice(choice.value, choice) + end) +end + +return M diff --git a/lua/taskwarrior/repair_tags.lua b/lua/taskwarrior/repair_tags.lua new file mode 100644 index 0000000..c2f4dec --- /dev/null +++ b/lua/taskwarrior/repair_tags.lua @@ -0,0 +1,191 @@ +-- taskwarrior/repair_tags.lua — one-time tag repair. +-- +-- Deliberately NOT a :Tw* command: you run this once, ever, and a permanent +-- command slot would clutter the list for every user. Invoke it directly: +-- :lua require("taskwarrior.repair_tags").run() +-- :lua require("taskwarrior.repair_tags").run("status:completed") +-- +-- Repairs the damage left by the hyphenated-tag bug: Taskwarrior 3 parses +-- the hyphen in a bare `+foo-bar` token as a subtraction operator, so +-- `task add "… +taco-tuesday"` silently filed the literal text +-- "+taco-tuesday" into the DESCRIPTION instead of creating a tag. Tasks +-- added that way are unfindable by `+taco-tuesday` because the tag never +-- existed. (The same happened via the plugin's literal-add fallback for +-- non-hyphenated tags.) +-- +-- This moves those tokens back where they belong: strip `+token` from the +-- description, add `token` to the task's tags. +-- +-- It NEVER writes without showing you every proposed change first. Two +-- deliberate conservatisms, because a description legitimately may contain +-- a `+`: +-- * a `+token` wrapped in quotes is skipped — that is someone *writing +-- about* a tag ("searching for \"+ais-research-taste\" fails"), not a +-- mis-parsed one; +-- * the default scope is pending tasks. Completed and deleted history is +-- rarely worth rewriting; pass an explicit filter to include it. + +local M = {} + +local command = require("taskwarrior.command") +local notify = require("taskwarrior.notify") + +-- A tag token: `+` then a tag-legal name, at a word boundary. Mirrors the +-- boundary rule the buffer highlighter uses so "housing+food" is not a tag. +local TOKEN = "%+([A-Za-z_][%w_%-]*)" + +local function is_quoted(text, start_idx) + local prev = start_idx > 1 and text:sub(start_idx - 1, start_idx - 1) or "" + local after_end = text:find("%s", start_idx) or (#text + 1) + local following = text:sub(after_end - 1, after_end - 1) + return (prev == '"' or prev == "'" or prev == "`") + or (following == '"' or following == "'" or following == "`") +end + +--- Find the repairs a task needs. Returns nil when it needs none. +--- Exposed for testing. +function M.plan_for(task) + local desc = task.description or "" + local existing = {} + for _, t in ipairs(task.tags or {}) do existing[t] = true end + + local found, spans = {}, {} + local pos = 1 + while true do + local s, e, name = desc:find(TOKEN, pos) + if not s then break end + local prev = s > 1 and desc:sub(s - 1, s - 1) or "" + -- Word boundary before the `+`, and not a quoted mention. + if (prev == "" or not prev:match("[%w_]")) and not is_quoted(desc, s) then + if not existing[name] and not vim.tbl_contains(found, name) then + found[#found + 1] = name + end + spans[#spans + 1] = { s, e } + end + pos = e + 1 + end + if #found == 0 then return nil end + + -- Rebuild the description without the token spans, right to left so the + -- earlier indices stay valid. + local new_desc = desc + for i = #spans, 1, -1 do + new_desc = new_desc:sub(1, spans[i][1] - 1) .. new_desc:sub(spans[i][2] + 1) + end + new_desc = new_desc:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if new_desc == "" then + -- The description was nothing but tags — keep the tag text as the + -- description rather than creating a task with no description at all. + new_desc = desc + end + + local tags = { unpack(task.tags or {}) } + vim.list_extend(tags, found) + return { + uuid = task.uuid, + old_description = desc, + new_description = new_desc, + added_tags = found, + tags = tags, + } +end + +--- Scan `filter` (default pending) and return the list of planned repairs. +function M.scan(filter) + filter = (filter and filter ~= "") and filter or "status:pending" + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks then return nil end + local plans = {} + for _, t in ipairs(tasks) do + local plan = M.plan_for(t) + if plan then plans[#plans + 1] = plan end + end + return plans +end + +local function preview_lines(plans, filter) + local lines = { + ("taskwarrior.nvim — tag repair preview (%s)"):format(filter), + ("%d task(s) would be changed. Nothing has been written yet."):format(#plans), + "", + } + for i, p in ipairs(plans) do + lines[#lines + 1] = ("%d. %s"):format(i, p.uuid:sub(1, 8)) + lines[#lines + 1] = (" tags + %s"):format(table.concat(p.added_tags, ", ")) + lines[#lines + 1] = (" before %s"):format(p.old_description) + lines[#lines + 1] = (" after %s"):format(p.new_description) + lines[#lines + 1] = "" + end + lines[#lines + 1] = "Quoted mentions (\"+tag\") are deliberately left alone." + return lines +end + +local function apply(plans) + local taskmd = require("taskwarrior.taskmd") + local ok_count, failures = 0, {} + for _, p in ipairs(plans) do + local ok, out = taskmd.tw_modify(p.uuid, { + description = p.new_description, + tags = p.tags, + }) + if ok then + ok_count = ok_count + 1 + else + failures[#failures + 1] = ("%s: %s"):format(p.uuid:sub(1, 8), out or "") + end + end + return ok_count, failures +end + +--- Preview, confirm, then repair. `filter` defaults to pending tasks. +function M.run(filter) + filter = (filter and filter ~= "") and filter or "status:pending" + local plans = M.scan(filter) + if not plans then + notify("error", "taskwarrior.nvim: repair scan failed", vim.log.levels.ERROR) + return + end + if #plans == 0 then + notify("view", ("taskwarrior.nvim: no tags to repair in `%s`"):format(filter)) + return + end + + -- Show every change in a scratch buffer before asking. The picker prompt + -- alone can't carry this much detail, and this is a destructive rewrite of + -- data the user did not ask us to touch. + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.api.nvim_buf_set_lines(buf, 0, -1, false, preview_lines(plans, filter)) + vim.bo[buf].modifiable = false + vim.cmd("tab split") + vim.api.nvim_win_set_buf(0, buf) + local preview_win = vim.api.nvim_get_current_win() + + vim.schedule(function() + vim.ui.select({ "Apply repairs", "Cancel" }, { + prompt = ("Repair %d task(s)?"):format(#plans), + }, function(choice) + if vim.api.nvim_win_is_valid(preview_win) then + pcall(vim.api.nvim_win_close, preview_win, true) + end + if choice ~= "Apply repairs" then + notify("view", "taskwarrior.nvim: repair cancelled — nothing written") + return + end + local ok_count, failures = apply(plans) + local msg = ("taskwarrior.nvim: repaired %d/%d task(s)"):format(ok_count, #plans) + if #failures > 0 then + notify("error", msg .. "\n" .. table.concat(failures, "\n"), vim.log.levels.ERROR) + else + notify("view", msg .. " — :TwUndo reverts one at a time") + end + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.b[b].task_filter ~= nil then + pcall(function() require("taskwarrior.buffer").refresh_buf(b) end) + end + end + end) + end) +end + +return M diff --git a/lua/taskwarrior/report.lua b/lua/taskwarrior/report.lua index 4fd3b0c..a249cae 100644 --- a/lua/taskwarrior/report.lua +++ b/lua/taskwarrior/report.lua @@ -35,9 +35,16 @@ end -- threaded from init.lua (so we don't introduce a circular require). function M.open(name, open_fn) if not name or name == "" then - vim.ui.select(M.names(), { prompt = "Report:" }, function(choice) - if choice then M.open(choice, open_fn) end - end) + local entries = {} + for _, n in ipairs(M.names()) do + local r = M.reports[n] + entries[#entries + 1] = { + value = n, + label = ("%-12s%s"):format(n, (r and r.filter) or ""), + } + end + require("taskwarrior.pick").select(entries, { prompt = "Report:" }, + function(choice) M.open(choice, open_fn) end) return end local report = M.reports[name] diff --git a/lua/taskwarrior/saved_views.lua b/lua/taskwarrior/saved_views.lua index cef7803..4eb4c6a 100644 --- a/lua/taskwarrior/saved_views.lua +++ b/lua/taskwarrior/saved_views.lua @@ -92,7 +92,9 @@ function M.load(name, open_fn, refresh_fn) end vim.notify(string.format("taskwarrior.nvim: loaded view %q", chosen)) end - if name then + -- `:TwLoad` with no argument passes "" (truthy in Lua), which used to fall + -- into finish("") and silently do nothing. Empty means "ask me". + if name and name ~= "" then finish(name) else local names = M.list_names() @@ -100,7 +102,7 @@ function M.load(name, open_fn, refresh_fn) vim.notify("taskwarrior.nvim: no saved views", vim.log.levels.WARN) return end - vim.ui.select(names, { prompt = "Load view:" }, finish) + require("taskwarrior.pick").select(names, { prompt = "Load view:" }, finish) end end diff --git a/lua/taskwarrior/table_view.lua b/lua/taskwarrior/table_view.lua new file mode 100644 index 0000000..64fbfb6 --- /dev/null +++ b/lua/taskwarrior/table_view.lua @@ -0,0 +1,303 @@ +-- taskwarrior/table_view.lua — :TwTable, a vit-style column view (tw 6e9f911d). +-- +-- Renders tasks as aligned columns so every data modality (id, urgency, +-- project, priority, due, tags, description, UDAs) is visible at once. +-- Columns come from config.table_columns, so users can add UDA columns, +-- reorder, relabel, resize, or plug in their own formatter. +-- +-- Read-only by design: this is a *view*. Editing still happens in the :Tw +-- markdown buffer, which opens for the task under the cursor. + +local M = {} + +local views = require("taskwarrior.views") + +-- Columns rendered when config.table_columns is unset. +M.DEFAULT_COLUMNS = { + { field = "id", label = "ID", width = 4, align = "right" }, + { field = "urgency", label = "Urg", width = 5, align = "right" }, + { field = "priority", label = "P", width = 1 }, + { field = "project", label = "Project", width = 14 }, + { field = "due", label = "Due", width = 10 }, + { field = "description", label = "Description" }, -- no width = takes the rest + { field = "tags", label = "Tags", width = 18 }, +} + +local function tw_date_to_ymd(val) + if type(val) ~= "string" then return nil end + local y, mo, d = val:match("^(%d%d%d%d)(%d%d)(%d%d)T") + if y then return ("%s-%s-%s"):format(y, mo, d) end + return val:match("^%d%d%d%d%-%d%d%-%d%d") and val:sub(1, 10) or nil +end + +-- Raw cell text for one column of one task. Unknown fields fall through to +-- the task table, so UDA columns work with no extra configuration. +local function cell_value(task, col) + local field = col.field + local raw = task[field] + if col.format then + local ok, out = pcall(col.format, raw, task) + if ok then return tostring(out or "") end + return "" + end + if field == "id" then + return task.id and task.id ~= 0 and tostring(task.id) or "" + elseif field == "uuid" then + return (task.uuid or ""):sub(1, 8) + elseif field == "urgency" then + return ("%.1f"):format(tonumber(task.urgency) or 0) + elseif field == "tags" then + return type(raw) == "table" and table.concat(raw, ",") or "" + elseif field == "annotations" then + return type(raw) == "table" and #raw > 0 and ("+" .. #raw) or "" + elseif field == "status" and task.start then + return "started" + elseif field == "due" or field == "scheduled" or field == "wait" + or field == "entry" or field == "until" or field == "end" then + return tw_date_to_ymd(raw) or (raw and tostring(raw) or "") + end + if raw == nil then return "" end + if type(raw) == "table" then return table.concat(raw, ",") end + return tostring(raw) +end + +local function pad(text, width, align) + local w = vim.fn.strdisplaywidth(text) + if w > width then + return width > 1 and (vim.fn.strcharpart(text, 0, width - 1) .. "…") + or vim.fn.strcharpart(text, 0, width) + end + local fill = string.rep(" ", width - w) + if align == "right" then return fill .. text end + return text .. fill +end + +-- Normalize a user column spec (string or table) into the internal form. +local function normalize_columns(cols) + local out = {} + for _, c in ipairs(cols or {}) do + if type(c) == "string" then + out[#out + 1] = { field = c, label = c:sub(1, 1):upper() .. c:sub(2) } + elseif type(c) == "table" and c.field then + out[#out + 1] = { + field = c.field, + label = c.label or (c.field:sub(1, 1):upper() .. c.field:sub(2)), + width = c.width, + align = c.align, + format = c.format, + } + end + end + return out +end + +-- Resolve widths: explicit width wins; otherwise size to content, and give +-- any leftover space to the flexible column (the first without a width). +local function resolve_widths(columns, tasks, total_width) + local widths, flex = {}, nil + for i, col in ipairs(columns) do + if col.width then + widths[i] = col.width + else + local w = vim.fn.strdisplaywidth(col.label) + for _, t in ipairs(tasks) do + w = math.max(w, vim.fn.strdisplaywidth(cell_value(t, col))) + end + widths[i] = w + if not flex then flex = i end + end + end + if flex then + local used = 0 + for i, w in ipairs(widths) do + if i ~= flex then used = used + w + 1 end + end + -- Always clamp to the leftover room, never to the content width: a + -- 300-char description must not push the row past the window edge. + -- 10 columns is the floor so the cell stays readable when the fixed + -- columns have eaten the whole budget (the row then overflows by + -- design rather than rendering an unusable 1-char cell). + local room = total_width - used - 2 + widths[flex] = math.max(10, math.min(widths[flex], room)) + end + return widths +end + +local function urgency_hl(value) + local u = tonumber(value) or 0 + if u >= 8 then return "TaskViewUrgHigh" end + if u >= 4 then return "TaskViewUrgMed" end + return "TaskViewUrgLow" +end + +-- Highlight group for a rendered cell, or nil for default text. +local function cell_hl(col, text, task) + local f = col.field + if f == "urgency" then return urgency_hl(task.urgency) end + if f == "project" then return text ~= "" and "TaskViewProject" or nil end + if f == "tags" then return text ~= "" and "TaskViewTag" or nil end + if f == "priority" then + if text == "H" then return "TaskViewUrgHigh" end + if text == "M" then return "TaskViewUrgMed" end + return text ~= "" and "TaskViewUrgLow" or nil + end + if f == "due" then + local ymd = tw_date_to_ymd(task.due) + if not ymd then return nil end + if ymd < os.date("!%Y-%m-%d") then return "TaskViewOverdue" end + if ymd == os.date("!%Y-%m-%d") then return "TaskViewToday" end + return "TaskViewDate" + end + return nil +end + +-- Build the buffer content. Returned separately from the window plumbing so +-- tests can assert on the geometry without opening anything. +-- Returns lines, highlights, row_uuids (buffer line index → task uuid). +function M.build(tasks, opts) + opts = opts or {} + local config = require("taskwarrior.config") + local columns = normalize_columns( + opts.columns or config.options.table_columns or M.DEFAULT_COLUMNS) + local total = opts.width or vim.o.columns + local widths = resolve_widths(columns, tasks, total) + + local lines, highlights, row_uuids = {}, {}, {} + + local title = ("taskwarrior.nvim — Table (%s)"):format(opts.filter or "status:pending") + lines[1] = title + highlights[#highlights + 1] = { 0, 0, #title, "TaskViewTitle" } + + local header_cells = {} + for i, col in ipairs(columns) do + header_cells[#header_cells + 1] = pad(col.label, widths[i], col.align) + end + local header = " " .. table.concat(header_cells, " ") + lines[#lines + 1] = header + highlights[#highlights + 1] = { #lines - 1, 0, #header, "TaskViewHeader" } + + local sep = " " .. string.rep("─", math.max(1, vim.fn.strdisplaywidth(header) - 2)) + lines[#lines + 1] = sep + highlights[#highlights + 1] = { #lines - 1, 0, #sep, "TaskViewSeparator" } + + for _, task in ipairs(tasks) do + local parts, col_byte = {}, 2 + local line_hls = {} + for i, col in ipairs(columns) do + local raw = cell_value(task, col) + local text = pad(raw, widths[i], col.align) + local hl = cell_hl(col, vim.trim(text), task) + if hl then + line_hls[#line_hls + 1] = { col_byte, col_byte + #text, hl } + end + parts[#parts + 1] = text + col_byte = col_byte + #text + 1 + end + lines[#lines + 1] = " " .. table.concat(parts, " ") + local li = #lines - 1 + for _, h in ipairs(line_hls) do + highlights[#highlights + 1] = { li, h[1], h[2], h[3] } + end + row_uuids[li] = task.uuid + end + + if #tasks == 0 then + lines[#lines + 1] = " No tasks match this filter." + end + + lines[#lines + 1] = "" + local hint = " open task in a :Tw buffer · r refresh · q close" + lines[#lines + 1] = hint + highlights[#highlights + 1] = { #lines - 1, 0, #hint, "TaskViewHint" } + + return lines, highlights, row_uuids +end + +-- Usable text width of a window: its total width minus the gutter (number, +-- sign and fold columns). `vim.o.columns` alone overcounts by the gutter, +-- which pushed the widest rows past the right edge and wrapped them. +local function text_width(win) + if win and vim.api.nvim_win_is_valid(win) then + local info = vim.fn.getwininfo(win)[1] + if info and info.width and info.textoff then + return math.max(20, info.width - info.textoff) + end + end + return vim.o.columns +end + +--- :TwTable [filter] — open (or refresh) the table view. +function M.open(filter) + filter = (filter and filter ~= "") and filter or "status:pending" + local row_uuids = {} + + local function do_render() + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks then + require("taskwarrior.notify")("error", + "taskwarrior.nvim: table view — export failed", vim.log.levels.ERROR) + return + end + local config = require("taskwarrior.config") + local custom = config.options.custom_urgency + table.sort(tasks, function(a, b) + local ua = custom and (tonumber(custom(a)) or 0) or (tonumber(a.urgency) or 0) + local ub = custom and (tonumber(custom(b)) or 0) or (tonumber(b.urgency) or 0) + if ua == ub then + return (a.description or "") < (b.description or "") + end + return ua > ub + end) + + -- First pass sizes against the current window; the view may open in a + -- new tab whose gutter differs, so re-measure once the window exists + -- and re-render if the usable width actually changed. + local width = text_width(vim.api.nvim_get_current_win()) + local lines, highlights, uuids = M.build(tasks, { filter = filter, width = width }) + row_uuids = uuids + local bufnr = views._open_scratch( + "taskwarrior.nvim Table", lines, highlights, do_render) + + local view_win = vim.fn.bufwinid(bufnr) + local actual = text_width(view_win ~= -1 and view_win or nil) + if actual ~= width then + lines, highlights, uuids = M.build(tasks, { filter = filter, width = actual }) + row_uuids = uuids + views._open_scratch("taskwarrior.nvim Table", lines, highlights, do_render) + end + + vim.keymap.set("n", "", function() + local row = vim.api.nvim_win_get_cursor(0)[1] - 1 + local uuid = row_uuids[row] + if not uuid then + require("taskwarrior.notify")("warn", + "taskwarrior.nvim: no task on this line", vim.log.levels.WARN) + return + end + require("taskwarrior").open("uuid:" .. uuid) + end, { buffer = bufnr, noremap = true, silent = true, + desc = "taskwarrior.nvim: open this task in a :Tw buffer" }) + + vim.keymap.set("n", "r", do_render, { buffer = bufnr, noremap = true, + silent = true, desc = "taskwarrior.nvim: refresh the table view" }) + + -- Column widths are computed against the window, so re-render when the + -- window changes size. clear = true keeps this to one autocmd per buffer + -- no matter how many times we re-render. + vim.api.nvim_create_augroup("TaskwarriorTable_" .. bufnr, { clear = true }) + vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { + group = "TaskwarriorTable_" .. bufnr, + callback = function() + if not vim.api.nvim_buf_is_valid(bufnr) then return true end + if vim.fn.bufwinid(bufnr) == -1 then return end + do_render() + end, + }) + + return bufnr + end + + return do_render() +end + +return M diff --git a/lua/taskwarrior/taskmd.lua b/lua/taskwarrior/taskmd.lua index 117a2e4..ef2b51a 100644 --- a/lua/taskwarrior/taskmd.lua +++ b/lua/taskwarrior/taskmd.lua @@ -328,6 +328,10 @@ end function M.shell_export(filter_str) local args, err = command.parse_args(filter_str) if not args then return nil, err end + -- Same normalization tw_export applies: without it, `+ais-research-taste` + -- is parsed by TW3 as (+ais) − (research) − (taste) and the export errors + -- with "Cannot subtract from a Boolean value". + args = normalize_tag_filters(normalize_duration_minutes(args)) table.insert(args, 1, "rc.json.array=on") args[#args + 1] = "export" local result = command.read(args, { ok_codes = { 0, 1 } }) @@ -361,13 +365,17 @@ local function fields_to_args(fields) local args = {} for key, val in pairs(fields) do if key == "tags" then + -- `tags:a,b` replacement instead of per-tag `+a +b`: TW3 mis-parses + -- `+foo-bar` (hyphen becomes a subtraction operator — on add the tag + -- silently lands in the description, on modify the command errors), + -- and replacement also applies partial removals, which `+t` deltas + -- never did. compute_diff always carries the FULL new tag set here. if type(val) == "table" then - for _, t in ipairs(val) do args[#args + 1] = "+" .. t end + args[#args + 1] = "tags:" .. table.concat(val, ",") end elseif key == "_removed_tags" then - if type(val) == "table" then - for _, t in ipairs(val) do args[#args + 1] = "-" .. t end - end + -- Obsolete: full-set `tags:` replacement above already covers removal. + -- Kept as an ignored key so older callers don't emit broken `-t` args. elseif key == "status" then -- skip elseif val == "" then @@ -419,6 +427,28 @@ function M.tw_modify(uuid, fields) return result.ok, result.output, result.code end +-- Append (or remove) a single tag safely. TW3 cannot parse `modify +foo-bar` +-- (hyphen becomes subtraction; the command errors or silently no-ops), so +-- read the task's current tag set, merge the delta, and replace via `tags:`. +-- Returns ok, output, code — same contract as tw_modify. +function M.tw_change_tag(uuid, tag, remove) + local current = M.shell_export("uuid:" .. uuid) + if not current or not current[1] then + return false, "could not read task " .. tostring(uuid) .. " to change its tags", 1 + end + local tags, found = {}, false + for _, t in ipairs(current[1].tags or {}) do + if t == tag then found = true end + if not (remove and t == tag) then tags[#tags + 1] = t end + end + if remove and not found then return true, "", 0 end + if not remove then + if found then return true, "", 0 end + tags[#tags + 1] = tag + end + return M.tw_modify(uuid, { tags = tags }) +end + local function simple_tw(verb) return function(uuid) local result = command.mutate({ uuid, verb }) @@ -1136,11 +1166,26 @@ function M.render(args) local descending = sort_spec:sub(-1) == "-" local sort_field = sort_spec:gsub("[+-]$", "") + -- Priority is ordinal, not alphabetical: comparing the letters put "L" + -- above "H" descending, so `priority-` listed the LEAST important tasks + -- first — the opposite of what the spec means (and of `urgency-` beside + -- it). Rank them so the numeric branch below does the right thing. + local PRIORITY_RANK = { H = 3, M = 2, L = 1 } + local function sort_value(task) + local v = task[sort_field] + if sort_field == "priority" and type(v) == "string" then + return PRIORITY_RANK[v:upper()] + end + return v + end table.sort(tasks, function(a, b) - local va, vb = a[sort_field], b[sort_field] + local va, vb = sort_value(a), sort_value(b) local ma, mb = va == nil, vb == nil if ma and mb then return false end - if ma ~= mb then return ma < mb end -- missing sorts last (ma=true → larger) + -- Exactly one side is missing → it sorts last, regardless of direction. + -- (`ma < mb` was a boolean comparison, which Lua rejects outright: any + -- sort on a field some tasks lack — priority, due, project — errored.) + if ma ~= mb then return mb end if type(va) == "number" and type(vb) == "number" then if descending then return va > vb else return va < vb end end @@ -1154,7 +1199,10 @@ function M.render(args) "", filter_str, sort_spec, group_part, udas_part, now_iso()) - local lines = { header, "" } + -- No blank line after the header: the header itself is concealed in the + -- task buffer, so a spacer under it just pushes the first task down two + -- rows for no visual benefit. Group headers below bring their own spacing. + local lines = { header } if group_field then local groups = {} local order = {} diff --git a/lua/taskwarrior/validate.lua b/lua/taskwarrior/validate.lua index 15fba07..6aea7da 100644 --- a/lua/taskwarrior/validate.lua +++ b/lua/taskwarrior/validate.lua @@ -9,7 +9,10 @@ local KNOWN_KEYS = { "on_delete", "confirm", "sort", "group", "fields", "wrap", "capture_key", "open_key", "filter_key", "sort_key", "group_key", "project_add_key", "filters", "projects", "icons", + "context_key", "view_key", "report_key", "border_style", "capture_width", "capture_height", "capture_confirm_close", + "capture_annotations", "capture_annotation_key", "field_colors", + "table_columns", "group_separator", "animation", "clamp_cursor", "day_start_hour", "urgency_coefficients", "urgency_value_mappers", "custom_urgency", "auto_backup", "auto_backup_keep", @@ -40,6 +43,8 @@ local TOP_LEVEL_TYPES = { sort_key = "string", -- nil OK group_key = "string", -- nil OK project_add_key = "string", -- nil OK + -- context_key / view_key / report_key accept `false` to disable, so they + -- are checked separately below rather than as plain strings. filters = "table", projects = "table", icons = "boolean", @@ -47,6 +52,9 @@ local TOP_LEVEL_TYPES = { capture_width = "number", -- nil OK capture_height = "number", capture_confirm_close = "boolean", + capture_annotations = "boolean", + field_colors = "table", + table_columns = "table", -- nil OK group_separator = "boolean", animation = "boolean", clamp_cursor = "boolean", @@ -169,6 +177,19 @@ function M.validate(opts) -- 2. Top-level type checks. check_types(opts, TOP_LEVEL_TYPES, nil) + -- Picker keymaps: a keystring, or false to disable. + for _, key in ipairs({ "context_key", "view_key", "report_key" }) do + local v = opts[key] + if v ~= nil and v ~= false and type(v) ~= "string" then + error( + ("taskwarrior.nvim: setup key '%s' must be a string or false, got %s"):format( + key, type(v) + ), + 0 + ) + end + end + -- feedback_endpoint: boolean false OR a string URL. local fe = opts.feedback_endpoint if fe ~= nil and fe ~= false and type(fe) ~= "string" then @@ -281,6 +302,79 @@ function M.validate(opts) end end + -- 8d. capture_annotation_key — string, list of strings, or false. + if opts.capture_annotation_key ~= nil and opts.capture_annotation_key ~= false then + local v = opts.capture_annotation_key + if type(v) == "table" then + for i, k in ipairs(v) do + if type(k) ~= "string" then + error( + ("taskwarrior.nvim: capture_annotation_key[%d] must be a string, got %s"):format( + i, type(k) + ), + 0 + ) + end + end + elseif type(v) ~= "string" then + error( + ("taskwarrior.nvim: capture_annotation_key must be a string, list, or false, got %s"):format( + type(v) + ), + 0 + ) + end + end + + -- 8c. Nested: table_columns — each entry is a field name or a table with + -- a `field` key; width must be a positive number when present. + if opts.table_columns ~= nil then + for i, col in ipairs(opts.table_columns) do + if type(col) == "table" then + if type(col.field) ~= "string" or col.field == "" then + error( + ("taskwarrior.nvim: table_columns[%d].field must be a non-empty string"):format(i), + 0 + ) + end + if col.width ~= nil and (type(col.width) ~= "number" or col.width < 1) then + error( + ("taskwarrior.nvim: table_columns[%d].width must be a number >= 1"):format(i), + 0 + ) + end + if col.format ~= nil and type(col.format) ~= "function" then + error( + ("taskwarrior.nvim: table_columns[%d].format must be a function"):format(i), + 0 + ) + end + elseif type(col) ~= "string" then + error( + ("taskwarrior.nvim: table_columns[%d] must be a string or table, got %s"):format( + i, type(col) + ), + 0 + ) + end + end + end + + -- 8b. Nested: field_colors — same value contract as tag_colors, keyed by + -- field name without the trailing colon. + if opts.field_colors ~= nil then + for field, val in pairs(opts.field_colors) do + if type(val) ~= "string" and type(val) ~= "table" then + error( + ("taskwarrior.nvim: field_colors['%s'] must be a string or table, got %s"):format( + tostring(field), type(val) + ), + 0 + ) + end + end + end + -- 9. Nested: urgency_colors — list of { threshold = number, hl = string|table }. if opts.urgency_colors ~= nil then for i, entry in ipairs(opts.urgency_colors) do diff --git a/lua/taskwarrior/views.lua b/lua/taskwarrior/views.lua index 4484ed8..8e5ed12 100644 --- a/lua/taskwarrior/views.lua +++ b/lua/taskwarrior/views.lua @@ -107,6 +107,11 @@ local function open_scratch(name, lines, highlights, render_fn) return buf end +-- Exported for taskwarrior.table_view, which is a view like the others but +-- lives in its own module because it carries user-configurable columns. +M._open_scratch = open_scratch +M._define_view_highlights = define_view_highlights + local function tw_date_to_ymd(val) if not val then return nil end if val:match("^%d%d%d%d%d%d%d%dT") then diff --git a/tests/e2e/spec/buffer_layout_e2e_spec.lua b/tests/e2e/spec/buffer_layout_e2e_spec.lua new file mode 100644 index 0000000..75a4aaf --- /dev/null +++ b/tests/e2e/spec/buffer_layout_e2e_spec.lua @@ -0,0 +1,169 @@ +-- buffer_layout_e2e_spec.lua — the task buffer starts at the top. +-- +-- The taskmd header comment is metadata, not content: it must cost zero +-- screen rows, and the first task must be drawn on the window's top line +-- with the cursor already on it. +-- +-- Asserting the extmark exists is NOT enough here — `conceal` blanks a row +-- but still occupies it, while `conceal_lines` (Neovim 0.11+) removes it. +-- The observable difference is `winline()`, so that is what's checked. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local buffer = require("taskwarrior.buffer") + +local has_conceal_lines = pcall(function() + local b = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(b, 0, -1, false, { "x" }) + local ok = pcall(vim.api.nvim_buf_set_extmark, b, + vim.api.nvim_create_namespace("probe"), 0, 0, { conceal_lines = "" }) + pcall(vim.api.nvim_buf_delete, b, { force = true }) + assert(ok) +end) + +describe("e2e task buffer layout", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + + local uuid = taskmd.tw_add("layout probe task", { project = "layoutdemo" }) + assert(uuid ~= "") + + it("renders no blank spacer between the header and the first task", function() + local out = taskmd.render({ filter = { "project:layoutdemo" }, sort = "urgency-" }) + local lines = vim.split(out, "\n", { plain = true }) + assert.is_truthy(lines[1]:match("^ 0, "header extmark is missing") + assert.is_nil(header[1][4].conceal_lines, + "header row was concealed away in an empty buffer — that hides the " .. + "empty-state hint with it, leaving a blank window") + end) + + it("still hides the header row when tasks ARE present", function() + if not has_conceal_lines then return end + vim.cmd("enew") + require("taskwarrior").open("project:layoutdemo") + vim.wait(300, function() return false end, 10) + local header = vim.api.nvim_buf_get_extmarks(vim.api.nvim_get_current_buf(), + vim.api.nvim_create_namespace("taskwarrior_hl"), { 0, 0 }, { 0, -1 }, + { details = true }) + assert.are.same("", header[1][4].conceal_lines, + "header row should be removed entirely when there are tasks to show") + end) + + -- `gg`, `:1` or a restored cursor position would otherwise park the cursor + -- on the concealed header row, where the first task LOOKS selected but any + -- keystroke edits the header and trips its read-only guard. Reported as + -- "editing my first task warns that the header is read-only". + -- + -- Driven with `doautocmd CursorMoved` rather than feedkeys: CursorMoved is + -- raised by the main loop's cursor check, which nvim_feedkeys() does not + -- reach in a headless spec. The real-keystroke path was confirmed + -- separately against a live child Neovim. + it("bounces the cursor off the header line onto the first task", function() + vim.cmd("enew") + require("taskwarrior").open("project:layoutdemo") + vim.wait(300, function() return false end, 10) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("doautocmd CursorMoved") + + local row = vim.api.nvim_win_get_cursor(0)[1] + assert.is_true(row >= 2, + "cursor stayed on the concealed header row — typing there edits the header") + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] or "" + assert.is_truthy(line:match("^%- %["), "did not land on a task line: " .. line) + end) + + it("does not bounce when the buffer has no tasks to bounce to", function() + vim.cmd("enew") + require("taskwarrior").open("project:definitely-no-such-project-here") + vim.wait(300, function() return false end, 10) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("doautocmd CursorMoved") + assert.are.same(1, vim.api.nvim_win_get_cursor(0)[1], + "with no tasks the header is the only line — nowhere to bounce") + end) + + it("cursor_to_first_task tolerates a buffer with no tasks", function() + vim.cmd("enew") + require("taskwarrior").open("project:definitely-no-such-project-here") + vim.wait(300, function() return false end, 10) + -- Must not error, and must not leave the cursor past the end. + local row = vim.api.nvim_win_get_cursor(0)[1] + local count = vim.api.nvim_buf_line_count(0) + assert.is_true(row >= 1 and row <= count, + ("cursor row %d outside buffer of %d lines"):format(row, count)) + end) + + it("the float path also opens on the first task", function() + vim.cmd("enew") + require("taskwarrior.buffer").open_float("project:layoutdemo") + vim.wait(300, function() return false end, 10) + + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] or "" + assert.is_truthy(line:match("^%- %["), + ("float cursor landed on a non-task line (row %d): %q"):format(row, line)) + vim.cmd("close") + end) +end) diff --git a/tests/e2e/spec/capture_flow_e2e_spec.lua b/tests/e2e/spec/capture_flow_e2e_spec.lua new file mode 100644 index 0000000..384708c --- /dev/null +++ b/tests/e2e/spec/capture_flow_e2e_spec.lua @@ -0,0 +1,128 @@ +-- capture_flow_e2e_spec.lua — drives the real quick-capture user journey +-- against a live task CLI. Covers two tw tasks: +-- * 0f0b9bbf — the add interface is a buffer that starts in insert mode +-- * 978fb9d1 — the post-add notification echoes the start of the task +-- so the user can confirm the right thing went through + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function open_capture() + require("taskwarrior").capture() + vim.wait(100, function() return false end, 10) + return vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() +end + +-- Fire the capture buffer's insert-mode mapping the way a user would. +local function press_enter(buf) + local maps = vim.api.nvim_buf_get_keymap(buf, "i") + for _, m in ipairs(maps) do + if m.lhs == "" and m.callback then + m.callback() + return true + end + end + return false +end + +describe("e2e quick-capture flow", function() + it("opens a floating buffer in insert mode (tw 0f0b9bbf)", function() + -- :startinsert only engages when control returns to the input loop, + -- which never happens inside this in-process spec — so the real UX is + -- observed over RPC in a child nvim whose main loop is idle between + -- requests. This proves a user invoking capture lands in insert mode. + local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h:h") + local sock = TMP .. "/capture-mode.sock" + local job = vim.fn.jobstart({ + "nvim", "--headless", "--listen", sock, "--cmd", "set rtp+=" .. root, + }) + assert.is_true(job > 0, "failed to spawn child nvim") + vim.wait(3000, function() return vim.fn.filereadable(sock) == 1 end, 20) + local chan = vim.fn.sockconnect("pipe", sock, { rpc = true }) + assert.is_true(chan > 0, "failed to connect to child nvim") + + vim.rpcrequest(chan, "nvim_exec_lua", + "require('taskwarrior').setup({}) require('taskwarrior').capture()", {}) + -- Give the child a main-loop turn so the pending startinsert engages. + vim.wait(300, function() + return vim.rpcrequest(chan, "nvim_get_mode").mode:sub(1, 1) == "i" + end, 20) + + local mode = vim.rpcrequest(chan, "nvim_get_mode").mode + local is_float = vim.rpcrequest(chan, "nvim_exec_lua", [[ + local cfg = vim.api.nvim_win_get_config(0) + return (cfg.relative ~= nil and cfg.relative ~= "") + and vim.bo[vim.api.nvim_get_current_buf()].buftype == "nofile" + ]], {}) + + vim.fn.chanclose(chan) + vim.fn.jobstop(job) + + assert.are.same("i", mode:sub(1, 1), + "capture window did not start in insert mode (child mode: " .. mode .. ")") + assert.is_true(is_float, "capture window is not a nofile float") + end) + + it("submitting shows a snippet of the added task and stores it (tw 978fb9d1)", function() + local buf, win = open_capture() + local desc = ("capture snippet check %d"):format(math.random(1, 1e9)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { desc .. " project:capturetest" }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) messages[#messages + 1] = tostring(msg) end + + assert.is_true(press_enter(buf), "capture mapping not found") + vim.wait(3000, function() + local t = taskmd.shell_export("project:capturetest") + return t and #t > 0 + end, 20) + vim.wait(200, function() return false end, 10) + vim.notify = orig_notify + + -- Observable TW state: the task exists with parsed fields. + local tasks = taskmd.shell_export("project:capturetest") or {} + local stored + for _, t in ipairs(tasks) do + if t.description == desc then stored = t end + end + assert.is_truthy(stored, "captured task not found in export") + + -- Notification carries the first characters of the description. + local prefix = desc:sub(1, 20) + local mentioned = false + for _, m in ipairs(messages) do + if m:find(prefix, 1, true) then mentioned = true end + end + assert.is_true(mentioned, + "no notification contained the task snippet; got: " + .. vim.inspect(messages)) + pcall(vim.api.nvim_win_close, win, true) + end) + + it("truncates long descriptions in the notification", function() + local buf, win = open_capture() + local long = ("verylongword "):rep(10) .. "tail-marker" + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { long }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) messages[#messages + 1] = tostring(msg) end + assert.is_true(press_enter(buf), "capture mapping not found") + vim.wait(3000, function() return #messages > 0 end, 20) + vim.notify = orig_notify + + local added + for _, m in ipairs(messages) do + if m:find("added", 1, true) then added = m end + end + assert.is_truthy(added, "no 'added' notification; got " .. vim.inspect(messages)) + assert.is_truthy(added:find("…", 1, true), + "long description was not truncated with an ellipsis: " .. added) + assert.is_nil(added:find("tail-marker", 1, true), + "notification should not contain the tail of a long description") + pcall(vim.api.nvim_win_close, win, true) + end) +end) diff --git a/tests/e2e/spec/capture_rich_e2e_spec.lua b/tests/e2e/spec/capture_rich_e2e_spec.lua new file mode 100644 index 0000000..0938838 --- /dev/null +++ b/tests/e2e/spec/capture_rich_e2e_spec.lua @@ -0,0 +1,158 @@ +-- capture_rich_e2e_spec.lua — the roomier, colored capture form (tw 14d722e8). +-- +-- Two properties, both observed rather than asserted-by-existence: +-- * lines below the first become real Taskwarrior annotations on save +-- (verified via `task export`), and capture_annotations = false opts out +-- * fields typed into the capture buffer get highlight extmarks — the +-- specific groups for known fields, the neutral TaskField group for +-- UDAs, and config.field_colors overrides on top + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function open_capture() + require("taskwarrior").capture() + vim.wait(100, function() return false end, 10) + return vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() +end + +local function press_enter(buf) + for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, "i")) do + if m.lhs == "" and m.callback then + m.callback() + return true + end + end + return false +end + +-- Highlight groups present on a line, keyed by the text they cover. +local function highlights_on(buf, line_nr) + require("taskwarrior.buffer").update_highlights(buf) + local line = vim.api.nvim_buf_get_lines(buf, line_nr, line_nr + 1, false)[1] or "" + local marks = vim.api.nvim_buf_get_extmarks(buf, -1, { line_nr, 0 }, + { line_nr, -1 }, { details = true }) + local out = {} + for _, m in ipairs(marks) do + local d = m[4] or {} + if d.hl_group and d.end_col then + out[line:sub(m[3] + 1, d.end_col)] = d.hl_group + end + end + return out +end + +describe("e2e rich capture (tw 14d722e8)", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + local config = require("taskwarrior.config") + + it("extra lines become annotations on the created task", function() + local buf, win = open_capture() + local desc = ("annotated capture %d"):format(math.random(1, 1e9)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + desc .. " project:annotest", + "first annotation line", + "", + "second annotation line", + }) + + assert.is_true(press_enter(buf)) + vim.wait(5000, function() + local t = (taskmd.shell_export("project:annotest") or {})[1] + return t and t.annotations and #t.annotations == 2 + end, 20) + pcall(vim.api.nvim_win_close, win, true) + + local tasks = taskmd.shell_export("project:annotest") or {} + local stored + for _, t in ipairs(tasks) do + if t.description == desc then stored = t end + end + assert.is_truthy(stored, "captured task not found") + local texts = {} + for _, a in ipairs(stored.annotations or {}) do + texts[#texts + 1] = a.description + end + table.sort(texts) + assert.are.same({ "first annotation line", "second annotation line" }, texts, + "extra capture lines did not become annotations") + end) + + it("capture_annotations = false ignores the extra lines", function() + local orig = config.options.capture_annotations + config.options.capture_annotations = false + + local buf, win = open_capture() + local desc = ("no annotations %d"):format(math.random(1, 1e9)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + desc .. " project:noannotest", + "this line should be dropped", + }) + assert.is_true(press_enter(buf)) + vim.wait(5000, function() + local t = (taskmd.shell_export("project:noannotest") or {})[1] + return t ~= nil + end, 20) + vim.wait(300, function() return false end, 10) + config.options.capture_annotations = orig + pcall(vim.api.nvim_win_close, win, true) + + local stored + for _, t in ipairs(taskmd.shell_export("project:noannotest") or {}) do + if t.description == desc then stored = t end + end + assert.is_truthy(stored, "captured task not found") + assert.are.same(0, #(stored.annotations or {}), + "annotations were created despite capture_annotations = false") + end) + + it("colors known fields, tags, and UDAs in the capture buffer", function() + local buf, win = open_capture() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + "buy milk project:home priority:H +errand-run utility:20", + }) + local hls = highlights_on(buf, 0) + pcall(vim.api.nvim_win_close, win, true) + + assert.are.same("TaskProject", hls["project:home"], + "project: not colored in capture buffer: " .. vim.inspect(hls)) + assert.are.same("TaskPriorityH", hls["priority:H"], + "priority: not colored: " .. vim.inspect(hls)) + assert.are.same("TaskTag", hls["+errand-run"], + "hyphenated tag not colored: " .. vim.inspect(hls)) + assert.are.same("TaskField", hls["utility:20"], + "UDA field not colored with the neutral group: " .. vim.inspect(hls)) + end) + + it("honors config.field_colors overrides", function() + local orig = config.options.field_colors + config.options.field_colors = { utility = "ErrorMsg" } + + local buf, win = open_capture() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "task utility:20" }) + local hls = highlights_on(buf, 0) + config.options.field_colors = orig + pcall(vim.api.nvim_win_close, win, true) + + assert.are.same("ErrorMsg", hls["utility:20"], + "field_colors override not applied: " .. vim.inspect(hls)) + end) + + it("does not color clock times or URLs as fields", function() + local buf, win = open_capture() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "standup at 09:30 see https://x.dev" }) + local hls = highlights_on(buf, 0) + pcall(vim.api.nvim_win_close, win, true) + + for text, group in pairs(hls) do + assert.is_nil(text:match("^09:30"), + "clock time was colored as a field (" .. group .. ")") + assert.is_nil(text:match("^https:"), + "URL was colored as a field (" .. group .. ")") + end + end) +end) diff --git a/tests/e2e/spec/context_e2e_spec.lua b/tests/e2e/spec/context_e2e_spec.lua new file mode 100644 index 0000000..aed4a45 --- /dev/null +++ b/tests/e2e/spec/context_e2e_spec.lua @@ -0,0 +1,177 @@ +-- context_e2e_spec.lua — Taskwarrior context support (tw c12b4cbd), driven +-- against the live task CLI seeded by tests/e2e/run.sh. +-- +-- TW 3.x applies contexts to reports but NOT to `export`, so the plugin +-- injects the active context's read filter into its render path. The +-- injected tokens are part of the rendered header, which is what keeps the +-- save path honest — the last test here is the safety property that saving +-- a context-narrowed buffer must never touch the tasks the context hides. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local context = require("taskwarrior.context") + +local function run_task(args) + return vim.fn.system("task rc.bulk=0 rc.confirmation=off rc.verbose=nothing " .. args) +end + +describe("e2e Taskwarrior contexts (tw c12b4cbd)", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + run_task("context define ctxwork project:ctxdemo") + local in_uuid = taskmd.tw_add("inside the context", { project = "ctxdemo" }) + local out_uuid = taskmd.tw_add("outside the context", { project = "ctxother" }) + assert(in_uuid ~= "" and out_uuid ~= "") + + before_each(function() + context.set("none") + end) + + after_each(function() + context.set("none") + end) + + it("set/current/list/read_filter round-trip", function() + assert.is_nil(context.current()) + context.set("ctxwork") + assert.are.same("ctxwork", context.current()) + assert.are.same("project:ctxdemo", context.read_filter()) + local names = context.list() + local found = false + for _, n in ipairs(names) do if n == "ctxwork" then found = true end end + assert.is_true(found, "ctxwork missing from context.list(): " .. vim.inspect(names)) + context.set("none") + assert.is_nil(context.current()) + end) + + it("task buffer honors the active context's read filter", function() + context.set("ctxwork") + vim.cmd("enew") + require("taskwarrior").open("") + vim.wait(200, function() return false end, 10) + local text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n") + assert.is_truthy(text:find(in_uuid:sub(1, 8), 1, true), + "context task missing from buffer") + assert.is_nil(text:find(out_uuid:sub(1, 8), 1, true), + "task outside the context leaked into the buffer") + end) + + it("uuid-targeted filters are never context-narrowed", function() + context.set("ctxwork") + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. out_uuid) + vim.wait(200, function() return false end, 10) + local text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n") + assert.is_truthy(text:find(out_uuid:sub(1, 8), 1, true), + "uuid-filtered buffer came up empty under an active context") + end) + + it("saving a context-narrowed buffer does not touch hidden tasks", function() + context.set("ctxwork") + vim.cmd("enew") + require("taskwarrior").open("") + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + -- Edit the in-context task's description, then save with auto-accept. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(in_uuid:sub(1, 8), 1, true) then + lines[i] = line:gsub("inside the context", "inside the context edited") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_select = vim.ui.select + vim.ui.select = function(items, _, cb) cb(items[1], 1) end + vim.cmd("silent write") + vim.wait(3000, function() + local t = taskmd.shell_export("uuid:" .. in_uuid)[1] + return t and t.description == "inside the context edited" + end, 20) + vim.ui.select = orig_select + + local hidden = taskmd.shell_export("uuid:" .. out_uuid)[1] + assert.is_truthy(hidden, "hidden task vanished entirely") + assert.are.same("pending", hidden.status, + "task hidden by the context was mutated by the save") + local edited = taskmd.shell_export("uuid:" .. in_uuid)[1] + assert.are.same("inside the context edited", edited.description) + end) + + it(":TwContext command is registered with completion", function() + local prefix = require("taskwarrior.config").options.command_prefix or "Tw" + local cmds = vim.api.nvim_get_commands({}) + assert.is_truthy(cmds[prefix .. "Context"], ":" .. prefix .. "Context not registered") + vim.cmd(prefix .. "Context ctxwork") + vim.wait(200, function() return context.current() == "ctxwork" end, 10) + assert.are.same("ctxwork", context.current()) + vim.cmd(prefix .. "Context none") + vim.wait(200, function() return context.current() == nil end, 10) + assert.is_nil(context.current()) + end) + + it("bare :TwContext clears the active context", function() + local prefix = require("taskwarrior.config").options.command_prefix or "Tw" + context.set("ctxwork") + assert.are.same("ctxwork", context.current()) + vim.cmd(prefix .. "Context") + vim.wait(500, function() return context.current() == nil end, 10) + assert.is_nil(context.current(), "bare :TwContext did not clear the context") + end) + + it("define creates a context and delete removes it", function() + local name = ("ctxmade%d"):format(math.random(1, 1e6)) + -- define offers to activate; decline so this test only checks definition. + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Leave inactive") end + context.define(name, "project:ctxdemo or +ctx-hyphen-tag") + vim.wait(2000, function() + return context.read_filter(name) ~= nil + end, 20) + vim.ui.select = orig_select + + assert.are.same("project:ctxdemo or +ctx-hyphen-tag", context.read_filter(name), + "context filter was not stored verbatim") + local names = context.list() + assert.is_true(vim.tbl_contains(names, name), + "defined context missing from list: " .. vim.inspect(names)) + + -- It actually works as a context. + context.set(name) + assert.are.same(name, context.current()) + context.set("none") + + context.delete(name) + vim.wait(2000, function() return context.read_filter(name) == nil end, 20) + assert.is_nil(context.read_filter(name), "context was not deleted") + assert.is_false(vim.tbl_contains(context.list(), name), + "deleted context still listed") + end) + + it("define refuses the reserved name 'none'", function() + local before = context.list() + -- The refusal is an intentional ERROR-level notify. Captured rather than + -- allowed through: an ERROR notify in headless Neovim writes to stderr + -- and makes the process exit non-zero, which would fail the suite for a + -- message the test is specifically asserting we DO emit. + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, _lvl, _o) messages[#messages + 1] = tostring(msg) end + context.define("none", "project:whatever") + vim.wait(300, function() return false end, 10) + vim.notify = orig_notify + + assert.are.same(#before, #context.list(), + "defining a context named 'none' should be refused") + local refused = false + for _, m in ipairs(messages) do + if m:find("reserved", 1, true) then refused = true end + end + assert.is_true(refused, + "expected an explanatory refusal; got: " .. vim.inspect(messages)) + end) +end) diff --git a/tests/e2e/spec/e2e_spec.lua b/tests/e2e/spec/e2e_spec.lua index c7cf55d..cede022 100644 --- a/tests/e2e/spec/e2e_spec.lua +++ b/tests/e2e/spec/e2e_spec.lua @@ -888,10 +888,16 @@ describe("e2e :TaskSync without a server", function() stub_select("cancel") capture_notify() require("taskwarrior.sync").run() - vim.wait(3000, function() + -- Wait for a TERMINAL state, not the "syncing" progress message. sync is + -- async (jobstart), so returning as soon as progress appears restored + -- vim.notify before the real failure landed — the ERROR then escaped to + -- stderr, which makes headless Neovim exit non-zero and fails the whole + -- e2e run for a message this test exists to assert. + vim.wait(5000, function() for _, e in ipairs(_notify_log) do - if e.msg:match("sync failed") or e.msg:match("sync complete") - or e.msg:match("syncing") then return true end + if e.msg:match("sync failed") or e.msg:match("sync complete") then + return true + end end return false end, 50) @@ -1829,12 +1835,23 @@ describe("e2e taskwarrior.icons", function() require("taskwarrior.config").options.icons = nil end) - it("icons=true (default) → NF glyph regardless of have_nerd_font", function() - -- Default semantics: `true` is an explicit opt-in to nerd-font, not a - -- request for detection. Avoids the common "I have NF in my terminal - -- but never set vim.g.have_nerd_font" gotcha. + -- `true` means AUTO-DETECT, not "force nerd-font". Shipping U+F* glyphs to + -- a terminal without a nerd font renders tofu, so the default has to be + -- safe; `"force-nf"` is the opt-in for users whose detection is broken. + -- This test previously asserted the opposite, contradicting both the + -- documented contract in config.lua and the passing unit test in + -- icons_spec.lua ("returns nil when icons = true (auto) and no nerd font"). + -- It predated the force-nf escape hatch. + it("icons=true (default) → ASCII when no nerd font is detected", function() vim.g.have_nerd_font = nil require("taskwarrior.config").options.icons = true + assert.equals("H", icons.get("priority_h")) + require("taskwarrior.config").options.icons = nil + end) + + it("icons='force-nf' → NF glyph regardless of have_nerd_font", function() + vim.g.have_nerd_font = nil + require("taskwarrior.config").options.icons = "force-nf" assert.equals("󰜷", icons.get("priority_h")) require("taskwarrior.config").options.icons = nil end) diff --git a/tests/e2e/spec/hyphen_tag_e2e_spec.lua b/tests/e2e/spec/hyphen_tag_e2e_spec.lua new file mode 100644 index 0000000..6b35500 --- /dev/null +++ b/tests/e2e/spec/hyphen_tag_e2e_spec.lua @@ -0,0 +1,126 @@ +-- hyphen_tag_e2e_spec.lua — end-to-end regression shield for hyphenated tag +-- names against a real Taskwarrior 3.x CLI (tw task 3b9dfcab). +-- +-- TW3's expression parser mis-parses bare `+foo-bar` tokens (the hyphen is +-- read as subtraction). Before the fix: +-- * :TwFilter +ais-research-taste → "Cannot subtract from a Boolean value" +-- and the buffer silently rendered nothing +-- * capture `+foo-bar` → the tag landed inside the description text +-- * modify_tag / inbox "+tag" → exit 2, tag never applied +-- +-- Each scenario here drives the plugin path and asserts the observable +-- Taskwarrior state, not just the absence of an error. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function unique_hyphen_tag(prefix) + return string.format("%s-hyphen-%d-%d", prefix, vim.fn.getpid(), math.random(1, 1e9)) +end + +describe("e2e hyphenated tags (tw 3b9dfcab)", function() + it("shell_export matches a task filtered by +hyphen-tag", function() + local tag = unique_hyphen_tag("filter") + local uuid, ok = taskmd.tw_add("filter by hyphen tag", { tags = { tag } }) + assert.is_true(ok) + assert.is_true(uuid ~= "") + + local tasks = taskmd.shell_export("+" .. tag) + assert.is_table(tasks) + assert.are.same(1, #tasks) + assert.are.same(uuid, tasks[1].uuid) + assert.are.same({ tag }, tasks[1].tags) + end) + + it("shell_export excludes via -hyphen-tag", function() + local tag = unique_hyphen_tag("excl") + local uuid = taskmd.tw_add("excluded by hyphen tag", { tags = { tag } }) + local tasks = taskmd.shell_export("status:pending -" .. tag) + assert.is_table(tasks) + for _, t in ipairs(tasks) do + assert.is_true(t.uuid ~= uuid, "task with excluded tag leaked through") + end + end) + + it("tw_add stores hyphenated tags as tags, not description text", function() + local tag = unique_hyphen_tag("add") + local uuid, ok = taskmd.tw_add("clean description", { tags = { tag, "plain" } }) + assert.is_true(ok) + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same("clean description", t.description) + table.sort(t.tags) + local expected = { "plain", tag } + table.sort(expected) + assert.are.same(expected, t.tags) + end) + + it("tw_change_tag appends and removes a hyphenated tag", function() + local base = unique_hyphen_tag("base") + local extra = unique_hyphen_tag("extra") + local uuid = taskmd.tw_add("tag delta target", { tags = { base } }) + + assert.is_true(taskmd.tw_change_tag(uuid, extra)) + local t = taskmd.shell_export("uuid:" .. uuid)[1] + table.sort(t.tags) + local expected = { base, extra } + table.sort(expected) + assert.are.same(expected, t.tags) + + assert.is_true(taskmd.tw_change_tag(uuid, base, true)) + t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same({ extra }, t.tags) + end) + + it("task buffer opened with a +hyphen-tag filter renders the task", function() + local tag = unique_hyphen_tag("buf") + local uuid = taskmd.tw_add("visible in filtered buffer", { tags = { tag } }) + + vim.cmd("enew") + require("taskwarrior").open("+" .. tag) + vim.wait(200, function() return false end, 10) + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local found = false + for _, line in ipairs(lines) do + if line:find(uuid:sub(1, 8), 1, true) then found = true end + end + assert.is_true(found, "filtered buffer did not render the tagged task:\n" + .. table.concat(lines, "\n")) + end) + + it("saving a buffer that drops one of two tags applies the removal", function() + local keep = unique_hyphen_tag("keep") + local drop = unique_hyphen_tag("drop") + local uuid = taskmd.tw_add("partial tag removal", { tags = { keep, drop } }) + + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. uuid) + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + -- Remove the `drop` tag from the rendered line, keep everything else. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(uuid:sub(1, 8), 1, true) then + lines[i] = line:gsub("%s*%+" .. vim.pesc(drop), "") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- Non-interactive apply: auto-accept the confirm picker. + local orig_select = vim.ui.select + vim.ui.select = function(items, _, cb) cb(items[1], 1) end + vim.cmd("silent write") + vim.wait(2000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.tags and #t.tags == 1 + end, 20) + vim.ui.select = orig_select + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same({ keep }, t.tags, + "expected only the kept tag after save-applied removal") + end) +end) diff --git a/tests/e2e/spec/no_confirm_save_e2e_spec.lua b/tests/e2e/spec/no_confirm_save_e2e_spec.lua new file mode 100644 index 0000000..6dd126b --- /dev/null +++ b/tests/e2e/spec/no_confirm_save_e2e_spec.lua @@ -0,0 +1,130 @@ +-- no_confirm_save_e2e_spec.lua — the configurable save popup (tw fca82462). +-- +-- With setup({ confirm = false }) the Apply/Cancel picker must never appear: +-- :w applies immediately, the summary notification names what happened and +-- points at :TwUndo, and :TwUndo actually reverts the mutation in +-- Taskwarrior. Driven against the live task CLI seeded by tests/e2e/run.sh. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +describe("e2e no-confirm save (tw fca82462)", function() + local orig_confirm + + before_each(function() + local config = require("taskwarrior.config") + if not next(config.options) then require("taskwarrior").setup({}) end + orig_confirm = config.options.confirm + config.options.confirm = false + end) + + after_each(function() + require("taskwarrior.config").options.confirm = orig_confirm + end) + + it(":w applies without a picker, notifies summary + undo hint, undo reverts", function() + local marker = ("noconfirm %d"):format(math.random(1, 1e9)) + local uuid = taskmd.tw_add(marker, { project = "noconfirmtest" }) + assert.is_true(uuid ~= "") + + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. uuid) + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + -- Edit the description in the buffer. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(marker, 1, true) then + lines[i] = line:gsub(vim.pesc(marker), marker .. " edited") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- The picker must NOT be involved in this flow. + local orig_select = vim.ui.select + local select_called = false + vim.ui.select = function(items, _, cb) + select_called = true + cb(nil) + end + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) messages[#messages + 1] = tostring(msg) end + + vim.cmd("silent write") + vim.wait(3000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.description == marker .. " edited" + end, 20) + + vim.notify = orig_notify + assert.is_false(select_called, + "confirm=false save still invoked vim.ui.select") + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same(marker .. " edited", t.description, + "save did not apply the edit") + + local summary + for _, m in ipairs(messages) do + if m:find("Applied:", 1, true) then summary = m end + end + assert.is_truthy(summary, "no apply summary notification; got: " + .. vim.inspect(messages)) + assert.is_truthy(summary:find("~1 modified", 1, true), + "summary does not name the modify: " .. summary) + assert.is_truthy(summary:find("Undo to revert", 1, true), + "summary does not mention the undo path: " .. summary) + + -- :TwUndo (driven via apply.undo) reverts the modify. + vim.ui.select = function(items, _, cb) cb("Undo", 1) end + require("taskwarrior").undo() + vim.wait(3000, function() + local cur = taskmd.shell_export("uuid:" .. uuid)[1] + return cur and cur.description == marker + end, 20) + vim.ui.select = orig_select + + local reverted = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same(marker, reverted.description, + "undo did not revert the modify") + end) + + it("confirm=true keeps the popup (control)", function() + local marker = ("withconfirm %d"):format(math.random(1, 1e9)) + local uuid = taskmd.tw_add(marker, { project = "noconfirmtest" }) + require("taskwarrior.config").options.confirm = true + + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. uuid) + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(marker, 1, true) then + lines[i] = line:gsub(vim.pesc(marker), marker .. " edited") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_select = vim.ui.select + local select_called = false + vim.ui.select = function(items, _, cb) + select_called = true + cb(nil) -- cancel + end + vim.cmd("silent write") + vim.wait(1000, function() return select_called end, 20) + vim.ui.select = orig_select + + assert.is_true(select_called, "confirm=true save never showed the picker") + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same(marker, t.description, + "cancelled confirm save must not mutate the task") + end) +end) diff --git a/tests/e2e/spec/pickers_e2e_spec.lua b/tests/e2e/spec/pickers_e2e_spec.lua new file mode 100644 index 0000000..9f1004d --- /dev/null +++ b/tests/e2e/spec/pickers_e2e_spec.lua @@ -0,0 +1,224 @@ +-- pickers_e2e_spec.lua — finite choices are chosen, not typed. +-- +-- Sort spec, grouping, context, saved view and report all have small known +-- answer sets, so each is driven by a picker rather than a free-text prompt. +-- These tests stub vim.ui.select (standing in for whatever backend the user +-- has — dressing/telescope, snacks, fzf-lua) and assert the *effect* of a +-- selection, not merely that a picker appeared. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local context = require("taskwarrior.context") + +-- Stub the picker: `chooser(labels)` returns the index to select, or nil to +-- cancel. Captures the rendered labels so tests can assert what was offered. +local function with_picker(chooser, fn) + local orig = vim.ui.select + local seen = {} + vim.ui.select = function(items, opts, cb) + local labels = {} + for i, item in ipairs(items) do + labels[i] = opts.format_item and opts.format_item(item) or tostring(item) + end + seen = { labels = labels, items = items, prompt = opts.prompt } + local idx = chooser(labels, items) + if idx == nil then return cb(nil) end + cb(items[idx], idx) + end + local ok, err = pcall(fn) + vim.ui.select = orig + if not ok then error(err) end + return seen +end + +-- Fire a normal-mode keymap by its description. Matching on `desc` rather +-- than `lhs` because Neovim stores the mapping with already +-- expanded to the current mapleader, which the spec cannot assume. +local function press(bufnr, desc_fragment) + local function search(maps) + for _, m in ipairs(maps) do + if m.callback and m.desc and m.desc:find(desc_fragment, 1, true) then + m.callback() + return true + end + end + return false + end + if bufnr and bufnr ~= 0 and search(vim.api.nvim_buf_get_keymap(bufnr, "n")) then + return true + end + if bufnr and search(vim.api.nvim_buf_get_keymap(bufnr, "n")) then return true end + return search(vim.api.nvim_get_keymap("n")) +end + +describe("e2e finite-choice pickers", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + local config = require("taskwarrior.config") + + taskmd.tw_add("picker probe alpha", { project = "pickdemo", priority = "H" }) + taskmd.tw_add("picker probe beta", { project = "pickdemo" }) + + local function open_buf() + vim.cmd("enew") + require("taskwarrior").open("project:pickdemo") + vim.wait(300, function() return false end, 10) + return vim.api.nvim_get_current_buf() + end + + it("sort key opens a picker and applies the chosen spec", function() + local bufnr = open_buf() + local seen = with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("due ↑", 1, true) then return i end + end + end, function() + assert.is_true(press(bufnr, "Change sort"), "sort key not mapped") + vim.wait(500, function() return vim.b[bufnr].task_sort == "due+" end, 10) + end) + + assert.are.same("due+", vim.b[bufnr].task_sort, + "picker selection did not change the buffer's sort") + -- The active spec is marked, so the picker also answers "what's set now?". + local marked = false + for _, l in ipairs(seen.labels) do + if l:sub(1, 3) == "● " or l:find("●", 1, true) then marked = true end + end + assert.is_true(marked, "no current-value marker in the sort picker") + end) + + it("group key opens a picker and applies the chosen field", function() + local bufnr = open_buf() + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("project", 1, true) then return i end + end + end, function() + assert.is_true(press(bufnr, "Change grouping"), "group key not mapped") + vim.wait(500, function() return vim.b[bufnr].task_group == "project" end, 10) + end) + assert.are.same("project", vim.b[bufnr].task_group) + + -- Choosing "none" clears it, rather than setting a literal group. + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("none", 1, true) then return i end + end + end, function() + press(bufnr, "Change grouping") + vim.wait(500, function() return vim.b[bufnr].task_group == nil end, 10) + end) + assert.is_nil(vim.b[bufnr].task_group, "'none' should clear grouping") + end) + + it("context key picks a context and activates it", function() + vim.fn.system("task rc.bulk=0 rc.confirmation=off rc.verbose=nothing " .. + "context define pickctx project:pickdemo") + context.set("none") + + local seen = with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("pickctx", 1, true) then return i end + end + end, function() + assert.is_true(press(0, "Pick Taskwarrior context"), "context key not mapped") + vim.wait(2000, function() return context.current() == "pickctx" end, 20) + end) + + assert.are.same("pickctx", context.current(), + "picker selection did not activate the context") + -- The picker shows each context's filter, so you can tell them apart. + local shows_filter = false + for _, l in ipairs(seen.labels) do + if l:find("project:pickdemo", 1, true) then shows_filter = true end + end + assert.is_true(shows_filter, "context picker does not show read filters") + context.set("none") + end) + + it("context picker offers 'none' and a define escape hatch", function() + local seen = with_picker(function() return nil end, function() + press(0, "Pick Taskwarrior context") + end) + local has_none, has_define = false, false + for _, l in ipairs(seen.labels) do + if l:find("clear context", 1, true) then has_none = true end + if l:find("define a new context", 1, true) then has_define = true end + end + assert.is_true(has_none, "context picker has no 'none' entry") + assert.is_true(has_define, "context picker has no define escape hatch") + end) + + it("cancelling a picker changes nothing", function() + local bufnr = open_buf() + vim.b[bufnr].task_sort = "urgency-" + with_picker(function() return nil end, function() + press(bufnr, "Change sort") + vim.wait(300, function() return false end, 10) + end) + assert.are.same("urgency-", vim.b[bufnr].task_sort, + "cancelling the picker must not change the sort") + end) + + it("bare :TwSort opens the picker instead of erroring", function() + local bufnr = open_buf() + local prefix = config.options.command_prefix or "Tw" + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("priority ↓", 1, true) then return i end + end + end, function() + vim.cmd(prefix .. "Sort") + vim.wait(500, function() return vim.b[bufnr].task_sort == "priority-" end, 10) + end) + assert.are.same("priority-", vim.b[bufnr].task_sort) + end) + + it("report picker opens the chosen report", function() + local seen = with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("overdue", 1, true) then return i end + end + end, function() + assert.is_true(press(0, "Pick a named report"), "report key not mapped") + vim.wait(1000, function() + return (vim.b[vim.api.nvim_get_current_buf()].task_filter or ""):find("OVERDUE") ~= nil + end, 20) + end) + + local shows_filter = false + for _, l in ipairs(seen.labels) do + if l:find("status:pending", 1, true) then shows_filter = true end + end + assert.is_true(shows_filter, "report picker does not show each report's filter") + local filter = vim.b[vim.api.nvim_get_current_buf()].task_filter or "" + assert.is_truthy(filter:find("OVERDUE"), + "overdue report did not open; filter = " .. filter) + end) + + it("saved-view picker loads the chosen view (and no-arg :TwLoad asks)", function() + local bufnr = open_buf() + vim.b[bufnr].task_sort = "due+" + require("taskwarrior").view_save("pickview") + vim.wait(300, function() return false end, 10) + + vim.cmd("enew") + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("pickview", 1, true) then return i end + end + end, function() + assert.is_true(press(0, "Pick a saved view"), "view key not mapped") + vim.wait(1000, function() + return vim.b[vim.api.nvim_get_current_buf()].task_filter ~= nil + end, 20) + end) + + local loaded = vim.api.nvim_get_current_buf() + assert.are.same("project:pickdemo", vim.b[loaded].task_filter, + "saved view did not load its filter") + end) +end) diff --git a/tests/e2e/spec/repair_tags_e2e_spec.lua b/tests/e2e/spec/repair_tags_e2e_spec.lua new file mode 100644 index 0000000..2d07ab1 --- /dev/null +++ b/tests/e2e/spec/repair_tags_e2e_spec.lua @@ -0,0 +1,103 @@ +-- repair_tags_e2e_spec.lua — :TwRepairTags against a live Taskwarrior CLI. +-- +-- Seeds the exact damage the hyphenated-tag bug produced (tag text stuck in +-- the description, tag never created), runs the repair, and asserts the task +-- becomes findable by the tag afterwards. Also asserts the two refusals that +-- keep this safe: a quoted mention is untouched, and Cancel writes nothing. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local repair = require("taskwarrior.repair_tags") + +-- Seed a task the way the bug did: the +tag lives in the description text +-- and no tag exists. `add --` forces a literal description. +local function seed_damaged(description) + local out = vim.fn.system({ + "task", "rc.bulk=0", "rc.confirmation=off", "rc.verbose=new-uuid", + "add", "--", description, + }) + return out:match("[0-9a-fA-F%-]+%-[0-9a-fA-F%-]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+") +end + +describe("e2e :TwRepairTags", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + + it("makes a damaged task findable by its tag again", function() + local tag = ("repair-me-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged("buy groceries +" .. tag) + assert.is_truthy(uuid, "seed failed") + + -- Precondition: this is exactly the reported symptom. + local before = taskmd.shell_export("+" .. tag) or {} + assert.are.same(0, #before, "seed did not reproduce the bug") + local seeded = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same("buy groceries +" .. tag, seeded.description) + + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Apply repairs") end + repair.run("uuid:" .. uuid) + vim.wait(5000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.tags and vim.tbl_contains(t.tags, tag) + end, 20) + vim.ui.select = orig_select + + local after = taskmd.shell_export("+" .. tag) or {} + assert.are.same(1, #after, "task is still not findable by its tag") + assert.are.same(uuid, after[1].uuid) + assert.are.same("buy groceries", after[1].description, + "tag text was not stripped from the description") + end) + + it("leaves a quoted mention alone", function() + local tag = ("quoted-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged(('filtering by "+%s" fails'):format(tag)) + local plans = repair.scan("uuid:" .. uuid) + assert.are.same({}, plans, + "a quoted tag mention must not be planned for repair") + end) + + it("Cancel writes nothing", function() + local tag = ("cancel-me-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged("do not touch +" .. tag) + + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Cancel") end + repair.run("uuid:" .. uuid) + vim.wait(1000, function() return false end, 20) + vim.ui.select = orig_select + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same("do not touch +" .. tag, t.description, + "Cancel must not modify the description") + assert.are.same(0, #(t.tags or {}), "Cancel must not add tags") + end) + + it("preserves tags the task already had", function() + local keep = ("kept-%d"):format(math.random(1, 1e9)) + local add = ("added-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged("mixed tags +" .. add) + -- Give it a real tag through the (now correct) plugin path. + assert.is_true(taskmd.tw_change_tag(uuid, keep)) + + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Apply repairs") end + repair.run("uuid:" .. uuid) + vim.wait(5000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.tags and #t.tags == 2 + end, 20) + vim.ui.select = orig_select + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + local tags = { unpack(t.tags or {}) } + table.sort(tags) + local expected = { keep, add } + table.sort(expected) + assert.are.same(expected, tags, "existing tag was lost during repair") + end) +end) diff --git a/tests/e2e/spec/table_view_e2e_spec.lua b/tests/e2e/spec/table_view_e2e_spec.lua new file mode 100644 index 0000000..f72ab6c --- /dev/null +++ b/tests/e2e/spec/table_view_e2e_spec.lua @@ -0,0 +1,142 @@ +-- table_view_e2e_spec.lua — :TwTable, the vit-style column view (tw 6e9f911d). +-- +-- Asserts the rendered geometry (columns actually align, nothing overflows +-- the window width), that config.table_columns really drives the layout +-- including UDA and custom-format columns, and that the view's opens +-- the right task in an editable :Tw buffer. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local table_view = require("taskwarrior.table_view") + +-- Split a rendered row on the 2-space left margin + single-space gutters is +-- ambiguous; instead assert alignment by comparing display columns of the +-- header cells against each row's cells at the same byte offsets. +local function display_width(s) return vim.fn.strdisplaywidth(s) end + +describe("e2e :TwTable (tw 6e9f911d)", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + local config = require("taskwarrior.config") + + local uuid = taskmd.tw_add("table view target", { + project = "tabledemo", priority = "H", tags = { "tbl-one", "tbl-two" }, + }) + assert(uuid ~= "") + + it("renders aligned rows that fit the window width", function() + local tasks = taskmd.shell_export("project:tabledemo") or {} + assert.is_true(#tasks > 0) + local lines = table_view.build(tasks, { filter = "project:tabledemo", width = 100 }) + + for i, line in ipairs(lines) do + assert.is_true(display_width(line) <= 100, + ("line %d overflows the 100-col budget (%d): %q") + :format(i, display_width(line), line)) + end + + -- Header, separator, then one row per task. + assert.is_truthy(lines[2]:find("Description", 1, true), "no Description header") + assert.is_truthy(lines[3]:find("─", 1, true), "no separator row") + local row + for _, l in ipairs(lines) do + if l:find("table view target", 1, true) then row = l end + end + assert.is_truthy(row, "task row missing:\n" .. table.concat(lines, "\n")) + assert.is_truthy(row:find("tabledemo", 1, true), "project cell missing") + assert.is_truthy(row:find("tbl%-one,tbl%-two"), "tags cell missing") + + -- Columns align: the description header and the description cell start + -- at the same display column. + local header = lines[2] + local hdr_col = display_width(header:sub(1, header:find("Description", 1, true) - 1)) + local cell_col = display_width(row:sub(1, row:find("table view target", 1, true) - 1)) + assert.are.same(hdr_col, cell_col, + "description column is not aligned with its header") + end) + + it("honors config.table_columns including UDA and format columns", function() + local orig = config.options.table_columns + config.options.table_columns = { + { field = "description", width = 20 }, + { field = "project", label = "Proj", width = 10 }, + { field = "tags", label = "Count", width = 6, + format = function(v) return tostring(#(v or {})) end }, + } + local tasks = taskmd.shell_export("project:tabledemo") or {} + local lines = table_view.build(tasks, { filter = "project:tabledemo", width = 80 }) + config.options.table_columns = orig + + assert.is_truthy(lines[2]:find("Proj", 1, true), "custom label missing from header") + assert.is_truthy(lines[2]:find("Count", 1, true), "format column missing from header") + assert.is_nil(lines[2]:find("Urg", 1, true), + "default columns leaked into a custom layout") + + local row + for _, l in ipairs(lines) do + if l:find("table view target", 1, true) then row = l end + end + assert.is_truthy(row, "task row missing under custom columns") + -- The format function turned the tag list into its count. + assert.is_truthy(row:find("2", 1, true), "format column did not render the count") + assert.is_nil(row:find("tbl-one", 1, true), + "raw tag value rendered despite a format function") + end) + + it("truncates over-long cells with an ellipsis instead of overflowing", function() + local long = taskmd.tw_add(("wordy "):rep(40) .. "end", { project = "tablewide" }) + local tasks = taskmd.shell_export("uuid:" .. long) or {} + local lines = table_view.build(tasks, { filter = "uuid", width = 100 }) + for _, line in ipairs(lines) do + assert.is_true(display_width(line) <= 100, + ("long description overflowed: %d cols"):format(display_width(line))) + end + local row + for _, l in ipairs(lines) do + if l:find("wordy", 1, true) then row = l end + end + assert.is_truthy(row, "long-description row missing") + assert.is_truthy(row:find("…", 1, true), "long cell was not ellipsized") + end) + + it(":TwTable opens a view buffer and opens that task for editing", function() + local prefix = config.options.command_prefix or "Tw" + assert.is_truthy(vim.api.nvim_get_commands({})[prefix .. "Table"], + ":" .. prefix .. "Table not registered") + + vim.cmd(prefix .. "Table project:tabledemo") + vim.wait(500, function() return false end, 10) + local view_buf = vim.api.nvim_get_current_buf() + assert.are.same("taskwarrior_view", vim.bo[view_buf].filetype) + assert.is_false(vim.bo[view_buf].modifiable, "table view should be read-only") + + -- Put the cursor on the task row, then fire . + local lines = vim.api.nvim_buf_get_lines(view_buf, 0, -1, false) + local row_nr + for i, l in ipairs(lines) do + if l:find("table view target", 1, true) then row_nr = i end + end + assert.is_truthy(row_nr, "task row not found in the opened view") + vim.api.nvim_win_set_cursor(0, { row_nr, 0 }) + + local cr + for _, m in ipairs(vim.api.nvim_buf_get_keymap(view_buf, "n")) do + if m.lhs == "" and m.callback then cr = m.callback end + end + assert.is_truthy(cr, " keymap missing from the table view") + cr() + vim.wait(500, function() + return vim.b[vim.api.nvim_get_current_buf()].task_filter ~= nil + end, 10) + + local task_buf = vim.api.nvim_get_current_buf() + assert.are.same("uuid:" .. uuid, vim.b[task_buf].task_filter, + " did not open the task under the cursor") + local text = table.concat(vim.api.nvim_buf_get_lines(task_buf, 0, -1, false), "\n") + assert.is_truthy(text:find("table view target", 1, true), + "opened buffer does not contain the task") + end) +end) diff --git a/tests/e2e/spec/undo_render_e2e_spec.lua b/tests/e2e/spec/undo_render_e2e_spec.lua new file mode 100644 index 0000000..bf9a425 --- /dev/null +++ b/tests/e2e/spec/undo_render_e2e_spec.lua @@ -0,0 +1,115 @@ +-- undo_render_e2e_spec.lua — `u` must not undo the render itself. +-- +-- Populating a task buffer is not a user edit. When it was undoable, `u` in +-- a freshly opened :Tw reverted the population and left an empty buffer — +-- which the save path reads as "every task in this filter was removed" and +-- duly offers to mark them all done/deleted. Reported as: pressing u gives a +-- blank screen, a header read-only warning, and a prompt to delete +-- everything. +-- +-- The important assertion is the last one: what a SAVE would do after `u`. +-- Line counts alone would not have caught the destructive part. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function planned_actions(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local tmpfile = vim.fn.tempname() + vim.fn.writefile(lines, tmpfile) + local res = taskmd.apply({ file = tmpfile, dry_run = true, on_delete = "done" }) + vim.fn.delete(tmpfile) + return res.actions or {}, res +end + +describe("e2e undo does not revert the render", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + + taskmd.tw_add("undo probe one", { project = "undorenderdemo" }) + taskmd.tw_add("undo probe two", { project = "undorenderdemo" }) + + local function open() + vim.cmd("enew") + require("taskwarrior").open("project:undorenderdemo") + vim.wait(300, function() return false end, 10) + return vim.api.nvim_get_current_buf() + end + + it("leaves the buffer intact when undoing a fresh open", function() + local bufnr = open() + local before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.is_true(#before >= 3, "fixture did not render both tasks") + + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local after = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(before, after, + "`u` on a fresh buffer changed its contents — the render was undoable") + end) + + it("keeps the buffer unmodified after undoing a fresh open", function() + local bufnr = open() + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + assert.is_false(vim.bo[bufnr].modified, + "undo dirtied a buffer the user never edited") + end) + + it("a save after undo would touch nothing — no mass delete", function() + local bufnr = open() + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local actions = planned_actions(bufnr) + assert.are.same(0, #actions, + "saving after `u` would run " .. #actions .. + " action(s) — undo must not stage a mass mutation: " .. vim.inspect(actions)) + end) + + it("still undoes the user's own edits", function() + local bufnr = open() + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local target + for i, l in ipairs(lines) do + if l:find("undo probe one", 1, true) then target = i end + end + assert.is_truthy(target, "probe task not rendered") + + -- A real, undoable user edit (not nvim_buf_set_lines, which the render + -- path deliberately makes non-undoable). + vim.api.nvim_win_set_cursor(0, { target, 0 }) + vim.cmd("normal! AZZZEDIT") + assert.is_truthy( + (vim.api.nvim_buf_get_lines(bufnr, target - 1, target, false)[1] or ""):find("ZZZEDIT"), + "test edit did not land") + + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local restored = vim.api.nvim_buf_get_lines(bufnr, target - 1, target, false)[1] or "" + assert.is_nil(restored:find("ZZZEDIT", 1, true), + "user's own edit was not undone — undo is now too aggressive") + assert.are.same(#lines, vim.api.nvim_buf_line_count(bufnr), + "undoing a user edit collapsed the buffer") + end) + + it("survives a refresh followed by undo", function() + local bufnr = open() + local before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + require("taskwarrior.buffer").refresh_buf(bufnr) + vim.wait(300, function() return false end, 10) + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local actions = planned_actions(bufnr) + assert.are.same(0, #actions, + "undo after a refresh staged " .. #actions .. " action(s)") + assert.are.same(#before, vim.api.nvim_buf_line_count(bufnr), + "undo after a refresh changed the buffer size") + end) +end) diff --git a/tests/lua/spec/hyphen_tag_spec.lua b/tests/lua/spec/hyphen_tag_spec.lua new file mode 100644 index 0000000..36adc86 --- /dev/null +++ b/tests/lua/spec/hyphen_tag_spec.lua @@ -0,0 +1,142 @@ +-- Regression spec for the hyphenated-tag class of bugs. +-- +-- TW3's expression parser treats `-` inside a bare `+tag` token as a +-- subtraction operator: +-- * filter `+ais-research-taste` → "Cannot subtract from a Boolean value" +-- * `add … +ais-research-taste` → tag silently lands in the description +-- * `modify +ais-research-taste` → exit 2 (or silent no-op with `--`) +-- +-- Fixes under test: +-- 1. shell_export routes parsed args through normalize_tag_filters +-- (`+t` → `tags.has:t`, `-t` → `tags.hasnt:t`, virtual tags verbatim). +-- 2. fields_to_args emits a single `tags:a,b` replacement arg instead of +-- per-tag `+a +b` deltas (also fixes partial tag removal on save). +-- 3. tw_change_tag merges a single-tag delta into the full set and +-- replaces, instead of `modify +t` / `modify -t`. + +local eq = assert.are.same + +local tm = require("taskwarrior.taskmd") +local command = require("taskwarrior.command") +local runtime = require("taskwarrior.runtime") + +describe("hyphen tags — fields_to_args replacement semantics", function() + it("emits one tags: arg for the full set", function() + local args = tm._fields_to_args({ tags = { "plain", "with-hyphens" } }) + eq({ "tags:plain,with-hyphens" }, args) + end) + + it("emits an empty tags: to clear all tags", function() + local args = tm._fields_to_args({ tags = {}, _removed_tags = { "old-tag" } }) + eq({ "tags:" }, args) + end) + + it("never emits +tag or -tag delta args", function() + local args = tm._fields_to_args({ + tags = { "keep-me" }, _removed_tags = { "drop-me" }, project = "p", + }) + for _, a in ipairs(args) do + assert.is_nil(a:match("^[%+%-]"), "unexpected delta arg: " .. a) + end + end) +end) + +describe("hyphen tags — shell_export filter normalization", function() + local orig_read, orig_executable + local captured + + before_each(function() + captured = nil + orig_read = command.read + orig_executable = vim.fn.executable + vim.fn.executable = function(_) return 1 end + runtime._reset_for_tests() + command.read = function(args, _) + captured = args + return { ok = true, output = "[]", code = 0 } + end + end) + + after_each(function() + command.read = orig_read + vim.fn.executable = orig_executable + runtime._reset_for_tests() + end) + + local function exported_args(filter) + tm.shell_export(filter) + assert.is_table(captured, "command.read was not invoked") + return captured + end + + it("rewrites +tag to tags.has:", function() + local args = exported_args("+ais-research-taste status:pending") + eq("tags.has:ais-research-taste", args[2]) + eq("status:pending", args[3]) + end) + + it("rewrites -tag to tags.hasnt:", function() + local args = exported_args("-ais-research-taste") + eq("tags.hasnt:ais-research-taste", args[2]) + end) + + it("keeps virtual tags verbatim", function() + local args = exported_args("+ACTIVE") + eq("+ACTIVE", args[2]) + end) +end) + +describe("hyphen tags — tw_change_tag merge-and-replace", function() + local orig_export, orig_modify + local modify_calls + + before_each(function() + modify_calls = {} + orig_export = tm.shell_export + orig_modify = tm.tw_modify + tm.tw_modify = function(uuid, fields) + modify_calls[#modify_calls + 1] = { uuid = uuid, fields = fields } + return true, "", 0 + end + end) + + after_each(function() + tm.shell_export = orig_export + tm.tw_modify = orig_modify + end) + + local function with_task_tags(tags) + tm.shell_export = function(_) + return { { uuid = "u-1", tags = tags } } + end + end + + it("appends a tag to the existing set", function() + with_task_tags({ "existing" }) + local ok = tm.tw_change_tag("u-1", "new-hyphen-tag") + assert.is_true(ok) + eq({ "existing", "new-hyphen-tag" }, modify_calls[1].fields.tags) + end) + + it("is a no-op when the tag is already present", function() + with_task_tags({ "already-there" }) + local ok = tm.tw_change_tag("u-1", "already-there") + assert.is_true(ok) + eq(0, #modify_calls) + end) + + it("removes a tag, keeping the rest", function() + with_task_tags({ "keep-this", "drop-this" }) + local ok = tm.tw_change_tag("u-1", "drop-this", true) + assert.is_true(ok) + eq({ "keep-this" }, modify_calls[1].fields.tags) + end) + + it("fails closed when the task cannot be read", function() + tm.shell_export = function(_) return nil end + local ok, out = tm.tw_change_tag("u-1", "anything") + assert.is_false(ok) + assert.is_truthy(out:match("could not read task")) + eq(0, #modify_calls) + end) +end) diff --git a/tests/lua/spec/issue_fixes_spec.lua b/tests/lua/spec/issue_fixes_spec.lua index b763087..33c4d27 100644 --- a/tests/lua/spec/issue_fixes_spec.lua +++ b/tests/lua/spec/issue_fixes_spec.lua @@ -262,7 +262,14 @@ describe("lint — completion= v:lua references resolve", function() ("%s references v:lua taskwarrior.%s which is not a function"):format(path, name)) end end - assert.is_true(checked >= 4, "expected to find completion specs, found " .. checked) + -- Floor guards against the scan silently matching nothing (pattern rot + -- would turn this test into a no-op that always passes). It is the + -- current number of string-based completion specs, so converting one to + -- a picker means lowering it deliberately — which is the review signal + -- we want. Currently: _capture_omnifunc, _complete_filter, + -- _complete_modify. (Sort and group used to be here; they are pickers + -- now, which needs no v:lua indirection at all.) + assert.is_true(checked >= 3, "expected to find completion specs, found " .. checked) end) end) diff --git a/tests/lua/spec/repair_tags_spec.lua b/tests/lua/spec/repair_tags_spec.lua new file mode 100644 index 0000000..b8bea89 --- /dev/null +++ b/tests/lua/spec/repair_tags_spec.lua @@ -0,0 +1,77 @@ +-- Unit spec for the tag-repair planner (:TwRepairTags). +-- +-- The planner rewrites descriptions, so its false-positive behaviour is the +-- part that matters: a description that merely *mentions* a tag in quotes +-- (a bug report, a note about a filter) must be left completely alone. + +local eq = assert.are.same +local repair = require("taskwarrior.repair_tags") + +describe("repair_tags.plan_for", function() + it("moves a hyphenated tag out of the description", function() + local p = repair.plan_for({ + uuid = "u1", description = "Taco Tuesday — grocery shop +taco-tuesday", + }) + assert.is_truthy(p) + eq("Taco Tuesday — grocery shop", p.new_description) + eq({ "taco-tuesday" }, p.added_tags) + eq({ "taco-tuesday" }, p.tags) + end) + + it("handles several tokens anywhere in the line", function() + local p = repair.plan_for({ + uuid = "u2", description = "+email connect michael with shon +followup", + }) + eq("connect michael with shon", p.new_description) + eq({ "email", "followup" }, p.added_tags) + end) + + it("merges with tags the task already has", function() + local p = repair.plan_for({ + uuid = "u3", description = "ship it +extra", tags = { "work" }, + }) + eq("ship it", p.new_description) + eq({ "extra" }, p.added_tags) + eq({ "work", "extra" }, p.tags) + end) + + it("leaves a task alone when the tag text is already a real tag", function() + -- Nothing is broken here: the task IS findable by +urgent. Rewriting a + -- description purely to de-duplicate text is risk without benefit, so + -- the planner stays out of it. + assert.is_nil(repair.plan_for({ + uuid = "u3b", description = "ship it +urgent", tags = { "work", "urgent" }, + })) + end) + + it("leaves a QUOTED mention alone (the bug-report case)", function() + local p = repair.plan_for({ + uuid = "u4", + description = 'when searching with TwFilter and "+ais-research-taste" it fails', + }) + assert.is_nil(p, "a quoted tag mention must not be rewritten") + end) + + it("ignores a + that is not at a word boundary", function() + assert.is_nil(repair.plan_for({ uuid = "u5", description = "housing+food budget" })) + assert.is_nil(repair.plan_for({ uuid = "u6", description = "sell SPY + SPYG today" })) + end) + + it("returns nil when there is nothing to repair", function() + assert.is_nil(repair.plan_for({ + uuid = "u7", description = "ordinary task", tags = { "work" }, + })) + end) + + it("keeps the original description when it is nothing but tags", function() + local p = repair.plan_for({ uuid = "u8", description = "+ais-research-taste" }) + eq({ "ais-research-taste" }, p.added_tags) + eq("+ais-research-taste", p.new_description, + "a tags-only description must not become empty") + end) + + it("collapses the whitespace left behind by a removed token", function() + local p = repair.plan_for({ uuid = "u9", description = "call +email bob now" }) + eq("call bob now", p.new_description) + end) +end) diff --git a/tests/lua/spec/sort_missing_field_spec.lua b/tests/lua/spec/sort_missing_field_spec.lua new file mode 100644 index 0000000..5e9f610 --- /dev/null +++ b/tests/lua/spec/sort_missing_field_spec.lua @@ -0,0 +1,76 @@ +-- Regression spec: sorting by a field that only SOME tasks have. +-- +-- The comparator used to do `return ma < mb` on two booleans ("is this side +-- missing?"), which Lua rejects with "attempt to compare two boolean +-- values". Any sort on a partially-populated field — priority, due, project +-- — therefore blew up the whole render with a Lua error instead of sorting. +-- Real databases always have such fields, so this hit `:TwSort priority-` +-- almost immediately. + +local eq = assert.are.same +local tm = require("taskwarrior.taskmd") + +-- Task descriptions in render order. The rendered line carries the fields +-- too (`has high priority:H`), so cut at the first metadata token. +local function descriptions(out) + local names = {} + for line in out:gmatch("[^\n]+") do + local d = line:match("^%- %[.%]%s+(.-)%s* M > L), not alphabetical. Sorting the letters + -- put "L" above "H" descending, so `priority-` — which reads as "most + -- important first", matching `urgency-` — listed the least important + -- tasks first. + it("orders priority by importance, not alphabetically", function() + eq({ "has high", "has low", "no priority" }, render("priority-")) + eq({ "has low", "has high", "no priority" }, render("priority+")) + end) + + it("handles every field missing without erroring", function() + local names = render("project-") + eq(3, #names) + end) +end)