feat(tui): add link picker and navigation history
CI / test (pull_request) Successful in 2m18s

Press [ in detail view to open link picker showing all [[links]]
in the current entry. Enter follows a link, resolving by title
then body substring. Navigation history stack enables esc to pop
back through followed links before returning to list.

Adds Store.ResolveLink() for non-transactional link resolution
from the TUI layer.
This commit is contained in:
2026-05-21 14:03:09 -04:00
parent 8426c2fbc1
commit 2684eb1d24
6 changed files with 201 additions and 0 deletions
+20
View File
@@ -55,6 +55,26 @@ func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body
return nil return nil
} }
func (s *Store) ResolveLink(ctx context.Context, linkText string) (*Entity, error) {
lower := strings.ToLower(linkText)
var id string
err := s.db.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(title) = ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, lower).Scan(&id)
if err != nil {
err = s.db.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(body) LIKE ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%").Scan(&id)
}
if err != nil {
return nil, ErrNotFound
}
return s.Get(ctx, id)
}
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) { func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT e.id, e.title, e.body, el.link_text SELECT e.id, e.title, e.body, el.link_text
+47
View File
@@ -223,3 +223,50 @@ func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks)) t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
} }
} }
func TestResolveLink_TitleMatch(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "nginx config"
target := &Entity{Body: "proxy_pass details", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
resolved, err := s.ResolveLink(ctx, "nginx config")
if err != nil {
t.Fatal(err)
}
if resolved.ID != target.ID {
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
}
}
func TestResolveLink_BodyFallback(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "deploy staging checklist", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
resolved, err := s.ResolveLink(ctx, "deploy staging")
if err != nil {
t.Fatal(err)
}
if resolved.ID != target.ID {
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
}
}
func TestResolveLink_NotFound(t *testing.T) {
s := testStore(t)
ctx := context.Background()
_, err := s.ResolveLink(ctx, "nonexistent entry")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
+14
View File
@@ -59,6 +59,10 @@ type backlinksLoadedMsg struct {
backlinks []db.Backlink backlinks []db.Backlink
} }
type linkFollowedMsg struct {
entity *db.Entity
}
type tagsLoadedMsg struct { type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
@@ -208,6 +212,16 @@ func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
} }
} }
func followLink(store *db.Store, linkText string) tea.Cmd {
return func() tea.Msg {
entity, err := store.ResolveLink(context.Background(), linkText)
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)
+2
View File
@@ -60,6 +60,8 @@ func renderHelp(width, height int) string {
{"!", "toggle pin"}, {"!", "toggle pin"},
{"r", "run checklist"}, {"r", "run checklist"},
{"f", "fill template"}, {"f", "fill template"},
{"[", "follow [[link]]"},
{"esc", "back (pops link history)"},
}}, }},
{"Stumble", [][2]string{ {"Stumble", [][2]string{
{"n / →", "skip to next"}, {"n / →", "skip to next"},
+65
View File
@@ -0,0 +1,65 @@
package tui
import (
"strings"
"github.com/lerko/nib/internal/link"
)
type linkPickerModel struct {
links []string
cursor int
}
func newLinkPicker(body string) linkPickerModel {
return linkPickerModel{
links: link.ExtractLinks(body),
}
}
func (lp linkPickerModel) selected() string {
if len(lp.links) == 0 || lp.cursor >= len(lp.links) {
return ""
}
return lp.links[lp.cursor]
}
func (lp linkPickerModel) update(key string) linkPickerModel {
switch key {
case "up", "k":
if lp.cursor > 0 {
lp.cursor--
}
case "down", "j":
if lp.cursor < len(lp.links)-1 {
lp.cursor++
}
}
return lp
}
func (lp linkPickerModel) view(width int) string {
var b strings.Builder
b.WriteString(titleStyle.Render("follow link") + "\n\n")
if len(lp.links) == 0 {
b.WriteString(hintDescStyle.Render(" no [[links]] in this entry"))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("esc:back"))
return b.String()
}
for i, lt := range lp.links {
label := "[[" + lt + "]]"
if i == lp.cursor {
b.WriteString(selectedItemStyle.Render(" " + label))
} else {
b.WriteString(listItemStyle.Render(label))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("enter:follow esc:back"))
return b.String()
}
+53
View File
@@ -24,6 +24,7 @@ const (
statePromote statePromote
stateAbsorb stateAbsorb
stateStumble stateStumble
stateLinkPicker
) )
type viewMode int type viewMode int
@@ -90,6 +91,8 @@ type model struct {
stumble stumbleModel stumble stumbleModel
showHelp bool showHelp bool
autocomplete autocompleteModel autocomplete autocompleteModel
linkPicker linkPickerModel
navStack []string
focus focusPane focus focusPane
splitDetail bool splitDetail bool
@@ -293,6 +296,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case linkFollowedMsg:
m.detail.setEntity(msg.entity)
m.state = stateDetail
return m, loadBacklinks(m.store, msg.entity.ID)
case tagsLoadedMsg: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags) m.tagRail.setTags(msg.tags)
@@ -346,6 +354,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateAbsorb(msg) return m.updateAbsorb(msg)
case stateStumble: case stateStumble:
return m.updateStumble(msg) return m.updateStumble(msg)
case stateLinkPicker:
return m.updateLinkPicker(msg)
default: default:
return m.updateKeys(msg) return m.updateKeys(msg)
} }
@@ -717,6 +727,19 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if len(m.navStack) > 0 {
prevID := m.navStack[len(m.navStack)-1]
m.navStack = m.navStack[:len(m.navStack)-1]
return m, tea.Batch(
func() tea.Msg {
e, err := m.store.Get(context.Background(), prevID)
if err != nil {
return errMsg{err}
}
return linkFollowedMsg{e}
},
)
}
if m.isSplit() { if m.isSplit() {
m.state = stateList m.state = stateList
m.splitDetail = true m.splitDetail = true
@@ -835,6 +858,14 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case "[":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
m.linkPicker = newLinkPicker(m.detail.entity.Body)
m.state = stateLinkPicker
return m, nil
}
return m, nil
case "r": case "r":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist { if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
@@ -927,6 +958,26 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m model) updateLinkPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateDetail
return m, nil
case "enter":
lt := m.linkPicker.selected()
if lt == "" {
return m, nil
}
if m.detail.entity != nil {
m.navStack = append(m.navStack, m.detail.entity.ID)
}
return m, followLink(m.store, lt)
default:
m.linkPicker = m.linkPicker.update(msg.String())
return m, nil
}
}
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc", "q": case "esc", "q":
@@ -1045,6 +1096,8 @@ func (m model) View() string {
content = m.absorb.view(m.width) content = m.absorb.view(m.width)
case stateStumble: case stateStumble:
content = m.stumble.view() content = m.stumble.view()
case stateLinkPicker:
content = m.linkPicker.view(m.width)
} }
header := m.headerView() header := m.headerView()