Files
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

1239 lines
27 KiB
Go

package tui
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db"
)
const statusTimeout = 2 * time.Second
type viewState int
const (
stateList viewState = iota
stateDetail
stateTagFilter
stateConfirm
statePromote
stateAbsorb
stateStumble
)
type viewMode int
const (
modeStream viewMode = iota
modeCards
)
type cardsSort int
const (
sortNewest cardsSort = iota
sortOldest
sortMostUsed
)
func (s cardsSort) String() string {
switch s {
case sortOldest:
return "oldest"
case sortMostUsed:
return "most used"
default:
return "newest"
}
}
func (s cardsSort) next() cardsSort {
switch s {
case sortNewest:
return sortOldest
case sortOldest:
return sortMostUsed
default:
return sortNewest
}
}
type focusPane int
const (
focusCapture focusPane = iota
focusTagRail
focusList
focusDetail
)
type model struct {
store *db.Store
state viewState
mode viewMode
width int
height int
list listModel
cards cardsModel
detail detailModel
input inputModel
filter filterModel
promote promoteModel
absorb absorbModel
tagRail tagRailModel
stumble stumbleModel
showHelp bool
autocomplete autocompleteModel
focus focusPane
splitDetail bool
showTagRail bool
filterTag string
confirmID string
cardsSort cardsSort
searchQuery string
searchTags []string
queryDateFrom *string
queryDateTo *string
queryCardType *db.CardType
status string
statusSeq int
err error
}
func newModel(store *db.Store) model {
loadTheme()
applyTheme()
inp := newInputModel()
inp.ti.Focus()
return model{
store: store,
state: stateList,
mode: modeStream,
focus: focusCapture,
showTagRail: true,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: inp,
filter: newFilterModel(),
tagRail: newTagRailModel(),
}
}
func (m *model) setFocus(f focusPane) tea.Cmd {
m.focus = f
if f == focusCapture {
return m.input.ti.Focus()
}
m.input.ti.Blur()
return nil
}
func (m *model) setStatus(msg string) tea.Cmd {
m.statusSeq++
m.status = msg
return clearStatusAfter(statusTimeout, m.statusSeq)
}
func (m model) Init() tea.Cmd {
return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus())
}
func (m model) listParams() db.ListParams {
p := db.DefaultListParams()
if m.filterTag != "" {
p.Tag = &m.filterTag
}
if m.queryDateFrom != nil {
p.From = m.queryDateFrom
}
if m.queryDateTo != nil {
p.To = m.queryDateTo
}
if m.queryCardType != nil {
p.CardTypeFilter = m.queryCardType
}
if m.mode == modeCards {
p.CardsOnly = true
switch m.cardsSort {
case sortNewest:
p.Sort = "created"
p.Order = "desc"
case sortOldest:
p.Sort = "created"
p.Order = "asc"
case sortMostUsed:
p.Sort = "use_count"
p.Order = "desc"
}
}
return p
}
func (m model) hasSearch() bool {
return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
}
func (m *model) applySearch() {
if m.mode == modeCards {
searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
var intentFiltered []*db.Entity
for _, e := range searchFiltered {
if matchesIntent(e, m.cards.intent) {
intentFiltered = append(intentFiltered, e)
}
}
m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent)
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:
m.width = msg.Width
m.height = msg.Height
if !m.isSplit() && m.splitDetail {
m.state = stateDetail
m.splitDetail = false
m.focus = focusList
}
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
case entitiesLoadedMsg:
if m.mode == modeCards {
m.cards.setEntities(msg.entities)
} else {
m.list.setEntities(msg.entities)
}
if m.hasSearch() {
m.applySearch()
}
m.err = nil
return m, nil
case railTagsLoadedMsg:
m.tagRail.setTags(msg.tags)
m.tagRail.activeTag = m.filterTag
return m, nil
case entityCreatedMsg:
m.input.clearText()
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
case entityDeletedMsg:
m.state = stateList
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted"))
case entityUpdatedMsg:
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
case entityPromotedMsg:
if !m.stumble.done && len(m.stumble.entries) > 0 {
m.stumble.advance()
m.state = stateStumble
} else {
m.state = stateList
}
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
case entityDemotedMsg:
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
case entityCopiedMsg:
return m, m.setStatus("copied")
case entityAbsorbedMsg:
if !m.stumble.done && len(m.stumble.entries) > 0 {
m.stumble.advance()
m.state = stateStumble
} else {
m.state = stateList
}
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID)
m.absorb.setSources(msg.entities)
m.absorb.setHeight(m.contentHeight())
m.state = stateAbsorb
return m, nil
case stepsPersistedMsg:
m.detail.mode = detailPreview
return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved"))
case templateCopiedMsg:
m.detail.mode = detailPreview
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved"))
case backlinksLoadedMsg:
if m.detail.entity != nil {
m.detail.backlinks = msg.backlinks
}
return m, nil
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
case staleEntitiesLoadedMsg:
m.stumble = newStumbleModel()
m.stumble.setEntries(msg.entities)
m.stumble.setSize(m.width, m.contentHeight())
m.state = stateStumble
return m, nil
case stumbleActionMsg:
return m, m.setStatus(msg.action)
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
return m, m.reloadAfterEdit()
}
return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated"))
case confirmTimeoutMsg:
if m.state == stateConfirm {
m.state = stateList
m.confirmID = ""
}
return m, nil
case statusClearMsg:
if msg.seq == m.statusSeq {
m.status = ""
}
return m, nil
case errMsg:
m.err = msg.err
return m, nil
case tea.KeyMsg:
m.err = nil
switch m.state {
case stateTagFilter:
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
case stateAbsorb:
return m.updateAbsorb(msg)
case stateStumble:
return m.updateStumble(msg)
default:
return m.updateKeys(msg)
}
default:
if m.focus == focusCapture {
var cmd tea.Cmd
m.input.ti, cmd = m.input.ti.Update(msg)
return m, cmd
}
}
return m, nil
}
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showHelp {
if msg.String() == "?" || msg.String() == "esc" || msg.String() == "q" {
m.showHelp = false
}
return m, nil
}
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if m.focus == focusCapture {
return m.updateCapture(msg)
}
return m.updateBrowse(msg)
}
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
val := m.input.ti.Value()
if val == "" {
cmd := m.setFocus(focusList)
return m, cmd
}
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.queryDateFrom = result.dateFrom
m.queryDateTo = result.dateTo
m.queryCardType = result.cardType
m.input.clearText()
m.autocomplete.active = false
if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil {
cmd := m.setFocus(focusList)
return m, tea.Batch(cmd, loadEntities(m.store, m.listParams()))
}
m.applySearch()
cmd := m.setFocus(focusList)
return m, cmd
}
if result.entity != nil {
m.autocomplete.active = false
return m, createEntity(m.store, result.entity)
}
return m, nil
case "tab":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "esc":
if m.autocomplete.active {
m.autocomplete.active = false
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "up":
if m.autocomplete.active {
m.autocomplete.moveUp()
return m, nil
}
case "down":
if m.autocomplete.active {
m.autocomplete.moveDown()
return m, nil
}
}
m.input = m.input.updateKey(msg)
m.updateAutocompleteSuggestions()
return m, nil
}
func (m *model) updateAutocompleteSuggestions() {
val := m.input.ti.Value()
pos := m.input.ti.Position()
start, end, prefix, ok := tagTokenAtCursor(val, pos)
if !ok || prefix == "" {
m.autocomplete.active = false
return
}
suggestions := filterTagSuggestions(m.tagRail.tags, prefix)
if len(suggestions) == 0 {
m.autocomplete.active = false
return
}
m.autocomplete.suggestions = suggestions
m.autocomplete.prefix = prefix
m.autocomplete.tokenStart = start
m.autocomplete.tokenEnd = end
m.autocomplete.active = true
if m.autocomplete.cursor >= len(suggestions) {
m.autocomplete.cursor = 0
}
}
func (m *model) acceptAutocomplete() {
if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 {
return
}
selected := m.autocomplete.selected()
if selected == "" {
return
}
val := m.input.ti.Value()
newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:]
m.input.ti.SetValue(newVal)
m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1)
m.autocomplete.active = false
}
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Tag rail focus handling
if m.focus == focusTagRail && m.state == stateList {
switch msg.String() {
case "j", "k", "up", "down":
m.tagRail = m.tagRail.update(msg.String())
return m, nil
case "enter":
tag := m.tagRail.selectedTag()
if tag != "" {
if tag == m.filterTag {
m.filterTag = ""
m.tagRail.activeTag = ""
} else {
m.filterTag = tag
m.tagRail.activeTag = tag
}
m.focus = focusList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "l", "tab", "esc":
m.focus = focusList
return m, nil
case "ctrl+b":
m.showTagRail = false
m.focus = focusList
m.recalcSizes()
return m, nil
}
}
// ctrl+b toggle from any browse focus
if msg.String() == "ctrl+b" && m.state == stateList {
m.showTagRail = !m.showTagRail
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
}
if m.splitDetail && m.state == stateList {
switch msg.String() {
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "l":
if m.focus == focusList {
m.focus = focusDetail
return m, nil
}
case "h":
if m.focus == focusDetail {
m.focus = focusList
return m, nil
}
if m.focus == focusList && m.railVisible() {
m.focus = focusTagRail
return m, nil
}
case "esc":
if m.focus == focusDetail {
m.focus = focusList
return m, nil
}
m.splitDetail = false
m.recalcSizes()
return m, nil
}
if m.focus == focusDetail {
switch msg.String() {
case "j", "k", "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d":
var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg)
return m, cmd
case "c":
if m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.detail.entity != nil && m.detail.mode == detailPreview {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
case "p":
if m.detail.entity != nil && m.detail.entity.CardType == nil {
m.promote = newPromoteModel(m.detail.entity.ID, m.detail.entity.Body)
m.state = statePromote
m.splitDetail = false
m.recalcSizes()
return m, nil
}
return m, nil
case "D":
if m.detail.entity != nil && m.detail.entity.CardType != nil {
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "!":
if m.detail.entity != nil {
return m, pinEntity(m.store, m.detail.entity)
}
return m, nil
case "r":
if m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
m.detail.mode = detailRun
m.splitDetail = false
m.state = stateDetail
m.recalcSizes()
return m, nil
}
}
return m, nil
case "f":
if m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
m.detail.mode = detailFill
m.splitDetail = false
m.state = stateDetail
m.recalcSizes()
return m, m.detail.fill.ti.Focus()
}
}
return m, nil
}
}
}
switch msg.String() {
case "q":
if m.state == stateList {
return m, tea.Quit
}
return m, nil
case "?":
m.showHelp = true
return m, nil
case "1":
if m.mode != modeStream {
m.mode = modeStream
m.state = stateList
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "2":
if m.mode != modeCards {
m.mode = modeCards
m.state = stateList
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "s":
if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next()
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String()))
}
return m, nil
case "S":
if m.state == stateList {
return m, loadStaleEntities(m.store)
}
return m, nil
case "T":
t := cycleTheme()
return m, m.setStatus("theme: " + t.Name)
case "i":
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
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "h":
if m.state == stateList && m.railVisible() && m.focus == focusList {
m.focus = focusTagRail
return m, nil
}
return m, nil
case "a":
if m.state == stateList {
cmd := m.setFocus(focusCapture)
return m, cmd
}
case "esc":
if m.state == stateDetail {
if m.detail.mode == detailRun {
var cmd tea.Cmd
if m.detail.run.dirty {
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
}
m.detail.mode = detailPreview
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
}
return m, cmd
}
if m.detail.mode == detailFill {
m.detail.mode = detailPreview
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
}
return m, nil
}
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
return m, nil
}
m.state = stateList
return m, nil
}
if m.state == stateList && m.hasSearch() {
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
m.searchQuery = ""
m.searchTags = nil
m.queryDateFrom = nil
m.queryDateTo = nil
m.queryCardType = nil
m.status = ""
if hadDBFilters {
return m, loadEntities(m.store, m.listParams())
}
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 = ""
return m, loadEntities(m.store, m.listParams())
}
if m.state == stateList {
cmd := m.setFocus(focusCapture)
return m, cmd
}
return m, nil
case "d":
if m.state == stateList {
if e := m.selectedEntity(); e != nil {
m.confirmID = e.ID
m.state = stateConfirm
return m, confirmTimeout()
}
}
return m, nil
case "x":
if m.state == stateList {
if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo {
return m, toggleTodo(m.store, e)
}
}
return m, nil
case "!":
e := m.selectedEntity()
if e != nil {
return m, pinEntity(m.store, e)
}
return m, nil
case "#":
if m.state == stateList {
if m.filterTag != "" {
m.filterTag = ""
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, loadTags(m.store)
}
return m, nil
case "m":
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
return m, m.setStatus("target must be fluid")
}
return m, loadAbsorbSources(m.store, e.ID)
}
return m, nil
case "p":
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
return m, m.setStatus("already a card")
}
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
case "D":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil {
return m, m.setStatus("already fluid")
}
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "c":
if m.state == stateDetail && m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
case "r":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
m.detail.mode = detailRun
return m, nil
}
}
return m, nil
case "f":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
m.detail.mode = detailFill
return m, m.detail.fill.ti.Focus()
}
}
return m, nil
case "enter":
if m.state == stateDetail && m.detail.mode == detailFill {
m.detail.fill.commitActive()
resolved := m.detail.fill.resolve()
return m, copyResolved(m.store, m.detail.fill.entityID, resolved)
}
if m.state == stateList {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
if m.isSplit() {
m.splitDetail = true
m.focus = focusDetail
m.recalcSizes()
} else {
m.state = stateDetail
}
return m, loadBacklinks(m.store, e.ID)
}
}
return m, nil
}
switch m.state {
case stateList:
if m.mode == modeCards {
m.cards = m.cards.update(msg)
} else {
m.list = m.list.update(msg)
}
if m.splitDetail {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
}
}
case stateDetail:
var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg)
return m, cmd
}
return m, nil
}
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
tag := m.filter.selectedTag()
if tag != "" {
m.filterTag = tag
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
default:
m.filter = m.filter.update(msg.String())
return m, nil
}
}
func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
id := m.confirmID
m.confirmID = ""
m.state = stateList
if msg.String() == "y" && id != "" {
return m, deleteEntity(m.store, id)
}
return m, nil
}
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
ct := m.promote.selectedType()
return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body)
default:
m.promote = m.promote.update(msg.String())
return m, nil
}
}
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) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.stumble.done {
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case "n", "right":
m.stumble.advance()
if m.stumble.done {
return m, m.setStatus("all caught up")
}
return m, nil
case "d":
e := m.stumble.current()
if e != nil {
m.stumble.removeCurrent()
return m, stumbleDismiss(m.store, e.ID)
}
return m, nil
case "!":
e := m.stumble.current()
if e != nil {
m.stumble.advance()
return m, stumblePin(m.store, e.ID)
}
return m, nil
case "p":
e := m.stumble.current()
if e != nil {
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
case "m":
e := m.stumble.current()
if e != nil {
if e.CardType != nil {
return m, m.setStatus("target must be fluid")
}
return m, loadAbsorbSources(m.store, e.ID)
}
return m, nil
}
return m, nil
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
}
var content string
switch m.state {
case stateList, stateConfirm:
listContent := m.listContent()
if m.splitDetail {
lw, rw := m.splitWidths()
ch := m.contentHeight()
left := lipgloss.NewStyle().Width(lw).Height(ch).Render(listContent)
sep := m.renderSeparator()
right := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.detail.view(rw))
content = lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)
} else {
content = listContent
}
if m.railVisible() {
rw := m.railWidth()
ch := m.contentHeight()
rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail))
sep := m.renderSeparator()
content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content)
}
case stateDetail:
content = m.detail.view(m.width)
case stateTagFilter:
content = m.filter.view(m.width)
case statePromote:
content = m.promote.view(m.width)
case stateAbsorb:
content = m.absorb.view(m.width)
case stateStumble:
content = m.stumble.view()
}
header := m.headerView()
captureBar := m.input.viewBar(m.width, m.focus == focusCapture)
statusLine := m.statusLine()
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
acView := m.autocomplete.view(m.width)
if acView != "" {
return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine
}
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
}
func (m model) listWidth() int {
if m.splitDetail {
lw, _ := m.splitWidths()
return lw
}
w := m.width - m.railWidth()
if m.railVisible() {
w--
}
return w
}
func (m model) listContent() string {
lw := m.listWidth()
if m.mode == modeCards {
return m.cards.view(lw)
}
return m.list.view(lw)
}
func (m model) headerView() string {
header := titleStyle.Render("nib") + " "
header += renderTab("stream", "1", m.mode == modeStream)
header += " " + separatorStyle.Render("│") + " "
header += renderTab("cards", "2", m.mode == modeCards)
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
if m.hasSearch() {
pill := "?"
if m.searchQuery != "" {
pill += m.searchQuery
}
for _, t := range m.searchTags {
pill += " #" + t
}
if m.queryDateFrom != nil {
pill += " from:" + *m.queryDateFrom
}
if m.queryDateTo != nil {
pill += " to:" + *m.queryDateTo
}
if m.queryCardType != nil {
pill += " ^" + string(*m.queryCardType)
}
header += " " + searchPillStyle.Render(pill)
}
if m.mode == modeCards && m.cards.intent != intentAll {
header += " " + affordanceStyle.Render(m.cards.intent.String())
}
if m.mode == modeCards {
header += " " + idStyle.Render("("+m.cardsSort.String()+")")
}
return header
}
func (m model) statusLine() string {
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
if m.err != nil {
return errorStyle.Render("error: " + m.err.Error())
}
return renderStatusBar(m, m.width)
}
func (m model) contentHeight() int {
h := m.height - 4
if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
n := m.autocomplete.visibleCount()
if len(m.autocomplete.suggestions) > maxSuggestions {
n++
}
h -= n + 1
}
if h < 1 {
h = 1
}
return h
}
func (m *model) recalcSizes() {
ch := m.contentHeight()
lw := m.listWidth()
if m.isSplit() && m.splitDetail {
_, rw := m.splitWidths()
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch)
} else {
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(lw, ch)
}
m.filter.setHeight(ch)
if m.railVisible() {
m.tagRail.setSize(m.railWidth(), ch)
}
}
func (m model) isSplit() bool {
return m.width >= 100
}
func (m model) railVisible() bool {
return m.showTagRail && m.width >= 100
}
func (m model) railWidth() int {
if !m.railVisible() {
return 0
}
w := m.width * 18 / 100
if w < 16 {
w = 16
}
return w
}
func (m model) splitWidths() (int, int) {
avail := m.width - m.railWidth()
if m.railVisible() {
avail--
}
left := avail * 40 / 100
right := avail - left - 1
return left, right
}
func (m model) renderSeparator() string {
ch := m.contentHeight()
lines := make([]string, ch)
for i := range lines {
lines[i] = "│"
}
return separatorStyle.Render(strings.Join(lines, "\n"))
}
func (m model) selectedEntity() *db.Entity {
switch {
case m.state == stateDetail:
return m.detail.entity
case m.mode == modeCards:
return m.cards.selected()
default:
return m.list.selected()
}
}
func (m model) reloadDetail(id string) tea.Cmd {
return tea.Batch(
loadEntities(m.store, m.listParams()),
loadBacklinks(m.store, id),
func() tea.Msg {
e, err := m.store.Get(context.Background(), id)
if err != nil {
return errMsg{err}
}
return entityUpdatedMsg{e, ""}
},
)
}
func (m model) reloadAfterEdit() tea.Cmd {
if m.detail.entity == nil {
return loadEntities(m.store, m.listParams())
}
return m.reloadDetail(m.detail.entity.ID)
}