feat(tui): add status bar, help overlay, tag filter, and entity actions

Status bar with entity count and context-sensitive key hints. Help
overlay via ? key. Tag filter via # with cursor-navigable tag list.
Todo toggle (x), pin (!), promote (p), demote (D), copy (c), edit (e)
via $EDITOR. Delete confirmation with 3s timeout. Date-grouped list
with completed todo and pinned indicators. Esc clears active tag filter.

Adds CompletedAt/ClearCompleted to EntityUpdate for todo toggling.
This commit is contained in:
2026-05-17 20:33:34 -04:00
parent 36999cd825
commit c2ea63dd16
10 changed files with 804 additions and 77 deletions
+155
View File
@@ -1,6 +1,11 @@
package tui
import (
"os"
"os/exec"
"time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
@@ -18,6 +23,29 @@ type entityDeletedMsg struct {
id string
}
type entityUpdatedMsg struct {
entity *db.Entity
action string
}
type entityPromotedMsg struct {
id string
}
type entityDemotedMsg struct {
id string
}
type entityCopiedMsg struct{}
type tagsLoadedMsg struct {
tags []db.TagCount
}
type editorFinishedMsg struct {
err error
}
type errMsg struct {
err error
}
@@ -49,3 +77,130 @@ func deleteEntity(store *db.Store, id string) tea.Cmd {
return entityDeletedMsg{id}
}
}
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
var update db.EntityUpdate
if e.CompletedAt == nil {
now := time.Now().UTC()
update = db.EntityUpdate{CompletedAt: &now}
} else {
update = db.EntityUpdate{ClearCompleted: true}
}
if err := store.Update(e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(e.ID)
if err != nil {
return errMsg{err}
}
action := "completed"
if e.CompletedAt != nil {
action = "reopened"
}
return entityUpdatedMsg{updated, action}
}
}
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
newPinned := !e.Pinned
update := db.EntityUpdate{Pinned: &newPinned}
if err := store.Update(e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(e.ID)
if err != nil {
return errMsg{err}
}
action := "pinned"
if !newPinned {
action = "unpinned"
}
return entityUpdatedMsg{updated, action}
}
}
func promoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Promote(id, db.CardSnippet, nil); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id}
}
}
func demoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Demote(id); err != nil {
return errMsg{err}
}
return entityDemotedMsg{id}
}
}
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(e.Body); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(e.ID); err != nil {
return errMsg{err}
}
return entityCopiedMsg{}
}
}
func loadTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(false)
if err != nil {
return errMsg{err}
}
return tagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
f, err := os.CreateTemp("", "nib-edit-*.md")
if err != nil {
return func() tea.Msg { return errMsg{err} }
}
if _, err := f.WriteString(e.Body); err != nil {
f.Close()
os.Remove(f.Name())
return func() tea.Msg { return errMsg{err} }
}
f.Close()
c := exec.Command(editor, f.Name())
return tea.ExecProcess(c, func(err error) tea.Msg {
defer os.Remove(f.Name())
if err != nil {
return editorFinishedMsg{err}
}
content, readErr := os.ReadFile(f.Name())
if readErr != nil {
return editorFinishedMsg{readErr}
}
newBody := string(content)
if newBody == e.Body {
return editorFinishedMsg{nil}
}
update := db.EntityUpdate{Body: &newBody}
if updateErr := store.Update(e.ID, &update); updateErr != nil {
return editorFinishedMsg{updateErr}
}
return editorFinishedMsg{nil}
})
}