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
|
||||||
|
}
|
||||||
@@ -40,6 +40,15 @@ type entityDemotedMsg struct {
|
|||||||
|
|
||||||
type entityCopiedMsg struct{}
|
type entityCopiedMsg struct{}
|
||||||
|
|
||||||
|
type entityAbsorbedMsg struct {
|
||||||
|
targetID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type absorbSourcesLoadedMsg struct {
|
||||||
|
targetID string
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
type tagsLoadedMsg struct {
|
type tagsLoadedMsg struct {
|
||||||
tags []db.TagCount
|
tags []db.TagCount
|
||||||
}
|
}
|
||||||
@@ -207,3 +216,22 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
|||||||
return editorFinishedMsg{nil}
|
return editorFinishedMsg{nil}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(db.DefaultListParams())
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return absorbSourcesLoadedMsg{targetID, entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Absorb(targetID, sourceID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityAbsorbedMsg{targetID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ func renderHelp(width, height int) string {
|
|||||||
{"tab", "cycle intent (cards)"},
|
{"tab", "cycle intent (cards)"},
|
||||||
}},
|
}},
|
||||||
{"Actions", [][2]string{
|
{"Actions", [][2]string{
|
||||||
{"a", "add entity"},
|
{"a", "add entity (or ?query to search)"},
|
||||||
{"d", "delete (with confirm)"},
|
{"d", "delete (with confirm)"},
|
||||||
{"x", "toggle todo completion"},
|
{"x", "toggle todo completion"},
|
||||||
{"!", "toggle pin"},
|
{"!", "toggle pin"},
|
||||||
{"#", "filter by tag"},
|
{"#", "filter by tag"},
|
||||||
|
{"m", "absorb (merge into target)"},
|
||||||
{"p", "promote to card"},
|
{"p", "promote to card"},
|
||||||
}},
|
}},
|
||||||
{"Detail View", [][2]string{
|
{"Detail View", [][2]string{
|
||||||
|
|||||||
+17
-2
@@ -8,6 +8,13 @@ import (
|
|||||||
"github.com/lerko/nib/internal/parse"
|
"github.com/lerko/nib/internal/parse"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type inputResult struct {
|
||||||
|
entity *db.Entity
|
||||||
|
query bool
|
||||||
|
body string
|
||||||
|
tags []string
|
||||||
|
}
|
||||||
|
|
||||||
type inputModel struct {
|
type inputModel struct {
|
||||||
ti textinput.Model
|
ti textinput.Model
|
||||||
active bool
|
active bool
|
||||||
@@ -32,7 +39,7 @@ func (i *inputModel) reset() {
|
|||||||
i.ti.Blur()
|
i.ti.Blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i inputModel) submit() *db.Entity {
|
func (i inputModel) submit() *inputResult {
|
||||||
val := i.ti.Value()
|
val := i.ti.Value()
|
||||||
if val == "" {
|
if val == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -43,6 +50,14 @@ func (i inputModel) submit() *db.Entity {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parsed.Query {
|
||||||
|
return &inputResult{
|
||||||
|
query: true,
|
||||||
|
body: parsed.Body,
|
||||||
|
tags: parsed.FilterTags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
e := &db.Entity{
|
e := &db.Entity{
|
||||||
Body: parsed.Body,
|
Body: parsed.Body,
|
||||||
Title: parsed.Title,
|
Title: parsed.Title,
|
||||||
@@ -63,7 +78,7 @@ func (i inputModel) submit() *db.Entity {
|
|||||||
e.Description = parsed.Description
|
e.Description = parsed.Description
|
||||||
}
|
}
|
||||||
|
|
||||||
return e
|
return &inputResult{entity: e}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type keyMap struct {
|
|||||||
Cards key.Binding
|
Cards key.Binding
|
||||||
Sort key.Binding
|
Sort key.Binding
|
||||||
Intent key.Binding
|
Intent key.Binding
|
||||||
|
Absorb key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -52,4 +53,5 @@ var keys = keyMap{
|
|||||||
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||||
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||||
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
||||||
|
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-7
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
type listModel struct {
|
type listModel struct {
|
||||||
entities []*db.Entity
|
entities []*db.Entity
|
||||||
|
filtered []*db.Entity
|
||||||
cursor int
|
cursor int
|
||||||
offset int
|
offset int
|
||||||
height int
|
height int
|
||||||
@@ -25,6 +26,7 @@ func newListModel() listModel {
|
|||||||
|
|
||||||
func (l *listModel) setEntities(entities []*db.Entity) {
|
func (l *listModel) setEntities(entities []*db.Entity) {
|
||||||
l.entities = entities
|
l.entities = entities
|
||||||
|
l.filtered = nil
|
||||||
if l.cursor >= len(entities) {
|
if l.cursor >= len(entities) {
|
||||||
l.cursor = max(0, len(entities)-1)
|
l.cursor = max(0, len(entities)-1)
|
||||||
}
|
}
|
||||||
@@ -35,11 +37,19 @@ func (l *listModel) setSize(width, height int) {
|
|||||||
l.height = height
|
l.height = height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l listModel) displayEntities() []*db.Entity {
|
||||||
|
if l.filtered != nil {
|
||||||
|
return l.filtered
|
||||||
|
}
|
||||||
|
return l.entities
|
||||||
|
}
|
||||||
|
|
||||||
func (l listModel) selected() *db.Entity {
|
func (l listModel) selected() *db.Entity {
|
||||||
if len(l.entities) == 0 || l.cursor >= len(l.entities) {
|
ents := l.displayEntities()
|
||||||
|
if len(ents) == 0 || l.cursor >= len(ents) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return l.entities[l.cursor]
|
return ents[l.cursor]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l listModel) update(msg tea.KeyMsg) listModel {
|
func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||||
@@ -52,7 +62,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if l.cursor < len(l.entities)-1 {
|
if l.cursor < len(l.displayEntities())-1 {
|
||||||
l.cursor++
|
l.cursor++
|
||||||
visible := l.visibleCount()
|
visible := l.visibleCount()
|
||||||
if l.cursor >= l.offset+visible {
|
if l.cursor >= l.offset+visible {
|
||||||
@@ -63,7 +73,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
|||||||
l.cursor = 0
|
l.cursor = 0
|
||||||
l.offset = 0
|
l.offset = 0
|
||||||
case "end", "G":
|
case "end", "G":
|
||||||
l.cursor = max(0, len(l.entities)-1)
|
l.cursor = max(0, len(l.displayEntities())-1)
|
||||||
visible := l.visibleCount()
|
visible := l.visibleCount()
|
||||||
if l.cursor >= visible {
|
if l.cursor >= visible {
|
||||||
l.offset = l.cursor - visible + 1
|
l.offset = l.cursor - visible + 1
|
||||||
@@ -74,7 +84,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
|||||||
l.offset = l.cursor
|
l.offset = l.cursor
|
||||||
}
|
}
|
||||||
case "pgdown", "ctrl+d":
|
case "pgdown", "ctrl+d":
|
||||||
l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount())
|
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
|
||||||
visible := l.visibleCount()
|
visible := l.visibleCount()
|
||||||
if l.cursor >= l.offset+visible {
|
if l.cursor >= l.offset+visible {
|
||||||
l.offset = l.cursor - visible + 1
|
l.offset = l.cursor - visible + 1
|
||||||
@@ -84,11 +94,12 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l listModel) view(width int) string {
|
func (l listModel) view(width int) string {
|
||||||
if len(l.entities) == 0 {
|
ents := l.displayEntities()
|
||||||
|
if len(ents) == 0 {
|
||||||
return statusStyle.Render("no entities")
|
return statusStyle.Render("no entities")
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := groupByDate(l.entities)
|
groups := groupByDate(ents)
|
||||||
|
|
||||||
type displayLine struct {
|
type displayLine struct {
|
||||||
text string
|
text string
|
||||||
|
|||||||
+121
-5
@@ -17,6 +17,7 @@ const (
|
|||||||
stateTagFilter
|
stateTagFilter
|
||||||
stateConfirm
|
stateConfirm
|
||||||
statePromote
|
statePromote
|
||||||
|
stateAbsorb
|
||||||
)
|
)
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
@@ -69,11 +70,14 @@ type model struct {
|
|||||||
input inputModel
|
input inputModel
|
||||||
filter filterModel
|
filter filterModel
|
||||||
promote promoteModel
|
promote promoteModel
|
||||||
|
absorb absorbModel
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
|
||||||
filterTag string
|
filterTag string
|
||||||
confirmID string
|
confirmID string
|
||||||
cardsSort cardsSort
|
cardsSort cardsSort
|
||||||
|
searchQuery string
|
||||||
|
searchTags []string
|
||||||
|
|
||||||
status string
|
status string
|
||||||
err error
|
err error
|
||||||
@@ -118,6 +122,34 @@ func (m model) listParams() db.ListParams {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) hasSearch() bool {
|
||||||
|
return m.searchQuery != "" || len(m.searchTags) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) applySearch() {
|
||||||
|
if m.mode == modeCards {
|
||||||
|
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
||||||
|
m.cards.filtered = nil
|
||||||
|
var pinned, rest []*db.Entity
|
||||||
|
for _, e := range filtered {
|
||||||
|
if !matchesIntent(e, m.cards.intent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Pinned {
|
||||||
|
pinned = append(pinned, e)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.cards.filtered = append(pinned, rest...)
|
||||||
|
if m.cards.cursor >= len(m.cards.filtered) {
|
||||||
|
m.cards.cursor = max(0, len(m.cards.filtered)-1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.list.filtered = filterEntities(m.list.entities, m.searchQuery, m.searchTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -135,6 +167,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
m.list.setEntities(msg.entities)
|
m.list.setEntities(msg.entities)
|
||||||
}
|
}
|
||||||
|
if m.hasSearch() {
|
||||||
|
m.applySearch()
|
||||||
|
}
|
||||||
m.err = nil
|
m.err = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -169,6 +204,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = "copied"
|
m.status = "copied"
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case entityAbsorbedMsg:
|
||||||
|
m.status = "absorbed"
|
||||||
|
m.state = stateList
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
|
||||||
|
case absorbSourcesLoadedMsg:
|
||||||
|
m.absorb = newAbsorbModel(msg.targetID)
|
||||||
|
m.absorb.setSources(msg.entities)
|
||||||
|
m.absorb.setHeight(m.contentHeight())
|
||||||
|
m.state = stateAbsorb
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tagsLoadedMsg:
|
case tagsLoadedMsg:
|
||||||
m.filter.setTags(msg.tags)
|
m.filter.setTags(msg.tags)
|
||||||
m.state = stateTagFilter
|
m.state = stateTagFilter
|
||||||
@@ -204,6 +251,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateConfirm(msg)
|
return m.updateConfirm(msg)
|
||||||
case statePromote:
|
case statePromote:
|
||||||
return m.updatePromote(msg)
|
return m.updatePromote(msg)
|
||||||
|
case stateAbsorb:
|
||||||
|
return m.updateAbsorb(msg)
|
||||||
default:
|
default:
|
||||||
return m.updateKeys(msg)
|
return m.updateKeys(msg)
|
||||||
}
|
}
|
||||||
@@ -263,6 +312,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "tab":
|
case "tab":
|
||||||
if m.mode == modeCards && m.state == stateList {
|
if m.mode == modeCards && m.state == stateList {
|
||||||
m.cards.setIntent(m.cards.intent.next())
|
m.cards.setIntent(m.cards.intent.next())
|
||||||
|
if m.hasSearch() {
|
||||||
|
m.applySearch()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -279,6 +331,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateList
|
m.state = stateList
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if m.state == stateList && m.hasSearch() {
|
||||||
|
m.searchQuery = ""
|
||||||
|
m.searchTags = nil
|
||||||
|
m.status = ""
|
||||||
|
if m.mode == modeCards {
|
||||||
|
m.cards.applyFilter()
|
||||||
|
} else {
|
||||||
|
m.list.filtered = nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.state == stateList && m.filterTag != "" {
|
if m.state == stateList && m.filterTag != "" {
|
||||||
m.filterTag = ""
|
m.filterTag = ""
|
||||||
m.status = ""
|
m.status = ""
|
||||||
@@ -331,6 +394,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "m":
|
||||||
|
e := m.selectedEntity()
|
||||||
|
if e != nil {
|
||||||
|
if e.CardType != nil {
|
||||||
|
m.status = "target must be fluid"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, loadAbsorbSources(m.store, e.ID)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case "p":
|
case "p":
|
||||||
e := m.selectedEntity()
|
e := m.selectedEntity()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -387,8 +461,20 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.input.reset()
|
m.input.reset()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
if e := m.input.submit(); e != nil {
|
result := m.input.submit()
|
||||||
return m, createEntity(m.store, e)
|
if result == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if result.query {
|
||||||
|
m.searchQuery = result.body
|
||||||
|
m.searchTags = result.tags
|
||||||
|
m.state = stateList
|
||||||
|
m.input.reset()
|
||||||
|
m.applySearch()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if result.entity != nil {
|
||||||
|
return m, createEntity(m.store, result.entity)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -441,6 +527,23 @@ func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.state = stateList
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
source := m.absorb.selectedSource()
|
||||||
|
if source != nil {
|
||||||
|
return m, absorbEntity(m.store, m.absorb.targetID, source.ID)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
m.absorb = m.absorb.update(msg)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
return renderHelp(m.width, m.height)
|
return renderHelp(m.width, m.height)
|
||||||
@@ -460,6 +563,8 @@ func (m model) View() string {
|
|||||||
content = m.filter.view(m.width)
|
content = m.filter.view(m.width)
|
||||||
case statePromote:
|
case statePromote:
|
||||||
content = m.promote.view(m.width)
|
content = m.promote.view(m.width)
|
||||||
|
case stateAbsorb:
|
||||||
|
content = m.absorb.view(m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
header := m.headerView()
|
header := m.headerView()
|
||||||
@@ -481,6 +586,17 @@ func (m model) headerView() string {
|
|||||||
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.hasSearch() {
|
||||||
|
pill := "?"
|
||||||
|
if m.searchQuery != "" {
|
||||||
|
pill += m.searchQuery
|
||||||
|
}
|
||||||
|
for _, t := range m.searchTags {
|
||||||
|
pill += " #" + t
|
||||||
|
}
|
||||||
|
header += " " + searchPillStyle.Render(pill)
|
||||||
|
}
|
||||||
|
|
||||||
if m.mode == modeCards && m.cards.intent != intentAll {
|
if m.mode == modeCards && m.cards.intent != intentAll {
|
||||||
header += " " + affordanceStyle.Render(m.cards.intent.String())
|
header += " " + affordanceStyle.Render(m.cards.intent.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity {
|
||||||
|
if query == "" && len(tags) == 0 {
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
lowerTags := make([]string, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
lowerTags[i] = strings.ToLower(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*db.Entity
|
||||||
|
for _, e := range entities {
|
||||||
|
if !matchesSearch(e, query, lowerTags) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesSearch(e *db.Entity, query string, tags []string) bool {
|
||||||
|
if len(tags) > 0 {
|
||||||
|
eTags := make(map[string]bool, len(e.Tags))
|
||||||
|
for _, t := range e.Tags {
|
||||||
|
eTags[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
if !eTags[t] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
haystack := strings.ToLower(e.Body)
|
||||||
|
if e.Title != nil {
|
||||||
|
haystack += " " + strings.ToLower(*e.Title)
|
||||||
|
}
|
||||||
|
if e.Description != nil {
|
||||||
|
haystack += " " + strings.ToLower(*e.Description)
|
||||||
|
}
|
||||||
|
return strings.Contains(haystack, query)
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ func countText(m model) string {
|
|||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
total = len(m.cards.filtered)
|
total = len(m.cards.filtered)
|
||||||
} else {
|
} else {
|
||||||
total = len(m.list.entities)
|
total = len(m.list.displayEntities())
|
||||||
}
|
}
|
||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
||||||
@@ -47,10 +47,12 @@ func contextHints(m model) string {
|
|||||||
return "y:confirm n:cancel"
|
return "y:confirm n:cancel"
|
||||||
case statePromote:
|
case statePromote:
|
||||||
return "j/k:nav enter:select esc:cancel"
|
return "j/k:nav enter:select esc:cancel"
|
||||||
|
case stateAbsorb:
|
||||||
|
return "j/k:nav enter:absorb esc:cancel"
|
||||||
default:
|
default:
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
||||||
}
|
}
|
||||||
return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit"
|
return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,4 +101,8 @@ var (
|
|||||||
|
|
||||||
checkPendingStyle = lipgloss.NewStyle().
|
checkPendingStyle = lipgloss.NewStyle().
|
||||||
Foreground(dim)
|
Foreground(dim)
|
||||||
|
|
||||||
|
searchPillStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
|
||||||
|
Bold(true)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user