diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 5c55db3..c60d54b 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. 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 keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). +- 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 pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over all your nodes** — type to narrow, Enter to insert, `` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. 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 keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - 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 31cd673..02cbc85 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,7 +334,7 @@ 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|Name]]` (labelled → readable + conceal-ready; collapses to bare on save); a **"+ Create new doc"** entry mints a `doc`. Follow (``) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over every node** (`node.list`) — type to narrow, `` inserts, **`` creates a doc named the current prompt** (a miss flows straight into "make it"). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. 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:** `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. diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 8bcec36..9195f36 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -68,11 +68,74 @@ function M.follow() require("heph.node").open(node.id) end ---- Pick a node (by full-text search) and insert a canonical `[[NODEID]]` link at ---- the cursor — the authoring path for wiki-links-by-id (§8.4); a node id is the ---- only thing that ever enters a stored link, so there's no name ambiguity. A ---- "Create" entry mints a new doc named after the query. No-op if cancelled. -function M.insert() +-- Insert the labelled form `[[id|Name]]` at the cursor (readable + conceal-ready; +-- it collapses to the canonical bare `[[id]]` on save, §8.4). +local function put_link(id, title) + vim.api.nvim_put({ "[[" .. id .. "|" .. title .. "]]" }, "c", true, true) +end + +-- Mint a doc named `title` and insert a link to it. +local function create_and_put(title) + if title and #title > 0 then + local node = rpc.call("node.create", { kind = "doc", title = title }) + put_link(node.id, node.title) + end +end + +-- Telescope is available and not explicitly disabled (tests force ui.select). +local function use_telescope() + return not vim.g.heph_force_ui_select and pcall(require, "telescope") +end + +-- A live, fuzzy-filtered Telescope picker over every node: type to narrow, +-- inserts the highlighted node, creates a doc named the current +-- prompt text (so a miss flows straight into "make it"). Telescope only. +local function telescope_insert() + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + + local nodes = rpc.call("node.list", vim.empty_dict()) or {} + pickers + .new({}, { + prompt_title = "Link to node ( = create from prompt)", + finder = finders.new_table({ + results = nodes, + entry_maker = function(n) + return { + value = n, + display = n.title .. " [" .. (n.kind or "node") .. "]", + ordinal = n.title, + } + end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(bufnr, map) + actions.select_default:replace(function() + local entry = action_state.get_selected_entry() + actions.close(bufnr) + if entry then + put_link(entry.value.id, entry.value.title) + end + end) + local create = function() + local title = action_state.get_current_line() + actions.close(bufnr) + create_and_put(title) + end + map("i", "", create) + map("n", "", create) + return true + end, + }) + :find() +end + +-- Fallback when Telescope isn't available: prompt a query, search, then pick +-- from the results (or a "+ Create" entry). `vim.ui.select` can't filter live. +local function uiselect_insert() vim.ui.input({ prompt = "Link to: " }, function(query) if not query or query == "" then return @@ -93,21 +156,26 @@ function M.insert() }, function(choice) if not choice then return - end - local id, title - if choice.__create then - local node = rpc.call("node.create", { kind = "doc", title = choice.title }) - id, title = node.id, node.title + elseif choice.__create then + create_and_put(choice.title) else - id, title = choice.id, choice.title + put_link(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 +--- Insert a canonical `[[NODEID|Name]]` link at the cursor (§8.4) by picking a +--- node — live-filtered via Telescope when available, else a search-then-select +--- prompt. A node id is the only thing that ever enters a stored link. +function M.insert() + if use_telescope() then + telescope_insert() + else + uiselect_insert() + end +end + --- Attach the buffer-local follow/insert keymaps and inline-`#hashtag` --- highlighting (only on heph:// buffers). function M.attach(buf)