Merge pull request #52 from stevedylandev/feat/add-blobs-tui 82f9c6a9
Steve Simkins · 2026-05-26 15:52 19 file(s) · +1666 −91
apps/blobs/.env.example +6 −0
6 6
BLOBS_MAX_UPLOAD_MB=100
7 7
BLOBS_PRESIGN_TTL_SECONDS=3600
8 8
9 +
# Default bucket for the CLI/TUI (`blobs <file>` and bare `blobs`). Overridden by -b/--bucket.
10 +
BLOBS_DEFAULT_BUCKET=
11 +
12 +
# Terminal image preview backend for the TUI: kitty | iterm | chafa | none. Auto-detected if empty.
13 +
BLOBS_PREVIEW=
14 +
9 15
# Pick one credential style:
10 16
#
11 17
# Option A — generic S3 endpoint (works with AWS, Minio, B2, R2, etc).
apps/blobs/Dockerfile +2 −2
1 1
# Build from repo root: docker build -t blobs -f apps/blobs/Dockerfile .
2 -
FROM golang:1.24-bookworm AS builder
2 +
FROM golang:1.25-bookworm AS builder
3 3
WORKDIR /app
4 4
COPY pkg/ ./pkg/
5 5
COPY apps/blobs/go.mod apps/blobs/go.sum ./apps/blobs/
16 16
ENV PORT=3000
17 17
ENV BLOBS_DB_PATH=/data/blobs.sqlite
18 18
EXPOSE 3000
19 -
CMD ["blobs"]
19 +
CMD ["blobs", "server"]
apps/blobs/README.md +29 −8
1 1
# blobs
2 2
3 -
Single-owner web browser for S3-compatible blob storage. Built for Cloudflare R2 but works with any S3-compatible endpoint (AWS S3, MinIO, Backblaze B2, etc).
3 +
Self-hosted browser, CLI, and TUI for S3-compatible blob storage. Built for Cloudflare R2 but works with any S3-compatible endpoint (AWS S3, MinIO, Backblaze B2, etc).
4 +
5 +
Single binary, three modes:
6 +
7 +
- `blobs server` — web UI (password-protected, session cookies)
8 +
- `blobs` (no args) — Yazi-style TUI for browsing buckets, copying URLs, opening files, previewing images inline
9 +
- `blobs <file>` — one-shot upload that prints the resulting URL to stdout
4 10
5 11
Features:
6 12
7 -
- Password login + session cookie auth
8 13
- Lists every bucket the credentials can see
9 -
- Folder/file navigation with breadcrumbs
10 -
- Inline image thumbnails in folder view
11 -
- File detail page: metadata, presigned download link, optional static public URL
12 -
- Upload (multi-file), replace, delete, create folder
14 +
- Folder/file navigation with breadcrumbs (web) or two-pane preview (TUI)
15 +
- Inline image previews via Kitty/Ghostty/iTerm2 graphics protocols (or `chafa` fallback)
16 +
- Presigned download URLs + optional permanent public URL map
17 +
- Upload (multi-file in web, single-file in CLI), replace, delete, create folder
13 18
14 -
## Quick start
19 +
## Quick start (server)
15 20
16 21
```sh
17 22
cp .env.example .env
18 23
# edit .env — set BLOBS_PASSWORD and either:
19 24
#   S3_ENDPOINT + S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY  (generic)
20 25
#   R2_ACCOUNT_ID + S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY  (R2)
21 -
go run .
26 +
go run . server
22 27
```
23 28
24 29
Visit `http://127.0.0.1:3000` and log in.
30 +
31 +
## CLI / TUI
32 +
33 +
The same binary also speaks directly to S3 (no server required):
34 +
35 +
```sh
36 +
blobs auth                          # interactive: writes ~/.config/blobs/config.toml
37 +
blobs                               # TUI — bucket picker then folder browse
38 +
blobs -b my-bucket                  # TUI — jump straight into my-bucket
39 +
blobs ./photo.png                   # upload to BLOBS_DEFAULT_BUCKET, print URL
40 +
blobs -b my-bucket --prefix imgs/ ./photo.png
41 +
```
42 +
43 +
The TUI auto-detects Kitty/Ghostty/iTerm2 graphics protocols for inline image previews and falls back to `chafa` when available. Override with `BLOBS_PREVIEW={kitty|iterm|chafa|none}`.
44 +
45 +
Key bindings in browse view: `enter`/`l` open, `h`/`esc` back, `y` copy URL (public if mapped, else presigned), `Y` force public, `K` copy S3 key, `o` open in browser, `u` upload, `d` delete, `r` refresh, `b` jump to buckets, `space` toggle preview, `?` help, `q` quit.
25 46
26 47
## Configuration
27 48
apps/blobs/clientconfig.go (added) +142 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"fmt"
6 +
	"os"
7 +
	"path/filepath"
8 +
	"time"
9 +
10 +
	"github.com/BurntSushi/toml"
11 +
	"github.com/stevedylandev/andromeda/pkg/config"
12 +
)
13 +
14 +
// ClientConfig is the on-disk client config for the blobs TUI/CLI.
15 +
// Server still reads from env/.env directly; this is purely for client use
16 +
// when running outside the server's working directory.
17 +
type ClientConfig struct {
18 +
	Endpoint        string            `toml:"endpoint"`
19 +
	Region          string            `toml:"region"`
20 +
	AccessKeyID     string            `toml:"access_key_id"`
21 +
	SecretAccessKey string            `toml:"secret_access_key"`
22 +
	R2AccountID     string            `toml:"r2_account_id,omitempty"`
23 +
	DefaultBucket   string            `toml:"default_bucket,omitempty"`
24 +
	PublicURLs      map[string]string `toml:"public_urls,omitempty"`
25 +
	PresignTTLSec   int               `toml:"presign_ttl_seconds,omitempty"`
26 +
}
27 +
28 +
// ClientFlags holds flag overrides parsed from argv.
29 +
type ClientFlags struct {
30 +
	Bucket string
31 +
	Prefix string
32 +
	Key    string
33 +
}
34 +
35 +
func clientConfigPath() (string, error) {
36 +
	dir, err := os.UserConfigDir()
37 +
	if err != nil {
38 +
		return "", err
39 +
	}
40 +
	return filepath.Join(dir, "blobs", "config.toml"), nil
41 +
}
42 +
43 +
// LoadClientConfig merges (precedence: flags > env > .env-in-cwd > toml > defaults).
44 +
func LoadClientConfig(flags ClientFlags) (ClientConfig, error) {
45 +
	// .env (best-effort, matches server behavior)
46 +
	config.LoadDotEnv(".env")
47 +
48 +
	cfg := ClientConfig{}
49 +
	if path, err := clientConfigPath(); err == nil {
50 +
		if data, rerr := os.ReadFile(path); rerr == nil {
51 +
			_ = toml.Unmarshal(data, &cfg)
52 +
		} else if !os.IsNotExist(rerr) {
53 +
			return cfg, rerr
54 +
		}
55 +
	}
56 +
57 +
	// env overrides toml
58 +
	if v := os.Getenv("S3_ENDPOINT"); v != "" {
59 +
		cfg.Endpoint = v
60 +
	}
61 +
	if v := os.Getenv("R2_ACCOUNT_ID"); v != "" {
62 +
		cfg.R2AccountID = v
63 +
	}
64 +
	if cfg.Endpoint == "" && cfg.R2AccountID != "" {
65 +
		cfg.Endpoint = "https://" + cfg.R2AccountID + ".r2.cloudflarestorage.com"
66 +
	}
67 +
	if v := os.Getenv("S3_REGION"); v != "" {
68 +
		cfg.Region = v
69 +
	}
70 +
	if cfg.Region == "" {
71 +
		cfg.Region = "auto"
72 +
	}
73 +
	if v := config.Getenv("S3_ACCESS_KEY_ID", os.Getenv("R2_ACCESS_KEY_ID")); v != "" {
74 +
		cfg.AccessKeyID = v
75 +
	}
76 +
	if v := config.Getenv("S3_SECRET_ACCESS_KEY", os.Getenv("R2_SECRET_ACCESS_KEY")); v != "" {
77 +
		cfg.SecretAccessKey = v
78 +
	}
79 +
	if v := os.Getenv("BLOBS_DEFAULT_BUCKET"); v != "" {
80 +
		cfg.DefaultBucket = v
81 +
	}
82 +
	if v := os.Getenv("BLOBS_PUBLIC_URLS"); v != "" {
83 +
		merged := parsePublicURLs(v)
84 +
		if cfg.PublicURLs == nil {
85 +
			cfg.PublicURLs = map[string]string{}
86 +
		}
87 +
		for k, val := range merged {
88 +
			cfg.PublicURLs[k] = val
89 +
		}
90 +
	}
91 +
	if v := config.GetenvInt("BLOBS_PRESIGN_TTL_SECONDS", 0); v > 0 {
92 +
		cfg.PresignTTLSec = v
93 +
	}
94 +
	if cfg.PresignTTLSec <= 0 {
95 +
		cfg.PresignTTLSec = 3600
96 +
	}
97 +
98 +
	// flag overrides
99 +
	if flags.Bucket != "" {
100 +
		cfg.DefaultBucket = flags.Bucket
101 +
	}
102 +
103 +
	return cfg, nil
104 +
}
105 +
106 +
// SaveClientConfig writes cfg to ~/.config/blobs/config.toml (0600).
107 +
func SaveClientConfig(cfg ClientConfig) error {
108 +
	path, err := clientConfigPath()
109 +
	if err != nil {
110 +
		return err
111 +
	}
112 +
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
113 +
		return err
114 +
	}
115 +
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
116 +
	if err != nil {
117 +
		return err
118 +
	}
119 +
	defer f.Close()
120 +
	return toml.NewEncoder(f).Encode(cfg)
121 +
}
122 +
123 +
// NewS3FromConfig builds an S3 client from ClientConfig.
124 +
func NewS3FromConfig(cfg ClientConfig) (*S3Client, error) {
125 +
	if cfg.Endpoint == "" {
126 +
		return nil, fmt.Errorf("S3 endpoint not configured (set S3_ENDPOINT, R2_ACCOUNT_ID, or run `blobs auth`)")
127 +
	}
128 +
	pubs := cfg.PublicURLs
129 +
	if pubs == nil {
130 +
		pubs = map[string]string{}
131 +
	}
132 +
	ttl := time.Duration(cfg.PresignTTLSec) * time.Second
133 +
	return NewS3Client(cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.SecretAccessKey, pubs, ttl)
134 +
}
135 +
136 +
// ResolveURL returns a public URL if the bucket is mapped, else a presigned URL.
137 +
func ResolveURL(ctx context.Context, s3 *S3Client, bucket, key string) (string, error) {
138 +
	if u, ok := s3.PublicURL(bucket, key); ok {
139 +
		return u, nil
140 +
	}
141 +
	return s3.PresignGet(ctx, bucket, key)
142 +
}
apps/blobs/cmd_auth.go (added) +82 −0
1 +
package main
2 +
3 +
import (
4 +
	"bufio"
5 +
	"fmt"
6 +
	"os"
7 +
	"strings"
8 +
	"syscall"
9 +
10 +
	"golang.org/x/term"
11 +
)
12 +
13 +
func runAuth(_ []string) {
14 +
	cfg, _ := LoadClientConfig(ClientFlags{})
15 +
	reader := bufio.NewReader(os.Stdin)
16 +
17 +
	cfg.Endpoint = promptDefault(reader, "S3 endpoint (or 'r2' for Cloudflare R2)", cfg.Endpoint)
18 +
	if strings.EqualFold(cfg.Endpoint, "r2") {
19 +
		cfg.R2AccountID = promptDefault(reader, "R2 account ID", cfg.R2AccountID)
20 +
		cfg.Endpoint = "https://" + cfg.R2AccountID + ".r2.cloudflarestorage.com"
21 +
	}
22 +
23 +
	region := cfg.Region
24 +
	if region == "" {
25 +
		region = "auto"
26 +
	}
27 +
	cfg.Region = promptDefault(reader, "Region", region)
28 +
29 +
	cfg.AccessKeyID = promptDefault(reader, "Access key ID", cfg.AccessKeyID)
30 +
31 +
	fmt.Print("Secret access key (hidden): ")
32 +
	secretBytes, err := term.ReadPassword(int(syscall.Stdin))
33 +
	fmt.Println()
34 +
	if err != nil {
35 +
		fmt.Fprintln(os.Stderr, "read secret:", err)
36 +
		os.Exit(1)
37 +
	}
38 +
	if s := strings.TrimSpace(string(secretBytes)); s != "" {
39 +
		cfg.SecretAccessKey = s
40 +
	}
41 +
42 +
	cfg.DefaultBucket = promptDefault(reader, "Default bucket (optional)", cfg.DefaultBucket)
43 +
44 +
	if cfg.DefaultBucket != "" {
45 +
		existing := ""
46 +
		if cfg.PublicURLs != nil {
47 +
			existing = cfg.PublicURLs[cfg.DefaultBucket]
48 +
		}
49 +
		pub := promptDefault(reader, "Public URL for "+cfg.DefaultBucket+" (optional)", existing)
50 +
		if pub != "" {
51 +
			if cfg.PublicURLs == nil {
52 +
				cfg.PublicURLs = map[string]string{}
53 +
			}
54 +
			cfg.PublicURLs[cfg.DefaultBucket] = pub
55 +
		}
56 +
	}
57 +
58 +
	if cfg.PresignTTLSec <= 0 {
59 +
		cfg.PresignTTLSec = 3600
60 +
	}
61 +
62 +
	if err := SaveClientConfig(cfg); err != nil {
63 +
		fmt.Fprintln(os.Stderr, "save:", err)
64 +
		os.Exit(1)
65 +
	}
66 +
	path, _ := clientConfigPath()
67 +
	fmt.Println("Saved", path)
68 +
}
69 +
70 +
func promptDefault(r *bufio.Reader, label, def string) string {
71 +
	if def != "" {
72 +
		fmt.Printf("%s [%s]: ", label, def)
73 +
	} else {
74 +
		fmt.Printf("%s: ", label)
75 +
	}
76 +
	line, _ := r.ReadString('\n')
77 +
	line = strings.TrimSpace(line)
78 +
	if line == "" {
79 +
		return def
80 +
	}
81 +
	return line
82 +
}
apps/blobs/cmd_server.go (added) +109 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
	"strconv"
10 +
	"time"
11 +
12 +
	"github.com/stevedylandev/andromeda/pkg/auth"
13 +
	"github.com/stevedylandev/andromeda/pkg/config"
14 +
)
15 +
16 +
func runServer(args []string) {
17 +
	config.LoadDotEnv(".env")
18 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
19 +
20 +
	host := config.Getenv("HOST", "127.0.0.1")
21 +
	port := config.GetenvInt("PORT", 3000)
22 +
	for i := 0; i < len(args); i++ {
23 +
		switch args[i] {
24 +
		case "--host":
25 +
			if i+1 < len(args) {
26 +
				host = args[i+1]
27 +
				i++
28 +
			}
29 +
		case "--port", "-p":
30 +
			if i+1 < len(args) {
31 +
				if n, err := strconv.Atoi(args[i+1]); err == nil {
32 +
					port = n
33 +
				}
34 +
				i++
35 +
			}
36 +
		}
37 +
	}
38 +
39 +
	dbPath := config.Getenv("BLOBS_DB_PATH", "blobs.sqlite")
40 +
	db, err := openDB(dbPath)
41 +
	if err != nil {
42 +
		log.Fatal(err)
43 +
	}
44 +
	defer db.Close()
45 +
46 +
	sessions := &auth.Store{
47 +
		DB:           db,
48 +
		CookieName:   "blobs_session",
49 +
		CookieSecure: config.GetenvBool("BLOBS_COOKIE_SECURE", false),
50 +
	}
51 +
	if err := sessions.EnsureSchema(); err != nil {
52 +
		log.Fatal(err)
53 +
	}
54 +
	sessions.PruneExpired()
55 +
	go func() {
56 +
		ticker := time.NewTicker(time.Hour)
57 +
		defer ticker.Stop()
58 +
		for range ticker.C {
59 +
			sessions.PruneExpired()
60 +
		}
61 +
	}()
62 +
63 +
	password := os.Getenv("BLOBS_PASSWORD")
64 +
	if password == "" {
65 +
		logger.Warn("BLOBS_PASSWORD not set, using default 'changeme'")
66 +
		password = "changeme"
67 +
	}
68 +
69 +
	endpoint := config.Getenv("S3_ENDPOINT", "")
70 +
	if endpoint == "" {
71 +
		if accountID := config.Getenv("R2_ACCOUNT_ID", ""); accountID != "" {
72 +
			endpoint = "https://" + accountID + ".r2.cloudflarestorage.com"
73 +
		}
74 +
	}
75 +
	accessKey := config.Getenv("S3_ACCESS_KEY_ID", config.Getenv("R2_ACCESS_KEY_ID", ""))
76 +
	secretKey := config.Getenv("S3_SECRET_ACCESS_KEY", config.Getenv("R2_SECRET_ACCESS_KEY", ""))
77 +
	region := config.Getenv("S3_REGION", "auto")
78 +
	publicURLs := parsePublicURLs(os.Getenv("BLOBS_PUBLIC_URLS"))
79 +
	presignTTL := time.Duration(config.GetenvInt("BLOBS_PRESIGN_TTL_SECONDS", 3600)) * time.Second
80 +
81 +
	client, err := NewS3Client(endpoint, region, accessKey, secretKey, publicURLs, presignTTL)
82 +
	if err != nil {
83 +
		log.Fatalf("configure S3: %v", err)
84 +
	}
85 +
	logger.Info("S3 client ready", "endpoint", endpoint, "region", region, "public_buckets", len(publicURLs))
86 +
87 +
	tmpl, err := buildTemplates()
88 +
	if err != nil {
89 +
		log.Fatal(err)
90 +
	}
91 +
92 +
	maxUploadMB := int64(config.GetenvInt("BLOBS_MAX_UPLOAD_MB", 100))
93 +
	app := &App{
94 +
		DB:             db,
95 +
		Log:            logger,
96 +
		Templates:      tmpl,
97 +
		Sessions:       sessions,
98 +
		S3:             client,
99 +
		Password:       password,
100 +
		CookieSecure:   sessions.CookieSecure,
101 +
		MaxUploadBytes: maxUploadMB << 20,
102 +
	}
103 +
104 +
	addr := fmt.Sprintf("%s:%d", host, port)
105 +
	logger.Info("blobs server running", "addr", addr)
106 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
107 +
		log.Fatal(err)
108 +
	}
109 +
}
apps/blobs/cmd_upload.go (added) +106 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"fmt"
6 +
	"mime"
7 +
	"os"
8 +
	"path/filepath"
9 +
	"strings"
10 +
)
11 +
12 +
func parseUploadArgs(args []string) (ClientFlags, string) {
13 +
	var (
14 +
		flags ClientFlags
15 +
		file  string
16 +
	)
17 +
	for i := 0; i < len(args); i++ {
18 +
		a := args[i]
19 +
		switch {
20 +
		case (a == "-b" || a == "--bucket") && i+1 < len(args):
21 +
			flags.Bucket = args[i+1]
22 +
			i++
23 +
		case strings.HasPrefix(a, "--bucket="):
24 +
			flags.Bucket = strings.TrimPrefix(a, "--bucket=")
25 +
		case a == "--prefix" && i+1 < len(args):
26 +
			flags.Prefix = args[i+1]
27 +
			i++
28 +
		case strings.HasPrefix(a, "--prefix="):
29 +
			flags.Prefix = strings.TrimPrefix(a, "--prefix=")
30 +
		case a == "--key" && i+1 < len(args):
31 +
			flags.Key = args[i+1]
32 +
			i++
33 +
		case strings.HasPrefix(a, "--key="):
34 +
			flags.Key = strings.TrimPrefix(a, "--key=")
35 +
		case !strings.HasPrefix(a, "-") && file == "":
36 +
			file = a
37 +
		}
38 +
	}
39 +
	return flags, file
40 +
}
41 +
42 +
func runUpload(args []string) {
43 +
	flags, file := parseUploadArgs(args)
44 +
	if file == "" {
45 +
		fmt.Fprintln(os.Stderr, "no file specified")
46 +
		os.Exit(2)
47 +
	}
48 +
49 +
	cfg, err := LoadClientConfig(flags)
50 +
	if err != nil {
51 +
		fmt.Fprintln(os.Stderr, "config:", err)
52 +
		os.Exit(1)
53 +
	}
54 +
	bucket := cfg.DefaultBucket
55 +
	if bucket == "" {
56 +
		fmt.Fprintln(os.Stderr, "no bucket: pass -b/--bucket or set BLOBS_DEFAULT_BUCKET")
57 +
		os.Exit(2)
58 +
	}
59 +
60 +
	s3, err := NewS3FromConfig(cfg)
61 +
	if err != nil {
62 +
		fmt.Fprintln(os.Stderr, "s3:", err)
63 +
		os.Exit(1)
64 +
	}
65 +
66 +
	f, err := os.Open(file)
67 +
	if err != nil {
68 +
		fmt.Fprintln(os.Stderr, "open:", err)
69 +
		os.Exit(1)
70 +
	}
71 +
	defer f.Close()
72 +
	info, err := f.Stat()
73 +
	if err != nil {
74 +
		fmt.Fprintln(os.Stderr, "stat:", err)
75 +
		os.Exit(1)
76 +
	}
77 +
78 +
	name := flags.Key
79 +
	if name == "" {
80 +
		name = filepath.Base(file)
81 +
	}
82 +
	prefix := flags.Prefix
83 +
	if prefix != "" && !strings.HasSuffix(prefix, "/") {
84 +
		prefix += "/"
85 +
	}
86 +
	key := prefix + name
87 +
88 +
	ct := mime.TypeByExtension(filepath.Ext(name))
89 +
	if ct == "" {
90 +
		ct = "application/octet-stream"
91 +
	}
92 +
93 +
	ctx := context.Background()
94 +
	if err := s3.Put(ctx, bucket, key, ct, f, info.Size()); err != nil {
95 +
		fmt.Fprintln(os.Stderr, "upload:", err)
96 +
		os.Exit(1)
97 +
	}
98 +
99 +
	url, err := ResolveURL(ctx, s3, bucket, key)
100 +
	if err != nil {
101 +
		fmt.Fprintln(os.Stderr, "url:", err)
102 +
		fmt.Println(key)
103 +
		return
104 +
	}
105 +
	fmt.Println(url)
106 +
}
apps/blobs/go.mod +27 −2
1 1
module github.com/stevedylandev/andromeda/apps/blobs
2 2
3 -
go 1.24.4
3 +
go 1.25.0
4 4
5 5
require (
6 +
	charm.land/bubbles/v2 v2.1.0
7 +
	charm.land/bubbletea/v2 v2.0.6
8 +
	charm.land/lipgloss/v2 v2.0.3
9 +
	github.com/BurntSushi/toml v1.6.0
6 10
	github.com/aws/aws-sdk-go-v2 v1.41.7
7 11
	github.com/aws/aws-sdk-go-v2/credentials v1.19.16
8 12
	github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
13 +
	github.com/charmbracelet/x/mosaic v0.0.0-20260525135217-abeec2b8bf0b
9 14
	github.com/stevedylandev/andromeda/pkg/auth v0.0.0
10 15
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
11 16
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
12 17
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
18 +
	github.com/stevedylandev/andromeda/pkg/tui v0.0.0
13 19
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
20 +
	golang.org/x/image v0.41.0
21 +
	golang.org/x/term v0.36.0
14 22
)
15 23
16 24
require (
25 +
	github.com/atotto/clipboard v0.1.4 // indirect
17 26
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
18 27
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
19 28
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
23 32
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
24 33
	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
25 34
	github.com/aws/smithy-go v1.25.1 // indirect
35 +
	github.com/charmbracelet/colorprofile v0.4.3 // indirect
36 +
	github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
37 +
	github.com/charmbracelet/x/ansi v0.11.7 // indirect
38 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
39 +
	github.com/charmbracelet/x/termios v0.1.1 // indirect
40 +
	github.com/charmbracelet/x/windows v0.2.2 // indirect
41 +
	github.com/clipperhouse/displaywidth v0.11.0 // indirect
42 +
	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
26 43
	github.com/dustin/go-humanize v1.0.1 // indirect
27 44
	github.com/google/uuid v1.6.0 // indirect
45 +
	github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
28 46
	github.com/mattn/go-isatty v0.0.20 // indirect
47 +
	github.com/mattn/go-runewidth v0.0.23 // indirect
48 +
	github.com/muesli/cancelreader v0.2.2 // indirect
29 49
	github.com/ncruces/go-strftime v0.1.9 // indirect
30 50
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
51 +
	github.com/rivo/uniseg v0.4.7 // indirect
52 +
	github.com/sahilm/fuzzy v0.1.1 // indirect
53 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
31 54
	golang.org/x/crypto v0.39.0 // indirect
32 55
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
33 -
	golang.org/x/sys v0.33.0 // indirect
56 +
	golang.org/x/sync v0.20.0 // indirect
57 +
	golang.org/x/sys v0.43.0 // indirect
34 58
	modernc.org/libc v1.65.7 // indirect
35 59
	modernc.org/mathutil v1.7.1 // indirect
36 60
	modernc.org/memory v1.11.0 // indirect
42 66
	github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
43 67
	github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
44 68
	github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite
69 +
	github.com/stevedylandev/andromeda/pkg/tui => ../../pkg/tui
45 70
	github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
46 71
)
apps/blobs/go.sum +54 −4
1 +
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
2 +
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
3 +
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
4 +
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
5 +
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
6 +
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
7 +
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
8 +
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
9 +
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
10 +
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
1 11
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
2 12
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
3 13
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
22 32
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
23 33
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
24 34
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
35 +
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
36 +
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
37 +
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
38 +
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
39 +
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
40 +
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
41 +
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
42 +
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
43 +
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
44 +
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
45 +
github.com/charmbracelet/x/mosaic v0.0.0-20260525135217-abeec2b8bf0b h1:lx38/9bc8Lpy64h+Ki8Iy0ahMG3JhQE3QmNRHF78eI8=
46 +
github.com/charmbracelet/x/mosaic v0.0.0-20260525135217-abeec2b8bf0b/go.mod h1:XaPvhIpKtjZOj8uQ+HabXB9uLJedjw/zJpIymbWw3mY=
47 +
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
48 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
49 +
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
50 +
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
51 +
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
52 +
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
53 +
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
54 +
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
55 +
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
56 +
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
25 57
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
26 58
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
27 59
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
28 60
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
29 61
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
30 62
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
63 +
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
64 +
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
65 +
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
66 +
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
31 67
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
32 68
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
69 +
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
70 +
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
71 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
72 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
33 73
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
34 74
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
35 75
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
36 76
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
77 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
78 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
79 +
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
80 +
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
81 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
82 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
37 83
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
38 84
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
39 85
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
40 86
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
87 +
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
88 +
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
41 89
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
42 90
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
43 -
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
44 -
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
91 +
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
92 +
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
45 93
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
46 -
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
47 -
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
94 +
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
95 +
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
96 +
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
97 +
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
48 98
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
49 99
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
50 100
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
apps/blobs/main.go +49 −75
1 +
// blobs — S3 browser, CLI uploader, and TUI.
2 +
//
3 +
//	blobs                              launch the interactive TUI
4 +
//	blobs tui [-b BUCKET]              launch the TUI (alias)
5 +
//	blobs auth                         interactive client config writer
6 +
//	blobs server [--host H] [--port P] run the web server
7 +
//	blobs [-b BUCKET] <file>           upload a file and print its URL
8 +
//	blobs --help
1 9
package main
2 10
3 11
import (
4 -
	"log"
5 -
	"log/slog"
6 -
	"net/http"
12 +
	"fmt"
7 13
	"os"
8 -
	"time"
9 -
10 -
	"github.com/stevedylandev/andromeda/pkg/auth"
11 -
	"github.com/stevedylandev/andromeda/pkg/config"
12 14
)
13 15
14 -
func main() {
15 -
	config.LoadDotEnv(".env")
16 -
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
16 +
const usage = `blobs — S3 browser, CLI, and TUI
17 17
18 -
	dbPath := config.Getenv("BLOBS_DB_PATH", "blobs.sqlite")
19 -
	db, err := openDB(dbPath)
20 -
	if err != nil {
21 -
		log.Fatal(err)
22 -
	}
23 -
	defer db.Close()
18 +
usage:
19 +
  blobs                              launch interactive TUI
20 +
  blobs tui [-b BUCKET] [--prefix P] launch TUI
21 +
  blobs auth                         write client config to ~/.config/blobs/config.toml
22 +
  blobs server [--host H] [--port P] run the web server
23 +
  blobs [-b BUCKET] [--prefix P] [--key K] <file>
24 +
                                     upload FILE, print URL to stdout
25 +
  blobs --help
24 26
25 -
	sessions := &auth.Store{
26 -
		DB:           db,
27 -
		CookieName:   "blobs_session",
28 -
		CookieSecure: config.GetenvBool("BLOBS_COOKIE_SECURE", false),
29 -
	}
30 -
	if err := sessions.EnsureSchema(); err != nil {
31 -
		log.Fatal(err)
32 -
	}
33 -
	sessions.PruneExpired()
34 -
	go func() {
35 -
		ticker := time.NewTicker(time.Hour)
36 -
		defer ticker.Stop()
37 -
		for range ticker.C {
38 -
			sessions.PruneExpired()
39 -
		}
40 -
	}()
27 +
env:
28 +
  BLOBS_DEFAULT_BUCKET   bucket used when -b is omitted
29 +
  S3_ENDPOINT            S3 endpoint URL
30 +
  S3_REGION              region (default "auto")
31 +
  S3_ACCESS_KEY_ID       access key
32 +
  S3_SECRET_ACCESS_KEY   secret key
33 +
  R2_ACCOUNT_ID          shortcut: derives endpoint for Cloudflare R2
34 +
  BLOBS_PUBLIC_URLS      bucket=url,bucket=url public URL map
35 +
  BLOBS_PRESIGN_TTL_SECONDS  presigned URL lifetime (default 3600)
36 +
  BLOBS_PREVIEW          override preview backend: kitty|iterm|chafa|none
37 +
`
41 38
42 -
	password := os.Getenv("BLOBS_PASSWORD")
43 -
	if password == "" {
44 -
		logger.Warn("BLOBS_PASSWORD not set, using default 'changeme'")
45 -
		password = "changeme"
39 +
func main() {
40 +
	args := os.Args[1:]
41 +
	if len(args) == 0 {
42 +
		runTUI(nil)
43 +
		return
46 44
	}
47 -
48 -
	endpoint := config.Getenv("S3_ENDPOINT", "")
49 -
	if endpoint == "" {
50 -
		if accountID := config.Getenv("R2_ACCOUNT_ID", ""); accountID != "" {
51 -
			endpoint = "https://" + accountID + ".r2.cloudflarestorage.com"
45 +
	switch args[0] {
46 +
	case "-h", "--help", "help":
47 +
		fmt.Print(usage)
48 +
	case "server":
49 +
		runServer(args[1:])
50 +
	case "tui":
51 +
		runTUI(args[1:])
52 +
	case "auth":
53 +
		runAuth(args[1:])
54 +
	default:
55 +
		if _, err := os.Stat(args[0]); err == nil {
56 +
			runUpload(args)
57 +
			return
52 58
		}
53 -
	}
54 -
	accessKey := config.Getenv("S3_ACCESS_KEY_ID", config.Getenv("R2_ACCESS_KEY_ID", ""))
55 -
	secretKey := config.Getenv("S3_SECRET_ACCESS_KEY", config.Getenv("R2_SECRET_ACCESS_KEY", ""))
56 -
	region := config.Getenv("S3_REGION", "auto")
57 -
	publicURLs := parsePublicURLs(os.Getenv("BLOBS_PUBLIC_URLS"))
58 -
	presignTTL := time.Duration(config.GetenvInt("BLOBS_PRESIGN_TTL_SECONDS", 3600)) * time.Second
59 -
60 -
	client, err := NewS3Client(endpoint, region, accessKey, secretKey, publicURLs, presignTTL)
61 -
	if err != nil {
62 -
		log.Fatalf("configure S3: %v", err)
63 -
	}
64 -
	logger.Info("S3 client ready", "endpoint", endpoint, "region", region, "public_buckets", len(publicURLs))
65 -
66 -
	tmpl, err := buildTemplates()
67 -
	if err != nil {
68 -
		log.Fatal(err)
69 -
	}
70 -
71 -
	maxUploadMB := int64(config.GetenvInt("BLOBS_MAX_UPLOAD_MB", 100))
72 -
	app := &App{
73 -
		DB:             db,
74 -
		Log:            logger,
75 -
		Templates:      tmpl,
76 -
		Sessions:       sessions,
77 -
		S3:             client,
78 -
		Password:       password,
79 -
		CookieSecure:   sessions.CookieSecure,
80 -
		MaxUploadBytes: maxUploadMB << 20,
81 -
	}
82 -
83 -
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
84 -
	logger.Info("blobs server running", "addr", addr)
85 -
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
86 -
		log.Fatal(err)
59 +
		// no file at args[0] — treat as TUI flags
60 +
		runTUI(args)
87 61
	}
88 62
}
apps/blobs/preview/preview.go (added) +81 −0
1 +
// Package preview renders images as half-block ANSI text suitable for
2 +
// embedding in a Bubble Tea viewport. Uses charmbracelet/x/mosaic.
3 +
package preview
4 +
5 +
import (
6 +
	"bytes"
7 +
	"fmt"
8 +
	"image"
9 +
	_ "image/gif"
10 +
	_ "image/jpeg"
11 +
	_ "image/png"
12 +
13 +
	"github.com/charmbracelet/x/mosaic"
14 +
	_ "golang.org/x/image/webp"
15 +
)
16 +
17 +
// Protocol is kept for API compatibility with the rest of the TUI.
18 +
// Only two states matter now: available or not.
19 +
type Protocol int
20 +
21 +
const (
22 +
	ProtoNone Protocol = iota
23 +
	ProtoMosaic
24 +
)
25 +
26 +
// Detect always returns ProtoMosaic — mosaic is pure Go and has no
27 +
// terminal/runtime requirements beyond truecolor support.
28 +
func Detect() Protocol { return ProtoMosaic }
29 +
30 +
// Render decodes img and returns a half-block ANSI rendering sized to
31 +
// cols x rows character cells.
32 +
func Render(p Protocol, img []byte, cols, rows int) (string, error) {
33 +
	if p != ProtoMosaic {
34 +
		return "", fmt.Errorf("preview disabled")
35 +
	}
36 +
	if cols <= 0 {
37 +
		cols = 40
38 +
	}
39 +
	if rows <= 0 {
40 +
		rows = 20
41 +
	}
42 +
	decoded, _, err := image.Decode(bytes.NewReader(img))
43 +
	if err != nil {
44 +
		return "", err
45 +
	}
46 +
	fitCols, fitRows := fitAspect(decoded.Bounds().Dx(), decoded.Bounds().Dy(), cols, rows)
47 +
	// mosaic's Width/Height are pixel dims; it emits one cell per 2 pixels.
48 +
	m := mosaic.New().
49 +
		Width(fitCols).
50 +
		Height(fitRows).
51 +
		Symbol(mosaic.All).
52 +
		Dither(true)
53 +
	return m.Render(decoded), nil
54 +
}
55 +
56 +
// fitAspect returns the largest (cols, rows) inside the maxCols x maxRows
57 +
// box that preserves the image's aspect ratio. Assumes terminal cells are
58 +
// roughly 1:2 (width:height), so one row covers about 2 image-units of
59 +
// vertical space per 1 image-unit horizontal per column.
60 +
func fitAspect(imgW, imgH, maxCols, maxRows int) (int, int) {
61 +
	if imgW <= 0 || imgH <= 0 {
62 +
		return maxCols, maxRows
63 +
	}
64 +
	const cellAspect = 2.0 // cell height / cell width
65 +
	imgAspect := float64(imgW) / float64(imgH)
66 +
	// rows of "image pixels" per cell column to keep aspect:
67 +
	// cols/rows = imgAspect * cellAspect
68 +
	cols := maxCols
69 +
	rows := int(float64(cols) / imgAspect / cellAspect)
70 +
	if rows > maxRows {
71 +
		rows = maxRows
72 +
		cols = int(float64(rows) * imgAspect * cellAspect)
73 +
	}
74 +
	if cols < 1 {
75 +
		cols = 1
76 +
	}
77 +
	if rows < 1 {
78 +
		rows = 1
79 +
	}
80 +
	return cols, rows
81 +
}
apps/blobs/tui_commands.go (added) +132 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"fmt"
6 +
	"io"
7 +
	"mime"
8 +
	"os"
9 +
	"path/filepath"
10 +
	"strings"
11 +
	"time"
12 +
13 +
	tea "charm.land/bubbletea/v2"
14 +
15 +
	"github.com/stevedylandev/andromeda/apps/blobs/preview"
16 +
)
17 +
18 +
func loadBucketsCmd(s3 *S3Client) tea.Cmd {
19 +
	return func() tea.Msg {
20 +
		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
21 +
		defer cancel()
22 +
		bs, err := s3.ListBuckets(ctx)
23 +
		return bucketsLoadedMsg{Buckets: bs, Err: err}
24 +
	}
25 +
}
26 +
27 +
func loadListingCmd(s3 *S3Client, bucket, prefix string) tea.Cmd {
28 +
	return func() tea.Msg {
29 +
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
30 +
		defer cancel()
31 +
		folders, files, err := s3.List(ctx, bucket, prefix)
32 +
		return listingLoadedMsg{Bucket: bucket, Prefix: prefix, Folders: folders, Files: files, Err: err}
33 +
	}
34 +
}
35 +
36 +
const previewMaxBytes = 5 << 20
37 +
38 +
func loadPreviewCmd(s3 *S3Client, proto preview.Protocol, seq int, bucket, key string, w, h int) tea.Cmd {
39 +
	return func() tea.Msg {
40 +
		if !isImageName(key) {
41 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "")}
42 +
		}
43 +
		if proto == preview.ProtoNone {
44 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "no preview backend (install chafa)")}
45 +
		}
46 +
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
47 +
		defer cancel()
48 +
		body, meta, err := s3.Get(ctx, bucket, key)
49 +
		if err != nil {
50 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Err: err}
51 +
		}
52 +
		defer body.Close()
53 +
		if meta != nil && meta.Size > previewMaxBytes {
54 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, meta.Size, "too large to preview")}
55 +
		}
56 +
		buf, err := io.ReadAll(io.LimitReader(body, previewMaxBytes+1))
57 +
		if err != nil {
58 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Err: err}
59 +
		}
60 +
		if len(buf) > previewMaxBytes {
61 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "too large to preview")}
62 +
		}
63 +
		rendered, rerr := preview.Render(proto, buf, w, h)
64 +
		if rerr != nil {
65 +
			return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "render: "+rerr.Error())}
66 +
		}
67 +
		return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: rendered}
68 +
	}
69 +
}
70 +
71 +
const previewDebounce = 150 * time.Millisecond
72 +
73 +
func debouncePreviewCmd(seq int, bucket, key string, w, h int) tea.Cmd {
74 +
	return tea.Tick(previewDebounce, func(time.Time) tea.Msg {
75 +
		return previewDebounceMsg{Seq: seq, Bucket: bucket, Key: key, W: w, H: h}
76 +
	})
77 +
}
78 +
79 +
func previewMetaText(key string, size int64, note string) string {
80 +
	ct := mime.TypeByExtension(filepath.Ext(key))
81 +
	if ct == "" {
82 +
		ct = "application/octet-stream"
83 +
	}
84 +
	out := fmt.Sprintf("key:  %s\ntype: %s", key, ct)
85 +
	if size > 0 {
86 +
		out += fmt.Sprintf("\nsize: %s", humanSize(size))
87 +
	}
88 +
	if note != "" {
89 +
		out += "\n\n" + note
90 +
	}
91 +
	return out
92 +
}
93 +
94 +
func deleteCmd(s3 *S3Client, bucket, key string) tea.Cmd {
95 +
	return func() tea.Msg {
96 +
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
97 +
		defer cancel()
98 +
		err := s3.Delete(ctx, bucket, key)
99 +
		return deletedMsg{Key: key, Err: err}
100 +
	}
101 +
}
102 +
103 +
func uploadCmd(s3 *S3Client, cfg ClientConfig, bucket, prefix, localPath string) tea.Cmd {
104 +
	return func() tea.Msg {
105 +
		f, err := os.Open(localPath)
106 +
		if err != nil {
107 +
			return uploadedMsg{Err: err}
108 +
		}
109 +
		defer f.Close()
110 +
		info, err := f.Stat()
111 +
		if err != nil {
112 +
			return uploadedMsg{Err: err}
113 +
		}
114 +
		name := filepath.Base(localPath)
115 +
		pfx := prefix
116 +
		if pfx != "" && !strings.HasSuffix(pfx, "/") {
117 +
			pfx += "/"
118 +
		}
119 +
		key := pfx + name
120 +
		ct := mime.TypeByExtension(filepath.Ext(name))
121 +
		if ct == "" {
122 +
			ct = "application/octet-stream"
123 +
		}
124 +
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
125 +
		defer cancel()
126 +
		if err := s3.Put(ctx, bucket, key, ct, f, info.Size()); err != nil {
127 +
			return uploadedMsg{Key: key, Err: err}
128 +
		}
129 +
		url, _ := ResolveURL(ctx, s3, bucket, key)
130 +
		return uploadedMsg{Key: key, URL: url}
131 +
	}
132 +
}
apps/blobs/tui_items.go (added) +52 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
6 +
	"charm.land/bubbles/v2/list"
7 +
	sharedtui "github.com/stevedylandev/andromeda/pkg/tui"
8 +
)
9 +
10 +
var listIDStyle = sharedtui.ListIDStyle
11 +
12 +
type bucketItem struct{ b BucketInfo }
13 +
14 +
func (i bucketItem) Title() string {
15 +
	if i.b.CreationDate != "" {
16 +
		return i.b.Name + listIDStyle.Render(" "+i.b.CreationDate)
17 +
	}
18 +
	return i.b.Name
19 +
}
20 +
func (i bucketItem) Description() string { return "" }
21 +
func (i bucketItem) FilterValue() string { return i.b.Name }
22 +
23 +
type folderItemTUI struct {
24 +
	prefix string // full S3 prefix (ends in /)
25 +
	name   string
26 +
}
27 +
28 +
func (i folderItemTUI) Title() string       { return "📁 " + i.name + "/" }
29 +
func (i folderItemTUI) Description() string { return "" }
30 +
func (i folderItemTUI) FilterValue() string { return i.name }
31 +
32 +
type fileItemTUI struct {
33 +
	obj ObjectInfo
34 +
}
35 +
36 +
func (i fileItemTUI) Title() string {
37 +
	return i.obj.Name + listIDStyle.Render(fmt.Sprintf("  %s  %s", humanSize(i.obj.Size), i.obj.LastModified))
38 +
}
39 +
func (i fileItemTUI) Description() string { return "" }
40 +
func (i fileItemTUI) FilterValue() string { return i.obj.Name }
41 +
42 +
func newList(title string, items []list.Item) list.Model {
43 +
	l := list.New(items, sharedtui.ANSIListDelegate(), 0, 0)
44 +
	l.Title = title
45 +
	l.Styles = sharedtui.ANSIListStyles()
46 +
	l.SetShowStatusBar(false)
47 +
	l.SetShowPagination(false)
48 +
	l.SetShowHelp(false)
49 +
	l.SetFilteringEnabled(true)
50 +
	l.DisableQuitKeybindings()
51 +
	return l
52 +
}
apps/blobs/tui_keys.go (added) +37 −0
1 +
package main
2 +
3 +
import (
4 +
	"charm.land/bubbles/v2/key"
5 +
	sharedtui "github.com/stevedylandev/andromeda/pkg/tui"
6 +
)
7 +
8 +
type tuiKeyMap struct {
9 +
	sharedtui.KeyMap
10 +
	Upload    key.Binding
11 +
	Buckets   key.Binding
12 +
	Preview   key.Binding
13 +
	CopyKey   key.Binding
14 +
}
15 +
16 +
func defaultTUIKeys() tuiKeyMap {
17 +
	return tuiKeyMap{
18 +
		KeyMap: sharedtui.DefaultKeys(),
19 +
		Upload: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "upload")),
20 +
		Buckets: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "buckets")),
21 +
		Preview: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle preview")),
22 +
		CopyKey: key.NewBinding(key.WithKeys("K"), key.WithHelp("K", "copy key")),
23 +
	}
24 +
}
25 +
26 +
func (k tuiKeyMap) ShortHelp() []key.Binding {
27 +
	return []key.Binding{k.Open, k.Back, k.Copy, k.CopyLink, k.OpenBrowser, k.Upload, k.Delete, k.Quit, k.Help}
28 +
}
29 +
30 +
func (k tuiKeyMap) FullHelp() [][]key.Binding {
31 +
	return [][]key.Binding{
32 +
		{k.Open, k.Back, k.Buckets, k.Refresh},
33 +
		{k.Copy, k.CopyLink, k.CopyKey, k.OpenBrowser},
34 +
		{k.Upload, k.Delete, k.Preview, k.Help},
35 +
		{k.ScrollUp, k.ScrollDown, k.Quit},
36 +
	}
37 +
}
apps/blobs/tui_messages.go (added) +49 −0
1 +
package main
2 +
3 +
import (
4 +
	sharedtui "github.com/stevedylandev/andromeda/pkg/tui"
5 +
)
6 +
7 +
type bucketsLoadedMsg struct {
8 +
	Buckets []BucketInfo
9 +
	Err     error
10 +
}
11 +
12 +
type listingLoadedMsg struct {
13 +
	Bucket  string
14 +
	Prefix  string
15 +
	Folders []string
16 +
	Files   []ObjectInfo
17 +
	Err     error
18 +
}
19 +
20 +
type previewLoadedMsg struct {
21 +
	Seq     int
22 +
	Bucket  string
23 +
	Key     string
24 +
	Content string // pre-rendered ANSI (image) or text
25 +
	Err     error
26 +
}
27 +
28 +
type previewDebounceMsg struct {
29 +
	Seq    int
30 +
	Bucket string
31 +
	Key    string
32 +
	W, H   int
33 +
}
34 +
35 +
type deletedMsg struct {
36 +
	Key string
37 +
	Err error
38 +
}
39 +
40 +
type uploadedMsg struct {
41 +
	Key string
42 +
	URL string
43 +
	Err error
44 +
}
45 +
46 +
type (
47 +
	statusMsg      = sharedtui.StatusMsg
48 +
	clearStatusMsg = sharedtui.ClearStatusMsg
49 +
)
apps/blobs/tui_model.go (added) +208 −0
1 +
package main
2 +
3 +
import (
4 +
	"time"
5 +
6 +
	"charm.land/bubbles/v2/help"
7 +
	"charm.land/bubbles/v2/list"
8 +
	"charm.land/bubbles/v2/textinput"
9 +
	"charm.land/bubbles/v2/viewport"
10 +
	tea "charm.land/bubbletea/v2"
11 +
12 +
	"github.com/stevedylandev/andromeda/apps/blobs/preview"
13 +
)
14 +
15 +
type tuiState uint8
16 +
17 +
const (
18 +
	stateBuckets tuiState = iota
19 +
	stateBrowse
20 +
)
21 +
22 +
type uploadPromptState uint8
23 +
24 +
const (
25 +
	uploadPromptOff uploadPromptState = iota
26 +
	uploadPromptActive
27 +
)
28 +
29 +
type tuiModel struct {
30 +
	s3   *S3Client
31 +
	cfg  ClientConfig
32 +
	opts tuiOptions
33 +
34 +
	state tuiState
35 +
36 +
	bucketsList list.Model
37 +
	browseList  list.Model
38 +
39 +
	preview      viewport.Model
40 +
	previewProto preview.Protocol
41 +
	showPreview  bool
42 +
	previewSeq   int
43 +
44 +
	currentBucket string
45 +
	currentPrefix string
46 +
47 +
	width, height int
48 +
	ready         bool
49 +
	loading       bool
50 +
51 +
	// modals
52 +
	confirmDelete bool
53 +
	uploadPrompt  uploadPromptState
54 +
	uploadInput   textinput.Model
55 +
56 +
	// help overlay
57 +
	showHelp bool
58 +
	help     help.Model
59 +
	keys     tuiKeyMap
60 +
61 +
	// status
62 +
	status      string
63 +
	statusOK    bool
64 +
	statusUntil time.Time
65 +
}
66 +
67 +
func newTUIModel(s3 *S3Client, cfg ClientConfig, opts tuiOptions) tuiModel {
68 +
	bl := newList("buckets", nil)
69 +
	brl := newList("/", nil)
70 +
	ti := textinput.New()
71 +
	ti.Placeholder = "/path/to/file"
72 +
	ti.Prompt = "upload: "
73 +
74 +
	m := tuiModel{
75 +
		s3:           s3,
76 +
		cfg:          cfg,
77 +
		opts:         opts,
78 +
		state:        stateBuckets,
79 +
		bucketsList:  bl,
80 +
		browseList:   brl,
81 +
		preview:      viewport.New(),
82 +
		previewProto: preview.Detect(),
83 +
		showPreview:  true,
84 +
		help:         help.New(),
85 +
		keys:         defaultTUIKeys(),
86 +
		uploadInput:  ti,
87 +
		loading:      true,
88 +
	}
89 +
	if cfg.DefaultBucket != "" {
90 +
		m.state = stateBrowse
91 +
		m.currentBucket = cfg.DefaultBucket
92 +
		m.currentPrefix = opts.Prefix
93 +
	}
94 +
	return m
95 +
}
96 +
97 +
func (m tuiModel) Init() tea.Cmd {
98 +
	cmds := []tea.Cmd{tea.RequestWindowSize}
99 +
	if m.state == stateBrowse {
100 +
		cmds = append(cmds, loadListingCmd(m.s3, m.currentBucket, m.currentPrefix))
101 +
	} else {
102 +
		cmds = append(cmds, loadBucketsCmd(m.s3))
103 +
	}
104 +
	return tea.Batch(cmds...)
105 +
}
106 +
107 +
func (m *tuiModel) setStatus(text string, ok bool) tea.Cmd {
108 +
	m.status = text
109 +
	m.statusOK = ok
110 +
	m.statusUntil = time.Now().Add(2 * time.Second)
111 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
112 +
}
113 +
114 +
func (m *tuiModel) selectedFile() (ObjectInfo, bool) {
115 +
	if m.state != stateBrowse {
116 +
		return ObjectInfo{}, false
117 +
	}
118 +
	it := m.browseList.SelectedItem()
119 +
	if it == nil {
120 +
		return ObjectInfo{}, false
121 +
	}
122 +
	f, ok := it.(fileItemTUI)
123 +
	if !ok {
124 +
		return ObjectInfo{}, false
125 +
	}
126 +
	return f.obj, true
127 +
}
128 +
129 +
func (m *tuiModel) selectedFolderPrefix() (string, bool) {
130 +
	if m.state != stateBrowse {
131 +
		return "", false
132 +
	}
133 +
	it := m.browseList.SelectedItem()
134 +
	if it == nil {
135 +
		return "", false
136 +
	}
137 +
	f, ok := it.(folderItemTUI)
138 +
	if !ok {
139 +
		return "", false
140 +
	}
141 +
	return f.prefix, true
142 +
}
143 +
144 +
func (m *tuiModel) selectedBucket() (string, bool) {
145 +
	if m.state != stateBuckets {
146 +
		return "", false
147 +
	}
148 +
	it := m.bucketsList.SelectedItem()
149 +
	if it == nil {
150 +
		return "", false
151 +
	}
152 +
	b, ok := it.(bucketItem)
153 +
	if !ok {
154 +
		return "", false
155 +
	}
156 +
	return b.b.Name, true
157 +
}
158 +
159 +
func (m *tuiModel) applyLayout() {
160 +
	if !m.ready {
161 +
		return
162 +
	}
163 +
	bodyH := m.height - 2
164 +
	if bodyH < 5 {
165 +
		bodyH = 5
166 +
	}
167 +
168 +
	switch m.state {
169 +
	case stateBuckets:
170 +
		fw, fh := paneFrameW(), paneFrameH()
171 +
		m.bucketsList.SetSize(max(m.width-fw, 1), max(bodyH-fh, 1))
172 +
	case stateBrowse:
173 +
		listW := m.width
174 +
		if m.showPreview {
175 +
			listW = m.width / 2
176 +
		}
177 +
		fw, fh := paneFrameW(), paneFrameH()
178 +
		m.browseList.SetSize(max(listW-fw, 1), max(bodyH-fh, 1))
179 +
		if m.showPreview {
180 +
			pw := m.width - listW
181 +
			m.preview.SetWidth(max(pw-fw, 1))
182 +
			m.preview.SetHeight(max(bodyH-fh-1, 1))
183 +
		}
184 +
	}
185 +
}
186 +
187 +
func setItemsFromListing(l *list.Model, prefix string, folders []string, files []ObjectInfo) tea.Cmd {
188 +
	items := make([]list.Item, 0, len(folders)+len(files))
189 +
	for _, fp := range folders {
190 +
		name := fp
191 +
		if len(prefix) <= len(fp) {
192 +
			name = fp[len(prefix):]
193 +
		}
194 +
		name = trimTrailingSlash(name)
195 +
		items = append(items, folderItemTUI{prefix: fp, name: name})
196 +
	}
197 +
	for _, f := range files {
198 +
		items = append(items, fileItemTUI{obj: f})
199 +
	}
200 +
	return l.SetItems(items)
201 +
}
202 +
203 +
func trimTrailingSlash(s string) string {
204 +
	if len(s) > 0 && s[len(s)-1] == '/' {
205 +
		return s[:len(s)-1]
206 +
	}
207 +
	return s
208 +
}
apps/blobs/tui_run.go (added) +55 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"strings"
7 +
8 +
	tea "charm.land/bubbletea/v2"
9 +
)
10 +
11 +
type tuiOptions struct {
12 +
	Bucket string
13 +
	Prefix string
14 +
}
15 +
16 +
func parseTUIArgs(args []string) tuiOptions {
17 +
	var opts tuiOptions
18 +
	for i := 0; i < len(args); i++ {
19 +
		a := args[i]
20 +
		switch {
21 +
		case (a == "-b" || a == "--bucket") && i+1 < len(args):
22 +
			opts.Bucket = args[i+1]
23 +
			i++
24 +
		case strings.HasPrefix(a, "--bucket="):
25 +
			opts.Bucket = strings.TrimPrefix(a, "--bucket=")
26 +
		case a == "--prefix" && i+1 < len(args):
27 +
			opts.Prefix = args[i+1]
28 +
			i++
29 +
		case strings.HasPrefix(a, "--prefix="):
30 +
			opts.Prefix = strings.TrimPrefix(a, "--prefix=")
31 +
		}
32 +
	}
33 +
	return opts
34 +
}
35 +
36 +
func runTUI(args []string) {
37 +
	opts := parseTUIArgs(args)
38 +
	cfg, err := LoadClientConfig(ClientFlags{Bucket: opts.Bucket})
39 +
	if err != nil {
40 +
		fmt.Fprintln(os.Stderr, "config:", err)
41 +
		os.Exit(1)
42 +
	}
43 +
	s3, err := NewS3FromConfig(cfg)
44 +
	if err != nil {
45 +
		fmt.Fprintln(os.Stderr, "s3:", err)
46 +
		os.Exit(1)
47 +
	}
48 +
49 +
	m := newTUIModel(s3, cfg, opts)
50 +
	p := tea.NewProgram(m)
51 +
	if _, err := p.Run(); err != nil {
52 +
		fmt.Fprintln(os.Stderr, err)
53 +
		os.Exit(1)
54 +
	}
55 +
}
apps/blobs/tui_update.go (added) +309 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"time"
6 +
7 +
	"charm.land/bubbles/v2/key"
8 +
	"charm.land/bubbles/v2/list"
9 +
	tea "charm.land/bubbletea/v2"
10 +
	sharedtui "github.com/stevedylandev/andromeda/pkg/tui"
11 +
)
12 +
13 +
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
14 +
	switch msg := msg.(type) {
15 +
16 +
	case tea.WindowSizeMsg:
17 +
		m.width, m.height = msg.Width, msg.Height
18 +
		m.ready = true
19 +
		m.applyLayout()
20 +
		return m, nil
21 +
22 +
	case bucketsLoadedMsg:
23 +
		m.loading = false
24 +
		if msg.Err != nil {
25 +
			return m, m.setStatus("buckets: "+msg.Err.Error(), false)
26 +
		}
27 +
		items := make([]list.Item, 0, len(msg.Buckets))
28 +
		for _, b := range msg.Buckets {
29 +
			items = append(items, bucketItem{b: b})
30 +
		}
31 +
		cmd := m.bucketsList.SetItems(items)
32 +
		return m, cmd
33 +
34 +
	case listingLoadedMsg:
35 +
		m.loading = false
36 +
		if msg.Err != nil {
37 +
			return m, m.setStatus("list: "+msg.Err.Error(), false)
38 +
		}
39 +
		m.currentBucket = msg.Bucket
40 +
		m.currentPrefix = msg.Prefix
41 +
		m.browseList.Title = msg.Bucket + ":/" + msg.Prefix
42 +
		cmd := setItemsFromListing(&m.browseList, msg.Prefix, msg.Folders, msg.Files)
43 +
		return m, tea.Batch(cmd, m.maybePreviewCmd())
44 +
45 +
	case previewDebounceMsg:
46 +
		if msg.Seq != m.previewSeq || msg.Bucket != m.currentBucket {
47 +
			return m, nil
48 +
		}
49 +
		return m, loadPreviewCmd(m.s3, m.previewProto, msg.Seq, msg.Bucket, msg.Key, msg.W, msg.H)
50 +
51 +
	case previewLoadedMsg:
52 +
		if msg.Seq != m.previewSeq || msg.Bucket != m.currentBucket {
53 +
			return m, nil
54 +
		}
55 +
		if msg.Err != nil {
56 +
			m.preview.SetContent("preview error: " + msg.Err.Error())
57 +
			return m, nil
58 +
		}
59 +
		m.preview.SetContent(msg.Content)
60 +
		m.preview.GotoTop()
61 +
		return m, nil
62 +
63 +
	case deletedMsg:
64 +
		if msg.Err != nil {
65 +
			return m, m.setStatus("delete: "+msg.Err.Error(), false)
66 +
		}
67 +
		return m, tea.Batch(
68 +
			loadListingCmd(m.s3, m.currentBucket, m.currentPrefix),
69 +
			m.setStatus("deleted "+msg.Key, true),
70 +
		)
71 +
72 +
	case uploadedMsg:
73 +
		if msg.Err != nil {
74 +
			return m, m.setStatus("upload: "+msg.Err.Error(), false)
75 +
		}
76 +
		cmds := []tea.Cmd{
77 +
			loadListingCmd(m.s3, m.currentBucket, m.currentPrefix),
78 +
			m.setStatus("uploaded "+msg.Key, true),
79 +
		}
80 +
		if msg.URL != "" {
81 +
			cmds = append(cmds, sharedtui.CopyToClipboardCmd(msg.URL, "copied url"))
82 +
		}
83 +
		return m, tea.Batch(cmds...)
84 +
85 +
	case statusMsg:
86 +
		return m, m.setStatus(msg.Text, msg.OK)
87 +
88 +
	case clearStatusMsg:
89 +
		if time.Now().Before(m.statusUntil) {
90 +
			return m, nil
91 +
		}
92 +
		m.status = ""
93 +
		return m, nil
94 +
95 +
	case tea.KeyPressMsg:
96 +
		return m.handleKey(msg)
97 +
	}
98 +
99 +
	return m, nil
100 +
}
101 +
102 +
func (m tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
103 +
	if msg.String() == "ctrl+c" {
104 +
		return m, tea.Quit
105 +
	}
106 +
107 +
	if m.uploadPrompt == uploadPromptActive {
108 +
		switch msg.String() {
109 +
		case "esc":
110 +
			m.uploadPrompt = uploadPromptOff
111 +
			m.uploadInput.Blur()
112 +
			return m, nil
113 +
		case "enter":
114 +
			path := m.uploadInput.Value()
115 +
			m.uploadPrompt = uploadPromptOff
116 +
			m.uploadInput.Blur()
117 +
			m.uploadInput.SetValue("")
118 +
			if path == "" {
119 +
				return m, nil
120 +
			}
121 +
			return m, uploadCmd(m.s3, m.cfg, m.currentBucket, m.currentPrefix, path)
122 +
		}
123 +
		var cmd tea.Cmd
124 +
		m.uploadInput, cmd = m.uploadInput.Update(msg)
125 +
		return m, cmd
126 +
	}
127 +
128 +
	if m.confirmDelete {
129 +
		switch msg.String() {
130 +
		case "y", "Y":
131 +
			m.confirmDelete = false
132 +
			f, ok := m.selectedFile()
133 +
			if !ok {
134 +
				return m, nil
135 +
			}
136 +
			return m, deleteCmd(m.s3, m.currentBucket, f.Key)
137 +
		case "n", "N", "esc", "q":
138 +
			m.confirmDelete = false
139 +
		}
140 +
		return m, nil
141 +
	}
142 +
143 +
	if m.showHelp {
144 +
		if key.Matches(msg, m.keys.Help) || msg.String() == "esc" || msg.String() == "q" {
145 +
			m.showHelp = false
146 +
		}
147 +
		return m, nil
148 +
	}
149 +
150 +
	switch m.state {
151 +
	case stateBuckets:
152 +
		return m.handleBucketsKey(msg)
153 +
	case stateBrowse:
154 +
		return m.handleBrowseKey(msg)
155 +
	}
156 +
	return m, nil
157 +
}
158 +
159 +
func (m tuiModel) handleBucketsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
160 +
	if m.bucketsList.SettingFilter() {
161 +
		var cmd tea.Cmd
162 +
		m.bucketsList, cmd = m.bucketsList.Update(msg)
163 +
		return m, cmd
164 +
	}
165 +
	switch {
166 +
	case key.Matches(msg, m.keys.Quit):
167 +
		return m, tea.Quit
168 +
	case key.Matches(msg, m.keys.Open):
169 +
		b, ok := m.selectedBucket()
170 +
		if !ok {
171 +
			return m, nil
172 +
		}
173 +
		m.state = stateBrowse
174 +
		m.currentBucket = b
175 +
		m.currentPrefix = ""
176 +
		m.loading = true
177 +
		m.applyLayout()
178 +
		return m, loadListingCmd(m.s3, b, "")
179 +
	case key.Matches(msg, m.keys.Refresh):
180 +
		m.loading = true
181 +
		return m, loadBucketsCmd(m.s3)
182 +
	case key.Matches(msg, m.keys.Help):
183 +
		m.showHelp = true
184 +
		return m, nil
185 +
	}
186 +
	var cmd tea.Cmd
187 +
	m.bucketsList, cmd = m.bucketsList.Update(msg)
188 +
	return m, cmd
189 +
}
190 +
191 +
func (m tuiModel) handleBrowseKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
192 +
	if m.browseList.SettingFilter() {
193 +
		var cmd tea.Cmd
194 +
		m.browseList, cmd = m.browseList.Update(msg)
195 +
		return m, cmd
196 +
	}
197 +
	switch {
198 +
	case key.Matches(msg, m.keys.Quit):
199 +
		return m, tea.Quit
200 +
	case key.Matches(msg, m.keys.Buckets):
201 +
		m.state = stateBuckets
202 +
		m.loading = true
203 +
		m.applyLayout()
204 +
		return m, loadBucketsCmd(m.s3)
205 +
	case key.Matches(msg, m.keys.Back):
206 +
		if m.currentPrefix == "" {
207 +
			if m.cfg.DefaultBucket == "" {
208 +
				m.state = stateBuckets
209 +
				m.loading = true
210 +
				m.applyLayout()
211 +
				return m, loadBucketsCmd(m.s3)
212 +
			}
213 +
			return m, nil
214 +
		}
215 +
		parent := parentPrefix(m.currentPrefix)
216 +
		m.loading = true
217 +
		return m, loadListingCmd(m.s3, m.currentBucket, parent)
218 +
	case key.Matches(msg, m.keys.Open):
219 +
		if pfx, ok := m.selectedFolderPrefix(); ok {
220 +
			m.loading = true
221 +
			return m, loadListingCmd(m.s3, m.currentBucket, pfx)
222 +
		}
223 +
		if f, ok := m.selectedFile(); ok {
224 +
			url, err := ResolveURL(context.Background(), m.s3, m.currentBucket, f.Key)
225 +
			if err != nil {
226 +
				return m, m.setStatus("url: "+err.Error(), false)
227 +
			}
228 +
			return m, sharedtui.OpenURLCmd(url)
229 +
		}
230 +
		return m, nil
231 +
	case key.Matches(msg, m.keys.Copy):
232 +
		f, ok := m.selectedFile()
233 +
		if !ok {
234 +
			return m, nil
235 +
		}
236 +
		url, err := ResolveURL(context.Background(), m.s3, m.currentBucket, f.Key)
237 +
		if err != nil {
238 +
			return m, m.setStatus("url: "+err.Error(), false)
239 +
		}
240 +
		return m, sharedtui.CopyToClipboardCmd(url, "copied url")
241 +
	case key.Matches(msg, m.keys.CopyLink):
242 +
		f, ok := m.selectedFile()
243 +
		if !ok {
244 +
			return m, nil
245 +
		}
246 +
		u, ok := m.s3.PublicURL(m.currentBucket, f.Key)
247 +
		if !ok {
248 +
			return m, m.setStatus("no public URL for bucket "+m.currentBucket, false)
249 +
		}
250 +
		return m, sharedtui.CopyToClipboardCmd(u, "copied public url")
251 +
	case key.Matches(msg, m.keys.CopyKey):
252 +
		f, ok := m.selectedFile()
253 +
		if !ok {
254 +
			return m, nil
255 +
		}
256 +
		return m, sharedtui.CopyToClipboardCmd(f.Key, "copied key")
257 +
	case key.Matches(msg, m.keys.OpenBrowser):
258 +
		f, ok := m.selectedFile()
259 +
		if !ok {
260 +
			return m, nil
261 +
		}
262 +
		url, err := ResolveURL(context.Background(), m.s3, m.currentBucket, f.Key)
263 +
		if err != nil {
264 +
			return m, m.setStatus("url: "+err.Error(), false)
265 +
		}
266 +
		return m, sharedtui.OpenURLCmd(url)
267 +
	case key.Matches(msg, m.keys.Delete):
268 +
		if _, ok := m.selectedFile(); ok {
269 +
			m.confirmDelete = true
270 +
		}
271 +
		return m, nil
272 +
	case key.Matches(msg, m.keys.Upload):
273 +
		m.uploadPrompt = uploadPromptActive
274 +
		m.uploadInput.Focus()
275 +
		return m, nil
276 +
	case key.Matches(msg, m.keys.Preview):
277 +
		m.showPreview = !m.showPreview
278 +
		m.applyLayout()
279 +
		if m.showPreview {
280 +
			return m, m.maybePreviewCmd()
281 +
		}
282 +
		return m, nil
283 +
	case key.Matches(msg, m.keys.Refresh):
284 +
		m.loading = true
285 +
		return m, loadListingCmd(m.s3, m.currentBucket, m.currentPrefix)
286 +
	case key.Matches(msg, m.keys.Help):
287 +
		m.showHelp = true
288 +
		return m, nil
289 +
	}
290 +
	var cmd tea.Cmd
291 +
	m.browseList, cmd = m.browseList.Update(msg)
292 +
	if m.showPreview {
293 +
		return m, tea.Batch(cmd, m.maybePreviewCmd())
294 +
	}
295 +
	return m, cmd
296 +
}
297 +
298 +
func (m *tuiModel) maybePreviewCmd() tea.Cmd {
299 +
	if !m.showPreview {
300 +
		return nil
301 +
	}
302 +
	f, ok := m.selectedFile()
303 +
	if !ok {
304 +
		m.preview.SetContent("")
305 +
		return nil
306 +
	}
307 +
	m.previewSeq++
308 +
	return debouncePreviewCmd(m.previewSeq, m.currentBucket, f.Key, m.preview.Width(), m.preview.Height())
309 +
}
apps/blobs/tui_view.go (added) +137 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
6 +
	tea "charm.land/bubbletea/v2"
7 +
	"charm.land/lipgloss/v2"
8 +
	sharedtui "github.com/stevedylandev/andromeda/pkg/tui"
9 +
)
10 +
11 +
var (
12 +
	paneBorder       = sharedtui.Border(lipgloss.RoundedBorder())
13 +
	paneBorderActive = sharedtui.BorderActive(lipgloss.RoundedBorder())
14 +
	tuiTitleStyle    = sharedtui.TitleStyle
15 +
	tuiOKStyle       = sharedtui.StatusOKStyle
16 +
	tuiErrStyle      = sharedtui.StatusErrStyle
17 +
	tuiHintStyle     = sharedtui.HintStyle
18 +
	tuiModalStyle    = sharedtui.ModalStyle
19 +
	tuiStatusModal   = sharedtui.StatusModalStyle
20 +
)
21 +
22 +
func paneFrameW() int { return paneBorder.GetHorizontalFrameSize() }
23 +
func paneFrameH() int { return paneBorder.GetVerticalFrameSize() }
24 +
25 +
func (m tuiModel) View() tea.View {
26 +
	if !m.ready {
27 +
		return tea.View{Content: "loading...", AltScreen: true}
28 +
	}
29 +
30 +
	bodyH := m.height - 2
31 +
	if bodyH < 5 {
32 +
		bodyH = 5
33 +
	}
34 +
35 +
	var body string
36 +
	switch m.state {
37 +
	case stateBuckets:
38 +
		body = paneBorderActive.Width(m.width).Height(bodyH).Render(m.bucketsList.View())
39 +
	case stateBrowse:
40 +
		body = m.renderBrowse()
41 +
	}
42 +
43 +
	footer := m.renderFooter()
44 +
	base := lipgloss.JoinVertical(lipgloss.Left, body, footer)
45 +
46 +
	var overlays []*lipgloss.Layer
47 +
	if m.showHelp {
48 +
		overlays = append(overlays, centerLay(m.width, m.height,
49 +
			tuiModalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())), 1))
50 +
	}
51 +
	if m.confirmDelete {
52 +
		name := ""
53 +
		if f, ok := m.selectedFile(); ok {
54 +
			name = f.Name
55 +
		}
56 +
		overlays = append(overlays, centerLay(m.width, m.height,
57 +
			tuiModalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", name)), 2))
58 +
	}
59 +
	if m.uploadPrompt == uploadPromptActive {
60 +
		dest := m.currentBucket + ":/" + m.currentPrefix
61 +
		overlays = append(overlays, centerLay(m.width, m.height,
62 +
			tuiModalStyle.Render("Upload to "+dest+"\n\n"+m.uploadInput.View()+"\n\nenter=upload  esc=cancel"), 2))
63 +
	}
64 +
	if m.status != "" {
65 +
		st := tuiOKStyle
66 +
		if !m.statusOK {
67 +
			st = tuiErrStyle
68 +
		}
69 +
		overlays = append(overlays, bottomLay(m.width, m.height,
70 +
			tuiStatusModal.Render(st.Render(m.status)), 3))
71 +
	}
72 +
73 +
	content := base
74 +
	if len(overlays) > 0 {
75 +
		layers := append([]*lipgloss.Layer{lipgloss.NewLayer(base)}, overlays...)
76 +
		canvas := lipgloss.NewCanvas(m.width, m.height)
77 +
		canvas.Compose(lipgloss.NewCompositor(layers...))
78 +
		content = canvas.Render()
79 +
	}
80 +
81 +
	return tea.View{Content: content, AltScreen: true}
82 +
}
83 +
84 +
func (m tuiModel) renderBrowse() string {
85 +
	bodyH := m.height - 2
86 +
	if bodyH < 5 {
87 +
		bodyH = 5
88 +
	}
89 +
	if !m.showPreview {
90 +
		return paneBorderActive.Width(m.width).Height(bodyH).Render(m.browseList.View())
91 +
	}
92 +
	listW := m.width / 2
93 +
	previewW := m.width - listW
94 +
	left := paneBorderActive.Width(listW).Height(bodyH).Render(m.browseList.View())
95 +
	header := tuiTitleStyle.Render("preview")
96 +
	previewBody := m.preview.View()
97 +
	inner := lipgloss.JoinVertical(lipgloss.Left, header, previewBody)
98 +
	right := paneBorder.Width(previewW).Height(bodyH).Render(inner)
99 +
	return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
100 +
}
101 +
102 +
func (m tuiModel) renderFooter() string {
103 +
	label := m.currentBucket
104 +
	if m.state == stateBrowse {
105 +
		label += ":/" + m.currentPrefix
106 +
	} else {
107 +
		label = "buckets"
108 +
	}
109 +
	help := m.help.ShortHelpView(m.keys.ShortHelp())
110 +
	return tuiHintStyle.Render(fmt.Sprintf("[%s] %s", label, help))
111 +
}
112 +
113 +
func centerLay(w, h int, content string, z int) *lipgloss.Layer {
114 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
115 +
	x := (w - cw) / 2
116 +
	y := (h - ch) / 2
117 +
	if x < 0 {
118 +
		x = 0
119 +
	}
120 +
	if y < 0 {
121 +
		y = 0
122 +
	}
123 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
124 +
}
125 +
126 +
func bottomLay(w, h int, content string, z int) *lipgloss.Layer {
127 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
128 +
	x := (w - cw) / 2
129 +
	y := h - ch - 1
130 +
	if x < 0 {
131 +
		x = 0
132 +
	}
133 +
	if y < 0 {
134 +
		y = 0
135 +
	}
136 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
137 +
}