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
+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()