feat(tui): add search via capture bar and absorb flow
Search uses existing parse grammar ?prefix — type `?query #tag` in capture bar to filter entities client-side. Substring match on body+title+description with AND tag filtering. Esc clears search. Absorb via m key on fluid entities — opens source picker showing all other entities, enter merges source into target. Uses existing store.Absorb() backend.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
type absorbModel struct {
|
||||
targetID string
|
||||
sources []*db.Entity
|
||||
cursor int
|
||||
offset int
|
||||
height int
|
||||
}
|
||||
|
||||
func newAbsorbModel(targetID string) absorbModel {
|
||||
return absorbModel{targetID: targetID}
|
||||
}
|
||||
|
||||
func (a *absorbModel) setSources(entities []*db.Entity) {
|
||||
a.sources = nil
|
||||
for _, e := range entities {
|
||||
if e.ID != a.targetID {
|
||||
a.sources = append(a.sources, e)
|
||||
}
|
||||
}
|
||||
a.cursor = 0
|
||||
a.offset = 0
|
||||
}
|
||||
|
||||
func (a *absorbModel) setHeight(h int) {
|
||||
a.height = h
|
||||
}
|
||||
|
||||
func (a absorbModel) selectedSource() *db.Entity {
|
||||
if len(a.sources) == 0 || a.cursor >= len(a.sources) {
|
||||
return nil
|
||||
}
|
||||
return a.sources[a.cursor]
|
||||
}
|
||||
|
||||
func (a absorbModel) update(msg tea.KeyMsg) absorbModel {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if a.cursor > 0 {
|
||||
a.cursor--
|
||||
if a.cursor < a.offset {
|
||||
a.offset = a.cursor
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if a.cursor < len(a.sources)-1 {
|
||||
a.cursor++
|
||||
visible := a.visibleCount()
|
||||
if a.cursor >= a.offset+visible {
|
||||
a.offset = a.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a absorbModel) view(width int) string {
|
||||
if len(a.sources) == 0 {
|
||||
return statusStyle.Render("no other entities")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("select source to merge"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
visible := a.visibleCount() - 4
|
||||
if visible <= 0 {
|
||||
visible = 10
|
||||
}
|
||||
end := min(a.offset+visible, len(a.sources))
|
||||
|
||||
for i := a.offset; i < end; i++ {
|
||||
e := a.sources[i]
|
||||
line := renderAbsorbSource(e, width-4)
|
||||
|
||||
if i == a.cursor {
|
||||
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||
} else {
|
||||
b.WriteString(listItemStyle.Render(line))
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a absorbModel) visibleCount() int {
|
||||
if a.height <= 0 {
|
||||
return 20
|
||||
}
|
||||
return a.height
|
||||
}
|
||||
|
||||
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
|
||||
var tags string
|
||||
if len(e.Tags) > 0 {
|
||||
limit := min(2, len(e.Tags))
|
||||
tagParts := make([]string, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
tagParts[i] = tagStyle.Render("#" + e.Tags[i])
|
||||
}
|
||||
tags = " " + strings.Join(tagParts, " ")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-20)
|
||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
Reference in New Issue
Block a user