Merge pull request 'feat(tui): add bubbletea terminal UI' (#30) from feat/tui into main

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-05-20 01:16:57 +00:00
39 changed files with 4083 additions and 186 deletions
+4 -1
View File
@@ -2,7 +2,7 @@ BINARY := nib
MODULE := github.com/lerko/nib MODULE := github.com/lerko/nib
GOFLAGS := -trimpath GOFLAGS := -trimpath
.PHONY: build dev watch test lint fmt vet clean run cert help .PHONY: build dev tui watch test lint fmt vet clean run cert help
## —— Build —————————————————————————————————— ## —— Build ——————————————————————————————————
@@ -12,6 +12,9 @@ build: ## Build production binary
dev: ## Build and run with default serve dev: ## Build and run with default serve
go run . serve go run . serve
tui: ## Launch the terminal UI
go run . tui
watch: ## Live-reload dev server (requires air) watch: ## Live-reload dev server (requires air)
air air
+16
View File
@@ -40,6 +40,9 @@ nib ls --tag ops
# list a specific month # list a specific month
nib ls --month 2026-05 nib ls --month 2026-05
# terminal UI
nib tui
# start the web UI # start the web UI
nib serve nib serve
``` ```
@@ -122,9 +125,22 @@ Or use `^type` inline: `nib "proxy trick #nginx ^card"`
| `nib absorb <target> <source>` | Merge source into target | | `nib absorb <target> <source>` | Merge source into target |
| `nib delete <id>` | Soft delete (repeat to hard delete) | | `nib delete <id>` | Soft delete (repeat to hard delete) |
| `nib serve` | Start web UI on `:4444` (or `--port`) | | `nib serve` | Start web UI on `:4444` (or `--port`) |
| `nib tui` | Launch the terminal UI |
IDs are prefix-matchable. If `01KRQ4` is unique, that's enough. IDs are prefix-matchable. If `01KRQ4` is unique, that's enough.
## Terminal UI
`nib tui` launches a keyboard-driven interface in your terminal.
- **Stream view** — entries grouped by date with compact gutter headers
- **Cards view** — promoted cards filtered by intent (grab/read/fill), sorted by usage
- **Split-pane detail** — wide terminals (100+ cols) show list and detail side-by-side
- **Capture drawer** — inline add with live preview of parsed entity
- **Search** — type `?query #tag` in the capture bar to filter
Navigation: `j`/`k` move, `enter` opens detail, `h`/`l` switch panes, `a` to add, `?` for full keybindings.
## Web UI ## Web UI
`nib serve` starts a local web interface with: `nib serve` starts a local web interface with:
+3 -76
View File
@@ -1,11 +1,9 @@
package cmd package cmd
import ( import (
"encoding/json"
"fmt" "fmt"
"regexp"
"strings"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display" "github.com/lerko/nib/internal/display"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -47,9 +45,9 @@ func runPromote(_ *cobra.Command, args []string) error {
return err return err
} }
cardData := generateCardData(cardType, e.Body) cd := carddata.GenerateCardData(cardType, e.Body)
if err := store.Promote(id, cardType, cardData); err != nil { if err := store.Promote(id, cardType, cd); err != nil {
if err == db.ErrAlreadyPromoted { if err == db.ErrAlreadyPromoted {
return fmt.Errorf("invalid_promote — entity %s is already a %s", return fmt.Errorf("invalid_promote — entity %s is already a %s",
display.FormatID(id), *e.CardType) display.FormatID(id), *e.CardType)
@@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error {
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType) fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
return nil return nil
} }
var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
func generateCardData(ct db.CardType, body string) *string {
var data string
switch ct {
case db.CardTemplate:
matches := templateSlotRe.FindAllStringSubmatch(body, -1)
type slot struct {
Name string `json:"name"`
Default string `json:"default"`
}
var slots []slot
seen := map[string]bool{}
for _, m := range matches {
name := m[1]
if !seen[name] {
slots = append(slots, slot{Name: name, Default: ""})
seen[name] = true
}
}
if slots == nil {
slots = []slot{}
}
b, _ := json.Marshal(map[string]any{"slots": slots})
data = string(b)
case db.CardChecklist:
type step struct {
Text string `json:"text"`
Done bool `json:"done"`
}
var steps []step
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
text := strings.TrimSpace(line[3:])
done := strings.HasPrefix(line, "[x]")
steps = append(steps, step{Text: text, Done: done})
}
}
if steps == nil {
steps = []step{{Text: body, Done: false}}
}
b, _ := json.Marshal(map[string]any{"steps": steps})
data = string(b)
case db.CardDecision:
b, _ := json.Marshal(map[string]any{
"chose": "",
"why": "",
"rejected": []string{},
})
data = string(b)
case db.CardLink:
url := ""
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
url = word
break
}
}
b, _ := json.Marshal(map[string]any{"url": url})
data = string(b)
default:
data = "{}"
}
return &data
}
+55
View File
@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"fmt"
"os" "os"
"strings" "strings"
@@ -26,6 +27,10 @@ func Execute() error {
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ") isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
if first != "help" && first != "completion" && if first != "help" && first != "completion" &&
!isFlag && !isSubcommand(first) { !isFlag && !isSubcommand(first) {
if near := nearSubcommand(first); near != "" {
fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near)
os.Exit(1)
}
// "--" stops cobra from parsing glyph prefixes like "-" as flags // "--" stops cobra from parsing glyph prefixes like "-" as flags
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...)) rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
} }
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
return false return false
} }
func nearSubcommand(name string) string {
for _, c := range rootCmd.Commands() {
if d := editDist(name, c.Name()); d > 0 && d <= 2 {
return c.Name()
}
for _, alias := range c.Aliases {
if d := editDist(name, alias); d > 0 && d <= 2 {
return alias
}
}
}
return ""
}
func editDist(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr := make([]int, lb+1)
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
ins := curr[j-1] + 1
del := prev[j] + 1
sub := prev[j-1] + cost
curr[j] = ins
if del < curr[j] {
curr[j] = del
}
if sub < curr[j] {
curr[j] = sub
}
}
prev = curr
}
return prev[lb]
}
func init() { func init() {
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(lsCmd) rootCmd.AddCommand(lsCmd)
+5 -3
View File
@@ -19,6 +19,7 @@ var WebFS fs.FS
var ( var (
servePort int servePort int
serveHost string
serveDev bool serveDev bool
tlsCert string tlsCert string
tlsKey string tlsKey string
@@ -32,6 +33,7 @@ var serveCmd = &cobra.Command{
func init() { func init() {
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)") serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)")
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development") serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file") serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file") serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
@@ -70,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
router = api.NewRouter(store, serveDev, WebFS) router = api.NewRouter(store, serveDev, WebFS)
} }
addr := fmt.Sprintf(":%d", port) addr := fmt.Sprintf("%s:%d", serveHost, port)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: router, Handler: router,
@@ -81,9 +83,9 @@ func runServe(_ *cobra.Command, _ []string) error {
go func() { go func() {
if useTLS { if useTLS {
fmt.Printf("nib serving on https://localhost%s\n", addr) fmt.Printf("nib serving on https://%s\n", addr)
} else { } else {
fmt.Printf("nib serving on http://localhost%s\n", addr) fmt.Printf("nib serving on http://%s\n", addr)
} }
if serveDev { if serveDev {
fmt.Println(" CORS enabled (dev mode)") fmt.Println(" CORS enabled (dev mode)")
+25
View File
@@ -0,0 +1,25 @@
package cmd
import (
"github.com/lerko/nib/internal/tui"
"github.com/spf13/cobra"
)
var tuiCmd = &cobra.Command{
Use: "tui",
Short: "launch the terminal UI",
RunE: runTUI,
}
func init() {
rootCmd.AddCommand(tuiCmd)
}
func runTUI(_ *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
return tui.Run(store)
}
+10
View File
@@ -77,6 +77,16 @@ For production, use a reverse proxy (Caddy, nginx) with real certificates in fro
Override with `--port` or the `NIB_PORT` environment variable. Override with `--port` or the `NIB_PORT` environment variable.
## Terminal UI
Run the TUI directly from source:
```sh
go run . tui
```
TUI code lives in `internal/tui/`. No live-reload — restart manually after changes.
## Typical Workflow ## Typical Workflow
1. `make cert` — once, generates dev TLS cert 1. `make cert` — once, generates dev TLS cert
+22 -1
View File
@@ -4,6 +4,9 @@ go 1.24.4
require ( require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@@ -11,15 +14,33 @@ require (
) )
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+45 -2
View File
@@ -1,8 +1,32 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -11,8 +35,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
@@ -20,11 +56,15 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
@@ -32,9 +72,12 @@ golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+21 -14
View File
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store return srv, store
} }
type listEnvelope struct {
Data []EntityResponse `json:"data"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
t.Helper()
var env listEnvelope
json.NewDecoder(resp.Body).Decode(&env)
return env.Data
}
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper() t.Helper()
b, err := json.Marshal(body) b, err := json.Marshal(body)
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 2 { if len(entities) != 2 {
t.Fatalf("expected 2, got %d", len(entities)) t.Fatalf("expected 2, got %d", len(entities))
} }
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 { if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities)) t.Fatalf("expected 1, got %d", len(entities))
} }
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 { if len(entities) != 1 {
t.Fatalf("expected 1 card, got %d", len(entities)) t.Fatalf("expected 1 card, got %d", len(entities))
} }
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var page1 []EntityResponse page1 := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&page1)
resp.Body.Close() resp.Body.Close()
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2") resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var page2 []EntityResponse page2 := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&page2)
resp.Body.Close() resp.Body.Close()
if len(page1) != 2 || len(page2) != 2 { if len(page1) != 2 || len(page2) != 2 {
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var entities []EntityResponse entities := decodeList(t, listResp)
json.NewDecoder(listResp.Body).Decode(&entities)
listResp.Body.Close() listResp.Body.Close()
for _, ent := range entities { for _, ent := range entities {
if ent.ID == source.ID { if ent.ID == source.ID {
@@ -686,8 +694,7 @@ func TestListEntities_TitleInResponse(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var entities []EntityResponse entities := decodeList(t, resp)
json.NewDecoder(resp.Body).Decode(&entities)
if len(entities) != 1 { if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities)) t.Fatalf("expected 1, got %d", len(entities))
} }
+29 -3
View File
@@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc {
} }
p.Offset = offset p.Offset = offset
} }
if p.Limit <= 0 {
p.Limit = 50
}
total, err := store.Count(p)
if err != nil {
writeInternalError(w, err)
return
}
entities, err := store.List(p) entities, err := store.List(p)
if err != nil { if err != nil {
@@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc {
return return
} }
resp := make([]EntityResponse, len(entities)) items := make([]EntityResponse, len(entities))
for i, e := range entities { for i, e := range entities {
resp[i] = entityToResponse(e) items[i] = entityToResponse(e)
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, map[string]any{
"data": items,
"total": total,
"limit": p.Limit,
"offset": p.Offset,
})
} }
} }
@@ -161,6 +175,10 @@ func createEntity(store *db.Store) http.HandlerFunc {
} }
if err := store.Create(e); err != nil { if err := store.Create(e); err != nil {
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
@@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
} }
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
@@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return return
} }
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
+102
View File
@@ -0,0 +1,102 @@
package carddata
import (
"encoding/json"
"regexp"
"strings"
"github.com/lerko/nib/internal/db"
)
var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
func GenerateCardData(ct db.CardType, body string) *string {
var data string
switch ct {
case db.CardTemplate:
matches := TemplateSlotRe.FindAllStringSubmatch(body, -1)
type slot struct {
Name string `json:"name"`
Default string `json:"default"`
}
var slots []slot
seen := map[string]bool{}
for _, m := range matches {
name := m[1]
if !seen[name] {
slots = append(slots, slot{Name: name, Default: ""})
seen[name] = true
}
}
if slots == nil {
slots = []slot{}
}
b, _ := json.Marshal(map[string]any{"slots": slots})
data = string(b)
case db.CardChecklist:
type step struct {
Text string `json:"text"`
Done bool `json:"done"`
}
var steps []step
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
text := strings.TrimSpace(line[3:])
done := strings.HasPrefix(line, "[x]")
steps = append(steps, step{Text: text, Done: done})
}
}
if steps == nil {
steps = []step{{Text: body, Done: false}}
}
b, _ := json.Marshal(map[string]any{"steps": steps})
data = string(b)
case db.CardDecision:
b, _ := json.Marshal(map[string]any{
"chose": "",
"why": "",
"rejected": []string{},
})
data = string(b)
case db.CardLink:
url := ""
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
url = word
break
}
}
b, _ := json.Marshal(map[string]any{"url": url})
data = string(b)
default:
data = "{}"
}
return &data
}
func DetectCardType(body string) *db.CardType {
if TemplateSlotRe.MatchString(body) {
ct := db.CardTemplate
return &ct
}
if strings.Contains(body, "chose:") || strings.Contains(body, "why:") {
ct := db.CardDecision
return &ct
}
if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") {
ct := db.CardChecklist
return &ct
}
for _, word := range strings.Fields(body) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
ct := db.CardLink
return &ct
}
}
return nil
}
@@ -1,4 +1,4 @@
package cmd package carddata
import ( import (
"encoding/json" "encoding/json"
@@ -8,14 +8,14 @@ import (
) )
func TestGenerateCardData_Snippet(t *testing.T) { func TestGenerateCardData_Snippet(t *testing.T) {
data := generateCardData(db.CardSnippet, "some snippet") data := GenerateCardData(db.CardSnippet, "some snippet")
if data == nil || *data != "{}" { if data == nil || *data != "{}" {
t.Errorf("snippet should produce {}, got %v", data) t.Errorf("snippet should produce {}, got %v", data)
} }
} }
func TestGenerateCardData_Template(t *testing.T) { func TestGenerateCardData_Template(t *testing.T) {
data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}") data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
if data == nil { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
@@ -41,7 +41,7 @@ func TestGenerateCardData_Template(t *testing.T) {
} }
func TestGenerateCardData_TemplateDedupe(t *testing.T) { func TestGenerateCardData_TemplateDedupe(t *testing.T) {
data := generateCardData(db.CardTemplate, "${x} and ${x}") data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
var parsed struct { var parsed struct {
Slots []struct { Slots []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(t *testing.T) {
} }
func TestGenerateCardData_TemplateNoSlots(t *testing.T) { func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
data := generateCardData(db.CardTemplate, "no placeholders here") data := GenerateCardData(db.CardTemplate, "no placeholders here")
var parsed struct { var parsed struct {
Slots []struct { Slots []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
func TestGenerateCardData_Checklist(t *testing.T) { func TestGenerateCardData_Checklist(t *testing.T) {
body := "[ ] step one\n[x] step two\n[ ] step three" body := "[ ] step one\n[x] step two\n[ ] step three"
data := generateCardData(db.CardChecklist, body) data := GenerateCardData(db.CardChecklist, body)
if data == nil { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
@@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(t *testing.T) {
} }
func TestGenerateCardData_ChecklistFallback(t *testing.T) { func TestGenerateCardData_ChecklistFallback(t *testing.T) {
data := generateCardData(db.CardChecklist, "no checkbox syntax") data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
var parsed struct { var parsed struct {
Steps []struct { Steps []struct {
Text string `json:"text"` Text string `json:"text"`
@@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) {
} }
func TestGenerateCardData_Decision(t *testing.T) { func TestGenerateCardData_Decision(t *testing.T) {
data := generateCardData(db.CardDecision, "which db?") data := GenerateCardData(db.CardDecision, "which db?")
var parsed struct { var parsed struct {
Chose string `json:"chose"` Chose string `json:"chose"`
Why string `json:"why"` Why string `json:"why"`
@@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(t *testing.T) {
} }
func TestGenerateCardData_Link(t *testing.T) { func TestGenerateCardData_Link(t *testing.T) {
data := generateCardData(db.CardLink, "check https://example.com/path for details") data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
var parsed struct { var parsed struct {
URL string `json:"url"` URL string `json:"url"`
} }
@@ -140,7 +140,7 @@ func TestGenerateCardData_Link(t *testing.T) {
} }
func TestGenerateCardData_LinkNoURL(t *testing.T) { func TestGenerateCardData_LinkNoURL(t *testing.T) {
data := generateCardData(db.CardLink, "no url here") data := GenerateCardData(db.CardLink, "no url here")
var parsed struct { var parsed struct {
URL string `json:"url"` URL string `json:"url"`
} }
+70 -27
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -16,6 +15,7 @@ var (
ErrAlreadyPromoted = errors.New("invalid_promote") ErrAlreadyPromoted = errors.New("invalid_promote")
ErrAlreadyFluid = errors.New("invalid_demote") ErrAlreadyFluid = errors.New("invalid_demote")
ErrTargetCrystallized = errors.New("invalid_absorb") ErrTargetCrystallized = errors.New("invalid_absorb")
ErrInvalidCardData = errors.New("invalid_card_data")
) )
type Store struct { type Store struct {
@@ -51,22 +51,23 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *Store) migrate() error { const currentSchema = 3
_, err := s.db.Exec(`
var migrations = []func(db *sql.DB) error{
// v1: initial schema
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS entities ( CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
modified_at TEXT NOT NULL, modified_at TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
glyph TEXT NOT NULL glyph TEXT NOT NULL,
CHECK (glyph IN ('todo', 'event', 'note')),
time_anchor TEXT, time_anchor TEXT,
completed_at TEXT, completed_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT, deleted_at TEXT,
card_type TEXT card_type TEXT,
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note')
OR card_type IS NULL),
card_data TEXT, card_data TEXT,
use_count INTEGER NOT NULL DEFAULT 0, use_count INTEGER NOT NULL DEFAULT 0,
last_used_at TEXT last_used_at TEXT
@@ -86,29 +87,29 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
ON entity_tags(tag); ON entity_tags(tag);
`) `)
if err != nil {
return err return err
} },
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) // v2: add title and description columns
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) func(db *sql.DB) error {
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
return nil
},
// Migrate CHECK constraint to include 'note' card type // v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
var needsMigrate bool func(db *sql.DB) error {
row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`) tx, err := db.Begin()
var ddl string
if row.Scan(&ddl) == nil {
hasNote := strings.Contains(ddl, "'link', 'note'")
hasModified := strings.Contains(ddl, "modified_at")
needsMigrate = !hasNote || !hasModified
}
if needsMigrate {
tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
// Disable FK checks during rebuild to avoid dangling references
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
return fmt.Errorf("migrate rename: %w", err) return fmt.Errorf("migrate rename: %w", err)
} }
@@ -118,7 +119,7 @@ func (s *Store) migrate() error {
modified_at TEXT NOT NULL, modified_at TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
glyph TEXT NOT NULL glyph TEXT NOT NULL
CHECK (glyph IN ('todo', 'event', 'note')), CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
time_anchor TEXT, time_anchor TEXT,
completed_at TEXT, completed_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
@@ -140,12 +141,54 @@ func (s *Store) migrate() error {
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil { if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
return fmt.Errorf("migrate drop: %w", err) return fmt.Errorf("migrate drop: %w", err)
} }
if err := tx.Commit(); err != nil {
return fmt.Errorf("migrate commit: %w", err) // Rebuild entity_tags to point FK at new entities table
if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags rename: %w", err)
}
if _, err := tx.Exec(`CREATE TABLE entity_tags (
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (entity_id, tag)
)`); err != nil {
return fmt.Errorf("migrate tags create: %w", err)
}
if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags copy: %w", err)
}
if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil {
return fmt.Errorf("migrate tags drop: %w", err)
}
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("migrate fk on: %w", err)
}
return tx.Commit()
},
}
func (s *Store) migrate() error {
s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`)
var version int
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version)
if err != nil {
version = 0
}
for i := version; i < len(migrations); i++ {
if err := migrations[i](s.db); err != nil {
return fmt.Errorf("migration %d: %w", i+1, err)
} }
} }
return nil if version == 0 {
_, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations))
} else if len(migrations) > version {
_, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations))
}
return err
} }
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
+46 -7
View File
@@ -95,6 +95,8 @@ type EntityUpdate struct {
Glyph *Glyph Glyph *Glyph
TimeAnchor *string TimeAnchor *string
ClearTime bool ClearTime bool
CompletedAt *time.Time
ClearCompleted bool
Pinned *bool Pinned *bool
CardType *CardType CardType *CardType
CardData *string CardData *string
@@ -102,6 +104,9 @@ type EntityUpdate struct {
} }
func (s *Store) Create(e *Entity) error { func (s *Store) Create(e *Entity) error {
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
return ErrInvalidCardData
}
now := time.Now().UTC() now := time.Now().UTC()
e.ID = nibulid.New() e.ID = nibulid.New()
e.CreatedAt = now e.CreatedAt = now
@@ -177,7 +182,7 @@ func (s *Store) Get(id string) (*Entity, error) {
return e, nil return e, nil
} }
func (s *Store) List(params ListParams) ([]*Entity, error) { func listWhere(params ListParams) (string, []any) {
var where []string var where []string
var args []any var args []any
@@ -212,18 +217,39 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
args = append(args, string(*params.CardTypeFilter)) args = append(args, string(*params.CardTypeFilter))
} }
whereClause := "" clause := ""
if len(where) > 0 { if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ") clause = "WHERE " + strings.Join(where, " AND ")
}
return clause, args
} }
func (s *Store) Count(params ListParams) (int, error) {
whereClause, args := listWhere(params)
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
var count int
err := s.db.QueryRow(query, args...).Scan(&count)
return count, err
}
func (s *Store) List(params ListParams) ([]*Entity, error) {
whereClause, args := listWhere(params)
orderCol := "e.created_at" orderCol := "e.created_at"
if params.Sort == "use_count" { switch params.Sort {
case "use_count":
orderCol = "e.use_count" orderCol = "e.use_count"
case "created_at", "":
orderCol = "e.created_at"
default:
orderCol = "e.created_at"
} }
orderDir := "DESC" orderDir := "DESC"
if strings.EqualFold(params.Order, "asc") { switch strings.ToLower(params.Order) {
case "asc":
orderDir = "ASC" orderDir = "ASC"
default:
orderDir = "DESC"
} }
limit := params.Limit limit := params.Limit
@@ -311,6 +337,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "time_anchor = ?") sets = append(sets, "time_anchor = ?")
args = append(args, *u.TimeAnchor) args = append(args, *u.TimeAnchor)
} }
if u.ClearCompleted {
sets = append(sets, "completed_at = NULL")
} else if u.CompletedAt != nil {
sets = append(sets, "completed_at = ?")
args = append(args, u.CompletedAt.Format(time.RFC3339))
}
if u.Pinned != nil { if u.Pinned != nil {
sets = append(sets, "pinned = ?") sets = append(sets, "pinned = ?")
args = append(args, boolToInt(*u.Pinned)) args = append(args, boolToInt(*u.Pinned))
@@ -320,6 +352,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
args = append(args, string(*u.CardType)) args = append(args, string(*u.CardType))
} }
if u.CardData != nil { if u.CardData != nil {
if !json.Valid([]byte(*u.CardData)) {
return ErrInvalidCardData
}
sets = append(sets, "card_data = ?") sets = append(sets, "card_data = ?")
args = append(args, *u.CardData) args = append(args, *u.CardData)
} }
@@ -354,6 +389,9 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
dataVal := "{}" dataVal := "{}"
if cardData != nil { if cardData != nil {
if !json.Valid([]byte(*cardData)) {
return ErrInvalidCardData
}
dataVal = *cardData dataVal = *cardData
} }
@@ -457,8 +495,9 @@ func (s *Store) Absorb(targetID, sourceID string) error {
} }
} }
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?", absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
now, sourceID); err != nil { if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
absorbNote, now, now, sourceID); err != nil {
return err return err
} }
+136
View File
@@ -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
}
+262
View File
@@ -0,0 +1,262 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type intent int
const (
intentAll intent = iota
intentGrab
intentRead
intentFill
)
func (i intent) String() string {
switch i {
case intentGrab:
return "grab"
case intentRead:
return "read"
case intentFill:
return "fill"
default:
return "all"
}
}
func (i intent) next() intent {
switch i {
case intentAll:
return intentGrab
case intentGrab:
return intentRead
case intentRead:
return intentFill
default:
return intentAll
}
}
func matchesIntent(e *db.Entity, i intent) bool {
if i == intentAll {
return true
}
ct := e.CardType
if ct == nil {
return i == intentGrab
}
switch i {
case intentGrab:
return *ct == db.CardSnippet
case intentRead:
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
case intentFill:
return *ct == db.CardTemplate || *ct == db.CardChecklist
}
return false
}
type cardsModel struct {
entities []*db.Entity
filtered []*db.Entity
cursor int
offset int
height int
width int
intent intent
}
func newCardsModel() cardsModel {
return cardsModel{}
}
func (c *cardsModel) setEntities(entities []*db.Entity) {
c.entities = entities
c.applyFilter()
}
func (c *cardsModel) setIntent(i intent) {
c.intent = i
c.cursor = 0
c.offset = 0
c.applyFilter()
}
func (c *cardsModel) applyFilter() {
c.filtered = nil
var pinned, rest []*db.Entity
for _, e := range c.entities {
if !matchesIntent(e, c.intent) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
c.filtered = append(pinned, rest...)
if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1)
}
}
func (c *cardsModel) setSize(width, height int) {
c.width = width
c.height = height
}
func (c cardsModel) selected() *db.Entity {
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
return nil
}
return c.filtered[c.cursor]
}
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
switch msg.String() {
case "up", "k":
if c.cursor > 0 {
c.cursor--
if c.cursor < c.offset {
c.offset = c.cursor
}
}
case "down", "j":
if c.cursor < len(c.filtered)-1 {
c.cursor++
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
case "home", "g":
c.cursor = 0
c.offset = 0
case "end", "G":
c.cursor = max(0, len(c.filtered)-1)
visible := c.visibleCount()
if c.cursor >= visible {
c.offset = c.cursor - visible + 1
}
case "pgup", "ctrl+u":
c.cursor = max(0, c.cursor-c.visibleCount())
if c.cursor < c.offset {
c.offset = c.cursor
}
case "pgdown", "ctrl+d":
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
return c
}
func (c cardsModel) view(width int) string {
if len(c.filtered) == 0 {
return statusStyle.Render("no cards")
}
var b strings.Builder
visible := c.visibleCount()
end := min(c.offset+visible, len(c.filtered))
for i := c.offset; i < end; i++ {
e := c.filtered[i]
line := renderCard(e, width-4)
if i == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) visibleCount() int {
if c.height <= 0 {
return 20
}
return c.height
}
func renderCard(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
}
affordance := detectAffordance(e)
affordStr := ""
if affordance != "" {
affordStr = " " + affordanceStyle.Render(affordance)
}
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
limit := min(2, len(e.Tags))
for _, t := range e.Tags[:limit] {
extras = append(extras, tagStyle.Render("#"+t))
}
}
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
useStr := ""
if e.UseCount > 0 {
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
}
line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-30)
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
}
return line
}
func detectAffordance(e *db.Entity) string {
if e.CardType == nil {
return ""
}
switch *e.CardType {
case db.CardSnippet:
return "code"
case db.CardTemplate:
return "fill"
case db.CardChecklist:
return "steps"
case db.CardDecision:
return "decide"
case db.CardLink:
return "link"
default:
return ""
}
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func TestMatchesIntent(t *testing.T) {
snippet := db.CardSnippet
template := db.CardTemplate
checklist := db.CardChecklist
note := db.CardNote
link := db.CardLink
decision := db.CardDecision
tests := []struct {
name string
cardType *db.CardType
intent intent
want bool
}{
{"all matches nil", nil, intentAll, true},
{"all matches snippet", &snippet, intentAll, true},
{"all matches template", &template, intentAll, true},
{"grab matches nil", nil, intentGrab, true},
{"grab matches snippet", &snippet, intentGrab, true},
{"grab rejects template", &template, intentGrab, false},
{"grab rejects note", &note, intentGrab, false},
{"read matches note", &note, intentRead, true},
{"read matches link", &link, intentRead, true},
{"read matches decision", &decision, intentRead, true},
{"read rejects snippet", &snippet, intentRead, false},
{"read rejects nil", nil, intentRead, false},
{"fill matches template", &template, intentFill, true},
{"fill matches checklist", &checklist, intentFill, true},
{"fill rejects snippet", &snippet, intentFill, false},
{"fill rejects nil", nil, intentFill, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &db.Entity{CardType: tt.cardType}
if got := matchesIntent(e, tt.intent); got != tt.want {
t.Fatalf("matchesIntent(%v, %v) = %v, want %v", tt.cardType, tt.intent, got, tt.want)
}
})
}
}
func TestDetectAffordance(t *testing.T) {
snippet := db.CardSnippet
template := db.CardTemplate
checklist := db.CardChecklist
decision := db.CardDecision
link := db.CardLink
note := db.CardNote
tests := []struct {
name string
cardType *db.CardType
want string
}{
{"nil", nil, ""},
{"snippet", &snippet, "code"},
{"template", &template, "fill"},
{"checklist", &checklist, "steps"},
{"decision", &decision, "decide"},
{"link", &link, "link"},
{"note", &note, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &db.Entity{CardType: tt.cardType}
if got := detectAffordance(e); got != tt.want {
t.Fatalf("detectAffordance(%v) = %q, want %q", tt.cardType, got, tt.want)
}
})
}
}
func TestIntentCycle(t *testing.T) {
order := []intent{intentAll, intentGrab, intentRead, intentFill, intentAll}
for i := 0; i < len(order)-1; i++ {
got := order[i].next()
if got != order[i+1] {
t.Fatalf("%v.next() = %v, want %v", order[i], got, order[i+1])
}
}
}
func TestApplyFilter_PinnedFirst(t *testing.T) {
snippet := db.CardSnippet
c := newCardsModel()
c.setEntities([]*db.Entity{
{ID: "1", Body: "a", CardType: &snippet},
{ID: "2", Body: "b", Pinned: true, CardType: &snippet},
{ID: "3", Body: "c", CardType: &snippet},
})
if len(c.filtered) != 3 {
t.Fatalf("expected 3 filtered, got %d", len(c.filtered))
}
if c.filtered[0].ID != "2" {
t.Fatalf("pinned entity should be first, got %s", c.filtered[0].ID)
}
}
func TestApplyFilter_CursorClamps(t *testing.T) {
snippet := db.CardSnippet
template := db.CardTemplate
c := newCardsModel()
c.setEntities([]*db.Entity{
{ID: "1", Body: "a", CardType: &snippet},
{ID: "2", Body: "b", CardType: &snippet},
{ID: "3", Body: "c", CardType: &template},
})
c.cursor = 2
c.setIntent(intentFill)
if len(c.filtered) != 1 {
t.Fatalf("expected 1 fill entity, got %d", len(c.filtered))
}
if c.cursor != 0 {
t.Fatalf("cursor should clamp to 0, got %d", c.cursor)
}
}
+266
View File
@@ -0,0 +1,266 @@
package tui
import (
"os"
"os/exec"
"strings"
"time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
)
type entitiesLoadedMsg struct {
entities []*db.Entity
}
type entityCreatedMsg struct {
entity *db.Entity
}
type entityDeletedMsg struct {
id string
}
type entityUpdatedMsg struct {
entity *db.Entity
action string
}
type entityPromotedMsg struct {
id string
cardType db.CardType
}
type entityDemotedMsg struct {
id string
}
type entityCopiedMsg struct{}
type entityAbsorbedMsg struct {
targetID string
}
type absorbSourcesLoadedMsg struct {
targetID string
entities []*db.Entity
}
type stepsPersistedMsg struct{}
type templateCopiedMsg struct{}
type tagsLoadedMsg struct {
tags []db.TagCount
}
type editorFinishedMsg struct {
err error
}
type errMsg struct {
err error
}
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(params)
if err != nil {
return errMsg{err}
}
return entitiesLoadedMsg{entities}
}
}
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := store.Create(e); err != nil {
return errMsg{err}
}
return entityCreatedMsg{e}
}
}
func deleteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(id); err != nil {
return errMsg{err}
}
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, ct db.CardType, body string) tea.Cmd {
return func() tea.Msg {
cd := carddata.GenerateCardData(ct, body)
if err := store.Promote(id, ct, cd); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id, ct}
}
}
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 {
editorEnv := os.Getenv("EDITOR")
if editorEnv == "" {
editorEnv = "vi"
}
parts := strings.Fields(editorEnv)
editor, editorArgs := parts[0], parts[1:]
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, append(editorArgs, 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}
})
}
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}
}
}
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
return func() tea.Msg {
update := db.EntityUpdate{CardData: &stepsJSON}
if err := store.Update(entityID, &update); err != nil {
return errMsg{err}
}
return stepsPersistedMsg{}
}
}
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(resolved); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(entityID); err != nil {
return errMsg{err}
}
return templateCopiedMsg{}
}
}
+23
View File
@@ -0,0 +1,23 @@
package tui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/display"
)
type confirmTimeoutMsg struct{}
func confirmTimeout() tea.Cmd {
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return confirmTimeoutMsg{}
})
}
func renderConfirm(entityID string) string {
short := display.FormatID(entityID)
return errorStyle.Render(fmt.Sprintf("delete %s? y to confirm, any key to cancel", short))
}
+239
View File
@@ -0,0 +1,239 @@
package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type detailMode int
const (
detailPreview detailMode = iota
detailRun
detailFill
)
type detailModel struct {
entity *db.Entity
scroll int
height int
width int
mode detailMode
run runModel
fill fillModel
}
func newDetailModel() detailModel {
return detailModel{}
}
func (d *detailModel) setEntity(e *db.Entity) {
d.entity = e
d.scroll = 0
d.mode = detailPreview
}
func (d *detailModel) setSize(width, height int) {
d.width = width
d.height = height
}
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
switch d.mode {
case detailRun:
d.run = d.run.update(msg.String())
return d, nil
case detailFill:
var cmd tea.Cmd
d.fill, cmd = d.fill.update(msg)
return d, cmd
default:
switch msg.String() {
case "up", "k":
if d.scroll > 0 {
d.scroll--
}
case "down", "j":
d.scroll++
}
return d, nil
}
}
func (d detailModel) view(width int) string {
switch d.mode {
case detailRun:
return d.run.view(width)
case detailFill:
return d.fill.view(width)
default:
return d.previewView(width)
}
}
func (d detailModel) previewView(width int) string {
if d.entity == nil {
return ""
}
e := d.entity
var b strings.Builder
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
if e.CardType != nil {
header += " " + affordanceStyle.Render(string(*e.CardType))
}
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
if e.Title != nil {
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
b.WriteString("\n")
}
b.WriteString(detailBodyStyle.Render(e.Body))
b.WriteString("\n")
if e.CardType != nil {
cardSection := renderCardData(e)
if cardSection != "" {
b.WriteString("\n")
b.WriteString(cardSection)
}
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
}
b.WriteString("\n")
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
b.WriteString("\n")
}
b.WriteString("\n")
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
if e.ModifiedAt != e.CreatedAt {
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
}
if e.TimeAnchor != nil {
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
}
if e.Pinned {
meta += "\n" + pinnedStyle.Render("pinned")
}
if e.CardType != nil {
meta += fmt.Sprintf("\ncard %s", *e.CardType)
}
if e.UseCount > 0 {
meta += fmt.Sprintf("\nused %d×", e.UseCount)
}
if e.CompletedAt != nil {
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
}
b.WriteString(idStyle.Render(meta))
lines := strings.Split(b.String(), "\n")
if d.scroll > 0 && d.scroll < len(lines) {
lines = lines[d.scroll:]
}
if d.height > 0 && len(lines) > d.height {
lines = lines[:d.height]
}
return strings.Join(lines, "\n")
}
func renderCardData(e *db.Entity) string {
if e.CardData == nil {
return ""
}
data, err := e.CardDataJSON()
if err != nil || data == nil {
return ""
}
var b strings.Builder
switch *e.CardType {
case db.CardChecklist:
steps, ok := data["steps"].([]interface{})
if !ok {
break
}
done := 0
for _, s := range steps {
step, ok := s.(map[string]interface{})
if !ok {
continue
}
text, _ := step["text"].(string)
isDone, _ := step["done"].(bool)
if isDone {
done++
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
} else {
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
}
}
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
b.WriteString(detailLabelStyle.Render(progress))
b.WriteString(" " + helpStyle.Render("r:run"))
case db.CardTemplate:
slots, ok := data["slots"].([]interface{})
if !ok {
break
}
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
for _, s := range slots {
slot, ok := s.(map[string]interface{})
if !ok {
continue
}
name, _ := slot["name"].(string)
def, _ := slot["default"].(string)
line := " ${" + name + "}"
if def != "" {
line += " " + detailValueStyle.Render("default: "+def)
}
b.WriteString(line + "\n")
}
b.WriteString(" " + helpStyle.Render("f:fill"))
case db.CardDecision:
if chose, ok := data["chose"].(string); ok && chose != "" {
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
}
if why, ok := data["why"].(string); ok && why != "" {
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
}
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
items := make([]string, 0, len(rejected))
for _, r := range rejected {
if s, ok := r.(string); ok {
items = append(items, s)
}
}
if len(items) > 0 {
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
}
}
case db.CardLink:
if url, ok := data["url"].(string); ok && url != "" {
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
}
}
return b.String()
}
+152
View File
@@ -0,0 +1,152 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
)
type fillSlot struct {
Name string
Default string
Value string
}
type fillModel struct {
slots []fillSlot
active int
body string
entityID string
ti textinput.Model
}
func newFillModel(entityID, body string) fillModel {
slots := discoverSlots(body)
m := fillModel{
slots: slots,
body: body,
entityID: entityID,
}
m.ti = textinput.New()
m.ti.CharLimit = 200
if len(slots) > 0 {
m.ti.Placeholder = slots[0].Name
m.ti.Focus()
if slots[0].Default != "" {
m.ti.SetValue(slots[0].Default)
}
}
return m
}
func discoverSlots(body string) []fillSlot {
matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1)
seen := map[string]bool{}
var slots []fillSlot
for _, m := range matches {
name := m[1]
if !seen[name] {
seen[name] = true
slots = append(slots, fillSlot{Name: name})
}
}
return slots
}
func (f fillModel) resolve() string {
result := f.body
for _, s := range f.slots {
val := s.Value
if val == "" {
val = "${" + s.Name + "}"
}
result = strings.ReplaceAll(result, "${"+s.Name+"}", val)
}
return result
}
func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) {
switch msg.String() {
case "tab":
f.commitActive()
if f.active < len(f.slots)-1 {
f.active++
} else {
f.active = 0
}
f.loadActive()
return f, nil
case "shift+tab":
f.commitActive()
if f.active > 0 {
f.active--
} else {
f.active = len(f.slots) - 1
}
f.loadActive()
return f, nil
}
f.ti, _ = f.ti.Update(msg)
return f, nil
}
func (f *fillModel) commitActive() {
if f.active < len(f.slots) {
f.slots[f.active].Value = f.ti.Value()
}
}
func (f *fillModel) loadActive() {
if f.active < len(f.slots) {
s := f.slots[f.active]
f.ti.SetValue(s.Value)
f.ti.Placeholder = s.Name
f.ti.Focus()
}
}
func (f fillModel) view(width int) string {
if len(f.slots) == 0 {
return statusStyle.Render("no slots")
}
var b strings.Builder
header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots))
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
for i, slot := range f.slots {
name := detailLabelStyle.Render(slot.Name)
var val string
if i == f.active {
val = f.ti.View()
} else if slot.Value != "" {
val = detailValueStyle.Render(slot.Value)
} else {
val = idStyle.Render("(empty)")
}
if i == f.active {
b.WriteString(selectedItemStyle.Render(" " + name + " " + val))
} else {
b.WriteString(listItemStyle.Render(name + " " + val))
}
b.WriteString("\n")
}
b.WriteString("\n")
preview := f.resolve()
if len(preview) > width-4 {
preview = preview[:width-7] + "…"
}
b.WriteString(detailBodyStyle.Render(preview))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel"))
return b.String()
}
+81
View File
@@ -0,0 +1,81 @@
package tui
import (
"testing"
)
func TestDiscoverSlots(t *testing.T) {
tests := []struct {
name string
body string
wantNames []string
}{
{"no slots", "plain text", nil},
{"single slot", "Hello ${name}", []string{"name"}},
{"multiple slots", "${greeting} ${name}, welcome to ${place}", []string{"greeting", "name", "place"}},
{"duplicate slot deduped", "${x} and ${x} again", []string{"x"}},
{"adjacent slots", "${a}${b}", []string{"a", "b"}},
{"nested braces ignored", "${{bad}}", nil},
{"empty body", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := discoverSlots(tt.body)
if len(tt.wantNames) == 0 && len(got) == 0 {
return
}
if len(got) != len(tt.wantNames) {
t.Fatalf("got %d slots, want %d", len(got), len(tt.wantNames))
}
for i, s := range got {
if s.Name != tt.wantNames[i] {
t.Fatalf("slot[%d].Name = %q, want %q", i, s.Name, tt.wantNames[i])
}
}
})
}
}
func TestResolve(t *testing.T) {
tests := []struct {
name string
body string
slots []fillSlot
want string
}{
{
"all filled",
"Hello ${name}, welcome to ${place}",
[]fillSlot{{Name: "name", Value: "Alice"}, {Name: "place", Value: "Nib"}},
"Hello Alice, welcome to Nib",
},
{
"unfilled stays as placeholder",
"${greeting} ${name}",
[]fillSlot{{Name: "greeting", Value: "Hi"}, {Name: "name"}},
"Hi ${name}",
},
{
"no slots",
"plain text",
nil,
"plain text",
},
{
"repeated slot filled everywhere",
"${x} and ${x}",
[]fillSlot{{Name: "x", Value: "Y"}},
"Y and Y",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := fillModel{body: tt.body, slots: tt.slots}
if got := f.resolve(); got != tt.want {
t.Fatalf("resolve() = %q, want %q", got, tt.want)
}
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"fmt"
"strings"
"github.com/lerko/nib/internal/db"
)
type filterModel struct {
tags []db.TagCount
cursor int
height int
}
func newFilterModel() filterModel {
return filterModel{}
}
func (f *filterModel) setTags(tags []db.TagCount) {
f.tags = tags
f.cursor = 0
}
func (f *filterModel) setHeight(h int) {
f.height = h
}
func (f filterModel) selectedTag() string {
if len(f.tags) == 0 || f.cursor >= len(f.tags) {
return ""
}
return f.tags[f.cursor].Tag
}
func (f filterModel) update(key string) filterModel {
switch key {
case "up", "k":
if f.cursor > 0 {
f.cursor--
}
case "down", "j":
if f.cursor < len(f.tags)-1 {
f.cursor++
}
}
return f
}
func (f filterModel) view(width int) string {
if len(f.tags) == 0 {
return statusStyle.Render("no tags")
}
var b strings.Builder
b.WriteString(titleStyle.Render("filter by tag"))
b.WriteString("\n\n")
visible := f.height - 4
if visible <= 0 {
visible = 10
}
offset := 0
if f.cursor >= visible {
offset = f.cursor - visible + 1
}
end := min(offset+visible, len(f.tags))
for i := offset; i < end; i++ {
tc := f.tags[i]
tag := fmt.Sprintf("#%-20s %d", tc.Tag, tc.Count)
if i == f.cursor {
b.WriteString(selectedItemStyle.Render(" " + tagStyle.Render(tag)))
} else {
b.WriteString(listItemStyle.Render(tagStyle.Render(tag)))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
+86
View File
@@ -0,0 +1,86 @@
package tui
import "strings"
func renderHelp(width, height int) string {
sections := []struct {
title string
binds [][2]string
}{
{"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"},
{"enter", "view detail"},
{"esc", "back / clear filter"},
}},
{"Views", [][2]string{
{"1", "stream view"},
{"2", "cards view"},
{"s", "cycle sort (cards)"},
{"tab", "cycle intent (cards)"},
}},
{"Actions", [][2]string{
{"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{
{"p", "promote to card"},
{"D", "demote to fluid"},
{"c", "copy to clipboard"},
{"e", "edit in $EDITOR"},
{"!", "toggle pin"},
{"r", "run checklist"},
{"f", "fill template"},
}},
{"Run Mode", [][2]string{
{"j/k", "move between steps"},
{"space", "toggle step"},
{"r", "reset all steps"},
{"esc", "save + exit"},
}},
{"Fill Mode", [][2]string{
{"tab", "next slot"},
{"shift+tab", "prev slot"},
{"enter", "copy resolved"},
{"esc", "cancel"},
}},
{"Split View", [][2]string{
{"l", "focus detail pane"},
{"h", "focus list pane"},
{"esc", "close detail / back"},
}},
{"Global", [][2]string{
{"?", "toggle help"},
{"q / ctrl+c", "quit"},
}},
}
var b strings.Builder
b.WriteString(detailHeaderStyle.Render("keybindings"))
b.WriteString("\n\n")
for _, s := range sections {
b.WriteString(titleStyle.Render(s.title))
b.WriteString("\n")
for _, bind := range s.binds {
key := helpKeyStyle.Render(bind[0])
desc := helpDescStyle.Render(bind[1])
b.WriteString(" " + key + " " + desc + "\n")
}
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("press ? or esc to close"))
lines := strings.Split(b.String(), "\n")
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
+176
View File
@@ -0,0 +1,176 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"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
preview *parse.Result
}
func newInputModel() inputModel {
ti := textinput.New()
ti.Placeholder = "capture a thought…"
ti.Prompt = inputPromptStyle.Render(" ")
ti.CharLimit = 500
return inputModel{ti: ti}
}
func (i *inputModel) focus() {
i.active = true
i.ti.Focus()
}
func (i *inputModel) reset() {
i.active = false
i.ti.SetValue("")
i.ti.Blur()
i.preview = nil
}
func (i inputModel) submit() *inputResult {
val := i.ti.Value()
if val == "" {
return nil
}
parsed, err := parse.Parse(val)
if err != nil {
return nil
}
if parsed.Query {
return &inputResult{
query: true,
body: parsed.Body,
tags: parsed.FilterTags,
}
}
e := &db.Entity{
Body: parsed.Body,
Title: parsed.Title,
Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags,
}
if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor
}
if parsed.CardSuffix != nil {
ct := db.CardType(*parsed.CardSuffix)
e.CardType = &ct
}
if parsed.Pin {
e.Pinned = true
}
if parsed.Description != nil {
e.Description = parsed.Description
}
return &inputResult{entity: e}
}
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
i.ti, _ = i.ti.Update(msg)
val := i.ti.Value()
if val != "" {
parsed, err := parse.Parse(val)
if err == nil {
i.preview = parsed
} else {
i.preview = nil
}
} else {
i.preview = nil
}
return i
}
func (i inputModel) view(width int) string {
var b strings.Builder
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
b.WriteString("\n")
b.WriteString(i.ti.View())
b.WriteString("\n")
b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder"))
b.WriteString("\n")
b.WriteString(i.renderPreview(width))
return b.String()
}
func (i inputModel) renderPreview(width int) string {
if i.preview == nil {
return drawerPreviewStyle.Render("")
}
p := i.preview
if p.Query {
q := "?"
if p.Body != "" {
q += p.Body
}
for _, t := range p.FilterTags {
q += " #" + t
}
return drawerPreviewStyle.Render("search: " + q)
}
glyph := glyphForParsed(p.Glyph)
body := p.Body
if p.Title != nil {
body = *p.Title
}
var parts []string
parts = append(parts, glyph, body)
for _, t := range p.Tags {
parts = append(parts, tagStyle.Render("#"+t))
}
if p.Pin {
parts = append(parts, pinnedStyle.Render("•"))
}
if p.CardSuffix != nil {
parts = append(parts, affordanceStyle.Render(*p.CardSuffix))
}
line := strings.Join(parts, " ")
maxW := width - 4
if maxW > 0 && len(stripAnsi(line)) > maxW {
line = truncate(line, maxW)
}
return drawerPreviewStyle.Render(line)
}
func glyphForParsed(glyph string) string {
switch glyph {
case "todo":
return "○"
case "event":
return "◇"
case "reminder":
return "△"
default:
return "—"
}
}
func drawerLines() int {
return 3
}
+65
View File
@@ -0,0 +1,65 @@
package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Back key.Binding
Add key.Binding
Delete key.Binding
Quit key.Binding
Help key.Binding
PageUp key.Binding
PageDn key.Binding
Top key.Binding
Bottom key.Binding
Todo key.Binding
Pin key.Binding
Filter key.Binding
Promote key.Binding
Demote key.Binding
Copy key.Binding
Edit key.Binding
Stream key.Binding
Cards key.Binding
Sort key.Binding
Intent key.Binding
Absorb key.Binding
Run key.Binding
Fill key.Binding
FocusLeft key.Binding
FocusRight key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")),
PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")),
Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")),
Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")),
Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")),
Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")),
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
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")),
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
}
+269
View File
@@ -0,0 +1,269 @@
package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type listModel struct {
entities []*db.Entity
filtered []*db.Entity
cursor int
offset int
height int
width int
}
func newListModel() listModel {
return 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)
}
}
func (l *listModel) setSize(width, height int) {
l.width = width
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 {
ents := l.displayEntities()
if len(ents) == 0 || l.cursor >= len(ents) {
return nil
}
return ents[l.cursor]
}
func (l listModel) update(msg tea.KeyMsg) listModel {
switch msg.String() {
case "up", "k":
if l.cursor > 0 {
l.cursor--
if l.cursor < l.offset {
l.offset = l.cursor
}
}
case "down", "j":
if l.cursor < len(l.displayEntities())-1 {
l.cursor++
visible := l.visibleCount()
if l.cursor >= l.offset+visible {
l.offset = l.cursor - visible + 1
}
}
case "home", "g":
l.cursor = 0
l.offset = 0
case "end", "G":
l.cursor = max(0, len(l.displayEntities())-1)
visible := l.visibleCount()
if l.cursor >= visible {
l.offset = l.cursor - visible + 1
}
case "pgup", "ctrl+u":
l.cursor = max(0, l.cursor-l.visibleCount())
if l.cursor < l.offset {
l.offset = l.cursor
}
case "pgdown", "ctrl+d":
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
}
}
return l
}
const dateGutterWidth = 9
func (l listModel) view(width int) string {
ents := l.displayEntities()
if len(ents) == 0 {
return statusStyle.Render("no entities")
}
groups := groupByDate(ents)
entityWidth := width - 4 - dateGutterWidth
type displayLine struct {
text string
entityIdx int
}
var lines []displayLine
entityIdx := 0
for _, g := range groups {
for i, e := range g.entities {
var gutter string
if i == 0 {
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
} else {
gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderEntity(e, entityWidth)
lines = append(lines, displayLine{
text: line,
entityIdx: entityIdx,
})
entityIdx++
}
}
visible := l.visibleCount()
offset := 0
if l.cursor >= visible {
offset = l.cursor - visible + 1
}
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.entityIdx == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (l listModel) visibleCount() int {
if l.height <= 0 {
return 20
}
return l.height
}
type dateGroup struct {
label string
entities []*db.Entity
}
func groupByDate(entities []*db.Entity) []dateGroup {
var groups []dateGroup
var current *dateGroup
for _, e := range entities {
label := formatDateLabel(e.CreatedAt)
if current == nil || current.label != label {
if current != nil {
groups = append(groups, *current)
}
current = &dateGroup{label: label}
}
current.entities = append(current.entities, e)
}
if current != nil {
groups = append(groups, *current)
}
return groups
}
func formatDateLabel(t time.Time) string {
return strings.ToLower(t.Format("Jan 2"))
}
func padRight(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
return s + strings.Repeat(" ", n-len(r))
}
func renderEntity(e *db.Entity, maxWidth int) string {
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
style := glyphStyle
if e.Glyph == db.GlyphTodo && e.CompletedAt != nil {
glyphStr = "●"
style = completedGlyphStyle
}
glyph := style.Render(glyphStr)
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
if e.Title != nil {
body = *e.Title
}
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
}
extras = append(extras, strings.Join(tagParts, " "))
}
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20)
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
}
return line
}
func truncate(s string, maxLen int) string {
if maxLen <= 3 {
return "…"
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen-1]) + "…"
}
func stripAnsi(s string) string {
var b strings.Builder
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
inEsc = false
}
continue
}
b.WriteRune(r)
}
return b.String()
}
+94
View File
@@ -0,0 +1,94 @@
package tui
import (
"testing"
"time"
"github.com/lerko/nib/internal/db"
)
func TestGroupByDate(t *testing.T) {
may19 := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC)
may19b := time.Date(2026, 5, 19, 14, 0, 0, 0, time.UTC)
may18 := time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC)
entities := []*db.Entity{
{ID: "1", CreatedAt: may19, Body: "a"},
{ID: "2", CreatedAt: may19b, Body: "b"},
{ID: "3", CreatedAt: may18, Body: "c"},
}
groups := groupByDate(entities)
if len(groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(groups))
}
if len(groups[0].entities) != 2 {
t.Fatalf("first group should have 2 entities, got %d", len(groups[0].entities))
}
if len(groups[1].entities) != 1 {
t.Fatalf("second group should have 1 entity, got %d", len(groups[1].entities))
}
if groups[0].label != "may 19" {
t.Fatalf("first group label = %q, want %q", groups[0].label, "may 19")
}
}
func TestGroupByDate_Empty(t *testing.T) {
groups := groupByDate(nil)
if len(groups) != 0 {
t.Fatalf("expected 0 groups, got %d", len(groups))
}
}
func TestGroupByDate_SingleEntity(t *testing.T) {
e := []*db.Entity{{ID: "1", CreatedAt: time.Now(), Body: "solo"}}
groups := groupByDate(e)
if len(groups) != 1 || len(groups[0].entities) != 1 {
t.Fatal("single entity should produce 1 group with 1 entity")
}
}
func TestTruncate(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{"short enough", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncated", "hello world", 6, "hello…"},
{"very short max", "hello", 3, "…"},
{"unicode", "héllo wörld", 7, "héllo …"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncate(tt.input, tt.maxLen)
if got != tt.want {
t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
})
}
}
func TestStripAnsi(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"no ansi", "hello", "hello"},
{"with color", "\x1b[31mred\x1b[0m", "red"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripAnsi(tt.input)
if got != tt.want {
t.Fatalf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+908
View File
@@ -0,0 +1,908 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db"
)
type viewState int
const (
stateList viewState = iota
stateDetail
stateInput
stateTagFilter
stateConfirm
statePromote
stateAbsorb
)
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 (
focusList focusPane = iota
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
showHelp bool
focus focusPane
splitDetail bool
filterTag string
confirmID string
cardsSort cardsSort
searchQuery string
searchTags []string
status string
err error
}
func newModel(store *db.Store) model {
return model{
store: store,
state: stateList,
mode: modeStream,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
}
}
func (m model) Init() tea.Cmd {
return loadEntities(m.store, m.listParams())
}
func (m model) listParams() db.ListParams {
p := db.DefaultListParams()
if m.filterTag != "" {
p.Tag = &m.filterTag
}
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
}
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:
m.width = msg.Width
m.height = msg.Height
if !m.isSplit() && m.splitDetail {
m.state = stateDetail
m.splitDetail = false
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 entityCreatedMsg:
m.state = stateList
m.input.reset()
m.recalcSizes()
m.status = "created"
return m, loadEntities(m.store, m.listParams())
case entityDeletedMsg:
m.status = "deleted"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg:
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityDemotedMsg:
m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id)
case entityCopiedMsg:
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 stepsPersistedMsg:
m.status = "steps saved"
m.detail.mode = detailPreview
return m, m.reloadDetail(m.detail.entity.ID)
case templateCopiedMsg:
m.status = "copied resolved"
m.detail.mode = detailPreview
return m, loadEntities(m.store, m.listParams())
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
} else {
m.status = "updated"
}
return m, m.reloadAfterEdit()
case confirmTimeoutMsg:
if m.state == stateConfirm {
m.state = stateList
m.confirmID = ""
}
return m, nil
case errMsg:
m.err = msg.err
return m, nil
case tea.KeyMsg:
m.err = nil
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter:
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
case stateAbsorb:
return m.updateAbsorb(msg)
default:
return m.updateKeys(msg)
}
}
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 m.splitDetail && m.state == stateList {
switch msg.String() {
case "l":
if m.focus == focusList {
m.focus = focusDetail
return m, nil
}
case "h":
if m.focus == focusDetail {
m.focus = focusList
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 "ctrl+c":
return m, tea.Quit
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()
m.status = "sort: " + m.cardsSort.String()
return m, loadEntities(m.store, m.listParams())
}
return m, nil
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
case "a":
if m.state == stateList {
m.state = stateInput
m.input.focus()
m.recalcSizes()
return m, m.input.ti.Focus()
}
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() {
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 = ""
return m, loadEntities(m.store, m.listParams())
}
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 {
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 {
if e.CardType != nil {
m.status = "already a card"
return m, nil
}
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 {
m.status = "already fluid"
return m, nil
}
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, 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) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateList
m.input.reset()
m.recalcSizes()
return m, nil
case "enter":
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.recalcSizes()
m.applySearch()
return m, nil
}
if result.entity != nil {
return m, createEntity(m.store, result.entity)
}
return m, nil
}
m.input = m.input.updateKey(msg)
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) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
}
var content string
switch m.state {
case stateList, stateInput, 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
}
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)
}
header := m.headerView()
footer := m.footerView()
return header + "\n" + content + "\n" + footer
}
func (m model) listContent() string {
if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw)
}
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.list.view(lw)
}
func (m model) headerView() string {
header := titleStyle.Render("nib")
modeName := "stream"
if m.mode == modeCards {
modeName = "cards"
}
header += " " + modeStyle.Render(modeName)
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
}
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) footerView() string {
if m.state == stateInput {
return m.input.view(m.width)
}
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
if m.err != nil {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return renderStatusBar(m, m.width)
}
func (m model) contentHeight() int {
return m.height - 3 - m.drawerHeight()
}
func (m model) drawerHeight() int {
if m.state == stateInput {
return drawerLines()
}
return 0
}
func (m *model) recalcSizes() {
ch := m.contentHeight()
if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths()
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch)
} else {
m.list.setSize(m.width, ch)
m.cards.setSize(m.width, ch)
m.detail.setSize(m.width, ch)
}
m.filter.setHeight(ch)
}
func (m model) isSplit() bool {
return m.width >= 100
}
func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100
right := m.width - 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()),
func() tea.Msg {
e, err := m.store.Get(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)
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type promoteOption struct {
cardType db.CardType
label string
group string
}
var promoteOptions = []promoteOption{
{db.CardSnippet, "snippet", "grab"},
{db.CardNote, "note", "read"},
{db.CardLink, "link", "read"},
{db.CardDecision, "decision", "read"},
{db.CardTemplate, "template", "fill"},
{db.CardChecklist, "checklist", "fill"},
}
type promoteModel struct {
cursor int
entityID string
body string
suggested *db.CardType
}
func newPromoteModel(entityID, body string) promoteModel {
return promoteModel{
entityID: entityID,
body: body,
suggested: carddata.DetectCardType(body),
}
}
func (p promoteModel) selectedType() db.CardType {
return promoteOptions[p.cursor].cardType
}
func (p promoteModel) update(key string) promoteModel {
switch key {
case "up", "k":
if p.cursor > 0 {
p.cursor--
}
case "down", "j":
if p.cursor < len(promoteOptions)-1 {
p.cursor++
}
}
return p
}
func (p promoteModel) view(width int) string {
var b string
b += titleStyle.Render("promote to card") + "\n\n"
currentGroup := ""
for i, opt := range promoteOptions {
if opt.group != currentGroup {
currentGroup = opt.group
b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n"
}
glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType)
label := glyph + " " + opt.label
if p.suggested != nil && *p.suggested == opt.cardType {
label += " " + affordanceStyle.Render("*")
}
if i == p.cursor {
b += selectedItemStyle.Render(" "+label) + "\n"
} else {
b += listItemStyle.Render(label) + "\n"
}
}
b += "\n" + helpStyle.Render("enter:select esc:cancel")
return b
}
+116
View File
@@ -0,0 +1,116 @@
package tui
import (
"encoding/json"
"fmt"
"strings"
)
type runStep struct {
Text string `json:"text"`
Done bool `json:"done"`
}
type runModel struct {
steps []runStep
cursor int
entityID string
dirty bool
}
func newRunModel(entityID string, cardData *string) runModel {
m := runModel{entityID: entityID}
m.steps = parseChecklist(cardData)
return m
}
func parseChecklist(cardData *string) []runStep {
if cardData == nil {
return nil
}
var data struct {
Steps []runStep `json:"steps"`
}
if err := json.Unmarshal([]byte(*cardData), &data); err != nil {
return nil
}
return data.Steps
}
func (r runModel) stepsJSON() string {
b, _ := json.Marshal(map[string]any{"steps": r.steps})
return string(b)
}
func (r runModel) doneCount() int {
n := 0
for _, s := range r.steps {
if s.Done {
n++
}
}
return n
}
func (r runModel) update(key string) runModel {
switch key {
case "up", "k":
if r.cursor > 0 {
r.cursor--
}
case "down", "j":
if r.cursor < len(r.steps)-1 {
r.cursor++
}
case " ":
if r.cursor < len(r.steps) {
r.steps[r.cursor].Done = !r.steps[r.cursor].Done
r.dirty = true
}
case "r":
for i := range r.steps {
r.steps[i].Done = false
}
r.dirty = true
}
return r
}
func (r runModel) view(width int) string {
if len(r.steps) == 0 {
return statusStyle.Render("no steps")
}
var b strings.Builder
done := r.doneCount()
total := len(r.steps)
pct := 0
if total > 0 {
pct = done * 100 / total
}
header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct)
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
for i, step := range r.steps {
var line string
if step.Done {
line = checkDoneStyle.Render("[✓] " + step.Text)
} else {
line = checkPendingStyle.Render("[ ] " + step.Text)
}
if i == r.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit"))
return b.String()
}
+65
View File
@@ -0,0 +1,65 @@
package tui
import (
"encoding/json"
"testing"
)
func TestParseChecklist(t *testing.T) {
tests := []struct {
name string
cardData *string
wantLen int
}{
{"nil data", nil, 0},
{"empty JSON", ptr("{}"), 0},
{"malformed JSON", ptr("{bad"), 0},
{"valid steps", ptr(`{"steps":[{"text":"step 1","done":false},{"text":"step 2","done":true}]}`), 2},
{"empty steps array", ptr(`{"steps":[]}`), 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseChecklist(tt.cardData)
if len(got) != tt.wantLen {
t.Fatalf("parseChecklist() returned %d steps, want %d", len(got), tt.wantLen)
}
})
}
}
func TestParseChecklist_PreservesDoneState(t *testing.T) {
data := `{"steps":[{"text":"first","done":false},{"text":"second","done":true}]}`
steps := parseChecklist(&data)
if steps[0].Done {
t.Fatal("step 0 should not be done")
}
if !steps[1].Done {
t.Fatal("step 1 should be done")
}
if steps[0].Text != "first" || steps[1].Text != "second" {
t.Fatalf("texts wrong: %q, %q", steps[0].Text, steps[1].Text)
}
}
func TestDoneCount(t *testing.T) {
r := newRunModel("id", ptr(`{"steps":[{"label":"a","done":true},{"label":"b","done":false},{"label":"c","done":true}]}`))
if got := r.doneCount(); got != 2 {
t.Fatalf("doneCount() = %d, want 2", got)
}
}
func TestStepsJSON_Roundtrip(t *testing.T) {
r := newRunModel("id", ptr(`{"steps":[{"text":"test","done":false}]}`))
out := r.stepsJSON()
var parsed struct {
Steps []runStep `json:"steps"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("stepsJSON() produced invalid JSON: %v", err)
}
if len(parsed.Steps) != 1 || parsed.Steps[0].Text != "test" {
t.Fatalf("roundtrip failed: %+v", parsed.Steps)
}
}
+55
View File
@@ -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)
}
+70
View File
@@ -0,0 +1,70 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func ptr[T any](v T) *T { return &v }
func TestFilterEntities(t *testing.T) {
entities := []*db.Entity{
{ID: "1", Body: "buy groceries", Tags: []string{"errand", "food"}},
{ID: "2", Body: "read chapter 5", Title: ptr("Go Book"), Tags: []string{"study"}},
{ID: "3", Body: "fix login bug", Description: ptr("auth middleware broken"), Tags: []string{"work", "urgent"}},
{ID: "4", Body: "empty tags"},
}
tests := []struct {
name string
query string
tags []string
wantIDs []string
}{
{"no filter returns all", "", nil, []string{"1", "2", "3", "4"}},
{"query matches body", "groceries", nil, []string{"1"}},
{"query case insensitive", "GROCERIES", nil, []string{"1"}},
{"query matches title", "go book", nil, []string{"2"}},
{"query matches description", "middleware", nil, []string{"3"}},
{"query no match", "nonexistent", nil, nil},
{"single tag filter", "", []string{"study"}, []string{"2"}},
{"multi tag filter all present", "", []string{"work", "urgent"}, []string{"3"}},
{"multi tag filter partial miss", "", []string{"work", "food"}, nil},
{"tag filter no match", "", []string{"missing"}, nil},
{"query plus tag", "fix", []string{"work"}, []string{"3"}},
{"query plus tag mismatch", "groceries", []string{"work"}, nil},
{"entity with nil title and description", "empty", nil, []string{"4"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterEntities(entities, tt.query, tt.tags)
gotIDs := make([]string, len(got))
for i, e := range got {
gotIDs[i] = e.ID
}
if len(tt.wantIDs) == 0 && len(gotIDs) == 0 {
return
}
if len(gotIDs) != len(tt.wantIDs) {
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
}
for i := range gotIDs {
if gotIDs[i] != tt.wantIDs[i] {
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
}
}
})
}
}
func TestMatchesSearch_NilFields(t *testing.T) {
e := &db.Entity{Body: "hello world"}
if !matchesSearch(e, "hello", nil) {
t.Fatal("should match body")
}
if matchesSearch(e, "title", nil) {
t.Fatal("should not match nil title")
}
}
+71
View File
@@ -0,0 +1,71 @@
package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
func renderStatusBar(m model, width int) string {
left := countText(m)
right := contextHints(m)
leftRendered := statusStyle.Render(left)
rightRendered := helpStyle.Render(right)
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered)
if gap < 0 {
gap = 0
}
pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + rightRendered
}
func countText(m model) string {
var total int
if m.mode == modeCards {
total = len(m.cards.filtered)
} else {
total = len(m.list.displayEntities())
}
if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
}
return fmt.Sprintf("%d entities", total)
}
func contextHints(m model) string {
switch m.state {
case stateDetail:
switch m.detail.mode {
case detailRun:
return "space:toggle j/k:nav r:reset esc:save+exit"
case detailFill:
return "tab:next shift+tab:prev enter:copy esc:cancel"
default:
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
}
case stateInput:
return ""
case stateTagFilter:
return "j/k:nav enter:select esc:cancel"
case stateConfirm:
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.splitDetail {
if m.focus == focusDetail {
return "h:list c:copy e:edit p:promote D:demote !:pin esc:back"
}
return "l:detail a:add d:del #:filter esc:close ?:help q:quit"
}
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/?search m:absorb d:del #:filter ?:help q:quit"
}
}
+125
View File
@@ -0,0 +1,125 @@
package tui
import "github.com/charmbracelet/lipgloss"
var (
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(highlight).
PaddingLeft(1)
statusStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
listItemStyle = lipgloss.NewStyle().
PaddingLeft(2)
selectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Bold(true).
Foreground(highlight).
SetString("")
glyphStyle = lipgloss.NewStyle().
Width(2)
completedGlyphStyle = lipgloss.NewStyle().
Width(2).
Foreground(dim)
tagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
idStyle = lipgloss.NewStyle().
Foreground(dim)
inputPromptStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
detailHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(highlight).
MarginBottom(1)
detailBodyStyle = lipgloss.NewStyle().
PaddingLeft(2).
PaddingTop(1)
helpStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"})
filterPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
Bold(true)
helpKeyStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true).
Width(18)
helpDescStyle = lipgloss.NewStyle().
Foreground(dim)
affordanceStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}).
Bold(true)
useCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"})
modeStyle = lipgloss.NewStyle().
Foreground(dim).
Bold(true)
detailLabelStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"})
checkDoneStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
checkPendingStyle = lipgloss.NewStyle().
Foreground(dim)
searchPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
Bold(true)
gutterStyle = lipgloss.NewStyle().
Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().
Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}).
PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().
Foreground(dim)
)
+20
View File
@@ -0,0 +1,20 @@
package tui
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
)
func Run(store *db.Store) error {
m := newModel(store)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
return err
}
return nil
}
+6 -6
View File
@@ -1247,9 +1247,9 @@
async function loadEntities() { async function loadEntities() {
const params = buildListParams(0); const params = buildListParams(0);
const results = await api.listEntities(params); const resp = await api.listEntities(params);
state.entities = results; state.entities = resp.data;
state.hasMore = results.length === PAGE_SIZE; state.hasMore = (resp.offset + resp.data.length) < resp.total;
state.selectedIndex = -1; state.selectedIndex = -1;
renderEntityList(); renderEntityList();
renderDetailPane(); renderDetailPane();
@@ -1258,9 +1258,9 @@
async function loadMore() { async function loadMore() {
const params = buildListParams(state.entities.length); const params = buildListParams(state.entities.length);
const results = await api.listEntities(params); const resp = await api.listEntities(params);
state.entities = state.entities.concat(results); state.entities = state.entities.concat(resp.data);
state.hasMore = results.length === PAGE_SIZE; state.hasMore = (resp.offset + resp.data.length) < resp.total;
renderEntityList(); renderEntityList();
} }