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
+14
View File
@@ -59,6 +59,10 @@ type backlinksLoadedMsg struct {
backlinks []db.Backlink
}
type linkFollowedMsg struct {
entity *db.Entity
}
type tagsLoadedMsg struct {
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 {
return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false)
+2
View File
@@ -60,6 +60,8 @@ func renderHelp(width, height int) string {
{"!", "toggle pin"},
{"r", "run checklist"},
{"f", "fill template"},
{"[", "follow [[link]]"},
{"esc", "back (pops link history)"},
}},
{"Stumble", [][2]string{
{"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
stateAbsorb
stateStumble
stateLinkPicker
)
type viewMode int
@@ -90,6 +91,8 @@ type model struct {
stumble stumbleModel
showHelp bool
autocomplete autocompleteModel
linkPicker linkPickerModel
navStack []string
focus focusPane
splitDetail bool
@@ -293,6 +296,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case linkFollowedMsg:
m.detail.setEntity(msg.entity)
m.state = stateDetail
return m, loadBacklinks(m.store, msg.entity.ID)
case tagsLoadedMsg:
m.filter.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)
case stateStumble:
return m.updateStumble(msg)
case stateLinkPicker:
return m.updateLinkPicker(msg)
default:
return m.updateKeys(msg)
}
@@ -717,6 +727,19 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
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() {
m.state = stateList
m.splitDetail = true
@@ -835,6 +858,14 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
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":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
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
}
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) {
switch msg.String() {
case "esc", "q":
@@ -1045,6 +1096,8 @@ func (m model) View() string {
content = m.absorb.view(m.width)
case stateStumble:
content = m.stumble.view()
case stateLinkPicker:
content = m.linkPicker.view(m.width)
}
header := m.headerView()