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)
}
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> {
let now = self.clock.now_ms();
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<_>>>()?)
}
/// 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.
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")?;

View file

@ -46,6 +46,11 @@ pub trait Store {
/// (tech-spec §6 `node.list`).
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
/// alias match first, then an exact, owner-scoped, non-tombstoned title
/// 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()) }))
}
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> {
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)?;
json!(store.create_node(p)?)
}
"node.linkable" => json!(store.list_linkable_nodes()?),
"node.update" => {
let p: UpdateParams = parse(params)?;
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]
fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() {
let (socket, _dir) = spawn_daemon();