diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 5730820..46c7ab4 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ 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; `` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; in-editor conceal of the id is next.) +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them.) - 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). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. 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. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index c3b9fa8..138c608 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,10 +334,10 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, > **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). -- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID]]`; a **"+ Create new doc"** entry mints a `doc` and inserts its id. Follow (``) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save); a **"+ Create new doc"** entry mints a `doc`. Follow (``) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* -- ⏳ **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), revealed in raw form when the cursor is on it. +- ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. - ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**. ## 9. Testing strategy (TDD, layered) @@ -470,7 +470,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `` 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::`, 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) — 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), and inline `#hashtags` in the body are unioned into the tag set on save. -5. ◐ **Wiki-links by node id (§8.4) — authoring DONE, display + migration next:** ✅ id-first resolution (`[[NODEID]]` resolves ahead of name; legacy `[[Name]]` still works) + the `heph.nvim` `[[` picker (`search` → insert `[[NODEID]]`, "+ Create" mints a doc) + id-direct follow. ⏳ Remaining: read-expansion/write-collapse projection (`[[ID]]`⟷`[[ID|Name]]`, needs an id→name batch RPC), conceal display, and the one-time `[[Title]]`→`[[ID]]` migration (then retire name-resolution + the canonical-context hack). See §8.4. +5. ◐ **Wiki-links by node id (§8.4) — authoring + display DONE, migration next:** ✅ id-first resolution; the `heph.nvim` `[[` picker (`search` → insert `[[NODEID|Name]]`, "+ Create" mints a doc) + id-direct follow; the **expand-on-read / collapse-on-write** projection (`heph-core::wikilink`); and **conceal display** (`conceal.lua` hides the id, shows the label as a `HephLink`, reveals on the cursor line). ⏳ Remaining: the one-time `[[Title]]`→`[[NODEID]]` migration (then retire name-resolution + the canonical-context hack). 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]]). 8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. diff --git a/heph.nvim/lua/heph/conceal.lua b/heph.nvim/lua/heph/conceal.lua new file mode 100644 index 0000000..d4c14c1 --- /dev/null +++ b/heph.nvim/lua/heph/conceal.lua @@ -0,0 +1,66 @@ +--- Conceal `[[NODEID|Name]]` links down to a styled "Name" hyperlink in node +--- buffers (tech-spec §8.4). The node id is structural noise to a reader, so we +--- hide the `[[id|` prefix and the `]]` suffix with conceal extmarks, leaving +--- the label visible and highlighted. `conceallevel=2` + an empty +--- `concealcursor` reveal the raw `[[id|Name]]` on the line the cursor is on, so +--- it stays directly editable. A bare `[[id]]` (briefly, before save→reload +--- canonicalises it) just hides its brackets. + +local M = {} + +local ns = vim.api.nvim_create_namespace("heph_link_conceal") + +--- Recompute conceal extmarks for every `[[…]]` span in `buf`. +function M.refresh(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + for lnum, line in ipairs(lines) do + local row = lnum - 1 + local from = 1 + while true do + local s = line:find("[[", from, true) -- 1-based byte of first `[` + if not s then + break + end + local e = line:find("]]", s + 2, true) -- 1-based byte of `]]` + if not e then + break + end + local inner = line:sub(s + 2, e - 1) + local pipe = inner:find("|", 1, true) + -- Byte columns are 0-based for extmarks; end_col is exclusive. + local open_end -- exclusive end of the hidden prefix (`[[` or `[[id|`) + if pipe then + open_end = (s + 1 + pipe) -- 0-based col just past the `|` + else + open_end = s + 1 -- 0-based col just past `[[` + end + -- Hide the prefix, highlight the visible label, hide the closing `]]`. + vim.api.nvim_buf_set_extmark(buf, ns, row, s - 1, { end_col = open_end, conceal = "" }) + vim.api.nvim_buf_set_extmark(buf, ns, row, open_end, { end_col = e - 1, hl_group = "HephLink" }) + vim.api.nvim_buf_set_extmark(buf, ns, row, e - 1, { end_col = e + 1, conceal = "" }) + from = e + 2 + end + end +end + +--- Enable link conceal for the current window + `buf`: define the highlight, +--- set the window conceal options, refresh now, and refresh on edits. +function M.attach(buf) + vim.api.nvim_set_hl(0, "HephLink", { link = "Underlined", default = true }) + vim.wo.conceallevel = 2 + vim.wo.concealcursor = "" -- reveal the raw link on the cursor's line + M.refresh(buf) + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = buf, + callback = function() + M.refresh(buf) + end, + desc = "heph: refresh [[link]] conceal", + }) +end + +return M diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index bbff461..8bcec36 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -94,8 +94,16 @@ function M.insert() if not choice then return end - local id = choice.__create and rpc.call("node.create", { kind = "doc", title = choice.title }).id or choice.id - vim.api.nvim_put({ "[[" .. id .. "]]" }, "c", true, true) + local id, title + if choice.__create then + local node = rpc.call("node.create", { kind = "doc", title = choice.title }) + id, title = node.id, node.title + else + id, title = choice.id, choice.title + end + -- Insert the labelled form `[[id|Name]]` (readable + conceal-ready); it + -- collapses to the canonical bare `[[id]]` on save (§8.4). + vim.api.nvim_put({ "[[" .. id .. "|" .. title .. "]]" }, "c", true, true) end) end) end diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua index 6bd9f2c..a986a13 100644 --- a/heph.nvim/lua/heph/node.lua +++ b/heph.nvim/lua/heph/node.lua @@ -34,6 +34,7 @@ function M.read(buf, uri) vim.bo[buf].fileformat = "unix" vim.bo[buf].modified = false require("heph.link").attach(buf) + require("heph.conceal").attach(buf) end --- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the body. diff --git a/heph.nvim/tests/e2e/link_insert_spec.lua b/heph.nvim/tests/e2e/link_insert_spec.lua index 9a5a8ef..7ad590f 100644 --- a/heph.nvim/tests/e2e/link_insert_spec.lua +++ b/heph.nvim/tests/e2e/link_insert_spec.lua @@ -54,6 +54,28 @@ describe("link insert picker", function() assert.is_true(linked, "expected a wiki link from src to the picked node") end) + it("conceals a link's id, leaving the name as a styled label", function() + local target = h.create_doc("Roofing", "") + -- A bare canonical link in the source; node.get expands it to [[id|Roofing]]. + local src = h.create_doc("Daily", "see [[" .. target.id .. "]]") + local buf = h.open(src.id) + + -- The expanded link is visible in the buffer... + assert.is_truthy(h.find(buf, "%[%[" .. target.id .. "|Roofing%]%]"), "expanded link missing") + -- ...and conceal extmarks hide the `[[id|` prefix + `]]` suffix. + local ns = vim.api.nvim_get_namespaces()["heph_link_conceal"] + assert.is_truthy(ns, "conceal namespace not registered") + local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + local conceals = 0 + for _, m in ipairs(marks) do + if m[4] and m[4].conceal == "" then + conceals = conceals + 1 + end + end + assert.is_true(conceals >= 2, "expected prefix+suffix conceal extmarks, got " .. conceals) + assert.are.equal(2, vim.wo.conceallevel, "conceallevel not set for the buffer's window") + end) + it("creates a new doc when the Create entry is chosen", function() local src = h.create_doc("Notes", "") local buf = h.open(src.id)