feat(nvim): conceal wiki-link ids to styled name hyperlinks (§8.4)

`conceal.lua` hides the `[[id|` prefix and `]]` suffix with conceal
extmarks (refreshed on edit), leaving the label as a styled `HephLink`;
`conceallevel=2` + empty `concealcursor` reveal the raw `[[id|Name]]` on
the cursor's line so it stays editable. The `[[` picker now inserts the
labelled `[[id|Name]]` form (readable + conceal-ready; collapses to bare
on save). e2e asserts the conceal extmarks + conceallevel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 12:35:24 -07:00
commit fd010a7066
6 changed files with 103 additions and 6 deletions

View file

@ -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 (`<CR>`) 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 (`<CR>`) 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 `↻`); `<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) — 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.