generated from eblume/project-template
feat(nvim): frontmatter edit surface — diff block into RPCs on save (§8.3)
Some checks failed
Build / validate (pull_request) Failing after 5m1s
Some checks failed
Build / validate (pull_request) Failing after 5m1s
Node buffers now open with the editable YAML frontmatter block on top
(node.get {frontmatter: true}); on :w, `frontmatter.lua` parses the
block, diffs it against what was rendered, and routes each changed field
to the right RPC:
- title → node.update rename
- attention → task.set_attention
- do_date/late_on/recurrence → task.set_schedule (YYYY-MM-DD → local-ms;
a removed line clears via null)
- project → task.set_project (resolved by name)
- tags → tag.add / tag.remove
A mistyped state surfaces the daemon's validation error; a buffer with no
block edits no metadata (deleting the block can't wipe tags). Body rides
node.update as before (the store strips any echoed frontmatter).
Body-position features are content-relative, so the prepended block
doesn't disturb them; e2e specs that targeted absolute line 1 now locate
body lines by content via a new `h.find` helper. New frontmatter_spec
covers render + the full diff→RPC round-trip. 21 nvim e2e specs green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef56c5d5f2
commit
0e9cfc1fd7
9 changed files with 328 additions and 23 deletions
|
|
@ -28,5 +28,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
|
|||
- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit.
|
||||
- `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.)
|
||||
- `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc.
|
||||
- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Link-follow and promotion are unaffected (they're content-relative, not line-absolute).
|
||||
- Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next).
|
||||
- Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface.
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@ edits, else adding the `wiki` link directly).
|
|||
| `:Heph promote [attention]` | Promote the `- [ ]` line under the cursor to a committed task |
|
||||
| `:Heph log <text>` | Append a breadcrumb to the current task's log |
|
||||
|
||||
A node buffer opens with an editable **YAML frontmatter** block on top (`id`,
|
||||
`kind`, `title`, `tags`, and — for a task or its context doc — the task's
|
||||
`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:`
|
||||
ref). On `:w`, `frontmatter.lua` diffs the block against what was rendered and
|
||||
issues the right RPC per changed field (rename, `set_attention`, `set_schedule`,
|
||||
`set_project`, `tag.add`/`tag.remove`); the store strips the block so it never
|
||||
enters the body (tech-spec §8.3). A buffer with no block edits no metadata.
|
||||
|
||||
"Current task" is resolved from the buffer: a `task` node, or a canonical-context
|
||||
doc whose owning task is followed via its `canonical-context` backlink. The
|
||||
`next`/`list` views render the titled rows the daemon returns (`list` enriched to
|
||||
|
|
|
|||
|
|
@ -300,14 +300,14 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2.
|
|||
|
||||
> **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1.
|
||||
|
||||
## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next)
|
||||
## 8.3 Frontmatter as an edit surface (built)
|
||||
|
||||
> **Status: backend built** (the projection); the `heph.nvim` diff layer is next. When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body.
|
||||
> **Status: built** (inline `#hashtags` on save are a small deferred follow-up). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body.
|
||||
|
||||
The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor:
|
||||
|
||||
- ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read.
|
||||
- ⏳ **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core.
|
||||
- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **Remaining:** inline `#hashtags` detected on save.
|
||||
|
||||
**The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route:
|
||||
|
||||
|
|
@ -468,7 +468,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi
|
|||
- ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.)
|
||||
- ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit).
|
||||
3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3).
|
||||
4. ◐ **YAML frontmatter as an edit surface (§8.3) — backend DONE, nvim next:** ✅ the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref. Round-trip is a no-op; inbound frontmatter is always stripped (safe vs any client). ⏳ Remaining: the `heph.nvim` diff-on-`BufWriteCmd` → structured RPCs (`title`→rename, `attention`→set_attention, dates→set_schedule, `project`→set_project, `tags`→tag.add/remove) + inline `#hashtags` on save.
|
||||
4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata). **Deferred follow-up:** inline `#hashtags` detected on save.
|
||||
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.
|
||||
6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9).
|
||||
7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]).
|
||||
|
|
|
|||
172
heph.nvim/lua/heph/frontmatter.lua
Normal file
172
heph.nvim/lua/heph/frontmatter.lua
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
--- Frontmatter as an edit surface (tech-spec §8.3). The daemon renders an
|
||||
--- editable YAML block atop a node's body on read (`node.get {frontmatter}`)
|
||||
--- and strips it on write; this module is the **smart-client** half: parse the
|
||||
--- buffer's frontmatter and translate each changed field into the right
|
||||
--- structured RPC (title→rename, attention→set_attention, dates→set_schedule,
|
||||
--- project→set_project, tags→tag.add/remove). The body itself rides `node.update`.
|
||||
local rpc = require("heph.rpc")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function trim(s)
|
||||
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
--- Unquote a YAML scalar (`"…"` with `\"`/`\\` escapes), else return it trimmed.
|
||||
local function unquote(s)
|
||||
s = trim(s)
|
||||
local inner = s:match('^"(.*)"$')
|
||||
if inner then
|
||||
return (inner:gsub('\\"', '"'):gsub("\\\\", "\\"))
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
--- Parse a YAML flow sequence `[a, b]` (or `[]`) into a list of scalars.
|
||||
local function parse_flow_seq(s)
|
||||
local inner = s:match("^%[(.*)%]$")
|
||||
if not inner then
|
||||
return {}
|
||||
end
|
||||
local out = {}
|
||||
for item in (inner .. ","):gmatch("(.-),") do
|
||||
item = trim(item)
|
||||
if item ~= "" then
|
||||
out[#out + 1] = unquote(item)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
--- Parse a leading `---` frontmatter block. Returns `(fm, body)` where `fm` is a
|
||||
--- table of string fields plus `tags` (a list), or `(nil, text)` when there is
|
||||
--- no conforming block (so a frontmatter-less buffer never looks like metadata).
|
||||
function M.parse(text)
|
||||
if text:sub(1, 4) ~= "---\n" then
|
||||
return nil, text
|
||||
end
|
||||
local lines = vim.split(text, "\n", { plain = true })
|
||||
local close
|
||||
for i = 2, #lines do
|
||||
if lines[i] == "---" then
|
||||
close = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if not close then
|
||||
return nil, text
|
||||
end
|
||||
local fm = { tags = {} }
|
||||
for j = 2, close - 1 do
|
||||
local key, val = lines[j]:match("^([%w_%-]+):%s*(.*)$")
|
||||
if key == "tags" then
|
||||
fm.tags = parse_flow_seq(val)
|
||||
elseif key then
|
||||
fm[key] = unquote(val)
|
||||
end
|
||||
end
|
||||
local body = table.concat({ unpack(lines, close + 1) }, "\n")
|
||||
return fm, body
|
||||
end
|
||||
|
||||
--- A `YYYY-MM-DD` string → epoch ms at **local midnight** (matching the
|
||||
--- daemon's `datespec`). Returns `nil, err` on a malformed date.
|
||||
local function date_to_ms(s)
|
||||
local y, m, d = tostring(s):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
|
||||
if not y then
|
||||
return nil, "expected a YYYY-MM-DD date, got " .. tostring(s)
|
||||
end
|
||||
return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000
|
||||
end
|
||||
|
||||
--- Set difference of two scalar lists → (added, removed).
|
||||
local function set_diff(old, new)
|
||||
local oldset, newset = {}, {}
|
||||
for _, x in ipairs(old or {}) do
|
||||
oldset[x] = true
|
||||
end
|
||||
for _, x in ipairs(new or {}) do
|
||||
newset[x] = true
|
||||
end
|
||||
local added, removed = {}, {}
|
||||
for x in pairs(newset) do
|
||||
if not oldset[x] then
|
||||
added[#added + 1] = x
|
||||
end
|
||||
end
|
||||
for x in pairs(oldset) do
|
||||
if not newset[x] then
|
||||
removed[#removed + 1] = x
|
||||
end
|
||||
end
|
||||
return added, removed
|
||||
end
|
||||
|
||||
--- Apply the difference between `canonical` (rendered on read) and the buffer's
|
||||
--- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags
|
||||
--- target); task scalars route to the owning task (`fm.task`). Raises on any
|
||||
--- RPC error (e.g. a mistyped `state` or an unknown `project`).
|
||||
function M.apply(node_id, canonical, fm)
|
||||
canonical = canonical or {}
|
||||
|
||||
-- Tags on the opened node.
|
||||
local added, removed = set_diff(canonical.tags, fm.tags)
|
||||
for _, t in ipairs(added) do
|
||||
rpc.call("tag.add", { node_id = node_id, tag = t })
|
||||
end
|
||||
for _, t in ipairs(removed) do
|
||||
rpc.call("tag.remove", { node_id = node_id, tag = t })
|
||||
end
|
||||
|
||||
-- Task scalars route to the owning task, if this node is or backs one.
|
||||
local task = fm.task or canonical.task
|
||||
if task then
|
||||
if fm.attention and fm.attention ~= canonical.attention then
|
||||
rpc.call("task.set_attention", { id = task, attention = fm.attention })
|
||||
end
|
||||
|
||||
-- Schedule (double-option): a present value sets, a removed line clears.
|
||||
local patch, changed = { id = task }, false
|
||||
local function sched(field)
|
||||
if fm[field] == canonical[field] then
|
||||
return
|
||||
end
|
||||
changed = true
|
||||
if fm[field] == nil or fm[field] == "" then
|
||||
patch[field] = vim.NIL -- cleared
|
||||
elseif field == "recurrence" then
|
||||
patch[field] = fm[field] -- a raw RRULE
|
||||
else
|
||||
local ms, err = date_to_ms(fm[field])
|
||||
if not ms then
|
||||
error("heph: " .. err)
|
||||
end
|
||||
patch[field] = ms
|
||||
end
|
||||
end
|
||||
sched("do_date")
|
||||
sched("late_on")
|
||||
sched("recurrence")
|
||||
if changed then
|
||||
rpc.call("task.set_schedule", patch)
|
||||
end
|
||||
|
||||
if fm.state and fm.state ~= canonical.state then
|
||||
rpc.call("task.set_state", { id = task, state = fm.state }) -- a typo → rpc error
|
||||
end
|
||||
|
||||
if fm.project ~= canonical.project then
|
||||
local pid = vim.NIL -- removed / blank → unfile
|
||||
if fm.project and fm.project ~= "" then
|
||||
local node = rpc.call("node.resolve", { title = fm.project })
|
||||
if not node then
|
||||
error("heph: no node named '" .. fm.project .. "' to file under")
|
||||
end
|
||||
pid = node.id
|
||||
end
|
||||
rpc.call("task.set_project", { id = task, project_id = pid })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -1,26 +1,34 @@
|
|||
--- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a
|
||||
--- real buffer named `heph://node/<id>`. `:e` loads it via `node.get`; `:w`
|
||||
--- saves the whole buffer back via `node.update` (the backend CRDT-diffs the
|
||||
--- whole-buffer text, so sending the full body is correct and idempotent).
|
||||
--- real buffer named `heph://node/<id>`. `:e` loads it via `node.get`
|
||||
--- (with the editable YAML **frontmatter** block prepended, §8.3); `:w` diffs
|
||||
--- the frontmatter into structured RPCs, then saves the body via `node.update`
|
||||
--- (the backend CRDT-diffs the whole body, and strips any frontmatter echoed
|
||||
--- back — so sending the stripped body is correct and idempotent).
|
||||
|
||||
local rpc = require("heph.rpc")
|
||||
local util = require("heph.util")
|
||||
local frontmatter = require("heph.frontmatter")
|
||||
|
||||
local M = {}
|
||||
|
||||
--- `BufReadCmd` handler for `heph://node/<id>`: load the body into the buffer.
|
||||
-- buf -> the frontmatter table rendered on read, so `write` can diff against it.
|
||||
M._canonical = {}
|
||||
|
||||
--- `BufReadCmd` handler for `heph://node/<id>`: load frontmatter + body.
|
||||
function M.read(buf, uri)
|
||||
local _, id = util.parse_uri(uri)
|
||||
if not id then
|
||||
error("heph: not a node uri: " .. tostring(uri))
|
||||
end
|
||||
local node = rpc.call("node.get", { id = id })
|
||||
local body = (node and node.body) or ""
|
||||
local node = rpc.call("node.get", { id = id, frontmatter = true })
|
||||
local text = (node and node.body) or ""
|
||||
local fm = frontmatter.parse(text)
|
||||
-- `plain` split keeps a trailing "" element for a trailing newline, so the
|
||||
-- body round-trips exactly through `table.concat` on write.
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true }))
|
||||
-- text round-trips exactly through `table.concat` on write.
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(text, "\n", { plain = true }))
|
||||
vim.b[buf].heph_node_id = id
|
||||
vim.b[buf].heph_node_kind = (node and node.kind) or "doc"
|
||||
M._canonical[buf] = fm or {}
|
||||
vim.bo[buf].buftype = "acwrite" -- written via BufWriteCmd, not to a file
|
||||
vim.bo[buf].filetype = "markdown"
|
||||
vim.bo[buf].fileformat = "unix"
|
||||
|
|
@ -28,14 +36,32 @@ function M.read(buf, uri)
|
|||
require("heph.link").attach(buf)
|
||||
end
|
||||
|
||||
--- `BufWriteCmd` handler: persist the whole buffer as the node body.
|
||||
--- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the body.
|
||||
function M.write(buf, _uri)
|
||||
local id = vim.b[buf].heph_node_id
|
||||
if not id then
|
||||
error("heph: buffer has no heph node id")
|
||||
end
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
rpc.call("node.update", { id = id, body = table.concat(lines, "\n") })
|
||||
local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n")
|
||||
local fm, body = frontmatter.parse(text)
|
||||
|
||||
local params = { id = id }
|
||||
if fm then
|
||||
-- A frontmatter block is present: translate its edits to RPCs. (Absent
|
||||
-- block ⇒ the user removed it; treat the whole buffer as body, touch no
|
||||
-- metadata.) The backend also strips defensively, so `body` is what stores.
|
||||
local canonical = M._canonical[buf] or {}
|
||||
frontmatter.apply(id, canonical, fm)
|
||||
if fm.title and fm.title ~= canonical.title then
|
||||
params.title = fm.title
|
||||
end
|
||||
params.body = body
|
||||
M._canonical[buf] = fm
|
||||
else
|
||||
params.body = text
|
||||
end
|
||||
|
||||
rpc.call("node.update", params)
|
||||
vim.bo[buf].modified = false
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@ describe("follow link", function()
|
|||
local a = h.create_doc("A", "see [[B]] here")
|
||||
|
||||
local buf = h.open(a.id)
|
||||
local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]
|
||||
local open_at = line:find("%[%[B") -- start of "[[B"
|
||||
assert.is_truthy(open_at)
|
||||
local lnum, col = h.find(buf, "%[%[B") -- the body line, below the frontmatter
|
||||
assert.is_truthy(lnum)
|
||||
-- Put the cursor on the target inside the brackets.
|
||||
vim.api.nvim_win_set_cursor(0, { 1, open_at + 1 })
|
||||
vim.api.nvim_win_set_cursor(0, { lnum, col + 2 })
|
||||
|
||||
require("heph.link").follow()
|
||||
|
||||
|
|
@ -32,8 +31,8 @@ describe("follow link", function()
|
|||
it("creates the target doc when following an unresolved [[link]]", function()
|
||||
local a = h.create_doc("Daily", "see [[New Topic]]")
|
||||
local buf = h.open(a.id)
|
||||
local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[New")
|
||||
vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) -- inside [[New Topic]]
|
||||
local lnum, col = h.find(buf, "%[%[New")
|
||||
vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) -- inside [[New Topic]]
|
||||
|
||||
require("heph.link").follow()
|
||||
|
||||
|
|
|
|||
85
heph.nvim/tests/e2e/frontmatter_spec.lua
Normal file
85
heph.nvim/tests/e2e/frontmatter_spec.lua
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
-- Frontmatter edit surface (tech-spec §8.3): a node buffer opens with an
|
||||
-- editable YAML block on top, and saving routes each changed field to the right
|
||||
-- structured RPC (rename / set_attention / set_schedule / set_project /
|
||||
-- tag.add). The body itself still round-trips through node.update.
|
||||
|
||||
local h = require("e2e.helpers")
|
||||
|
||||
-- Replace `key: …` in the frontmatter `lines`, or insert it before the closing
|
||||
-- `---` fence when the key isn't present yet.
|
||||
local function set_field(lines, key, value)
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match("^" .. key .. ":") then
|
||||
lines[i] = key .. ": " .. value
|
||||
return
|
||||
end
|
||||
end
|
||||
local fences = 0
|
||||
for i, line in ipairs(lines) do
|
||||
if line == "---" then
|
||||
fences = fences + 1
|
||||
if fences == 2 then
|
||||
table.insert(lines, i, key .. ": " .. value)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe("frontmatter edit surface", function()
|
||||
local ctx
|
||||
before_each(function()
|
||||
ctx = h.start()
|
||||
end)
|
||||
after_each(function()
|
||||
h.stop(ctx)
|
||||
end)
|
||||
|
||||
it("renders an editable block atop a node body", function()
|
||||
local doc = h.create_doc("Roof", "# Roof\n\nnotes")
|
||||
local buf = h.open(doc.id)
|
||||
local first = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]
|
||||
assert.are.equal("---", first, "buffer should open with a frontmatter fence")
|
||||
assert.is_truthy(h.find(buf, "^title: Roof"), "title not in frontmatter")
|
||||
assert.is_truthy(h.find(buf, "# Roof"), "body content missing below frontmatter")
|
||||
end)
|
||||
|
||||
it("routes frontmatter edits to structured RPCs on save", function()
|
||||
local proj = ctx.q:call("node.create", { kind = "project", title = "Camano" })
|
||||
local task = ctx.q:call("task.create", { title = "Fix roof", attention = "red" })
|
||||
local ctxid
|
||||
for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do
|
||||
if l.link_type == "canonical-context" then
|
||||
ctxid = l.dst_id
|
||||
end
|
||||
end
|
||||
|
||||
local buf = h.open(ctxid)
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
set_field(lines, "title", "Fix the roof") -- rename the (doc) node
|
||||
set_field(lines, "attention", "blue") -- → task.set_attention
|
||||
set_field(lines, "tags", "[roofing]") -- → tag.add on the doc
|
||||
set_field(lines, "project", "Camano") -- → task.set_project
|
||||
set_field(lines, "do_date", "2026-06-10") -- → task.set_schedule
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
h.save(buf)
|
||||
|
||||
-- The task picked up attention, project, and a do-date.
|
||||
local t = ctx.q:call("task.get", { id = task.node_id })
|
||||
assert.are.equal("blue", t.attention)
|
||||
assert.is_truthy(t.do_date, "do_date should be set")
|
||||
local filed = false
|
||||
for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do
|
||||
if l.link_type == "in-project" and l.dst_id == proj.id then
|
||||
filed = true
|
||||
end
|
||||
end
|
||||
assert.is_true(filed, "task should be filed under Camano")
|
||||
|
||||
-- The opened node was renamed and tagged.
|
||||
assert.are.equal("Fix the roof", ctx.q:call("node.get", { id = ctxid }).title)
|
||||
local tags = ctx.q:call("tag.list", { node_id = ctxid })
|
||||
assert.are.equal(1, #tags)
|
||||
assert.are.equal("roofing", tags[1])
|
||||
end)
|
||||
end)
|
||||
|
|
@ -178,6 +178,19 @@ function M.open(id)
|
|||
return vim.api.nvim_get_current_buf()
|
||||
end
|
||||
|
||||
--- The (1-based line, 0-based byte col) of the first match of Lua `pattern` in
|
||||
--- `buf`, or nil. Lets specs target body content by what it says rather than an
|
||||
--- absolute line number — node buffers now carry a frontmatter block on top
|
||||
--- (tech-spec §8.3), so the body no longer starts at line 1.
|
||||
function M.find(buf, pattern)
|
||||
for i, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
|
||||
local s = line:find(pattern)
|
||||
if s then
|
||||
return i, s - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.set_lines(buf, lines)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ describe("promote context item", function()
|
|||
it("mints a task, links the source line, and surfaces it in next", function()
|
||||
local container = h.create_doc("Errands", "- [ ] call plumber")
|
||||
local buf = h.open(container.id)
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- on the context-item line
|
||||
local lnum = h.find(buf, "call plumber") -- the context-item line, below frontmatter
|
||||
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
||||
|
||||
local task = require("heph.task").promote_under_cursor({ attention = "orange" })
|
||||
assert.is_truthy(task.node_id)
|
||||
|
|
@ -32,7 +33,7 @@ describe("promote context item", function()
|
|||
-- The source line became a wiki-link to the task (persisted + in-buffer).
|
||||
assert.are.equal("- [[call plumber]]", ctx.q:call("node.get", { id = container.id }).body)
|
||||
local reloaded = vim.api.nvim_get_current_buf()
|
||||
assert.are.equal("- [[call plumber]]", vim.api.nvim_buf_get_lines(reloaded, 0, 1, false)[1])
|
||||
assert.is_truthy(h.find(reloaded, "%- %[%[call plumber%]%]"), "rewritten link line missing from buffer")
|
||||
|
||||
-- ...and the container backlinks the task.
|
||||
local linked = false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue