hephaestus/heph.nvim/lua/heph/link.lua
Erich Blume 1737f8c266
Some checks failed
Build / validate (pull_request) Failing after 8s
feat(nvim): preview pane in the Telescope [[ link picker (§8.4)
Add a buffer previewer that shows the highlighted node's body as you
filter — for a task (no body of its own), it previews the canonical-
context doc instead. RPC-fed (heph nodes aren't files, unlike
obsidian.nvim's notes), pcall-guarded so a fetch miss just blanks the
preview. Reuses the global fzy_native sorter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:10:44 -07:00

226 lines
8 KiB
Lua

--- `[[wiki-link]]` parsing and following (tech-spec §8).
---
--- The cursor grammar mirrors `heph-core`'s `extract.rs`: a span `[[target]]`
--- or `[[target|display]]`, where the resolvable name is everything left of the
--- first `|`, trimmed. Resolution goes through `node.resolve` (exact, the same
--- mapping that materializes stored `wiki` links) — never fuzzy `search`, which
--- would mis-jump.
local rpc = require("heph.rpc")
local util = require("heph.util")
local M = {}
--- The wiki target under the cursor on the current line, or nil. Scans for the
--- `[[...]]` span that contains the cursor column.
function M.target_under_cursor()
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2] + 1 -- 1-based byte column
local from = 1
while true do
local open_s, open_e = line:find("[[", from, true)
if not open_s then
return nil
end
local close_s, close_e = line:find("]]", open_e + 1, true)
if not close_s then
return nil
end
if col >= open_s and col <= close_e then
local inner = line:sub(open_e + 1, close_s - 1)
local target = inner:match("^([^|]*)") or ""
target = target:gsub("^%s+", ""):gsub("%s+$", "")
return (#target > 0) and target or nil
end
from = close_e + 1
end
end
--- Follow the `[[link]]` under the cursor to its node, **creating** the target
--- doc if it doesn't exist yet (the zettelkasten follow-or-create gesture). The
--- newly-created doc resolves the source's previously-unresolved wiki-link, so
--- re-saving the source materializes the backlink.
function M.follow()
local target = M.target_under_cursor()
if not target then
util.notify("no [[link]] under cursor", vim.log.levels.WARN)
return
end
local node = rpc.call("node.resolve", { title = target })
if not node then
node = rpc.call("node.create", { kind = "doc", title = target, body = "" })
util.notify("created [[" .. target .. "]]")
-- Materialize the source's wiki-link to the new doc — it was unresolved when
-- the source was saved, so extraction skipped it (tech-spec §5). If the
-- source has unsaved edits, saving re-extracts and materializes it (and
-- persists the edits); otherwise add the link directly (a no-op re-save
-- wouldn't re-extract).
local src = vim.api.nvim_get_current_buf()
local src_id = vim.b[src].heph_node_id
if src_id then
if vim.bo[src].modified then
pcall(require("heph.node").write, src, vim.api.nvim_buf_get_name(src))
else
pcall(rpc.call, "links.add", { src = src_id, dst = node.id, link_type = "wiki" })
end
end
end
require("heph.node").open(node.id)
end
-- 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,
-- <CR> inserts the highlighted node, <C-x> creates a doc named the current
-- prompt text (so a miss flows straight into "make it"). Telescope only.
-- The markdown body to preview for a picker entry: a doc/journal's own body,
-- or — for a task (which has no body of its own) — its canonical-context doc.
local function preview_body(node)
local ok, body = pcall(function()
local id = node.id
if node.kind == "task" then
for _, l in ipairs(rpc.call("links.outgoing", { id = node.id }) or {}) do
if l.link_type == "canonical-context" then
id = l.dst_id
break
end
end
end
local fetched = rpc.call("node.get", { id = id })
return (fetched and fetched.body) or ""
end)
return vim.split(ok and body or "", "\n", { plain = true })
end
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 previewers = require("telescope.previewers")
local nodes = rpc.call("node.list", vim.empty_dict()) or {}
pickers
.new({}, {
prompt_title = "Link to node (<C-x> = 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({}),
previewer = previewers.new_buffer_previewer({
title = "Preview",
define_preview = function(self, entry)
vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, preview_body(entry.value))
vim.bo[self.state.bufnr].filetype = "markdown"
end,
}),
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", "<C-x>", create)
map("n", "<C-x>", 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
end
local items = {}
for _, hit in ipairs(rpc.call("search", { query = query }) or {}) do
items[#items + 1] = hit
end
items[#items + 1] = { __create = true, title = query }
require("heph.picker").select(items, {
prompt = "Link to",
format = function(it)
if it.__create then
return "+ Create new doc: " .. it.title
end
return it.title .. " [" .. (it.kind or "node") .. "]"
end,
}, function(choice)
if not choice then
return
elseif choice.__create then
create_and_put(choice.title)
else
put_link(choice.id, choice.title)
end
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)
vim.keymap.set("n", "<CR>", function()
M.follow()
end, { buffer = buf, desc = "heph: follow [[link]]" })
-- Typing `[[` opens the node picker (Obsidian-style), inserting `[[NODEID]]`.
vim.keymap.set("i", "[[", function()
M.insert()
end, { buffer = buf, desc = "heph: insert [[link]]" })
-- Render inline #hashtags in italics so they stand out — matching the
-- save-time tag detection (whitespace-prefixed `#word`, never a `# heading`).
-- `default = true` leaves a user's own `HephHashtag` definition intact.
vim.api.nvim_set_hl(0, "HephHashtag", { italic = true, default = true })
vim.api.nvim_buf_call(buf, function()
vim.cmd([[syntax match HephHashtag /\v%(^|\s)@<=#[0-9A-Za-z_-]+/ containedin=ALL]])
end)
end
return M