Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 618335513b | |||
| 476abbed00 | |||
| 39975a6787 | |||
| 778fab3edd | |||
| a141b2fd4f | |||
| f89ca8acb9 | |||
| e09919b679 | |||
| babf1d6620 | |||
| 77222ff1b8 | |||
| 1066c0bc7d | |||
| ce335cabd6 | |||
| c2ea63dd16 | |||
| 36999cd825 | |||
| d995d1e708 | |||
| dd8878ebcf | |||
| 805467486b | |||
| 4980714583 | |||
| 6d8170d219 | |||
| 73c6a315c1 | |||
| d5fa6cc56b | |||
| 8555d0da19 | |||
| ec907d0e0d | |||
| a854f02854 | |||
| 824192f581 | |||
| c2506ef7fd | |||
| 2b177eeae9 | |||
| 840084fbb0 | |||
| 4ec876b2d2 |
@@ -0,0 +1,18 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
bin = "./tmp/nib"
|
||||
cmd = "go build -o ./tmp/nib ."
|
||||
args_bin = ["serve"]
|
||||
delay = 500
|
||||
exclude_dir = ["tmp", "testdata", "docs"]
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
include_ext = ["go", "html", "css", "js", "svg"]
|
||||
kill_delay = 500
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
@@ -1,5 +1,7 @@
|
||||
# Binary
|
||||
nib
|
||||
tmp/
|
||||
certs/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
BINARY := nib
|
||||
MODULE := github.com/lerko/nib
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
.PHONY: build dev tui watch test lint fmt vet clean run cert help
|
||||
|
||||
## —— Build ——————————————————————————————————
|
||||
|
||||
build: ## Build production binary
|
||||
go build $(GOFLAGS) -o $(BINARY) .
|
||||
|
||||
dev: ## Build and run with default serve
|
||||
go run . serve
|
||||
|
||||
tui: ## Launch the terminal UI
|
||||
go run . tui
|
||||
|
||||
watch: ## Live-reload dev server (requires air)
|
||||
air
|
||||
|
||||
## —— Quality ————————————————————————————————
|
||||
|
||||
test: ## Run all tests
|
||||
go test ./...
|
||||
|
||||
test-v: ## Run all tests (verbose)
|
||||
go test -v ./...
|
||||
|
||||
test-cover: ## Run tests with coverage report
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
@rm -f coverage.out
|
||||
|
||||
lint: vet fmt-check ## Run all linters
|
||||
|
||||
vet: ## Run go vet
|
||||
go vet ./...
|
||||
|
||||
fmt: ## Format all Go files
|
||||
gofmt -w .
|
||||
|
||||
fmt-check: ## Check formatting (fails if unformatted)
|
||||
@test -z "$$(gofmt -l .)" || (echo "unformatted files:" && gofmt -l . && exit 1)
|
||||
|
||||
## —— Utility ————————————————————————————————
|
||||
|
||||
cert: ## Generate self-signed dev TLS cert (certs/)
|
||||
@mkdir -p certs
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-nodes -keyout certs/dev.key -out certs/dev.crt -days 365 \
|
||||
-subj "/CN=localhost"
|
||||
@echo " certs/dev.crt and certs/dev.key ready"
|
||||
@echo " usage: make run ARGS=\"serve --tls-cert certs/dev.crt --tls-key certs/dev.key\""
|
||||
|
||||
clean: ## Remove build artifacts
|
||||
rm -f $(BINARY) coverage.out
|
||||
|
||||
run: build ## Build then run with args (usage: make run ARGS="serve")
|
||||
./$(BINARY) $(ARGS)
|
||||
|
||||
tidy: ## Tidy and verify module dependencies
|
||||
go mod tidy
|
||||
go mod verify
|
||||
|
||||
## —— Help ———————————————————————————————————
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -40,6 +40,9 @@ nib ls --tag ops
|
||||
# list a specific month
|
||||
nib ls --month 2026-05
|
||||
|
||||
# terminal UI
|
||||
nib tui
|
||||
|
||||
# start the web UI
|
||||
nib serve
|
||||
```
|
||||
@@ -122,9 +125,22 @@ Or use `^type` inline: `nib "proxy trick #nginx ^card"`
|
||||
| `nib absorb <target> <source>` | Merge source into target |
|
||||
| `nib delete <id>` | Soft delete (repeat to hard delete) |
|
||||
| `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.
|
||||
|
||||
## 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
|
||||
|
||||
`nib serve` starts a local web interface with:
|
||||
|
||||
+3
-76
@@ -1,11 +1,9 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/lerko/nib/internal/carddata"
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -47,9 +45,9 @@ func runPromote(_ *cobra.Command, args []string) error {
|
||||
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 {
|
||||
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
||||
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)
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +27,10 @@ func Execute() error {
|
||||
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
||||
if first != "help" && first != "completion" &&
|
||||
!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
|
||||
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
||||
}
|
||||
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
|
||||
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() {
|
||||
rootCmd.AddCommand(addCmd)
|
||||
rootCmd.AddCommand(lsCmd)
|
||||
|
||||
+29
-5
@@ -19,7 +19,10 @@ var WebFS fs.FS
|
||||
|
||||
var (
|
||||
servePort int
|
||||
serveHost string
|
||||
serveDev bool
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
@@ -29,12 +32,20 @@ var serveCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444)")
|
||||
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().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
|
||||
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func runServe(_ *cobra.Command, _ []string) error {
|
||||
useTLS := tlsCert != "" && tlsKey != ""
|
||||
if (tlsCert != "") != (tlsKey != "") {
|
||||
return fmt.Errorf("both --tls-cert and --tls-key are required for TLS")
|
||||
}
|
||||
|
||||
port := servePort
|
||||
if port == 0 {
|
||||
if envPort := os.Getenv("NIB_PORT"); envPort != "" {
|
||||
@@ -43,6 +54,8 @@ func runServe(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("invalid NIB_PORT: %w", err)
|
||||
}
|
||||
port = p
|
||||
} else if useTLS {
|
||||
port = 4443
|
||||
} else {
|
||||
port = 4444
|
||||
}
|
||||
@@ -59,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
|
||||
router = api.NewRouter(store, serveDev, WebFS)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
addr := fmt.Sprintf("%s:%d", serveHost, port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
@@ -69,12 +82,23 @@ func runServe(_ *cobra.Command, _ []string) error {
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
fmt.Printf("nib serving on %s\n", addr)
|
||||
if useTLS {
|
||||
fmt.Printf("nib serving on https://%s\n", addr)
|
||||
} else {
|
||||
fmt.Printf("nib serving on http://%s\n", addr)
|
||||
}
|
||||
if serveDev {
|
||||
fmt.Println(" CORS enabled (dev mode)")
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
|
||||
var listenErr error
|
||||
if useTLS {
|
||||
listenErr = srv.ListenAndServeTLS(tlsCert, tlsKey)
|
||||
} else {
|
||||
listenErr = srv.ListenAndServe()
|
||||
}
|
||||
if listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", listenErr)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Development Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.24+
|
||||
- [air](https://github.com/air-verse/air) (live-reload, install: `go install github.com/air-verse/air@latest`)
|
||||
- OpenSSL (for dev TLS certs)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
make cert # one-time: generate self-signed TLS cert
|
||||
make watch # start dev server with live-reload (HTTP, port 4444)
|
||||
```
|
||||
|
||||
## Make Targets
|
||||
|
||||
### Build
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make build` | Compile production binary (`./nib`) |
|
||||
| `make dev` | Run dev server from source (HTTP, port 4444) |
|
||||
| `make watch` | Live-reload dev server via air — auto-rebuilds on save |
|
||||
|
||||
### Quality
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make test` | Run all tests |
|
||||
| `make test-v` | Run all tests with verbose output |
|
||||
| `make test-cover` | Run tests with per-function coverage report |
|
||||
| `make lint` | Run vet + format check |
|
||||
| `make vet` | Static analysis via `go vet` |
|
||||
| `make fmt` | Auto-format all Go files |
|
||||
| `make fmt-check` | Check formatting without modifying (CI-friendly) |
|
||||
|
||||
### Utility
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make cert` | Generate self-signed dev TLS cert in `certs/` (valid 365 days) |
|
||||
| `make clean` | Remove build artifacts |
|
||||
| `make tidy` | Tidy and verify Go module dependencies |
|
||||
| `make run ARGS="..."` | Build then run with custom args |
|
||||
| `make help` | List all targets |
|
||||
|
||||
## TLS
|
||||
|
||||
nib supports TLS via `--tls-cert` and `--tls-key` flags on the `serve` command.
|
||||
|
||||
### Dev (self-signed)
|
||||
|
||||
```sh
|
||||
make cert
|
||||
make run ARGS="serve --tls-cert certs/dev.crt --tls-key certs/dev.key"
|
||||
```
|
||||
|
||||
Serves HTTPS on port 4443. Browser will warn about the self-signed cert — accept once.
|
||||
|
||||
This is needed for features that require a secure context (e.g. clipboard API).
|
||||
|
||||
### Production
|
||||
|
||||
For production, use a reverse proxy (Caddy, nginx) with real certificates in front of nib's HTTP server. Alternatively, pass real cert/key paths directly:
|
||||
|
||||
```sh
|
||||
./nib serve --tls-cert /path/to/cert.pem --tls-key /path/to/key.pem
|
||||
```
|
||||
|
||||
### Port Defaults
|
||||
|
||||
| Mode | Default Port |
|
||||
|------|-------------|
|
||||
| HTTP | 4444 |
|
||||
| HTTPS | 4443 |
|
||||
|
||||
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
|
||||
|
||||
1. `make cert` — once, generates dev TLS cert
|
||||
2. `make watch` — start coding, air rebuilds on save
|
||||
3. Edit code, save, changes appear automatically
|
||||
4. `make test` — verify nothing broke
|
||||
5. `make lint` — check formatting and vet
|
||||
6. Commit and push
|
||||
@@ -4,6 +4,9 @@ go 1.24.4
|
||||
|
||||
require (
|
||||
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/oklog/ulid/v2 v2.1.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -11,15 +14,33 @@ 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.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-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/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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/mathutil v1.7.1 // 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/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/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/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/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
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/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=
|
||||
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=
|
||||
@@ -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/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
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/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+33
-17
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.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 {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(entities))
|
||||
}
|
||||
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(entities))
|
||||
}
|
||||
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1 card, got %d", len(entities))
|
||||
}
|
||||
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var page1 []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&page1)
|
||||
page1 := decodeList(t, resp)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var page2 []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&page2)
|
||||
page2 := decodeList(t, resp)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(page1) != 2 || len(page2) != 2 {
|
||||
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(listResp.Body).Decode(&entities)
|
||||
entities := decodeList(t, listResp)
|
||||
listResp.Body.Close()
|
||||
for _, ent := range entities {
|
||||
if ent.ID == source.ID {
|
||||
@@ -630,7 +638,10 @@ func TestUpdateEntity_Title(t *testing.T) {
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||
mustJSON(map[string]any{"title": "new title"})))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -651,7 +662,10 @@ func TestUpdateEntity_Description(t *testing.T) {
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||
mustJSON(map[string]any{"description": "new desc"})))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -674,11 +688,13 @@ func TestListEntities_TitleInResponse(t *testing.T) {
|
||||
"title": title,
|
||||
}).Body.Close()
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/entities")
|
||||
resp, err := http.Get(srv.URL + "/api/entities")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(entities))
|
||||
}
|
||||
|
||||
@@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]EntityResponse, len(entities))
|
||||
items := make([]EntityResponse, len(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 == db.ErrInvalidCardData {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
if err == db.ErrInvalidCardData {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
||||
return
|
||||
}
|
||||
if err == db.ErrInvalidCardData {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
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 (
|
||||
"encoding/json"
|
||||
@@ -8,14 +8,14 @@ import (
|
||||
)
|
||||
|
||||
func TestGenerateCardData_Snippet(t *testing.T) {
|
||||
data := generateCardData(db.CardSnippet, "some snippet")
|
||||
data := GenerateCardData(db.CardSnippet, "some snippet")
|
||||
if data == nil || *data != "{}" {
|
||||
t.Errorf("snippet should produce {}, got %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestGenerateCardData_Template(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 {
|
||||
Slots []struct {
|
||||
Name string `json:"name"`
|
||||
@@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(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 {
|
||||
Slots []struct {
|
||||
Name string `json:"name"`
|
||||
@@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
||||
|
||||
func TestGenerateCardData_Checklist(t *testing.T) {
|
||||
body := "[ ] step one\n[x] step two\n[ ] step three"
|
||||
data := generateCardData(db.CardChecklist, body)
|
||||
data := GenerateCardData(db.CardChecklist, body)
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(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 {
|
||||
Steps []struct {
|
||||
Text string `json:"text"`
|
||||
@@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateCardData_Decision(t *testing.T) {
|
||||
data := generateCardData(db.CardDecision, "which db?")
|
||||
data := GenerateCardData(db.CardDecision, "which db?")
|
||||
var parsed struct {
|
||||
Chose string `json:"chose"`
|
||||
Why string `json:"why"`
|
||||
@@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(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 {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestGenerateCardData_Link(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 {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
+107
-10
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -14,6 +15,7 @@ var (
|
||||
ErrAlreadyPromoted = errors.New("invalid_promote")
|
||||
ErrAlreadyFluid = errors.New("invalid_demote")
|
||||
ErrTargetCrystallized = errors.New("invalid_absorb")
|
||||
ErrInvalidCardData = errors.New("invalid_card_data")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
@@ -49,22 +51,23 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
const currentSchema = 3
|
||||
|
||||
var migrations = []func(db *sql.DB) error{
|
||||
// v1: initial schema
|
||||
func(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
glyph TEXT NOT NULL
|
||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
||||
glyph TEXT NOT NULL,
|
||||
time_anchor TEXT,
|
||||
completed_at TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
card_type TEXT
|
||||
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link')
|
||||
OR card_type IS NULL),
|
||||
card_type TEXT,
|
||||
card_data TEXT,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at TEXT
|
||||
@@ -84,14 +87,108 @@ func (s *Store) migrate() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
||||
ON entity_tags(tag);
|
||||
`)
|
||||
return err
|
||||
},
|
||||
|
||||
// v2: add title and description columns
|
||||
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
|
||||
},
|
||||
|
||||
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||
func(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
|
||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
|
||||
// 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)
|
||||
}
|
||||
|
||||
return nil
|
||||
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||
return fmt.Errorf("migrate rename: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`CREATE TABLE entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
glyph TEXT NOT NULL
|
||||
CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
|
||||
time_anchor TEXT,
|
||||
completed_at TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
card_type TEXT
|
||||
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note')
|
||||
OR card_type IS NULL),
|
||||
card_data TEXT,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
title TEXT,
|
||||
description TEXT
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("migrate create: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO entities SELECT * FROM _entities_migrate`); err != nil {
|
||||
return fmt.Errorf("migrate copy: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
|
||||
return fmt.Errorf("migrate drop: %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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+48
-8
@@ -27,6 +27,7 @@ const (
|
||||
CardChecklist CardType = "checklist"
|
||||
CardDecision CardType = "decision"
|
||||
CardLink CardType = "link"
|
||||
CardNote CardType = "note"
|
||||
)
|
||||
|
||||
func ValidGlyph(s string) bool {
|
||||
@@ -39,7 +40,7 @@ func ValidGlyph(s string) bool {
|
||||
|
||||
func ValidCardType(s string) bool {
|
||||
switch CardType(s) {
|
||||
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink:
|
||||
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -94,6 +95,8 @@ type EntityUpdate struct {
|
||||
Glyph *Glyph
|
||||
TimeAnchor *string
|
||||
ClearTime bool
|
||||
CompletedAt *time.Time
|
||||
ClearCompleted bool
|
||||
Pinned *bool
|
||||
CardType *CardType
|
||||
CardData *string
|
||||
@@ -101,6 +104,9 @@ type EntityUpdate struct {
|
||||
}
|
||||
|
||||
func (s *Store) Create(e *Entity) error {
|
||||
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
|
||||
return ErrInvalidCardData
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
e.ID = nibulid.New()
|
||||
e.CreatedAt = now
|
||||
@@ -176,7 +182,7 @@ func (s *Store) Get(id string) (*Entity, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
func listWhere(params ListParams) (string, []any) {
|
||||
var where []string
|
||||
var args []any
|
||||
|
||||
@@ -211,18 +217,39 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
args = append(args, string(*params.CardTypeFilter))
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
clause := ""
|
||||
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"
|
||||
if params.Sort == "use_count" {
|
||||
switch params.Sort {
|
||||
case "use_count":
|
||||
orderCol = "e.use_count"
|
||||
case "created_at", "":
|
||||
orderCol = "e.created_at"
|
||||
default:
|
||||
orderCol = "e.created_at"
|
||||
}
|
||||
orderDir := "DESC"
|
||||
if strings.EqualFold(params.Order, "asc") {
|
||||
switch strings.ToLower(params.Order) {
|
||||
case "asc":
|
||||
orderDir = "ASC"
|
||||
default:
|
||||
orderDir = "DESC"
|
||||
}
|
||||
|
||||
limit := params.Limit
|
||||
@@ -310,6 +337,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
||||
sets = append(sets, "time_anchor = ?")
|
||||
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 {
|
||||
sets = append(sets, "pinned = ?")
|
||||
args = append(args, boolToInt(*u.Pinned))
|
||||
@@ -319,6 +352,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
||||
args = append(args, string(*u.CardType))
|
||||
}
|
||||
if u.CardData != nil {
|
||||
if !json.Valid([]byte(*u.CardData)) {
|
||||
return ErrInvalidCardData
|
||||
}
|
||||
sets = append(sets, "card_data = ?")
|
||||
args = append(args, *u.CardData)
|
||||
}
|
||||
@@ -353,6 +389,9 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
||||
|
||||
dataVal := "{}"
|
||||
if cardData != nil {
|
||||
if !json.Valid([]byte(*cardData)) {
|
||||
return ErrInvalidCardData
|
||||
}
|
||||
dataVal = *cardData
|
||||
}
|
||||
|
||||
@@ -456,8 +495,9 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
||||
now, sourceID); err != nil {
|
||||
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
|
||||
if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
|
||||
absorbNote, now, now, sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{
|
||||
db.CardChecklist: "☐",
|
||||
db.CardDecision: "⚖",
|
||||
db.CardLink: "↗",
|
||||
db.CardNote: "¶",
|
||||
}
|
||||
|
||||
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
||||
|
||||
@@ -27,6 +27,8 @@ var validCardTypes = map[string]string{
|
||||
"checklist": "checklist",
|
||||
"decision": "decision",
|
||||
"link": "link",
|
||||
"note": "note",
|
||||
"n": "note",
|
||||
}
|
||||
|
||||
func Parse(input string) (*Result, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+150
-21
@@ -4,19 +4,22 @@
|
||||
const GLYPHS = {
|
||||
note: '—', todo: '○', event: '◇', reminder: '△',
|
||||
snippet: '◆', template: '◈', checklist: '☐',
|
||||
decision: '⚖', link: '↗',
|
||||
decision: '⚖', link: '↗', note: '¶',
|
||||
};
|
||||
|
||||
const GLYPH_CLASSES = {
|
||||
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder',
|
||||
snippet: 'glyph-snippet', template: 'glyph-template',
|
||||
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
||||
link: 'glyph-link',
|
||||
link: 'glyph-link', note: 'glyph-note',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
|
||||
const READ_TYPES = ['note', 'link', 'decision'];
|
||||
const FILL_TYPES = ['template', 'checklist'];
|
||||
const GRAB_TYPES = ['snippet'];
|
||||
|
||||
const state = {
|
||||
view: 'stream',
|
||||
@@ -121,7 +124,7 @@
|
||||
|
||||
// ========== Grammar parser (mirrors Go parser) ==========
|
||||
|
||||
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
|
||||
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link', note: 'note', n: 'note' };
|
||||
|
||||
function validateTime(s) {
|
||||
const parts = s.split(':');
|
||||
@@ -363,7 +366,7 @@
|
||||
html += '<div class="rail-lbl">intent</div>';
|
||||
for (const k of ['grab', 'read', 'fill']) {
|
||||
const on = state.intent === k ? ' on' : '';
|
||||
const count = k === 'grab' ? state.entities.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length;
|
||||
const count = k === 'grab' ? state.entities.filter(e => !e.card_type || GRAB_TYPES.includes(e.card_type)).length : k === 'read' ? state.entities.filter(e => READ_TYPES.includes(e.card_type)).length : state.entities.filter(e => FILL_TYPES.includes(e.card_type)).length;
|
||||
html += `<button class="rail-item${on}" data-intent="${k}">`;
|
||||
html += `<span class="rail-arrow">${state.intent === k ? '▸' : ''}</span>`;
|
||||
html += '<span class="rail-dot"></span>';
|
||||
@@ -664,12 +667,65 @@
|
||||
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote</button>`;
|
||||
}
|
||||
if (e.card_type) {
|
||||
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy</button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
} else {
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (e.card_type) {
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const hasDecision = data.chose != null;
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasLink = !!data.url;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasLink && !hasDecision) {
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||
}
|
||||
if (hasFill) {
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||
}
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
} else {
|
||||
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
||||
}
|
||||
|
||||
return `<div class="exp-inner">
|
||||
<div class="exp-body md">${renderMd(e.body || '')}</div>
|
||||
${content}
|
||||
${tags ? `<div class="exp-tags">${tags}</div>` : ''}
|
||||
<div class="exp-acts">${actions}</div>
|
||||
<div class="exp-toolbar">
|
||||
@@ -1191,9 +1247,9 @@
|
||||
|
||||
async function loadEntities() {
|
||||
const params = buildListParams(0);
|
||||
const results = await api.listEntities(params);
|
||||
state.entities = results;
|
||||
state.hasMore = results.length === PAGE_SIZE;
|
||||
const resp = await api.listEntities(params);
|
||||
state.entities = resp.data;
|
||||
state.hasMore = (resp.offset + resp.data.length) < resp.total;
|
||||
state.selectedIndex = -1;
|
||||
renderEntityList();
|
||||
renderDetailPane();
|
||||
@@ -1202,9 +1258,9 @@
|
||||
|
||||
async function loadMore() {
|
||||
const params = buildListParams(state.entities.length);
|
||||
const results = await api.listEntities(params);
|
||||
state.entities = state.entities.concat(results);
|
||||
state.hasMore = results.length === PAGE_SIZE;
|
||||
const resp = await api.listEntities(params);
|
||||
state.entities = state.entities.concat(resp.data);
|
||||
state.hasMore = (resp.offset + resp.data.length) < resp.total;
|
||||
renderEntityList();
|
||||
}
|
||||
|
||||
@@ -1317,9 +1373,13 @@
|
||||
},
|
||||
|
||||
async deleteEntity(id) {
|
||||
const prevIdx = state.selectedIndex;
|
||||
await api.deleteEntity(id);
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
if (state.entities.length > 0) {
|
||||
selectEntity(Math.min(prevIdx, state.entities.length - 1));
|
||||
}
|
||||
showToast('deleted');
|
||||
},
|
||||
|
||||
@@ -1785,9 +1845,10 @@
|
||||
});
|
||||
|
||||
function filterByIntent(entities) {
|
||||
if (state.view !== 'cards' || state.intent === 'grab') return entities;
|
||||
if (state.intent === 'read') return entities.filter(e => e.card_data);
|
||||
if (state.intent === 'fill') return entities.filter(e => e.body && /\$\{.+\}/.test(e.body));
|
||||
if (state.view !== 'cards') return entities;
|
||||
if (state.intent === 'grab') return entities.filter(e => !e.card_type || GRAB_TYPES.includes(e.card_type));
|
||||
if (state.intent === 'read') return entities.filter(e => READ_TYPES.includes(e.card_type));
|
||||
if (state.intent === 'fill') return entities.filter(e => FILL_TYPES.includes(e.card_type));
|
||||
return entities;
|
||||
}
|
||||
|
||||
@@ -1831,20 +1892,88 @@
|
||||
|
||||
// ========== Theme ==========
|
||||
|
||||
const THEMES = ['dark', 'paper', 'tinycard'];
|
||||
const THEME_ICONS = { dark: '◑', paper: '◐', tinycard: '◈' };
|
||||
const THEMES_DARK = [
|
||||
{ id: 'dark', label: 'Noir', swatch: '#c8942a' },
|
||||
{ id: 'tinycard', label: 'Tinycard', swatch: '#ad8ee6' },
|
||||
{ id: 'catppuccin', label: 'Catppuccin', swatch: '#cba6f7' },
|
||||
{ id: 'nord', label: 'Nord', swatch: '#88c0d0' },
|
||||
{ id: 'dracula', label: 'Dracula', swatch: '#bd93f9' },
|
||||
{ id: 'gruvbox', label: 'Gruvbox', swatch: '#fabd2f' },
|
||||
{ id: 'rosepine', label: 'Rosé Pine', swatch: '#c4a7e7' },
|
||||
{ id: 'tokyonight', label: 'Tokyo Night', swatch: '#7aa2f7' },
|
||||
{ id: 'solarized', label: 'Solarized', swatch: '#268bd2' },
|
||||
];
|
||||
const THEMES_LIGHT = [
|
||||
{ id: 'paper', label: 'Paper', swatch: '#8a6018' },
|
||||
{ id: 'catppuccin-latte', label: 'Catppuccin Latte', swatch: '#8839ef' },
|
||||
{ id: 'rosepine-dawn', label: 'Rosé Pine Dawn', swatch: '#907aa9' },
|
||||
{ id: 'solarized-light', label: 'Solarized Light', swatch: '#268bd2' },
|
||||
];
|
||||
const ALL_THEME_IDS = [...THEMES_DARK, ...THEMES_LIGHT].map(t => t.id);
|
||||
|
||||
const themeToggle = $('#theme-toggle');
|
||||
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
|
||||
if (!THEMES.includes(nibTheme)) nibTheme = 'dark';
|
||||
if (!ALL_THEME_IDS.includes(nibTheme)) nibTheme = 'dark';
|
||||
document.documentElement.setAttribute('data-theme', nibTheme);
|
||||
themeToggle.textContent = THEME_ICONS[nibTheme];
|
||||
themeToggle.textContent = '◑';
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
nibTheme = THEMES[(THEMES.indexOf(nibTheme) + 1) % THEMES.length];
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'theme-popover';
|
||||
function buildPopover() {
|
||||
popover.innerHTML = '';
|
||||
const addSection = (label, themes) => {
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'theme-popover-label';
|
||||
lbl.textContent = label;
|
||||
popover.appendChild(lbl);
|
||||
themes.forEach(t => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'theme-popover-item' + (t.id === nibTheme ? ' active' : '');
|
||||
item.dataset.theme = t.id;
|
||||
item.innerHTML = `<span class="theme-popover-swatch" style="background:${t.swatch}"></span>${t.label}`;
|
||||
popover.appendChild(item);
|
||||
});
|
||||
};
|
||||
addSection('Dark', THEMES_DARK);
|
||||
addSection('Light', THEMES_LIGHT);
|
||||
}
|
||||
buildPopover();
|
||||
themeToggle.appendChild(popover);
|
||||
|
||||
let previewTheme = null;
|
||||
themeToggle.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.theme-popover-item')) return;
|
||||
popover.classList.toggle('open');
|
||||
});
|
||||
|
||||
popover.addEventListener('mouseover', (e) => {
|
||||
const item = e.target.closest('.theme-popover-item');
|
||||
if (!item) return;
|
||||
previewTheme = item.dataset.theme;
|
||||
document.documentElement.setAttribute('data-theme', previewTheme);
|
||||
});
|
||||
|
||||
popover.addEventListener('mouseleave', () => {
|
||||
previewTheme = null;
|
||||
document.documentElement.setAttribute('data-theme', nibTheme);
|
||||
});
|
||||
|
||||
popover.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.theme-popover-item');
|
||||
if (!item) return;
|
||||
nibTheme = item.dataset.theme;
|
||||
previewTheme = null;
|
||||
document.documentElement.setAttribute('data-theme', nibTheme);
|
||||
localStorage.setItem('nib:theme', nibTheme);
|
||||
themeToggle.textContent = THEME_ICONS[nibTheme];
|
||||
popover.classList.remove('open');
|
||||
buildPopover();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!themeToggle.contains(e.target)) popover.classList.remove('open');
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') popover.classList.remove('open');
|
||||
});
|
||||
|
||||
// ========== Init ==========
|
||||
|
||||
+33
-19
@@ -44,31 +44,45 @@
|
||||
<h3>promote to card</h3>
|
||||
<div class="modal-sub" id="promote-sub"></div>
|
||||
<div class="type-picker">
|
||||
<button data-type="snippet" class="type-btn">
|
||||
<span class="type-glyph glyph-snippet">◆</span>
|
||||
<span class="type-name">snippet</span>
|
||||
<span class="type-hint">quick reference, command, code</span>
|
||||
</button>
|
||||
<button data-type="template" class="type-btn">
|
||||
<span class="type-glyph glyph-template">◈</span>
|
||||
<span class="type-name">template</span>
|
||||
<span class="type-hint">fillable with ${slot}s</span>
|
||||
</button>
|
||||
<button data-type="checklist" class="type-btn">
|
||||
<span class="type-glyph glyph-checklist">☐</span>
|
||||
<span class="type-name">checklist</span>
|
||||
<span class="type-hint">step-by-step process</span>
|
||||
</button>
|
||||
<button data-type="decision" class="type-btn">
|
||||
<span class="type-glyph glyph-decision">⚖</span>
|
||||
<span class="type-name">decision</span>
|
||||
<span class="type-hint">record a choice + rationale</span>
|
||||
<div class="type-col">
|
||||
<div class="type-col-lbl">read</div>
|
||||
<button data-type="note" class="type-btn">
|
||||
<span class="type-glyph glyph-note">¶</span>
|
||||
<span class="type-name">note</span>
|
||||
<span class="type-hint">markdown content</span>
|
||||
</button>
|
||||
<button data-type="link" class="type-btn">
|
||||
<span class="type-glyph glyph-link">↗</span>
|
||||
<span class="type-name">link</span>
|
||||
<span class="type-hint">reference URL</span>
|
||||
</button>
|
||||
<button data-type="decision" class="type-btn">
|
||||
<span class="type-glyph glyph-decision">⚖</span>
|
||||
<span class="type-name">decision</span>
|
||||
<span class="type-hint">choice + rationale</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="type-col">
|
||||
<div class="type-col-lbl">grab</div>
|
||||
<button data-type="snippet" class="type-btn">
|
||||
<span class="type-glyph glyph-snippet">◆</span>
|
||||
<span class="type-name">snippet</span>
|
||||
<span class="type-hint">code, command, text</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="type-col">
|
||||
<div class="type-col-lbl">fill</div>
|
||||
<button data-type="template" class="type-btn">
|
||||
<span class="type-glyph glyph-template">◈</span>
|
||||
<span class="type-name">template</span>
|
||||
<span class="type-hint">fillable ${slot}s</span>
|
||||
</button>
|
||||
<button data-type="checklist" class="type-btn">
|
||||
<span class="type-glyph glyph-checklist">☐</span>
|
||||
<span class="type-name">checklist</span>
|
||||
<span class="type-hint">step-by-step</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close">esc to cancel</button>
|
||||
</div>
|
||||
|
||||
+330
-25
@@ -74,6 +74,226 @@
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1e1e2e;
|
||||
--surf: #181825;
|
||||
--raised: #313244;
|
||||
--border: #45475a;
|
||||
--soft: #252536;
|
||||
--text: #cdd6f4;
|
||||
--muted: #a6adc8;
|
||||
--dim: #6c7086;
|
||||
--accent: #cba6f7;
|
||||
--a-bg: rgba(203,166,247,.09);
|
||||
--a-str: rgba(203,166,247,.22);
|
||||
--todo: #f9e2af;
|
||||
--note: #94e2d5;
|
||||
--event: #89b4fa;
|
||||
--remind: #fab387;
|
||||
--ok: #a6e3a1;
|
||||
--danger: #f38ba8;
|
||||
--lineage: #f5c2e7;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
color-scheme: dark;
|
||||
--bg: #2e3440;
|
||||
--surf: #3b4252;
|
||||
--raised: #434c5e;
|
||||
--border: #4c566a;
|
||||
--soft: #363d4a;
|
||||
--text: #eceff4;
|
||||
--muted: #d8dee9;
|
||||
--dim: #4c566a;
|
||||
--accent: #88c0d0;
|
||||
--a-bg: rgba(136,192,208,.09);
|
||||
--a-str: rgba(136,192,208,.22);
|
||||
--todo: #ebcb8b;
|
||||
--note: #8fbcbb;
|
||||
--event: #81a1c1;
|
||||
--remind: #d08770;
|
||||
--ok: #a3be8c;
|
||||
--danger: #bf616a;
|
||||
--lineage: #b48ead;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
color-scheme: dark;
|
||||
--bg: #282a36;
|
||||
--surf: #21222c;
|
||||
--raised: #44475a;
|
||||
--border: #6272a4;
|
||||
--soft: #343746;
|
||||
--text: #f8f8f2;
|
||||
--muted: #bfbfbf;
|
||||
--dim: #6272a4;
|
||||
--accent: #bd93f9;
|
||||
--a-bg: rgba(189,147,249,.09);
|
||||
--a-str: rgba(189,147,249,.22);
|
||||
--todo: #f1fa8c;
|
||||
--note: #8be9fd;
|
||||
--event: #8be9fd;
|
||||
--remind: #ffb86c;
|
||||
--ok: #50fa7b;
|
||||
--danger: #ff5555;
|
||||
--lineage: #ff79c6;
|
||||
}
|
||||
|
||||
[data-theme="gruvbox"] {
|
||||
color-scheme: dark;
|
||||
--bg: #282828;
|
||||
--surf: #1d2021;
|
||||
--raised: #3c3836;
|
||||
--border: #504945;
|
||||
--soft: #32302f;
|
||||
--text: #ebdbb2;
|
||||
--muted: #a89984;
|
||||
--dim: #665c54;
|
||||
--accent: #fabd2f;
|
||||
--a-bg: rgba(250,189,47,.09);
|
||||
--a-str: rgba(250,189,47,.22);
|
||||
--todo: #fabd2f;
|
||||
--note: #8ec07c;
|
||||
--event: #83a598;
|
||||
--remind: #fe8019;
|
||||
--ok: #b8bb26;
|
||||
--danger: #fb4934;
|
||||
--lineage: #d3869b;
|
||||
}
|
||||
|
||||
[data-theme="rosepine"] {
|
||||
color-scheme: dark;
|
||||
--bg: #191724;
|
||||
--surf: #1f1d2e;
|
||||
--raised: #26233a;
|
||||
--border: #403d52;
|
||||
--soft: #211f30;
|
||||
--text: #e0def4;
|
||||
--muted: #908caa;
|
||||
--dim: #6e6a86;
|
||||
--accent: #c4a7e7;
|
||||
--a-bg: rgba(196,167,231,.09);
|
||||
--a-str: rgba(196,167,231,.22);
|
||||
--todo: #f6c177;
|
||||
--note: #9ccfd8;
|
||||
--event: #31748f;
|
||||
--remind: #ea9a97;
|
||||
--ok: #a6da95;
|
||||
--danger: #eb6f92;
|
||||
--lineage: #c4a7e7;
|
||||
}
|
||||
|
||||
[data-theme="tokyonight"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1a1b26;
|
||||
--surf: #16161e;
|
||||
--raised: #292e42;
|
||||
--border: #3b4261;
|
||||
--soft: #1f2335;
|
||||
--text: #c0caf5;
|
||||
--muted: #a9b1d6;
|
||||
--dim: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--a-bg: rgba(122,162,247,.09);
|
||||
--a-str: rgba(122,162,247,.22);
|
||||
--todo: #e0af68;
|
||||
--note: #7dcfff;
|
||||
--event: #7aa2f7;
|
||||
--remind: #ff9e64;
|
||||
--ok: #9ece6a;
|
||||
--danger: #f7768e;
|
||||
--lineage: #bb9af7;
|
||||
}
|
||||
|
||||
[data-theme="solarized"] {
|
||||
color-scheme: dark;
|
||||
--bg: #002b36;
|
||||
--surf: #073642;
|
||||
--raised: #094552;
|
||||
--border: #586e75;
|
||||
--soft: #05303b;
|
||||
--text: #839496;
|
||||
--muted: #657b83;
|
||||
--dim: #586e75;
|
||||
--accent: #268bd2;
|
||||
--a-bg: rgba(38,139,210,.09);
|
||||
--a-str: rgba(38,139,210,.22);
|
||||
--todo: #b58900;
|
||||
--note: #2aa198;
|
||||
--event: #268bd2;
|
||||
--remind: #cb4b16;
|
||||
--ok: #859900;
|
||||
--danger: #dc322f;
|
||||
--lineage: #6c71c4;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin-latte"] {
|
||||
color-scheme: light;
|
||||
--bg: #eff1f5;
|
||||
--surf: #e6e9ef;
|
||||
--raised: #dce0e8;
|
||||
--border: #ccd0da;
|
||||
--soft: #e4e7ed;
|
||||
--text: #4c4f69;
|
||||
--muted: #6c6f85;
|
||||
--dim: #9ca0b0;
|
||||
--accent: #8839ef;
|
||||
--a-bg: rgba(136,57,239,.08);
|
||||
--a-str: rgba(136,57,239,.18);
|
||||
--todo: #df8e1d;
|
||||
--note: #179299;
|
||||
--event: #1e66f5;
|
||||
--remind: #fe640b;
|
||||
--ok: #40a02b;
|
||||
--danger: #d20f39;
|
||||
--lineage: #ea76cb;
|
||||
}
|
||||
|
||||
[data-theme="rosepine-dawn"] {
|
||||
color-scheme: light;
|
||||
--bg: #faf4ed;
|
||||
--surf: #fffaf3;
|
||||
--raised: #f2e9e1;
|
||||
--border: #dfdad9;
|
||||
--soft: #f4ede8;
|
||||
--text: #575279;
|
||||
--muted: #797593;
|
||||
--dim: #9893a5;
|
||||
--accent: #907aa9;
|
||||
--a-bg: rgba(144,122,169,.08);
|
||||
--a-str: rgba(144,122,169,.18);
|
||||
--todo: #ea9d34;
|
||||
--note: #56949f;
|
||||
--event: #286983;
|
||||
--remind: #d7827e;
|
||||
--ok: #56949f;
|
||||
--danger: #b4637a;
|
||||
--lineage: #907aa9;
|
||||
}
|
||||
|
||||
[data-theme="solarized-light"] {
|
||||
color-scheme: light;
|
||||
--bg: #fdf6e3;
|
||||
--surf: #eee8d5;
|
||||
--raised: #e4ddc8;
|
||||
--border: #d3cbb7;
|
||||
--soft: #f5eedb;
|
||||
--text: #657b83;
|
||||
--muted: #586e75;
|
||||
--dim: #93a1a1;
|
||||
--accent: #268bd2;
|
||||
--a-bg: rgba(38,139,210,.08);
|
||||
--a-str: rgba(38,139,210,.18);
|
||||
--todo: #b58900;
|
||||
--note: #2aa198;
|
||||
--event: #268bd2;
|
||||
--remind: #cb4b16;
|
||||
--ok: #859900;
|
||||
--danger: #dc322f;
|
||||
--lineage: #6c71c4;
|
||||
}
|
||||
|
||||
/* ── RESET ──────────────────────────────────────────── */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
@@ -162,6 +382,7 @@ nav { display: flex; gap: 2px; }
|
||||
#search-input::placeholder { color: var(--dim); }
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r1);
|
||||
@@ -174,6 +395,60 @@ nav { display: flex; gap: 2px; }
|
||||
|
||||
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.theme-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 900;
|
||||
background: var(--surf);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r3);
|
||||
padding: 8px 0;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.3);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-popover.open { display: block; }
|
||||
|
||||
.theme-popover-label {
|
||||
padding: 4px 12px;
|
||||
font-size: 10px;
|
||||
font-family: var(--mono);
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.theme-popover-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: var(--sans);
|
||||
color: var(--muted);
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
|
||||
.theme-popover-item:hover {
|
||||
background: var(--a-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-popover-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-popover-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── MAIN LAYOUT ────────────────────────────────────── */
|
||||
main {
|
||||
display: grid;
|
||||
@@ -414,7 +689,8 @@ main.focus-peek .resize-handle { visibility: hidden; }
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-item.exp-full .exp-body {
|
||||
.entity-item.exp-full .exp-body,
|
||||
.card-row.exp-full .exp-body {
|
||||
-webkit-line-clamp: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -461,6 +737,7 @@ main.focus-peek .resize-handle { visibility: hidden; }
|
||||
.glyph-checklist { color: var(--remind); }
|
||||
.glyph-decision { color: var(--note); }
|
||||
.glyph-link { color: var(--event); }
|
||||
.glyph-note { color: var(--note); }
|
||||
|
||||
.entity-content {
|
||||
flex: 1;
|
||||
@@ -961,7 +1238,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
|
||||
.peek-body:hover { background: var(--raised); }
|
||||
|
||||
.peek-body.md {
|
||||
.peek-body.md,
|
||||
.exp-body.md {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
@@ -969,35 +1247,40 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
}
|
||||
|
||||
.peek-body.md h1, .peek-body.md h2, .peek-body.md h3,
|
||||
.peek-body.md h4, .peek-body.md h5, .peek-body.md h6 {
|
||||
.peek-body.md h4, .peek-body.md h5, .peek-body.md h6,
|
||||
.exp-body.md h1, .exp-body.md h2, .exp-body.md h3,
|
||||
.exp-body.md h4, .exp-body.md h5, .exp-body.md h6 {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 14px 0 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.peek-body.md h1 { font-size: 17px; }
|
||||
.peek-body.md h2 { font-size: 15px; }
|
||||
.peek-body.md h3 { font-size: 14px; }
|
||||
.peek-body.md h1, .exp-body.md h1 { font-size: 17px; }
|
||||
.peek-body.md h2, .exp-body.md h2 { font-size: 15px; }
|
||||
.peek-body.md h3, .exp-body.md h3 { font-size: 14px; }
|
||||
|
||||
.peek-body.md p { margin: 0 0 10px; }
|
||||
.peek-body.md p:last-child { margin-bottom: 0; }
|
||||
.peek-body.md p, .exp-body.md p { margin: 0 0 10px; }
|
||||
.peek-body.md p:last-child, .exp-body.md p:last-child { margin-bottom: 0; }
|
||||
|
||||
.peek-body.md ul, .peek-body.md ol {
|
||||
.peek-body.md ul, .peek-body.md ol,
|
||||
.exp-body.md ul, .exp-body.md ol {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.peek-body.md li { margin-bottom: 3px; }
|
||||
.peek-body.md li, .exp-body.md li { margin-bottom: 3px; }
|
||||
|
||||
.peek-body.md blockquote {
|
||||
.peek-body.md blockquote,
|
||||
.exp-body.md blockquote {
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 12px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.peek-body.md code {
|
||||
.peek-body.md code,
|
||||
.exp-body.md code {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg);
|
||||
@@ -1006,7 +1289,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.peek-body.md pre {
|
||||
.peek-body.md pre,
|
||||
.exp-body.md pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
@@ -1015,7 +1299,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.peek-body.md pre code {
|
||||
.peek-body.md pre code,
|
||||
.exp-body.md pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
@@ -1023,18 +1308,20 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.peek-body.md a {
|
||||
.peek-body.md a,
|
||||
.exp-body.md a {
|
||||
color: var(--event);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(104,152,200,.3);
|
||||
}
|
||||
|
||||
.peek-body.md a:hover { border-bottom-color: var(--event); }
|
||||
.peek-body.md a:hover, .exp-body.md a:hover { border-bottom-color: var(--event); }
|
||||
|
||||
.peek-body.md strong { font-weight: 600; }
|
||||
.peek-body.md em { font-style: italic; color: var(--muted); }
|
||||
.peek-body.md strong, .exp-body.md strong { font-weight: 600; }
|
||||
.peek-body.md em, .exp-body.md em { font-style: italic; color: var(--muted); }
|
||||
|
||||
.peek-body.md hr {
|
||||
.peek-body.md hr,
|
||||
.exp-body.md hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0;
|
||||
@@ -1368,7 +1655,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
border-radius: var(--r3);
|
||||
padding: 22px;
|
||||
z-index: 101;
|
||||
min-width: 300px;
|
||||
min-width: 380px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
@@ -1391,16 +1679,32 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
}
|
||||
|
||||
.type-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.type-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.type-col-lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .14em;
|
||||
color: var(--dim);
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
@@ -1414,8 +1718,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
.type-btn:hover { border-color: var(--accent); background: var(--raised); }
|
||||
.type-btn.suggested { border-color: var(--accent); background: var(--a-bg); }
|
||||
|
||||
.type-glyph { font-size: 13px; width: 16px; flex-shrink: 0; }
|
||||
.type-name { font-family: var(--mono); font-size: 12px; color: var(--text); min-width: 72px; }
|
||||
.type-glyph { font-size: 13px; flex-shrink: 0; }
|
||||
.type-name { font-family: var(--mono); font-size: 11px; color: var(--text); }
|
||||
|
||||
.type-hint {
|
||||
font-family: var(--sans);
|
||||
@@ -1516,6 +1820,7 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
#tag-rail { display: none !important; }
|
||||
.resize-handle { display: none !important; }
|
||||
#entity-panel { overflow: auto; }
|
||||
#capture-bar { position: sticky; bottom: 0; z-index: 10; }
|
||||
#detail-pane {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user