feat: init blobs tui 710abfa9
Steve Simkins · 2026-05-25 19:43 18 file(s) · +1662 −89
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/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 +25 −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
10 14
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
11 15
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
12 16
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
17 +
	github.com/stevedylandev/andromeda/pkg/tui v0.0.0
13 18
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
19 +
	golang.org/x/term v0.36.0
14 20
)
15 21
16 22
require (
23 +
	github.com/atotto/clipboard v0.1.4 // indirect
17 24
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
18 25
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
19 26
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
23 30
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
24 31
	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
25 32
	github.com/aws/smithy-go v1.25.1 // indirect
33 +
	github.com/charmbracelet/colorprofile v0.4.3 // indirect
34 +
	github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
35 +
	github.com/charmbracelet/x/ansi v0.11.7 // indirect
36 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
37 +
	github.com/charmbracelet/x/termios v0.1.1 // indirect
38 +
	github.com/charmbracelet/x/windows v0.2.2 // indirect
39 +
	github.com/clipperhouse/displaywidth v0.11.0 // indirect
40 +
	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
26 41
	github.com/dustin/go-humanize v1.0.1 // indirect
27 42
	github.com/google/uuid v1.6.0 // indirect
43 +
	github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
28 44
	github.com/mattn/go-isatty v0.0.20 // indirect
45 +
	github.com/mattn/go-runewidth v0.0.23 // indirect
46 +
	github.com/muesli/cancelreader v0.2.2 // indirect
29 47
	github.com/ncruces/go-strftime v0.1.9 // indirect
30 48
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
49 +
	github.com/rivo/uniseg v0.4.7 // indirect
50 +
	github.com/sahilm/fuzzy v0.1.1 // indirect
51 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
31 52
	golang.org/x/crypto v0.39.0 // indirect
32 53
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
33 -
	golang.org/x/sys v0.33.0 // indirect
54 +
	golang.org/x/sync v0.20.0 // indirect
55 +
	golang.org/x/sys v0.43.0 // indirect
34 56
	modernc.org/libc v1.65.7 // indirect
35 57
	modernc.org/mathutil v1.7.1 // indirect
36 58
	modernc.org/memory v1.11.0 // indirect
42 64
	github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
43 65
	github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
44 66
	github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite
67 +
	github.com/stevedylandev/andromeda/pkg/tui => ../../pkg/tui
45 68
	github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
46 69
)
apps/blobs/go.sum +50 −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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
46 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
47 +
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
48 +
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
49 +
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
50 +
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
51 +
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
52 +
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
53 +
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
54 +
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
25 55
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
26 56
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
27 57
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
28 58
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
29 59
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
30 60
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
61 +
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
62 +
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
63 +
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
64 +
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
31 65
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
32 66
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
67 +
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
68 +
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
69 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
70 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
33 71
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
34 72
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
35 73
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
36 74
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
75 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
76 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
77 +
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
78 +
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
79 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
80 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
37 81
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
38 82
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
39 83
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
40 84
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
41 85
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
42 86
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=
87 +
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
88 +
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
45 89
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=
90 +
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
91 +
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
92 +
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
93 +
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
48 94
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
49 95
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
50 96
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) +116 −0
1 +
// Package preview renders images inline in supported terminals.
2 +
//
3 +
// Detection cascade: kitty/ghostty graphics → iTerm2 inline images →
4 +
// chafa fallback → metadata-only text.
5 +
package preview
6 +
7 +
import (
8 +
	"bytes"
9 +
	"encoding/base64"
10 +
	"fmt"
11 +
	"os"
12 +
	"os/exec"
13 +
	"strings"
14 +
)
15 +
16 +
type Protocol int
17 +
18 +
const (
19 +
	ProtoNone Protocol = iota
20 +
	ProtoKitty
21 +
	ProtoITerm
22 +
	ProtoChafa
23 +
)
24 +
25 +
// Detect inspects env vars to pick a preview protocol.
26 +
// Order: explicit BLOBS_PREVIEW override → kitty/ghostty → iTerm2/wezterm → chafa binary.
27 +
func Detect() Protocol {
28 +
	if v := strings.ToLower(os.Getenv("BLOBS_PREVIEW")); v != "" {
29 +
		switch v {
30 +
		case "kitty":
31 +
			return ProtoKitty
32 +
		case "iterm", "iterm2":
33 +
			return ProtoITerm
34 +
		case "chafa":
35 +
			if _, err := exec.LookPath("chafa"); err == nil {
36 +
				return ProtoChafa
37 +
			}
38 +
		case "none", "off":
39 +
			return ProtoNone
40 +
		}
41 +
	}
42 +
	if os.Getenv("KITTY_WINDOW_ID") != "" || os.Getenv("GHOSTTY_RESOURCES_DIR") != "" {
43 +
		return ProtoKitty
44 +
	}
45 +
	switch strings.ToLower(os.Getenv("TERM_PROGRAM")) {
46 +
	case "iterm.app", "wezterm":
47 +
		return ProtoITerm
48 +
	}
49 +
	if strings.HasPrefix(os.Getenv("TERM"), "xterm-kitty") {
50 +
		return ProtoKitty
51 +
	}
52 +
	if _, err := exec.LookPath("chafa"); err == nil {
53 +
		return ProtoChafa
54 +
	}
55 +
	return ProtoNone
56 +
}
57 +
58 +
// Render returns a string to print into a TUI viewport / pane.
59 +
// w, h are character cell dimensions of the target pane.
60 +
func Render(p Protocol, img []byte, w, h int) (string, error) {
61 +
	switch p {
62 +
	case ProtoKitty:
63 +
		return kittyEscape(img), nil
64 +
	case ProtoITerm:
65 +
		return itermEscape(img), nil
66 +
	case ProtoChafa:
67 +
		return chafaRender(img, w, h)
68 +
	default:
69 +
		return "", fmt.Errorf("no preview protocol available")
70 +
	}
71 +
}
72 +
73 +
func kittyEscape(img []byte) string {
74 +
	enc := base64.StdEncoding.EncodeToString(img)
75 +
	const chunk = 4096
76 +
	var b strings.Builder
77 +
	for i := 0; i < len(enc); i += chunk {
78 +
		end := i + chunk
79 +
		if end > len(enc) {
80 +
			end = len(enc)
81 +
		}
82 +
		more := 1
83 +
		if end == len(enc) {
84 +
			more = 0
85 +
		}
86 +
		if i == 0 {
87 +
			fmt.Fprintf(&b, "\x1b_Ga=T,f=100,m=%d;%s\x1b\\", more, enc[i:end])
88 +
		} else {
89 +
			fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, enc[i:end])
90 +
		}
91 +
	}
92 +
	return b.String()
93 +
}
94 +
95 +
func itermEscape(img []byte) string {
96 +
	enc := base64.StdEncoding.EncodeToString(img)
97 +
	return fmt.Sprintf("\x1b]1337;File=inline=1;preserveAspectRatio=1:%s\x07", enc)
98 +
}
99 +
100 +
func chafaRender(img []byte, w, h int) (string, error) {
101 +
	if w <= 0 {
102 +
		w = 40
103 +
	}
104 +
	if h <= 0 {
105 +
		h = 20
106 +
	}
107 +
	size := fmt.Sprintf("%dx%d", w, h)
108 +
	cmd := exec.Command("chafa", "--size", size, "--format", "symbols", "-")
109 +
	cmd.Stdin = bytes.NewReader(img)
110 +
	var out bytes.Buffer
111 +
	cmd.Stdout = &out
112 +
	if err := cmd.Run(); err != nil {
113 +
		return "", err
114 +
	}
115 +
	return out.String(), nil
116 +
}
apps/blobs/tui_commands.go (added) +124 −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, bucket, key string, w, h int) tea.Cmd {
39 +
	return func() tea.Msg {
40 +
		if !isImageName(key) {
41 +
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "")}
42 +
		}
43 +
		if proto == preview.ProtoNone {
44 +
			return previewLoadedMsg{Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "no preview backend")}
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{Bucket: bucket, Key: key, Err: err}
51 +
		}
52 +
		defer body.Close()
53 +
		if meta != nil && meta.Size > previewMaxBytes {
54 +
			return previewLoadedMsg{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{Bucket: bucket, Key: key, Err: err}
59 +
		}
60 +
		if len(buf) > previewMaxBytes {
61 +
			return previewLoadedMsg{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{Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "render: "+rerr.Error())}
66 +
		}
67 +
		return previewLoadedMsg{Bucket: bucket, Key: key, Content: rendered}
68 +
	}
69 +
}
70 +
71 +
func previewMetaText(key string, size int64, note string) string {
72 +
	ct := mime.TypeByExtension(filepath.Ext(key))
73 +
	if ct == "" {
74 +
		ct = "application/octet-stream"
75 +
	}
76 +
	out := fmt.Sprintf("key:  %s\ntype: %s", key, ct)
77 +
	if size > 0 {
78 +
		out += fmt.Sprintf("\nsize: %s", humanSize(size))
79 +
	}
80 +
	if note != "" {
81 +
		out += "\n\n" + note
82 +
	}
83 +
	return out
84 +
}
85 +
86 +
func deleteCmd(s3 *S3Client, bucket, key string) tea.Cmd {
87 +
	return func() tea.Msg {
88 +
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
89 +
		defer cancel()
90 +
		err := s3.Delete(ctx, bucket, key)
91 +
		return deletedMsg{Key: key, Err: err}
92 +
	}
93 +
}
94 +
95 +
func uploadCmd(s3 *S3Client, cfg ClientConfig, bucket, prefix, localPath string) tea.Cmd {
96 +
	return func() tea.Msg {
97 +
		f, err := os.Open(localPath)
98 +
		if err != nil {
99 +
			return uploadedMsg{Err: err}
100 +
		}
101 +
		defer f.Close()
102 +
		info, err := f.Stat()
103 +
		if err != nil {
104 +
			return uploadedMsg{Err: err}
105 +
		}
106 +
		name := filepath.Base(localPath)
107 +
		pfx := prefix
108 +
		if pfx != "" && !strings.HasSuffix(pfx, "/") {
109 +
			pfx += "/"
110 +
		}
111 +
		key := pfx + name
112 +
		ct := mime.TypeByExtension(filepath.Ext(name))
113 +
		if ct == "" {
114 +
			ct = "application/octet-stream"
115 +
		}
116 +
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
117 +
		defer cancel()
118 +
		if err := s3.Put(ctx, bucket, key, ct, f, info.Size()); err != nil {
119 +
			return uploadedMsg{Key: key, Err: err}
120 +
		}
121 +
		url, _ := ResolveURL(ctx, s3, bucket, key)
122 +
		return uploadedMsg{Key: key, URL: url}
123 +
	}
124 +
}
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) +41 −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 +
	Bucket  string
22 +
	Key     string
23 +
	Content string // pre-rendered ANSI (image) or text
24 +
	Err     error
25 +
}
26 +
27 +
type deletedMsg struct {
28 +
	Key string
29 +
	Err error
30 +
}
31 +
32 +
type uploadedMsg struct {
33 +
	Key string
34 +
	URL string
35 +
	Err error
36 +
}
37 +
38 +
type (
39 +
	statusMsg      = sharedtui.StatusMsg
40 +
	clearStatusMsg = sharedtui.ClearStatusMsg
41 +
)
apps/blobs/tui_model.go (added) +207 −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 +
43 +
	currentBucket string
44 +
	currentPrefix string
45 +
46 +
	width, height int
47 +
	ready         bool
48 +
	loading       bool
49 +
50 +
	// modals
51 +
	confirmDelete bool
52 +
	uploadPrompt  uploadPromptState
53 +
	uploadInput   textinput.Model
54 +
55 +
	// help overlay
56 +
	showHelp bool
57 +
	help     help.Model
58 +
	keys     tuiKeyMap
59 +
60 +
	// status
61 +
	status      string
62 +
	statusOK    bool
63 +
	statusUntil time.Time
64 +
}
65 +
66 +
func newTUIModel(s3 *S3Client, cfg ClientConfig, opts tuiOptions) tuiModel {
67 +
	bl := newList("buckets", nil)
68 +
	brl := newList("/", nil)
69 +
	ti := textinput.New()
70 +
	ti.Placeholder = "/path/to/file"
71 +
	ti.Prompt = "upload: "
72 +
73 +
	m := tuiModel{
74 +
		s3:           s3,
75 +
		cfg:          cfg,
76 +
		opts:         opts,
77 +
		state:        stateBuckets,
78 +
		bucketsList:  bl,
79 +
		browseList:   brl,
80 +
		preview:      viewport.New(),
81 +
		previewProto: preview.Detect(),
82 +
		showPreview:  true,
83 +
		help:         help.New(),
84 +
		keys:         defaultTUIKeys(),
85 +
		uploadInput:  ti,
86 +
		loading:      true,
87 +
	}
88 +
	if cfg.DefaultBucket != "" {
89 +
		m.state = stateBrowse
90 +
		m.currentBucket = cfg.DefaultBucket
91 +
		m.currentPrefix = opts.Prefix
92 +
	}
93 +
	return m
94 +
}
95 +
96 +
func (m tuiModel) Init() tea.Cmd {
97 +
	cmds := []tea.Cmd{tea.RequestWindowSize}
98 +
	if m.state == stateBrowse {
99 +
		cmds = append(cmds, loadListingCmd(m.s3, m.currentBucket, m.currentPrefix))
100 +
	} else {
101 +
		cmds = append(cmds, loadBucketsCmd(m.s3))
102 +
	}
103 +
	return tea.Batch(cmds...)
104 +
}
105 +
106 +
func (m *tuiModel) setStatus(text string, ok bool) tea.Cmd {
107 +
	m.status = text
108 +
	m.statusOK = ok
109 +
	m.statusUntil = time.Now().Add(2 * time.Second)
110 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
111 +
}
112 +
113 +
func (m *tuiModel) selectedFile() (ObjectInfo, bool) {
114 +
	if m.state != stateBrowse {
115 +
		return ObjectInfo{}, false
116 +
	}
117 +
	it := m.browseList.SelectedItem()
118 +
	if it == nil {
119 +
		return ObjectInfo{}, false
120 +
	}
121 +
	f, ok := it.(fileItemTUI)
122 +
	if !ok {
123 +
		return ObjectInfo{}, false
124 +
	}
125 +
	return f.obj, true
126 +
}
127 +
128 +
func (m *tuiModel) selectedFolderPrefix() (string, bool) {
129 +
	if m.state != stateBrowse {
130 +
		return "", false
131 +
	}
132 +
	it := m.browseList.SelectedItem()
133 +
	if it == nil {
134 +
		return "", false
135 +
	}
136 +
	f, ok := it.(folderItemTUI)
137 +
	if !ok {
138 +
		return "", false
139 +
	}
140 +
	return f.prefix, true
141 +
}
142 +
143 +
func (m *tuiModel) selectedBucket() (string, bool) {
144 +
	if m.state != stateBuckets {
145 +
		return "", false
146 +
	}
147 +
	it := m.bucketsList.SelectedItem()
148 +
	if it == nil {
149 +
		return "", false
150 +
	}
151 +
	b, ok := it.(bucketItem)
152 +
	if !ok {
153 +
		return "", false
154 +
	}
155 +
	return b.b.Name, true
156 +
}
157 +
158 +
func (m *tuiModel) applyLayout() {
159 +
	if !m.ready {
160 +
		return
161 +
	}
162 +
	bodyH := m.height - 2
163 +
	if bodyH < 5 {
164 +
		bodyH = 5
165 +
	}
166 +
167 +
	switch m.state {
168 +
	case stateBuckets:
169 +
		fw, fh := paneFrameW(), paneFrameH()
170 +
		m.bucketsList.SetSize(max(m.width-fw, 1), max(bodyH-fh, 1))
171 +
	case stateBrowse:
172 +
		listW := m.width
173 +
		if m.showPreview {
174 +
			listW = m.width / 2
175 +
		}
176 +
		fw, fh := paneFrameW(), paneFrameH()
177 +
		m.browseList.SetSize(max(listW-fw, 1), max(bodyH-fh, 1))
178 +
		if m.showPreview {
179 +
			pw := m.width - listW
180 +
			m.preview.SetWidth(max(pw-fw, 1))
181 +
			m.preview.SetHeight(max(bodyH-fh, 1))
182 +
		}
183 +
	}
184 +
}
185 +
186 +
func setItemsFromListing(l *list.Model, prefix string, folders []string, files []ObjectInfo) tea.Cmd {
187 +
	items := make([]list.Item, 0, len(folders)+len(files))
188 +
	for _, fp := range folders {
189 +
		name := fp
190 +
		if len(prefix) <= len(fp) {
191 +
			name = fp[len(prefix):]
192 +
		}
193 +
		name = trimTrailingSlash(name)
194 +
		items = append(items, folderItemTUI{prefix: fp, name: name})
195 +
	}
196 +
	for _, f := range files {
197 +
		items = append(items, fileItemTUI{obj: f})
198 +
	}
199 +
	return l.SetItems(items)
200 +
}
201 +
202 +
func trimTrailingSlash(s string) string {
203 +
	if len(s) > 0 && s[len(s)-1] == '/' {
204 +
		return s[:len(s)-1]
205 +
	}
206 +
	return s
207 +
}
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) +303 −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 previewLoadedMsg:
46 +
		if msg.Err != nil {
47 +
			m.preview.SetContent("preview error: " + msg.Err.Error())
48 +
			return m, nil
49 +
		}
50 +
		if msg.Bucket == m.currentBucket {
51 +
			m.preview.SetContent(msg.Content)
52 +
			m.preview.GotoTop()
53 +
		}
54 +
		return m, nil
55 +
56 +
	case deletedMsg:
57 +
		if msg.Err != nil {
58 +
			return m, m.setStatus("delete: "+msg.Err.Error(), false)
59 +
		}
60 +
		return m, tea.Batch(
61 +
			loadListingCmd(m.s3, m.currentBucket, m.currentPrefix),
62 +
			m.setStatus("deleted "+msg.Key, true),
63 +
		)
64 +
65 +
	case uploadedMsg:
66 +
		if msg.Err != nil {
67 +
			return m, m.setStatus("upload: "+msg.Err.Error(), false)
68 +
		}
69 +
		cmds := []tea.Cmd{
70 +
			loadListingCmd(m.s3, m.currentBucket, m.currentPrefix),
71 +
			m.setStatus("uploaded "+msg.Key, true),
72 +
		}
73 +
		if msg.URL != "" {
74 +
			cmds = append(cmds, sharedtui.CopyToClipboardCmd(msg.URL, "copied url"))
75 +
		}
76 +
		return m, tea.Batch(cmds...)
77 +
78 +
	case statusMsg:
79 +
		return m, m.setStatus(msg.Text, msg.OK)
80 +
81 +
	case clearStatusMsg:
82 +
		if time.Now().Before(m.statusUntil) {
83 +
			return m, nil
84 +
		}
85 +
		m.status = ""
86 +
		return m, nil
87 +
88 +
	case tea.KeyPressMsg:
89 +
		return m.handleKey(msg)
90 +
	}
91 +
92 +
	return m, nil
93 +
}
94 +
95 +
func (m tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
96 +
	if msg.String() == "ctrl+c" {
97 +
		return m, tea.Quit
98 +
	}
99 +
100 +
	if m.uploadPrompt == uploadPromptActive {
101 +
		switch msg.String() {
102 +
		case "esc":
103 +
			m.uploadPrompt = uploadPromptOff
104 +
			m.uploadInput.Blur()
105 +
			return m, nil
106 +
		case "enter":
107 +
			path := m.uploadInput.Value()
108 +
			m.uploadPrompt = uploadPromptOff
109 +
			m.uploadInput.Blur()
110 +
			m.uploadInput.SetValue("")
111 +
			if path == "" {
112 +
				return m, nil
113 +
			}
114 +
			return m, uploadCmd(m.s3, m.cfg, m.currentBucket, m.currentPrefix, path)
115 +
		}
116 +
		var cmd tea.Cmd
117 +
		m.uploadInput, cmd = m.uploadInput.Update(msg)
118 +
		return m, cmd
119 +
	}
120 +
121 +
	if m.confirmDelete {
122 +
		switch msg.String() {
123 +
		case "y", "Y":
124 +
			m.confirmDelete = false
125 +
			f, ok := m.selectedFile()
126 +
			if !ok {
127 +
				return m, nil
128 +
			}
129 +
			return m, deleteCmd(m.s3, m.currentBucket, f.Key)
130 +
		case "n", "N", "esc", "q":
131 +
			m.confirmDelete = false
132 +
		}
133 +
		return m, nil
134 +
	}
135 +
136 +
	if m.showHelp {
137 +
		if key.Matches(msg, m.keys.Help) || msg.String() == "esc" || msg.String() == "q" {
138 +
			m.showHelp = false
139 +
		}
140 +
		return m, nil
141 +
	}
142 +
143 +
	switch m.state {
144 +
	case stateBuckets:
145 +
		return m.handleBucketsKey(msg)
146 +
	case stateBrowse:
147 +
		return m.handleBrowseKey(msg)
148 +
	}
149 +
	return m, nil
150 +
}
151 +
152 +
func (m tuiModel) handleBucketsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
153 +
	if m.bucketsList.SettingFilter() {
154 +
		var cmd tea.Cmd
155 +
		m.bucketsList, cmd = m.bucketsList.Update(msg)
156 +
		return m, cmd
157 +
	}
158 +
	switch {
159 +
	case key.Matches(msg, m.keys.Quit):
160 +
		return m, tea.Quit
161 +
	case key.Matches(msg, m.keys.Open):
162 +
		b, ok := m.selectedBucket()
163 +
		if !ok {
164 +
			return m, nil
165 +
		}
166 +
		m.state = stateBrowse
167 +
		m.currentBucket = b
168 +
		m.currentPrefix = ""
169 +
		m.loading = true
170 +
		m.applyLayout()
171 +
		return m, loadListingCmd(m.s3, b, "")
172 +
	case key.Matches(msg, m.keys.Refresh):
173 +
		m.loading = true
174 +
		return m, loadBucketsCmd(m.s3)
175 +
	case key.Matches(msg, m.keys.Help):
176 +
		m.showHelp = true
177 +
		return m, nil
178 +
	}
179 +
	var cmd tea.Cmd
180 +
	m.bucketsList, cmd = m.bucketsList.Update(msg)
181 +
	return m, cmd
182 +
}
183 +
184 +
func (m tuiModel) handleBrowseKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
185 +
	if m.browseList.SettingFilter() {
186 +
		var cmd tea.Cmd
187 +
		m.browseList, cmd = m.browseList.Update(msg)
188 +
		return m, cmd
189 +
	}
190 +
	switch {
191 +
	case key.Matches(msg, m.keys.Quit):
192 +
		return m, tea.Quit
193 +
	case key.Matches(msg, m.keys.Buckets):
194 +
		m.state = stateBuckets
195 +
		m.loading = true
196 +
		m.applyLayout()
197 +
		return m, loadBucketsCmd(m.s3)
198 +
	case key.Matches(msg, m.keys.Back):
199 +
		if m.currentPrefix == "" {
200 +
			if m.cfg.DefaultBucket == "" {
201 +
				m.state = stateBuckets
202 +
				m.loading = true
203 +
				m.applyLayout()
204 +
				return m, loadBucketsCmd(m.s3)
205 +
			}
206 +
			return m, nil
207 +
		}
208 +
		parent := parentPrefix(m.currentPrefix)
209 +
		m.loading = true
210 +
		return m, loadListingCmd(m.s3, m.currentBucket, parent)
211 +
	case key.Matches(msg, m.keys.Open):
212 +
		if pfx, ok := m.selectedFolderPrefix(); ok {
213 +
			m.loading = true
214 +
			return m, loadListingCmd(m.s3, m.currentBucket, pfx)
215 +
		}
216 +
		if f, ok := m.selectedFile(); ok {
217 +
			url, err := ResolveURL(context.Background(), m.s3, m.currentBucket, f.Key)
218 +
			if err != nil {
219 +
				return m, m.setStatus("url: "+err.Error(), false)
220 +
			}
221 +
			return m, sharedtui.OpenURLCmd(url)
222 +
		}
223 +
		return m, nil
224 +
	case key.Matches(msg, m.keys.Copy):
225 +
		f, ok := m.selectedFile()
226 +
		if !ok {
227 +
			return m, nil
228 +
		}
229 +
		url, err := ResolveURL(context.Background(), m.s3, m.currentBucket, f.Key)
230 +
		if err != nil {
231 +
			return m, m.setStatus("url: "+err.Error(), false)
232 +
		}
233 +
		return m, sharedtui.CopyToClipboardCmd(url, "copied url")
234 +
	case key.Matches(msg, m.keys.CopyLink):
235 +
		f, ok := m.selectedFile()
236 +
		if !ok {
237 +
			return m, nil
238 +
		}
239 +
		u, ok := m.s3.PublicURL(m.currentBucket, f.Key)
240 +
		if !ok {
241 +
			return m, m.setStatus("no public URL for bucket "+m.currentBucket, false)
242 +
		}
243 +
		return m, sharedtui.CopyToClipboardCmd(u, "copied public url")
244 +
	case key.Matches(msg, m.keys.CopyKey):
245 +
		f, ok := m.selectedFile()
246 +
		if !ok {
247 +
			return m, nil
248 +
		}
249 +
		return m, sharedtui.CopyToClipboardCmd(f.Key, "copied key")
250 +
	case key.Matches(msg, m.keys.OpenBrowser):
251 +
		f, ok := m.selectedFile()
252 +
		if !ok {
253 +
			return m, nil
254 +
		}
255 +
		url, err := ResolveURL(context.Background(), m.s3, m.currentBucket, f.Key)
256 +
		if err != nil {
257 +
			return m, m.setStatus("url: "+err.Error(), false)
258 +
		}
259 +
		return m, sharedtui.OpenURLCmd(url)
260 +
	case key.Matches(msg, m.keys.Delete):
261 +
		if _, ok := m.selectedFile(); ok {
262 +
			m.confirmDelete = true
263 +
		}
264 +
		return m, nil
265 +
	case key.Matches(msg, m.keys.Upload):
266 +
		m.uploadPrompt = uploadPromptActive
267 +
		m.uploadInput.Focus()
268 +
		return m, nil
269 +
	case key.Matches(msg, m.keys.Preview):
270 +
		m.showPreview = !m.showPreview
271 +
		m.applyLayout()
272 +
		if m.showPreview {
273 +
			return m, m.maybePreviewCmd()
274 +
		}
275 +
		return m, nil
276 +
	case key.Matches(msg, m.keys.Refresh):
277 +
		m.loading = true
278 +
		return m, loadListingCmd(m.s3, m.currentBucket, m.currentPrefix)
279 +
	case key.Matches(msg, m.keys.Help):
280 +
		m.showHelp = true
281 +
		return m, nil
282 +
	}
283 +
	var cmd tea.Cmd
284 +
	m.browseList, cmd = m.browseList.Update(msg)
285 +
	if m.showPreview {
286 +
		return m, tea.Batch(cmd, m.maybePreviewCmd())
287 +
	}
288 +
	return m, cmd
289 +
}
290 +
291 +
func (m tuiModel) maybePreviewCmd() tea.Cmd {
292 +
	if !m.showPreview {
293 +
		return nil
294 +
	}
295 +
	f, ok := m.selectedFile()
296 +
	if !ok {
297 +
		m.preview.SetContent("")
298 +
		return nil
299 +
	}
300 +
	w := m.preview.Width()
301 +
	h := m.preview.Height()
302 +
	return loadPreviewCmd(m.s3, m.previewProto, m.currentBucket, f.Key, w, h)
303 +
}
apps/blobs/tui_view.go (added) +129 −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 +
	var body string
31 +
	switch m.state {
32 +
	case stateBuckets:
33 +
		body = paneBorderActive.Width(m.width - paneFrameW()).Height(m.height - 2 - paneFrameH()).Render(m.bucketsList.View())
34 +
	case stateBrowse:
35 +
		body = m.renderBrowse()
36 +
	}
37 +
38 +
	footer := m.renderFooter()
39 +
	base := lipgloss.JoinVertical(lipgloss.Left, body, footer)
40 +
41 +
	var overlays []*lipgloss.Layer
42 +
	if m.showHelp {
43 +
		overlays = append(overlays, centerLay(m.width, m.height,
44 +
			tuiModalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())), 1))
45 +
	}
46 +
	if m.confirmDelete {
47 +
		name := ""
48 +
		if f, ok := m.selectedFile(); ok {
49 +
			name = f.Name
50 +
		}
51 +
		overlays = append(overlays, centerLay(m.width, m.height,
52 +
			tuiModalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", name)), 2))
53 +
	}
54 +
	if m.uploadPrompt == uploadPromptActive {
55 +
		dest := m.currentBucket + ":/" + m.currentPrefix
56 +
		overlays = append(overlays, centerLay(m.width, m.height,
57 +
			tuiModalStyle.Render("Upload to "+dest+"\n\n"+m.uploadInput.View()+"\n\nenter=upload  esc=cancel"), 2))
58 +
	}
59 +
	if m.status != "" {
60 +
		st := tuiOKStyle
61 +
		if !m.statusOK {
62 +
			st = tuiErrStyle
63 +
		}
64 +
		overlays = append(overlays, bottomLay(m.width, m.height,
65 +
			tuiStatusModal.Render(st.Render(m.status)), 3))
66 +
	}
67 +
68 +
	content := base
69 +
	if len(overlays) > 0 {
70 +
		layers := append([]*lipgloss.Layer{lipgloss.NewLayer(base)}, overlays...)
71 +
		canvas := lipgloss.NewCanvas(m.width, m.height)
72 +
		canvas.Compose(lipgloss.NewCompositor(layers...))
73 +
		content = canvas.Render()
74 +
	}
75 +
76 +
	return tea.View{Content: content, AltScreen: true}
77 +
}
78 +
79 +
func (m tuiModel) renderBrowse() string {
80 +
	bodyH := m.height - 2
81 +
	if !m.showPreview {
82 +
		return paneBorderActive.Width(m.width - paneFrameW()).Height(bodyH - paneFrameH()).Render(m.browseList.View())
83 +
	}
84 +
	listW := m.width / 2
85 +
	previewW := m.width - listW
86 +
	left := paneBorderActive.Width(listW - paneFrameW()).Height(bodyH - paneFrameH()).Render(m.browseList.View())
87 +
	header := tuiTitleStyle.Render("preview")
88 +
	previewBody := m.preview.View()
89 +
	inner := lipgloss.JoinVertical(lipgloss.Left, header, previewBody)
90 +
	right := paneBorder.Width(previewW - paneFrameW()).Height(bodyH - paneFrameH()).Render(inner)
91 +
	return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
92 +
}
93 +
94 +
func (m tuiModel) renderFooter() string {
95 +
	label := m.currentBucket
96 +
	if m.state == stateBrowse {
97 +
		label += ":/" + m.currentPrefix
98 +
	} else {
99 +
		label = "buckets"
100 +
	}
101 +
	help := m.help.ShortHelpView(m.keys.ShortHelp())
102 +
	return tuiHintStyle.Render(fmt.Sprintf("[%s] %s", label, help))
103 +
}
104 +
105 +
func centerLay(w, h int, content string, z int) *lipgloss.Layer {
106 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
107 +
	x := (w - cw) / 2
108 +
	y := (h - ch) / 2
109 +
	if x < 0 {
110 +
		x = 0
111 +
	}
112 +
	if y < 0 {
113 +
		y = 0
114 +
	}
115 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
116 +
}
117 +
118 +
func bottomLay(w, h int, content string, z int) *lipgloss.Layer {
119 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
120 +
	x := (w - cw) / 2
121 +
	y := h - ch - 1
122 +
	if x < 0 {
123 +
		x = 0
124 +
	}
125 +
	if y < 0 {
126 +
		y = 0
127 +
	}
128 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
129 +
}