feat(tui): add bubbletea terminal UI #30
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", ¬e, intentGrab, false},
|
||||||
|
|
||||||
|
{"read matches note", ¬e, 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", ¬e, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user