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:
+121
-5
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user