feat(tui): include backlinks in link picker
CI / test (pull_request) Successful in 2m25s

Link picker now shows both outgoing [[links]] and backlinks in
a unified list with section headers. Backlinks follow by entity
ID directly, outgoing links resolve by text. Navigating into a
backlink works the same as following an outgoing link — pushes
to nav stack, esc pops back.
This commit is contained in:
2026-05-21 14:12:28 -04:00
parent 2684eb1d24
commit 4517b2e37c
3 changed files with 73 additions and 17 deletions
+10
View File
@@ -222,6 +222,16 @@ func followLink(store *db.Store, linkText string) tea.Cmd {
} }
} }
func followLinkByID(store *db.Store, entityID string) tea.Cmd {
return func() tea.Msg {
entity, err := store.Get(context.Background(), entityID)
if err != nil {
return errMsg{err}
}
return linkFollowedMsg{entity}
}
}
func loadRailTags(store *db.Store) tea.Cmd { func loadRailTags(store *db.Store) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false) tags, err := store.ListTags(context.Background(), false)
+56 -13
View File
@@ -3,25 +3,52 @@ package tui
import ( import (
"strings" "strings"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/link" "github.com/lerko/nib/internal/link"
) )
type linkKind int
const (
linkOutgoing linkKind = iota
linkBacklink
)
type linkItem struct {
text string
entityID string
kind linkKind
}
type linkPickerModel struct { type linkPickerModel struct {
links []string items []linkItem
cursor int cursor int
} }
func newLinkPicker(body string) linkPickerModel { func newLinkPicker(body string, backlinks []db.Backlink) linkPickerModel {
return linkPickerModel{ var items []linkItem
links: link.ExtractLinks(body),
for _, lt := range link.ExtractLinks(body) {
items = append(items, linkItem{text: lt, kind: linkOutgoing})
} }
for _, bl := range backlinks {
label := bl.Body
if bl.Title != nil {
label = *bl.Title
} else if len(label) > 50 {
label = label[:50] + "…"
}
items = append(items, linkItem{text: label, entityID: bl.EntityID, kind: linkBacklink})
} }
func (lp linkPickerModel) selected() string { return linkPickerModel{items: items}
if len(lp.links) == 0 || lp.cursor >= len(lp.links) {
return ""
} }
return lp.links[lp.cursor]
func (lp linkPickerModel) selected() linkItem {
if len(lp.items) == 0 || lp.cursor >= len(lp.items) {
return linkItem{}
}
return lp.items[lp.cursor]
} }
func (lp linkPickerModel) update(key string) linkPickerModel { func (lp linkPickerModel) update(key string) linkPickerModel {
@@ -31,7 +58,7 @@ func (lp linkPickerModel) update(key string) linkPickerModel {
lp.cursor-- lp.cursor--
} }
case "down", "j": case "down", "j":
if lp.cursor < len(lp.links)-1 { if lp.cursor < len(lp.items)-1 {
lp.cursor++ lp.cursor++
} }
} }
@@ -42,15 +69,31 @@ func (lp linkPickerModel) view(width int) string {
var b strings.Builder var b strings.Builder
b.WriteString(titleStyle.Render("follow link") + "\n\n") b.WriteString(titleStyle.Render("follow link") + "\n\n")
if len(lp.links) == 0 { if len(lp.items) == 0 {
b.WriteString(hintDescStyle.Render(" no [[links]] in this entry")) b.WriteString(hintDescStyle.Render(" no links or backlinks"))
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(helpStyle.Render("esc:back")) b.WriteString(helpStyle.Render("esc:back"))
return b.String() return b.String()
} }
for i, lt := range lp.links { prevKind := linkKind(-1)
label := "[[" + lt + "]]" for i, item := range lp.items {
if item.kind != prevKind {
if item.kind == linkOutgoing {
b.WriteString(dateHeaderStyle.Render("── outgoing ──") + "\n")
} else {
b.WriteString(dateHeaderStyle.Render("── backlinks ──") + "\n")
}
prevKind = item.kind
}
var label string
if item.kind == linkOutgoing {
label = "[[" + item.text + "]]"
} else {
label = "← " + item.text
}
if i == lp.cursor { if i == lp.cursor {
b.WriteString(selectedItemStyle.Render(" " + label)) b.WriteString(selectedItemStyle.Render(" " + label))
} else { } else {
+7 -4
View File
@@ -860,7 +860,7 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "[": case "[":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
m.linkPicker = newLinkPicker(m.detail.entity.Body) m.linkPicker = newLinkPicker(m.detail.entity.Body, m.detail.backlinks)
m.state = stateLinkPicker m.state = stateLinkPicker
return m, nil return m, nil
} }
@@ -964,14 +964,17 @@ func (m model) updateLinkPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.state = stateDetail m.state = stateDetail
return m, nil return m, nil
case "enter": case "enter":
lt := m.linkPicker.selected() item := m.linkPicker.selected()
if lt == "" { if item.text == "" && item.entityID == "" {
return m, nil return m, nil
} }
if m.detail.entity != nil { if m.detail.entity != nil {
m.navStack = append(m.navStack, m.detail.entity.ID) m.navStack = append(m.navStack, m.detail.entity.ID)
} }
return m, followLink(m.store, lt) if item.kind == linkBacklink {
return m, followLinkByID(m.store, item.entityID)
}
return m, followLink(m.store, item.text)
default: default:
m.linkPicker = m.linkPicker.update(msg.String()) m.linkPicker = m.linkPicker.update(msg.String())
return m, nil return m, nil