diff --git a/internal/tui/absorb.go b/internal/tui/absorb.go new file mode 100644 index 0000000..eb51ce3 --- /dev/null +++ b/internal/tui/absorb.go @@ -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 +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index ee98959..d93281e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -40,6 +40,15 @@ type entityDemotedMsg struct { type entityCopiedMsg struct{} +type entityAbsorbedMsg struct { + targetID string +} + +type absorbSourcesLoadedMsg struct { + targetID string + entities []*db.Entity +} + type tagsLoadedMsg struct { tags []db.TagCount } @@ -207,3 +216,22 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { 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} + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index bc50754..bfec228 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -21,11 +21,12 @@ func renderHelp(width, height int) string { {"tab", "cycle intent (cards)"}, }}, {"Actions", [][2]string{ - {"a", "add entity"}, + {"a", "add entity (or ?query to search)"}, {"d", "delete (with confirm)"}, {"x", "toggle todo completion"}, {"!", "toggle pin"}, {"#", "filter by tag"}, + {"m", "absorb (merge into target)"}, {"p", "promote to card"}, }}, {"Detail View", [][2]string{ diff --git a/internal/tui/input.go b/internal/tui/input.go index 485f937..8b46272 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -8,6 +8,13 @@ import ( "github.com/lerko/nib/internal/parse" ) +type inputResult struct { + entity *db.Entity + query bool + body string + tags []string +} + type inputModel struct { ti textinput.Model active bool @@ -32,7 +39,7 @@ func (i *inputModel) reset() { i.ti.Blur() } -func (i inputModel) submit() *db.Entity { +func (i inputModel) submit() *inputResult { val := i.ti.Value() if val == "" { return nil @@ -43,6 +50,14 @@ func (i inputModel) submit() *db.Entity { return nil } + if parsed.Query { + return &inputResult{ + query: true, + body: parsed.Body, + tags: parsed.FilterTags, + } + } + e := &db.Entity{ Body: parsed.Body, Title: parsed.Title, @@ -63,7 +78,7 @@ func (i inputModel) submit() *db.Entity { e.Description = parsed.Description } - return e + return &inputResult{entity: e} } func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 19b26fb..5fae286 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -26,6 +26,7 @@ type keyMap struct { Cards key.Binding Sort key.Binding Intent key.Binding + Absorb key.Binding } var keys = keyMap{ @@ -52,4 +53,5 @@ var keys = keyMap{ Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), + Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), } diff --git a/internal/tui/list.go b/internal/tui/list.go index 57462e7..6ba4a2c 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -13,6 +13,7 @@ import ( type listModel struct { entities []*db.Entity + filtered []*db.Entity cursor int offset int height int @@ -25,6 +26,7 @@ func newListModel() listModel { func (l *listModel) setEntities(entities []*db.Entity) { l.entities = entities + l.filtered = nil if l.cursor >= len(entities) { l.cursor = max(0, len(entities)-1) } @@ -35,11 +37,19 @@ func (l *listModel) setSize(width, height int) { 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 { - if len(l.entities) == 0 || l.cursor >= len(l.entities) { + ents := l.displayEntities() + if len(ents) == 0 || l.cursor >= len(ents) { return nil } - return l.entities[l.cursor] + return ents[l.cursor] } func (l listModel) update(msg tea.KeyMsg) listModel { @@ -52,7 +62,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel { } } case "down", "j": - if l.cursor < len(l.entities)-1 { + if l.cursor < len(l.displayEntities())-1 { l.cursor++ visible := l.visibleCount() if l.cursor >= l.offset+visible { @@ -63,7 +73,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel { l.cursor = 0 l.offset = 0 case "end", "G": - l.cursor = max(0, len(l.entities)-1) + l.cursor = max(0, len(l.displayEntities())-1) visible := l.visibleCount() if l.cursor >= visible { l.offset = l.cursor - visible + 1 @@ -74,7 +84,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel { l.offset = l.cursor } 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() if l.cursor >= l.offset+visible { 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 { - if len(l.entities) == 0 { + ents := l.displayEntities() + if len(ents) == 0 { return statusStyle.Render("no entities") } - groups := groupByDate(l.entities) + groups := groupByDate(ents) type displayLine struct { text string diff --git a/internal/tui/model.go b/internal/tui/model.go index 90240ab..bd1be9a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -17,6 +17,7 @@ const ( stateTagFilter stateConfirm statePromote + stateAbsorb ) type viewMode int @@ -69,11 +70,14 @@ type model struct { input inputModel filter filterModel promote promoteModel + absorb absorbModel showHelp bool - filterTag string - confirmID string - cardsSort cardsSort + filterTag string + confirmID string + cardsSort cardsSort + searchQuery string + searchTags []string status string err error @@ -118,6 +122,34 @@ func (m model) listParams() db.ListParams { 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) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -135,6 +167,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.list.setEntities(msg.entities) } + if m.hasSearch() { + m.applySearch() + } m.err = nil return m, nil @@ -169,6 +204,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = "copied" 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: m.filter.setTags(msg.tags) m.state = stateTagFilter @@ -204,6 +251,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateConfirm(msg) case statePromote: return m.updatePromote(msg) + case stateAbsorb: + return m.updateAbsorb(msg) default: return m.updateKeys(msg) } @@ -263,6 +312,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab": if m.mode == modeCards && m.state == stateList { m.cards.setIntent(m.cards.intent.next()) + if m.hasSearch() { + m.applySearch() + } return m, nil } return m, nil @@ -279,6 +331,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = stateList 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 != "" { m.filterTag = "" m.status = "" @@ -331,6 +394,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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": e := m.selectedEntity() if e != nil { @@ -387,8 +461,20 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.input.reset() return m, nil case "enter": - if e := m.input.submit(); e != nil { - return m, createEntity(m.store, e) + result := m.input.submit() + 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 } @@ -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 { if m.showHelp { return renderHelp(m.width, m.height) @@ -460,6 +563,8 @@ func (m model) View() string { content = m.filter.view(m.width) case statePromote: content = m.promote.view(m.width) + case stateAbsorb: + content = m.absorb.view(m.width) } header := m.headerView() @@ -481,6 +586,17 @@ func (m model) headerView() string { 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 { header += " " + affordanceStyle.Render(m.cards.intent.String()) } diff --git a/internal/tui/search.go b/internal/tui/search.go new file mode 100644 index 0000000..abb2577 --- /dev/null +++ b/internal/tui/search.go @@ -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) +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index d102bf2..bfde093 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -27,7 +27,7 @@ func countText(m model) string { if m.mode == modeCards { total = len(m.cards.filtered) } else { - total = len(m.list.entities) + total = len(m.list.displayEntities()) } if 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" case statePromote: return "j/k:nav enter:select esc:cancel" + case stateAbsorb: + return "j/k:nav enter:absorb esc:cancel" default: if m.mode == modeCards { 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" } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index fbad368..9c896b8 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -101,4 +101,8 @@ var ( checkPendingStyle = lipgloss.NewStyle(). Foreground(dim) + + searchPillStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}). + Bold(true) )