From 2684eb1d24cd5c21423b44b69290cb5d78ea5ef7 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 21 May 2026 14:03:09 -0400 Subject: [PATCH 1/2] feat(tui): add link picker and navigation history 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. --- internal/db/links.go | 20 ++++++++++++ internal/db/links_test.go | 47 +++++++++++++++++++++++++++ internal/tui/commands.go | 14 ++++++++ internal/tui/help.go | 2 ++ internal/tui/linkpicker.go | 65 ++++++++++++++++++++++++++++++++++++++ internal/tui/model.go | 53 +++++++++++++++++++++++++++++++ 6 files changed, 201 insertions(+) create mode 100644 internal/tui/linkpicker.go diff --git a/internal/db/links.go b/internal/db/links.go index a3722eb..d5d1d2f 100644 --- a/internal/db/links.go +++ b/internal/db/links.go @@ -55,6 +55,26 @@ func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body 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) { rows, err := s.db.QueryContext(ctx, ` SELECT e.id, e.title, e.body, el.link_text diff --git a/internal/db/links_test.go b/internal/db/links_test.go index 2877569..e1665f4 100644 --- a/internal/db/links_test.go +++ b/internal/db/links_test.go @@ -223,3 +223,50 @@ func TestSyncLinks_DeletedSourceHidden(t *testing.T) { 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) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 6f591c8..536f872 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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) diff --git a/internal/tui/help.go b/internal/tui/help.go index 20fae7f..bdba9f7 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -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"}, diff --git a/internal/tui/linkpicker.go b/internal/tui/linkpicker.go new file mode 100644 index 0000000..577fa73 --- /dev/null +++ b/internal/tui/linkpicker.go @@ -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() +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 530bcf7..e0b21a3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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() -- 2.52.0 From 4517b2e37cbcbc812dc177a5907340be1cbac4e0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 21 May 2026 14:12:28 -0400 Subject: [PATCH 2/2] feat(tui): include backlinks in link picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/tui/commands.go | 10 ++++++ internal/tui/linkpicker.go | 69 +++++++++++++++++++++++++++++++------- internal/tui/model.go | 11 +++--- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 536f872..a4dab85 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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 { return func() tea.Msg { tags, err := store.ListTags(context.Background(), false) diff --git a/internal/tui/linkpicker.go b/internal/tui/linkpicker.go index 577fa73..11d6bf6 100644 --- a/internal/tui/linkpicker.go +++ b/internal/tui/linkpicker.go @@ -3,25 +3,52 @@ 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 { - links []string + items []linkItem cursor int } -func newLinkPicker(body string) linkPickerModel { - return linkPickerModel{ - links: link.ExtractLinks(body), +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() string { - if len(lp.links) == 0 || lp.cursor >= len(lp.links) { - return "" +func (lp linkPickerModel) selected() linkItem { + if len(lp.items) == 0 || lp.cursor >= len(lp.items) { + return linkItem{} } - return lp.links[lp.cursor] + return lp.items[lp.cursor] } func (lp linkPickerModel) update(key string) linkPickerModel { @@ -31,7 +58,7 @@ func (lp linkPickerModel) update(key string) linkPickerModel { lp.cursor-- } case "down", "j": - if lp.cursor < len(lp.links)-1 { + if lp.cursor < len(lp.items)-1 { lp.cursor++ } } @@ -42,15 +69,31 @@ 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")) + 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() } - for i, lt := range lp.links { - label := "[[" + lt + "]]" + 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 { diff --git a/internal/tui/model.go b/internal/tui/model.go index e0b21a3..3c163e4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -860,7 +860,7 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "[": 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 return m, nil } @@ -964,14 +964,17 @@ func (m model) updateLinkPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = stateDetail return m, nil case "enter": - lt := m.linkPicker.selected() - if lt == "" { + 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) } - 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: m.linkPicker = m.linkPicker.update(msg.String()) return m, nil -- 2.52.0