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