Files
nib-v1/internal/tui/detail.go
T
lerko 1e58433936
CI / test (pull_request) Successful in 2m27s
feat(db): add wiki-link extraction, resolution, and backlinks
[[wiki-links]] in entry bodies are extracted at save time, resolved
to entity IDs (title match first, body substring fallback), and
stored in entity_links junction table. Backlinks surface in TUI
detail view showing entries that link to the current entry.

Schema migration v5 adds entity_links with CASCADE/SET NULL
semantics. Links sync on Create, Update, and Absorb.
2026-05-21 13:34:56 -04:00

302 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type detailMode int
const (
detailPreview detailMode = iota
detailRun
detailFill
)
type detailModel struct {
entity *db.Entity
backlinks []db.Backlink
scroll int
height int
width int
mode detailMode
run runModel
fill fillModel
}
func newDetailModel() detailModel {
return detailModel{}
}
func (d *detailModel) setEntity(e *db.Entity) {
d.entity = e
d.backlinks = nil
d.scroll = 0
d.mode = detailPreview
}
func (d *detailModel) setSize(width, height int) {
d.width = width
d.height = height
}
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
switch d.mode {
case detailRun:
d.run = d.run.update(msg.String())
return d, nil
case detailFill:
var cmd tea.Cmd
d.fill, cmd = d.fill.update(msg)
return d, cmd
default:
switch msg.String() {
case "up", "k":
if d.scroll > 0 {
d.scroll--
}
case "down", "j":
d.scroll++
case "pgdown", "ctrl+d":
d.scroll += d.height
case "pgup", "ctrl+u":
d.scroll -= d.height
if d.scroll < 0 {
d.scroll = 0
}
case "home", "g":
d.scroll = 0
case "end", "G":
d.scroll = 1<<31 - 1
}
return d, nil
}
}
func (d detailModel) view(width int) string {
switch d.mode {
case detailRun:
return d.run.view(width)
case detailFill:
return d.fill.view(width)
default:
return d.previewView(width)
}
}
func (d detailModel) previewView(width int) string {
if d.entity == nil {
return ""
}
e := d.entity
var b strings.Builder
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
if e.CardType != nil {
header += " " + affordanceStyle.Render(string(*e.CardType))
}
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
if e.Title != nil {
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
b.WriteString("\n")
}
bodyWidth := width - 4
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath(glamourStyle()),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(detailBodyStyle.Render(rendered))
b.WriteString("\n")
if e.CardType != nil {
cardSection := renderCardData(e)
if cardSection != "" {
b.WriteString("\n")
b.WriteString(cardSection)
}
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
}
b.WriteString("\n")
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
b.WriteString("\n")
}
if len(d.backlinks) > 0 {
b.WriteString("\n")
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
b.WriteString("\n")
for _, bl := range d.backlinks {
label := bl.Body
if bl.Title != nil {
label = *bl.Title
} else if len(label) > 40 {
label = label[:40] + "…"
}
line := " " + backlinkStyle.Render(label)
if bl.LinkText != "" {
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
}
b.WriteString(line + "\n")
}
}
b.WriteString("\n")
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
if e.ModifiedAt != e.CreatedAt {
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
}
if e.TimeAnchor != nil {
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
}
if e.Pinned {
meta += "\n" + pinnedStyle.Render("pinned")
}
if e.CardType != nil {
meta += fmt.Sprintf("\ncard %s", *e.CardType)
}
if e.UseCount > 0 {
meta += fmt.Sprintf("\nused %d×", e.UseCount)
}
if e.CompletedAt != nil {
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
}
b.WriteString(idStyle.Render(meta))
lines := strings.Split(b.String(), "\n")
totalLines := len(lines)
maxScroll := totalLines - d.height
if maxScroll < 0 {
maxScroll = 0
}
scroll := d.scroll
if scroll > maxScroll {
scroll = maxScroll
}
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
lines[0] = lines[0] + indicator
}
if scroll > 0 && scroll < totalLines {
lines = lines[scroll:]
}
if d.height > 0 && len(lines) > d.height {
lines = lines[:d.height]
}
return strings.Join(lines, "\n")
}
func renderCardData(e *db.Entity) string {
if e.CardData == nil {
return ""
}
data, err := e.CardDataJSON()
if err != nil || data == nil {
return ""
}
var b strings.Builder
switch *e.CardType {
case db.CardChecklist:
steps, ok := data["steps"].([]interface{})
if !ok {
break
}
done := 0
for _, s := range steps {
step, ok := s.(map[string]interface{})
if !ok {
continue
}
text, _ := step["text"].(string)
isDone, _ := step["done"].(bool)
if isDone {
done++
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
} else {
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
}
}
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
b.WriteString(detailLabelStyle.Render(progress))
b.WriteString(" " + helpStyle.Render("r:run"))
case db.CardTemplate:
slots, ok := data["slots"].([]interface{})
if !ok {
break
}
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
for _, s := range slots {
slot, ok := s.(map[string]interface{})
if !ok {
continue
}
name, _ := slot["name"].(string)
def, _ := slot["default"].(string)
line := " ${" + name + "}"
if def != "" {
line += " " + detailValueStyle.Render("default: "+def)
}
b.WriteString(line + "\n")
}
b.WriteString(" " + helpStyle.Render("f:fill"))
case db.CardDecision:
if chose, ok := data["chose"].(string); ok && chose != "" {
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
}
if why, ok := data["why"].(string); ok && why != "" {
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
}
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
items := make([]string, 0, len(rejected))
for _, r := range rejected {
if s, ok := r.(string); ok {
items = append(items, s)
}
}
if len(items) > 0 {
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
}
}
case db.CardLink:
if url, ok := data["url"].(string); ok && url != "" {
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
}
}
return b.String()
}