feat(tui): add link picker and navigation history #45
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,26 @@ 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 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)
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"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 {
|
||||||
|
items []linkItem
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLinkPicker(body string, backlinks []db.Backlink) linkPickerModel {
|
||||||
|
var items []linkItem
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
return linkPickerModel{items: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if lp.cursor > 0 {
|
||||||
|
lp.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if lp.cursor < len(lp.items)-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.items) == 0 {
|
||||||
|
b.WriteString(hintDescStyle.Render(" no links or backlinks"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("esc:back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
prevKind := linkKind(-1)
|
||||||
|
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 {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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.detail.backlinks)
|
||||||
|
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,29 @@ 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":
|
||||||
|
item := m.linkPicker.selected()
|
||||||
|
if item.text == "" && item.entityID == "" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.detail.entity != nil {
|
||||||
|
m.navStack = append(m.navStack, m.detail.entity.ID)
|
||||||
|
}
|
||||||
|
if item.kind == linkBacklink {
|
||||||
|
return m, followLinkByID(m.store, item.entityID)
|
||||||
|
}
|
||||||
|
return m, followLink(m.store, item.text)
|
||||||
|
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 +1099,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user