feat(tui): add universal archive browser rows
This commit is contained in:
parent
559f3504a8
commit
0396564386
152
tui/tui.go
152
tui/tui.go
@ -2,10 +2,12 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@ -21,6 +23,23 @@ type Item struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Title string
|
||||
EmptyMessage string
|
||||
@ -29,6 +48,77 @@ type Options struct {
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
type BrowseOptions struct {
|
||||
AppName string
|
||||
Title string
|
||||
EmptyMessage string
|
||||
Rows []Row
|
||||
JSON bool
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
func Browse(ctx context.Context, opts BrowseOptions) error {
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = os.Stdout
|
||||
}
|
||||
if opts.JSON {
|
||||
enc := json.NewEncoder(opts.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(opts.Rows)
|
||||
}
|
||||
items := make([]Item, 0, len(opts.Rows))
|
||||
for _, row := range opts.Rows {
|
||||
items = append(items, row.Item())
|
||||
}
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(opts.AppName)
|
||||
if title != "" {
|
||||
title += " archive"
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "archive"
|
||||
}
|
||||
empty := strings.TrimSpace(opts.EmptyMessage)
|
||||
if empty == "" && strings.TrimSpace(opts.AppName) != "" {
|
||||
empty = opts.AppName + " has no local archive rows yet"
|
||||
}
|
||||
err := Run(ctx, Options{
|
||||
Title: title,
|
||||
EmptyMessage: empty,
|
||||
Items: items,
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
})
|
||||
if err != nil && errors.Is(err, ErrNotTerminal) {
|
||||
app := strings.TrimSpace(opts.AppName)
|
||||
if app == "" {
|
||||
return fmt.Errorf("%w; run tui from a TTY or pass --json", err)
|
||||
}
|
||||
return fmt.Errorf("%w; run %s tui from a TTY or pass --json", err, app)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Row) Item() Item {
|
||||
title := firstNonEmpty(r.Title, r.Text, r.ID, "(untitled)")
|
||||
tags := append([]string(nil), r.Tags...)
|
||||
if r.Kind != "" {
|
||||
tags = append([]string{r.Kind}, tags...)
|
||||
}
|
||||
if r.Source != "" {
|
||||
tags = append([]string{r.Source}, tags...)
|
||||
}
|
||||
return Item{
|
||||
Title: title,
|
||||
Subtitle: r.subtitle(),
|
||||
Detail: r.detail(),
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if opts.Stdin == nil {
|
||||
opts.Stdin = os.Stdin
|
||||
@ -64,6 +154,68 @@ func Run(ctx context.Context, opts Options) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Row) subtitle() string {
|
||||
parts := []string{r.Scope, r.Container, r.Author, r.CreatedAt, r.UpdatedAt}
|
||||
var out []string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func (r Row) detail() string {
|
||||
var lines []string
|
||||
if text := strings.TrimSpace(r.Text); text != "" && text != strings.TrimSpace(r.Title) {
|
||||
lines = append(lines, text)
|
||||
}
|
||||
for _, line := range []string{
|
||||
fieldLine("id", r.ID),
|
||||
fieldLine("parent", r.ParentID),
|
||||
fieldLine("scope", r.Scope),
|
||||
fieldLine("container", r.Container),
|
||||
fieldLine("author", r.Author),
|
||||
fieldLine("url", r.URL),
|
||||
} {
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(r.Fields) > 0 {
|
||||
keys := make([]string, 0, len(r.Fields))
|
||||
for key := range r.Fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if line := fieldLine(key, r.Fields[key]); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func fieldLine(key, value string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
return ""
|
||||
}
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type model struct {
|
||||
title string
|
||||
items []Item
|
||||
|
||||
@ -1,10 +1,65 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBrowseJSONUsesUniversalRows(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
rows := []Row{{
|
||||
Source: "slack",
|
||||
Kind: "message",
|
||||
ID: "C1/123",
|
||||
Scope: "T1",
|
||||
Container: "general",
|
||||
Author: "vincent",
|
||||
Title: "ship it",
|
||||
Text: "ship crawlkit tui",
|
||||
CreatedAt: "2026-05-01T12:00:00Z",
|
||||
Fields: map[string]string{"thread": "123"},
|
||||
}}
|
||||
if err := Browse(context.Background(), BrowseOptions{AppName: "slacrawl", Rows: rows, JSON: true, Stdout: &out}); err != nil {
|
||||
t.Fatalf("Browse json: %v", err)
|
||||
}
|
||||
var decoded []Row
|
||||
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode json: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(decoded) != 1 || decoded[0].Source != "slack" || decoded[0].Kind != "message" || decoded[0].Title != "ship it" {
|
||||
t.Fatalf("decoded rows = %#v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRowItemUsesSharedArchiveShape(t *testing.T) {
|
||||
item := Row{
|
||||
Source: "discord",
|
||||
Kind: "message",
|
||||
ID: "m1",
|
||||
Scope: "@me",
|
||||
Container: "dm",
|
||||
Author: "sam",
|
||||
Title: "panic locked database",
|
||||
Text: "full message text",
|
||||
Fields: map[string]string{"reply_to": "m0"},
|
||||
}.Item()
|
||||
if item.Title != "panic locked database" {
|
||||
t.Fatalf("title = %q", item.Title)
|
||||
}
|
||||
if !strings.Contains(item.Subtitle, "@me") || !strings.Contains(item.Subtitle, "dm") || !strings.Contains(item.Subtitle, "sam") {
|
||||
t.Fatalf("subtitle = %q", item.Subtitle)
|
||||
}
|
||||
if !strings.Contains(item.Detail, "full message text") || !strings.Contains(item.Detail, "reply_to=m0") {
|
||||
t.Fatalf("detail = %q", item.Detail)
|
||||
}
|
||||
if len(item.Tags) < 2 || item.Tags[0] != "discord" || item.Tags[1] != "message" {
|
||||
t.Fatalf("tags = %#v", item.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelFilterAndRender(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "notcrawl",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user