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
+20
View File
@@ -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
+47
View File
@@ -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)
}
}