feat: node.linkable — first-class link targets for the [[ picker (§8.4)
Some checks failed
Build / validate (pull_request) Failing after 8s

The picker listed every node, so each task showed up twice (itself + its
same-titled canonical-context doc) plus tag/log noise — and the new
preview made the duplicates look identical. New `Store::list_linkable_nodes`
/ `node.linkable` returns non-tombstoned nodes minus `tag`s and the docs
that are a task's canonical-context or log attachment (you link the task,
not its body). The Telescope picker now sources from it.

Tests: a socket test (5 nodes → 2 linkable: task + standalone doc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 13:15:29 -07:00
commit 2fc48a1aa9
9 changed files with 57 additions and 3 deletions

View file

@ -289,6 +289,10 @@ impl Store for LocalStore {
nodes::list(&self.conn, &self.owner_id, kind) nodes::list(&self.conn, &self.owner_id, kind)
} }
fn list_linkable_nodes(&self) -> Result<Vec<Node>> {
nodes::list_linkable(&self.conn, &self.owner_id)
}
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> { fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
let now = self.clock.now_ms(); let now = self.clock.now_ms();
nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date)

View file

@ -407,6 +407,23 @@ pub(super) fn list(conn: &Connection, owner: &str, kind: Option<NodeKind>) -> Re
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
/// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes, minus
/// the internal noise — `tag` nodes, and the `doc` nodes that are a task's
/// **canonical-context** or **log** attachment (you link the task, not its
/// auto-created body/log). Title-sorted, for the `[[` picker.
pub(super) fn list_linkable(conn: &Connection, owner: &str) -> Result<Vec<Node>> {
let sql = format!(
"SELECT {COLUMNS} FROM nodes
WHERE owner_id = ?1 AND tombstoned = 0 AND kind != 'tag'
AND id NOT IN (SELECT dst_id FROM links
WHERE type IN ('canonical-context', 'log-of') AND tombstoned = 0)
ORDER BY title COLLATE NOCASE"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([owner], from_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// A node's aliases (wiki-link names), sorted. Empty until aliases are written. /// A node's aliases (wiki-link names), sorted. Empty until aliases are written.
pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> { pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?;

View file

@ -46,6 +46,11 @@ pub trait Store {
/// (tech-spec §6 `node.list`). /// (tech-spec §6 `node.list`).
fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>>; fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>>;
/// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes
/// minus the internal noise — `tag` nodes and the `doc`s that are a task's
/// canonical-context or log attachment. Backs the `[[` picker.
fn list_linkable_nodes(&self) -> Result<Vec<Node>>;
/// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an /// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an
/// alias match first, then an exact, owner-scoped, non-tombstoned title /// alias match first, then an exact, owner-scoped, non-tombstoned title
/// match; `None` if nothing matches (an unresolved link is allowed, §5). /// match; `None` if nothing matches (an unresolved link is allowed, §5).

View file

@ -216,6 +216,10 @@ impl Store for RemoteStore {
self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) })) self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) }))
} }
fn list_linkable_nodes(&self) -> Result<Vec<Node>> {
self.call_as("node.linkable", json!({}))
}
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> { fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
self.call_as("journal.open_or_create", json!({ "date": date })) self.call_as("journal.open_or_create", json!({ "date": date }))
} }

View file

@ -324,6 +324,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: NewNode = parse(params)?; let p: NewNode = parse(params)?;
json!(store.create_node(p)?) json!(store.create_node(p)?)
} }
"node.linkable" => json!(store.list_linkable_nodes()?),
"node.update" => { "node.update" => {
let p: UpdateParams = parse(params)?; let p: UpdateParams = parse(params)?;
json!(store.update_node(&p.id, p.title, p.body)?) json!(store.update_node(&p.id, p.title, p.body)?)

View file

@ -274,6 +274,28 @@ fn tag_add_list_remove_over_socket() {
); );
} }
#[test]
fn node_linkable_excludes_context_docs_logs_and_tags() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
// task.create → a task node + a same-titled canonical-context doc.
let task = c.call("task.create", json!({ "title": "Fix roof" })).unwrap();
let task_id = task["node_id"].as_str().unwrap().to_string();
// a standalone doc, a tag node, and a log doc (first append).
c.call("node.create", json!({ "kind": "doc", "title": "Notes" })).unwrap();
c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })).unwrap();
c.call("log.append", json!({ "task_id": task_id, "text": "started" })).unwrap();
// Only first-class targets: the task and the standalone doc — not the
// context doc, the log doc, or the tag (5 nodes total ⇒ 2 linkable).
let nodes = c.call("node.linkable", json!({})).unwrap();
let arr = nodes.as_array().unwrap();
assert_eq!(arr.len(), 2, "expected just the task + standalone doc:\n{arr:#?}");
assert!(arr.iter().any(|n| n["title"] == "Fix roof" && n["kind"] == "task"));
assert!(arr.iter().any(|n| n["title"] == "Notes" && n["kind"] == "doc"));
}
#[test] #[test]
fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() {
let (socket, _dir) = spawn_daemon(); let (socket, _dir) = spawn_daemon();

View file

@ -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` 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-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. - `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.
- 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 with a preview pane** (the node's body, or a task's context doc) — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) 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 your linkable nodes with a preview pane** (the node's body, or a task's context doc) — the list shows first-class targets only (a task appears once; its internal context/log docs and tag nodes are hidden, via the new `node.linkable` query) — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) 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 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). - 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. - 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.

View file

@ -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**. > **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). - ✅ **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 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`) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (`<CR>`) 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 the linkable nodes** (`node.linkable` — non-tombstoned nodes minus `tag`s and the `doc`s that are a task's canonical-context/log attachment, so a task appears once, not twice) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. 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. - **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.)* - ✅ **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. - ✅ **`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.

View file

@ -117,7 +117,8 @@ local function telescope_insert()
local action_state = require("telescope.actions.state") local action_state = require("telescope.actions.state")
local previewers = require("telescope.previewers") local previewers = require("telescope.previewers")
local nodes = rpc.call("node.list", vim.empty_dict()) or {} -- First-class targets only (excludes tags + tasks' context/log docs, §8.4).
local nodes = rpc.call("node.linkable", vim.empty_dict()) or {}
pickers pickers
.new({}, { .new({}, {
prompt_title = "Link to node (<C-x> = create from prompt)", prompt_title = "Link to node (<C-x> = create from prompt)",