feat(tui): add universal archive browser rows

This commit is contained in:
Vincent Koc 2026-05-01 13:06:47 -07:00
parent 559f3504a8
commit 0396564386
No known key found for this signature in database
2 changed files with 207 additions and 0 deletions

View File

@ -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

View File

@ -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",