cmd/012 loads the config with flags and environment on top, adds 012 config (path, edit, default, themes, set-key, delete-key), and resolves the JEV key from TYPESAFE_API_KEY, the credential store or jev-api-key-command. .env files are no longer read; a .env with a key gets a note suggesting set-key. The base URL must be https except on localhost. File > Settings gains Theme (a picker with live preview), JEV API key (a masked prompt that stores the key), Open config file and Reload config. Themes with their own colors draw the bars and the screen out to the terminal's edge; chart images and notifications can be turned off.
- commit
- 7e69e7501d42c4e050f00871cdfe1ec98f21db6f
- parent
- 13d59e829144a9235215c01963c1eb315d28485a
- author
- Dami <dami.etoile@gmail.com>
| A | cmd/ | 180 | ++++++++++++++++++++++++ |
| M | cmd/ | 154 | +++++++++++++++++---- |
| A | cmd/ | 169 | +++++++++++++++++++++++ |
| M | cmd/ | 38 | +---- |
| D | cmd/ | 22 | --- |
| M | go.mod | 2 | +- |
| M | internal/ | 12 | ++ |
| A | internal/ | 43 | ++++++ |
| M | internal/ | 6 | +- |
| A | internal/ | 142 | +++++++++++++++++++ |
| A | internal/ | 152 | +++++++++++++++++++++ |
| M | internal/ | 66 | ++------- |
| M | internal/ | 23 | --- |
| M | internal/ | 6 | +- |
| M | internal/ | 40 | ++++- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 10 | +- |
| M | internal/ | 45 | ++++++- |
| A | internal/ | 202 | +++++++++++++++++++++++++++ |
| A | internal/ | 221 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 14 | ++ |
| A | internal/ | 138 | +++++++++++++++++++ |
| M | internal/ | 2 | +- |
@@ -0,0 +1,180 @@
1+package main2+3+import (4+ "bufio"5+ "context"6+ "errors"7+ "fmt"8+ "os"9+ "strings"10+ "time"11+12+ "github.com/charmbracelet/x/term"13+14+ "github.com/FelineStateMachine/012/internal/config"15+ "github.com/FelineStateMachine/012/internal/jev"16+ "github.com/FelineStateMachine/012/internal/keyring"17+ "github.com/FelineStateMachine/012/internal/ui/theme"18+)19+20+// runConfig is `012 config ...`: see docs/config.md.21+func runConfig(args []string, e env) error {22+ cfg, err := loadConfig(e, nil)23+ if err != nil {24+ return err25+ }26+ sub := ""27+ if len(args) > 0 {28+ sub = args[0]29+ }30+ if len(args) > 1 {31+ return usage()32+ }33+ switch sub {34+ case "":35+ fmt.Fprint(e.stdout, cfg.Show())36+ fmt.Fprintf(e.stdout, "\n# JEV API key: %s\n", keyStatus(cfg, e))37+ case "path":38+ fmt.Fprintln(e.stdout, cfg.Path)39+ case "default":40+ fmt.Fprint(e.stdout, config.DefaultFile())41+ case "edit":42+ return editConfig(cfg.Path, e)43+ case "themes":44+ listThemes(cfg, e)45+ case "set-key":46+ return setKey(cfg, e)47+ case "delete-key":48+ return deleteKey(cfg, e)49+ default:50+ return usage()51+ }52+ return nil53+}54+55+// keyStatus says where the API key would come from, without showing it56+// or running jev-api-key-command.57+func keyStatus(c *config.Config, e env) string {58+ if strings.TrimSpace(e.getenv(jev.KeyEnv)) != "" {59+ return "from $" + jev.KeyEnv60+ }61+ if store := keyStore(c, e); store != nil {62+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)63+ defer cancel()64+ _, err := store.Get(ctx)65+ switch {66+ case err == nil:67+ return "stored in the " + store.Name()68+ case !errors.Is(err, keyring.ErrNotFound):69+ return "the " + store.Name() + " couldn't be read: " + err.Error()70+ }71+ }72+ if c.String("jev-api-key-command") != "" {73+ return "from jev-api-key-command (not run here)"74+ }75+ return "not set; run 012 config set-key"76+}77+78+func editConfig(path string, e env) error {79+ if err := config.EnsureFile(path); err != nil {80+ return err81+ }82+ cmd, err := config.Editor(path, e.getenv)83+ if err != nil {84+ return err85+ }86+ if e.editorIO {87+ cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr88+ }89+ return cmd.Run()90+}91+92+func listThemes(c *config.Config, e env) {93+ current := c.Theme()94+ for _, t := range theme.List(config.ThemesDir()) {95+ kind := "light"96+ if t.Dark {97+ kind = "dark"98+ }99+ switch {100+ case t.Name == theme.Terminal:101+ kind = "the terminal's own colors"102+ case t.User:103+ kind += ", " + config.ThemesDir()104+ }105+ mark := " "106+ if strings.EqualFold(t.Name, current.Light) || strings.EqualFold(t.Name, current.Dark) {107+ mark = "*"108+ }109+ fmt.Fprintf(e.stdout, "%s %-36s %s\n", mark, t.Name, kind)110+ }111+}112+113+// setKey stores the API key read from the terminal, without echo, or114+// from stdin when it's a pipe. The key is never an argument.115+func setKey(c *config.Config, e env) error {116+ store := keyStore(c, e)117+ if store == nil {118+ return errors.New("jev-credential-store is off in the config; use jev-api-key-command or TYPESAFE_API_KEY instead")119+ }120+ var key string121+ var err error122+ if e.isTTY {123+ key, err = e.readKey("TypeSafe API key (not shown): ")124+ } else {125+ key, err = firstLine(e.stdin)126+ }127+ if err != nil {128+ return err129+ }130+ if key = strings.TrimSpace(key); key == "" {131+ return errors.New("no key given; nothing changed")132+ }133+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)134+ defer cancel()135+ if err := store.Set(ctx, key); err != nil {136+ return err137+ }138+ fmt.Fprintf(e.stdout, "Saved the JEV API key in the %s.\n", store.Name())139+ if strings.TrimSpace(e.getenv(jev.KeyEnv)) != "" {140+ fmt.Fprintf(e.stdout, "$%s is set too, and wins while it is.\n", jev.KeyEnv)141+ }142+ return nil143+}144+145+func deleteKey(c *config.Config, e env) error {146+ store := keyStore(c, e)147+ if store == nil {148+ return errors.New("jev-credential-store is off in the config")149+ }150+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)151+ defer cancel()152+ switch err := store.Delete(ctx); {153+ case errors.Is(err, keyring.ErrNotFound):154+ fmt.Fprintf(e.stdout, "No JEV API key in the %s.\n", store.Name())155+ case err != nil:156+ return err157+ default:158+ fmt.Fprintf(e.stdout, "Deleted the JEV API key from the %s.\n", store.Name())159+ }160+ return nil161+}162+163+func firstLine(r interface{ Read([]byte) (int, error) }) (string, error) {164+ sc := bufio.NewScanner(r)165+ if sc.Scan() {166+ return sc.Text(), nil167+ }168+ return "", sc.Err()169+}170+171+func isTerminal(f *os.File) bool { return term.IsTerminal(f.Fd()) }172+173+// readPassword prompts on stderr and reads a line from the terminal with174+// echo off.175+func readPassword(prompt string) (string, error) {176+ fmt.Fprint(os.Stderr, prompt)177+ b, err := term.ReadPassword(os.Stdin.Fd())178+ fmt.Fprintln(os.Stderr)179+ return string(b), err180+}@@ -2,55 +2,157 @@
22 package main33 44 import (5+ "context"56 "errors"67 "fmt"8+ "io"79 "io/fs"810 "os"9- "path/filepath"1011 1112 tea "charm.land/bubbletea/v2"1213 14+ "github.com/FelineStateMachine/012/internal/config"1315 "github.com/FelineStateMachine/012/internal/fileio"1416 "github.com/FelineStateMachine/012/internal/jev"17+ "github.com/FelineStateMachine/012/internal/keyring"1518 "github.com/FelineStateMachine/012/internal/sheet"1619 "github.com/FelineStateMachine/012/internal/ui"1720 )1821 1922 func main() {20- if err := run(os.Args[1:]); err != nil {23+ if err := run(os.Args[1:], system()); err != nil {2124 fmt.Fprintln(os.Stderr, "012:", err)2225 os.Exit(1)2326 }2427 }2528 26-func run(args []string) error {27- tc, args, err := telemetryFlags(args)29+// env is what 012 takes from the process, so tests can substitute a30+// temporary config and a fake credential store.31+type env struct {32+ getenv func(string) string33+ keys keyring.Store // the OS credential store34+ stdin io.Reader35+ stdout io.Writer36+ stderr io.Writer37+ readKey func(prompt string) (string, error) // reads a secret from the terminal without echo38+ isTTY bool // stdin is a terminal39+ runTUI func(m tea.Model) error40+ editorIO bool // run the editor attached to the terminal41+}42+43+func system() env {44+ return env{45+ getenv: os.Getenv, keys: keyring.System(),46+ stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr,47+ readKey: readPassword, isTTY: isTerminal(os.Stdin), editorIO: true,48+ runTUI: func(m tea.Model) error { _, err := tea.NewProgram(m).Run(); return err },49+ }50+}51+52+func usage() error {53+ return errors.New("usage: 012 " + config.FlagUsage() + " [file]: a " + sheet.FileExt +54+ " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +55+ " 012 config [path|edit|default|themes|set-key|delete-key]")56+}57+58+func run(args []string, e env) error {59+ if len(args) > 0 && args[0] == "config" {60+ return runConfig(args[1:], e)61+ }62+ flags, args, err := config.ParseFlags(args)2863 if err != nil || len(args) > 1 {29- return errors.New("usage: 012 [--log file.jsonl] [--otlp http://localhost:4318] [file]: a " + sheet.FileExt + " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import")64+ return usage()3065 }31- stopTelemetry, err := startTelemetry(tc)66+ cfg, err := loadConfig(e, flags)67+ if err != nil {68+ return err69+ }70+ stopTelemetry, err := startTelemetry(telemetryConfig(cfg))3271 if err != nil {3372 return err3473 }3574 defer stopTelemetry()36- // JEV functions run when an API key is set, in the environment or a37- // .env file next to the sheet or in the current directory. EnableJEV38- // gives the workbook the cache, which has its JEV cells queue their39- // questions.40- var jevClient jev.Client41- var jevCache *jev.Cache42- dirs := []string{"."}43- if len(args) == 1 {44- dirs = append(dirs, filepath.Dir(args[0]))75+76+ m, err := openModel(args)77+ if err != nil {78+ return err4579 }46- if cfg, ok := jev.LoadConfig(dirs...); ok {47- c, err := jev.NewClient(cfg)48- if err != nil {49- return fmt.Errorf("JEV: %w", err)80+ settings := ui.Settings{81+ Config: cfg,82+ Reload: func() *config.Config { c, _ := loadConfig(e, flags); return c },83+ ThemesDir: config.ThemesDir(),84+ Keys: keyStore(cfg, e),85+ Connect: func(key string) (jev.Client, error) { return jev.NewClient(jevConfig(cfg, key)) },86+ Notes: configNotes(cfg),87+ }88+ client, notes := startJEV(cfg, e, settings.Keys)89+ settings.Notes = append(settings.Notes, notes...)90+ if client != nil {91+ m.EnableJEV(client, jev.NewCache())92+ }93+ m.Configure(settings)94+ return e.runTUI(m)95+}96+97+// loadConfig reads the config file with the environment and flags.98+func loadConfig(e env, flags map[string]string) (*config.Config, error) {99+ path, err := config.DefaultPath()100+ if err != nil {101+ return nil, fmt.Errorf("finding the config directory: %w", err)102+ }103+ return config.Load(path, e.getenv, flags), nil104+}105+106+// configNotes summarizes the config's warnings for the context line.107+func configNotes(c *config.Config) []string {108+ switch n := len(c.Warnings); n {109+ case 0:110+ return nil111+ case 1:112+ return []string{"Config: " + c.Warnings[0].String()}113+ default:114+ return []string{fmt.Sprintf("Config: %s (and %d more; run 012 config)", c.Warnings[0], n-1)}115+ }116+}117+118+// keyStore is the credential store, unless the config turns it off.119+func keyStore(c *config.Config, e env) keyring.Store {120+ if !c.Bool("jev-credential-store") {121+ return nil122+ }123+ return e.keys124+}125+126+func jevConfig(c *config.Config, key string) jev.Config {127+ return jev.Config{APIKey: key, BaseURL: c.String("jev-base-url"), Model: c.String("jev-model")}128+}129+130+// startJEV finds the API key and connects, returning notes for the131+// context line: why the key couldn't be read, or that a .env file's key132+// is no longer read.133+func startJEV(c *config.Config, e env, store keyring.Store) (jev.Client, []string) {134+ ctx, cancel := context.WithTimeout(context.Background(), 2*jev.KeyCommandTimeout)135+ defer cancel()136+ key, _, err := jev.ResolveKey(ctx, jev.KeySources{Getenv: e.getenv, Store: store, Command: c.String("jev-api-key-command")})137+ var notes []string138+ if err != nil {139+ notes = append(notes, "JEV API key: "+err.Error())140+ }141+ if key == "" {142+ if jev.DotEnvHasKey(".") {143+ notes = append(notes, "012 no longer reads TYPESAFE_API_KEY from .env files: run 012 config set-key to store it")50144 }51- jevClient, jevCache = c, jev.NewCache()145+ return nil, notes52146 }147+ client, err := jev.NewClient(jevConfig(c, key))148+ if err != nil {149+ return nil, append(notes, "JEV: "+err.Error())150+ }151+ return client, notes152+}53153 154+// openModel opens the sheet named in args, or starts an import of it.155+func openModel(args []string) (*ui.Model, error) {54156 s, name := sheet.New(), ""55157 var importName string56158 if len(args) == 1 {@@ -58,7 +160,7 @@ func run(args []string) error {
58160 // Other formats are imported in the background once the59161 // screen is up, with progress; the file must exist.60162 if _, err := os.Stat(args[0]); err != nil {61- return err163+ return nil, err62164 }63165 importName, args = args[0], nil64166 }@@ -70,11 +172,11 @@ func run(args []string) error {
70172 case errors.Is(err, fs.ErrNotExist):71173 // Start a new worksheet that will be saved under this name.72174 case err != nil:73- return err175+ return nil, err74176 default:75177 defer f.Close()76178 if s, err = sheet.Read(f); err != nil {77- return fmt.Errorf("%s: %w", name, err)179+ return nil, fmt.Errorf("%s: %w", name, err)78180 }79181 }80182 }@@ -82,9 +184,5 @@ func run(args []string) error {
82184 if importName != "" {83185 m.Import(importName)84186 }85- if jevClient != nil {86- m.EnableJEV(jevClient, jevCache)87- }88- _, err = tea.NewProgram(m).Run()89- return err187+ return m, nil90188 }@@ -0,0 +1,169 @@
1+package main2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/config"14+ "github.com/FelineStateMachine/012/internal/keyring"15+)16+17+// testEnv points the config at a temporary directory and the credential18+// store at memory, so tests never touch the user's config or keychain.19+func testEnv(t *testing.T, vars map[string]string) (env, *bytes.Buffer, *keyring.Memory) {20+ t.Helper()21+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())22+ out := &bytes.Buffer{}23+ store := &keyring.Memory{}24+ return env{25+ getenv: func(k string) string { return vars[k] },26+ keys: store, stdin: strings.NewReader(""), stdout: out, stderr: out,27+ readKey: func(string) (string, error) { t.Fatal("read from a terminal"); return "", nil },28+ runTUI: func(tea.Model) error { return nil },29+ }, out, store30+}31+32+func writeConfig(t *testing.T, text string) string {33+ t.Helper()34+ path, _ := config.DefaultPath()35+ os.MkdirAll(filepath.Dir(path), 0o700)36+ if err := os.WriteFile(path, []byte(text), 0o600); err != nil {37+ t.Fatal(err)38+ }39+ return path40+}41+42+func TestConfigCommands(t *testing.T) {43+ e, out, _ := testEnv(t, map[string]string{"O12_THEME": "Dracula"})44+ path := writeConfig(t, "theme = Nord\nlog-level = warn\nwhat = 1\n")45+ if err := run([]string{"config", "path"}, e); err != nil || strings.TrimSpace(out.String()) != path {46+ t.Errorf("path: %q %v", out, err)47+ }48+ out.Reset()49+ if err := run([]string{"config"}, e); err != nil {50+ t.Fatal(err)51+ }52+ for _, want := range []string{"theme = Dracula", "# $O12_THEME", "log-level = warn", path + ":2", "chart-images = true", "# default",53+ `warning: ` + path + `:3: unknown key "what"`, "# JEV API key: not set; run 012 config set-key"} {54+ if !strings.Contains(out.String(), want) {55+ t.Errorf("012 config lacks %q:\n%s", want, out)56+ }57+ }58+ out.Reset()59+ if err := run([]string{"config", "default"}, e); err != nil || !strings.Contains(out.String(), "# theme = terminal") {60+ t.Errorf("default: %v\n%s", err, out)61+ }62+ out.Reset()63+ if err := run([]string{"config", "themes"}, e); err != nil || !strings.Contains(out.String(), "* Dracula") || !strings.Contains(out.String(), " terminal") {64+ t.Errorf("themes: %v\n%s", err, out)65+ }66+ if err := run([]string{"config", "bogus"}, e); err == nil {67+ t.Error("unknown subcommand")68+ }69+}70+71+func TestSetAndDeleteKey(t *testing.T) {72+ const key = "ts-live-abc123"73+ e, out, store := testEnv(t, nil)74+ e.stdin = strings.NewReader(key + "\n")75+ if err := run([]string{"config", "set-key"}, e); err != nil {76+ t.Fatal(err)77+ }78+ if got, _ := store.Get(context.Background()); got != key {79+ t.Errorf("stored %q", got)80+ }81+ if strings.Contains(out.String(), key) {82+ t.Errorf("the key was printed: %s", out)83+ }84+ out.Reset()85+ run([]string{"config"}, e)86+ if !strings.Contains(out.String(), "JEV API key: stored in the test store") || strings.Contains(out.String(), key) {87+ t.Errorf("status: %s", out)88+ }89+ out.Reset()90+ if err := run([]string{"config", "delete-key"}, e); err != nil || !strings.Contains(out.String(), "Deleted") {91+ t.Errorf("delete: %v %s", err, out)92+ }93+ e.stdin = strings.NewReader("")94+ if err := run([]string{"config", "set-key"}, e); err == nil {95+ t.Error("empty key accepted")96+ }97+98+ // A terminal reads the key without echo.99+ e.isTTY = true100+ e.readKey = func(string) (string, error) { return "from-tty", nil }101+ if err := run([]string{"config", "set-key"}, e); err != nil {102+ t.Fatal(err)103+ }104+ if got, _ := store.Get(context.Background()); got != "from-tty" {105+ t.Errorf("stored %q", got)106+ }107+108+ writeConfig(t, "jev-credential-store = false\n")109+ if err := run([]string{"config", "set-key"}, e); err == nil || !strings.Contains(err.Error(), "jev-credential-store is off") {110+ t.Errorf("store off: %v", err)111+ }112+}113+114+func TestStartJEV(t *testing.T) {115+ e, _, store := testEnv(t, nil)116+ c, _ := loadConfig(e, nil)117+ if client, notes := startJEV(c, e, store); client != nil || len(notes) != 0 {118+ t.Errorf("no key: %v %v", client, notes)119+ }120+ store.Set(context.Background(), "k")121+ if client, notes := startJEV(c, e, store); client == nil || len(notes) != 0 {122+ t.Errorf("stored key: %v %v", client, notes)123+ }124+125+ // A .env with a key is never read, but noticed.126+ store.Delete(context.Background())127+ dir := t.TempDir()128+ t.Chdir(dir)129+ os.WriteFile(".env", []byte("TYPESAFE_API_KEY=should-not-be-read\nTYPESAFE_BASE_URL=http://evil.example\n"), 0o600)130+ client, notes := startJEV(c, e, store)131+ if client != nil || len(notes) != 1 || !strings.Contains(notes[0], "012 config set-key") || strings.Contains(notes[0], "should-not") {132+ t.Errorf(".env: %v %v", client, notes)133+ }134+135+ // The base URL only comes from the config or environment, and must136+ // be https.137+ e.getenv = func(k string) string {138+ return map[string]string{"TYPESAFE_API_KEY": "k", "TYPESAFE_BASE_URL": "http://evil.example"}[k]139+ }140+ c, _ = loadConfig(e, nil)141+ if c.String("jev-base-url") != "" || len(c.Warnings) == 0 {142+ t.Errorf("http base URL accepted: %q", c.String("jev-base-url"))143+ }144+}145+146+func TestTelemetryConfig(t *testing.T) {147+ e, _, _ := testEnv(t, map[string]string{"O12_LOG": "env.jsonl", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://env:4318"})148+ writeConfig(t, "log-file = file.jsonl\nlog-level = debug\n")149+ flags, rest, err := config.ParseFlags([]string{"--log", "flag.jsonl", "b.csv"})150+ if err != nil || len(rest) != 1 {151+ t.Fatal(rest, err)152+ }153+ c, _ := loadConfig(e, flags)154+ tc := telemetryConfig(c)155+ if tc.LogPath != "flag.jsonl" || tc.OTLP.Endpoint != "http://env:4318" || tc.Level.String() != "DEBUG" {156+ t.Errorf("got %+v", tc)157+ }158+}159+160+func TestRunWarnsOnConfig(t *testing.T) {161+ e, _, _ := testEnv(t, nil)162+ writeConfig(t, "bogus = 1\ntheme = No Such Theme\n")163+ if err := run(nil, e); err != nil {164+ t.Fatal(err)165+ }166+ if err := run([]string{"a", "b"}, e); err == nil {167+ t.Error("two files")168+ }169+}@@ -1,40 +1,24 @@
11 package main22 33 import (4- "errors"54 "log/slog"65 "runtime/debug"7- "strings"86 7+ "github.com/FelineStateMachine/012/internal/config"98 "github.com/FelineStateMachine/012/internal/sheet"109 "github.com/FelineStateMachine/012/internal/telemetry"1110 )1211 13-// telemetryFlags takes --log path and --otlp url (or --log=path,14-// --otlp=url) out of args. Without them the log file comes from O12_LOG15-// and the OTLP endpoint from OTEL_EXPORTER_OTLP_ENDPOINT (and the other16-// OTEL_* variables), and telemetry stays off when none is set.17-func telemetryFlags(args []string) (telemetry.Config, []string, error) {18- c := telemetry.ConfigFromEnv()19- flags := map[string]*string{"--log": &c.LogPath, "--otlp": &c.OTLP.Endpoint}20- var rest []string21- for i := 0; i < len(args); i++ {22- a := args[i]23- name, value, hasValue := strings.Cut(a, "=")24- dst, ok := flags[name]25- switch {26- case !ok:27- rest = append(rest, a)28- case hasValue:29- *dst = value30- case i+1 < len(args):31- *dst = args[i+1]32- i++33- default:34- return c, nil, errors.New(name + " needs a value")35- }36- }37- return c, rest, nil12+// telemetryConfig is where telemetry goes: the log file, level and OTLP13+// endpoint from the config (whose flags and variables win over the14+// file), and the rest of OTLP's settings from the OTEL_* variables.15+// Telemetry stays off when neither a log file nor an endpoint is set.16+func telemetryConfig(c *config.Config) telemetry.Config {17+ tc := telemetry.ConfigFromEnv()18+ tc.LogPath = c.String("log-file")19+ tc.OTLP.Endpoint = c.String("otlp-endpoint")20+ tc.Level.UnmarshalText([]byte(c.String("log-level")))21+ return tc3822 }3923 4024 // startTelemetry opens the log and reports every recalculation to it.@@ -1,22 +0,0 @@
1-package main2-3-import (4- "slices"5- "testing"6-)7-8-func TestTelemetryFlags(t *testing.T) {9- t.Setenv("O12_LOG", "")10- t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://env:4318")11- c, rest, err := telemetryFlags([]string{"--log", "a.jsonl", "--otlp=http://localhost:4318", "b.csv"})12- if err != nil || c.LogPath != "a.jsonl" || c.OTLP.Endpoint != "http://localhost:4318" || !slices.Equal(rest, []string{"b.csv"}) {13- t.Errorf("got %+v %v %v", c, rest, err)14- }15- c, rest, err = telemetryFlags([]string{"b.csv"})16- if err != nil || c.OTLP.Endpoint != "http://env:4318" || len(rest) != 1 {17- t.Errorf("env endpoint: %+v %v %v", c, rest, err)18- }19- if _, _, err := telemetryFlags([]string{"--otlp"}); err == nil {20- t.Error("--otlp without a value")21- }22-}@@ -8,6 +8,7 @@ require (
88 github.com/FelineStateMachine/typesafe-go v0.2.099 github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f978861010 github.com/charmbracelet/x/ansi v0.11.811+ github.com/charmbracelet/x/term v0.2.21112 github.com/cockroachdb/apd/v3 v3.2.31213 github.com/parquet-go/parquet-go v0.32.01314 github.com/sahilm/fuzzy v0.1.3@@ -20,7 +21,6 @@ require (
2021 require (2122 github.com/andybalholm/brotli v1.1.1 // indirect2223 github.com/charmbracelet/colorprofile v0.4.3 // indirect23- github.com/charmbracelet/x/term v0.2.2 // indirect2424 github.com/charmbracelet/x/termios v0.1.1 // indirect2525 github.com/charmbracelet/x/windows v0.2.2 // indirect2626 github.com/clipperhouse/displaywidth v0.11.0 // indirect@@ -235,6 +235,18 @@ func (c *Config) set(o *Option, v string, src Source) {
235235 }236236 }237237 238+// Override sets an option for the rest of the session, over every other239+// source, e.g. a theme picked in Settings. why says who set it. An240+// invalid value is ignored and returned as an error.241+func (c *Config) Override(name, value, why string) error {242+ o := mustLookup(name)243+ if err := o.validate(value); err != nil {244+ return err245+ }246+ c.vals[name] = []Value{{Raw: value, Src: Source{Kind: FromFlag, Name: why}}}247+ return nil248+}249+238250 func (c *Config) warn(src Source, msg string) {239251 c.Warnings = append(c.Warnings, Warning{Src: src, Msg: msg})240252 }@@ -0,0 +1,43 @@
1+package config2+3+import (4+ "errors"5+ "io/fs"6+ "os"7+ "os/exec"8+ "path/filepath"9+ "runtime"10+)11+12+// Editor returns the command that opens path in the user's editor:13+// $VISUAL, then $EDITOR (either may carry arguments, e.g. "code -w"),14+// else vi, or notepad on Windows.15+func Editor(path string, getenv func(string) string) (*exec.Cmd, error) {16+ ed := getenv("VISUAL")17+ if ed == "" {18+ ed = getenv("EDITOR")19+ }20+ if ed == "" {21+ ed = "vi"22+ if runtime.GOOS == "windows" {23+ ed = "notepad"24+ }25+ }26+ argv, err := SplitCommand(ed)27+ if err != nil {28+ return nil, err29+ }30+ return exec.Command(argv[0], append(argv[1:], path)...), nil31+}32+33+// EnsureFile creates the config file at path, with every option34+// commented out at its default, when it doesn't exist yet.35+func EnsureFile(path string) error {36+ if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {37+ return err38+ }39+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {40+ return err41+ }42+ return os.WriteFile(path, []byte(DefaultFile()), 0o600)43+}@@ -142,7 +142,11 @@ func (o *Option) validate(v string) error {
142142 return fmt.Errorf("%q isn't one of %s", v, strings.Join(o.Values, ", "))143143 }144144 case URL:145- u, err := url.Parse(v)145+ withScheme := v146+ if !strings.Contains(v, "://") {147+ withScheme = "http://" + v // host:port, as OTEL_EXPORTER_OTLP_ENDPOINT allows148+ }149+ u, err := url.Parse(withScheme)146150 if err != nil || u.Host == "" {147151 return fmt.Errorf("%q isn't a URL", v)148152 }@@ -0,0 +1,142 @@
1+package jev2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "errors"8+ "fmt"9+ "os"10+ "os/exec"11+ "path/filepath"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/012/internal/config"16+ "github.com/FelineStateMachine/012/internal/keyring"17+)18+19+// The API key is never in the config file or on a command line. It comes20+// from, in order:21+//22+// 1. TYPESAFE_API_KEY in the environment;23+// 2. the OS credential store, where `012 config set-key` and File >24+// Settings > JEV API key put it;25+// 3. jev-api-key-command, a command that prints it (a password26+// manager's CLI), run without a shell.27+28+// KeyEnv is the environment variable that holds the key.29+const KeyEnv = "TYPESAFE_API_KEY"30+31+// KeyCommandTimeout bounds jev-api-key-command.32+const KeyCommandTimeout = 10 * time.Second33+34+// KeySources are the places ResolveKey looks.35+type KeySources struct {36+ Getenv func(string) string37+ Store keyring.Store // nil skips the credential store38+ Command string // jev-api-key-command; empty skips it39+ Timeout time.Duration // for Command; KeyCommandTimeout when zero40+ // run runs the command; tests replace it. It gets the program and41+ // its arguments exactly as they'd be executed.42+ run func(ctx context.Context, argv []string) ([]byte, error)43+}44+45+// ResolveKey finds the API key. from says where it came from:46+// "TYPESAFE_API_KEY", the store's name, or "jev-api-key-command". With no47+// key anywhere it returns "", "", nil. A store or command that fails is48+// skipped, and its error is returned only if no later source has a key.49+func ResolveKey(ctx context.Context, s KeySources) (key, from string, err error) {50+ if s.Getenv != nil {51+ if k := strings.TrimSpace(s.Getenv(KeyEnv)); k != "" {52+ return k, KeyEnv, nil53+ }54+ }55+ var errs []error56+ if s.Store != nil {57+ k, err := s.Store.Get(ctx)58+ switch {59+ case err == nil && strings.TrimSpace(k) != "":60+ return strings.TrimSpace(k), s.Store.Name(), nil61+ case err != nil && !errors.Is(err, keyring.ErrNotFound):62+ errs = append(errs, err)63+ }64+ }65+ if s.Command != "" {66+ k, err := s.runCommand(ctx)67+ if err == nil {68+ return k, "jev-api-key-command", nil69+ }70+ errs = append(errs, err)71+ }72+ return "", "", errors.Join(errs...)73+}74+75+// runCommand runs jev-api-key-command and returns the first line it76+// prints. The command is split into words (quotes group, nothing is77+// expanded) and run directly, not through a shell.78+func (s KeySources) runCommand(ctx context.Context) (string, error) {79+ argv, err := config.SplitCommand(s.Command)80+ if err != nil {81+ return "", fmt.Errorf("jev-api-key-command: %w", err)82+ }83+ timeout := s.Timeout84+ if timeout <= 0 {85+ timeout = KeyCommandTimeout86+ }87+ ctx, cancel := context.WithTimeout(ctx, timeout)88+ defer cancel()89+ run := s.run90+ if run == nil {91+ run = execCommand92+ }93+ out, err := run(ctx, argv)94+ switch {95+ case ctx.Err() == context.DeadlineExceeded:96+ return "", fmt.Errorf("jev-api-key-command: %s didn't finish in %s", argv[0], timeout)97+ case err != nil:98+ return "", fmt.Errorf("jev-api-key-command: %s: %w", argv[0], err)99+ }100+ line, _, _ := strings.Cut(string(out), "\n")101+ if line = strings.TrimSpace(line); line == "" {102+ return "", fmt.Errorf("jev-api-key-command: %s printed nothing", argv[0])103+ }104+ return line, nil105+}106+107+// execCommand runs argv with no stdin, returning stdout. What the command108+// writes to stderr is reported by its first line only.109+func execCommand(ctx context.Context, argv []string) ([]byte, error) {110+ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)111+ var out, errOut bytes.Buffer112+ cmd.Stdout, cmd.Stderr = &out, &errOut113+ cmd.WaitDelay = time.Second // don't wait on children holding stdout open114+ if err := cmd.Run(); err != nil {115+ if msg, _, _ := strings.Cut(strings.TrimSpace(errOut.String()), "\n"); msg != "" {116+ return nil, fmt.Errorf("%w: %s", err, msg)117+ }118+ return nil, err119+ }120+ return out.Bytes(), nil121+}122+123+// DotEnvHasKey reports whether dir/.env sets TYPESAFE_API_KEY. It reads124+// only the variable names: the key itself is never read into memory125+// past the scanner's line, kept or shown. 012 no longer reads .env files126+// (a .env beside a downloaded sheet could send the key elsewhere); this127+// is only to suggest `012 config set-key` to people who used one.128+func DotEnvHasKey(dir string) bool {129+ f, err := os.Open(filepath.Join(dir, ".env"))130+ if err != nil {131+ return false132+ }133+ defer f.Close()134+ sc := bufio.NewScanner(f)135+ for sc.Scan() {136+ line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sc.Text()), "export "))137+ if name, _, ok := strings.Cut(line, "="); ok && strings.TrimSpace(name) == KeyEnv {138+ return true139+ }140+ }141+ return false142+}@@ -0,0 +1,152 @@
1+package jev2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "runtime"9+ "slices"10+ "strings"11+ "testing"12+ "time"13+14+ "github.com/FelineStateMachine/012/internal/keyring"15+)16+17+func env(vals map[string]string) func(string) string {18+ return func(k string) string { return vals[k] }19+}20+21+func storeWith(key string) *keyring.Memory {22+ m := &keyring.Memory{}23+ if key != "" {24+ m.Set(context.Background(), key)25+ }26+ return m27+}28+29+// fakeCommand records the argv it's asked to run and prints out.30+type fakeCommand struct {31+ argv [][]string32+ out string33+ err error34+}35+36+func (f *fakeCommand) run(_ context.Context, argv []string) ([]byte, error) {37+ f.argv = append(f.argv, argv)38+ return []byte(f.out), f.err39+}40+41+func TestResolveKeyOrder(t *testing.T) {42+ ctx := context.Background()43+ cmd := &fakeCommand{out: "from-command\nsecond line\n"}44+ s := KeySources{45+ Getenv: env(map[string]string{KeyEnv: " from-env "}),46+ Store: storeWith("from-store"),47+ Command: "pass show typesafe",48+ run: cmd.run,49+ }50+ if k, from, err := ResolveKey(ctx, s); k != "from-env" || from != KeyEnv || err != nil {51+ t.Errorf("the environment comes first: %q %q %v", k, from, err)52+ }53+ s.Getenv = env(nil)54+ if k, from, _ := ResolveKey(ctx, s); k != "from-store" || from != "test store" {55+ t.Errorf("then the credential store: %q %q", k, from)56+ }57+ if len(cmd.argv) != 0 {58+ t.Error("the command ran although the store had a key")59+ }60+ s.Store = storeWith("")61+ if k, from, _ := ResolveKey(ctx, s); k != "from-command" || from != "jev-api-key-command" {62+ t.Errorf("then the command's first line: %q %q", k, from)63+ }64+ if !slices.Equal(cmd.argv[0], []string{"pass", "show", "typesafe"}) {65+ t.Errorf("argv %q", cmd.argv[0])66+ }67+ s.Command = ""68+ if k, from, err := ResolveKey(ctx, s); k != "" || from != "" || err != nil {69+ t.Errorf("no key anywhere: %q %q %v", k, from, err)70+ }71+}72+73+func TestResolveKeyFailures(t *testing.T) {74+ ctx := context.Background()75+ broken := &keyring.Memory{Err: errors.New("keychain locked")}76+ cmd := &fakeCommand{out: "k"}77+ k, _, err := ResolveKey(ctx, KeySources{Store: broken, Command: "get-key", run: cmd.run})78+ if k != "k" || err != nil {79+ t.Errorf("a failing store falls through to the command: %q %v", k, err)80+ }81+ cmd = &fakeCommand{err: errors.New("exit status 1")}82+ _, _, err = ResolveKey(ctx, KeySources{Store: broken, Command: "get-key", run: cmd.run})83+ if err == nil || !strings.Contains(err.Error(), "keychain locked") || !strings.Contains(err.Error(), "get-key: exit status 1") {84+ t.Errorf("both failures reported: %v", err)85+ }86+ cmd = &fakeCommand{out: "\n"}87+ if _, _, err := ResolveKey(ctx, KeySources{Command: "get-key", run: cmd.run}); err == nil || !strings.Contains(err.Error(), "printed nothing") {88+ t.Errorf("empty output: %v", err)89+ }90+ if _, _, err := ResolveKey(ctx, KeySources{Command: "get 'key"}); err == nil {91+ t.Error("an unclosed quote ran")92+ }93+}94+95+// The command runs without a shell: quotes group words, and nothing is96+// expanded or piped.97+func TestKeyCommandNoShell(t *testing.T) {98+ cmd := &fakeCommand{out: "k"}99+ ResolveKey(context.Background(), KeySources{Command: `op read "op://Private/Type Safe/credential" $HOME;rm`, run: cmd.run})100+ want := []string{"op", "read", "op://Private/Type Safe/credential", "$HOME;rm"}101+ if !slices.Equal(cmd.argv[0], want) {102+ t.Errorf("argv %q, want %q", cmd.argv[0], want)103+ }104+}105+106+func TestKeyCommandRuns(t *testing.T) {107+ if runtime.GOOS == "windows" {108+ t.Skip("uses sh")109+ }110+ k, _, err := ResolveKey(context.Background(), KeySources{Command: `sh -c 'printf "real-key\n"'`})111+ if k != "real-key" || err != nil {112+ t.Errorf("%q %v", k, err)113+ }114+}115+116+func TestKeyCommandTimeout(t *testing.T) {117+ if runtime.GOOS == "windows" {118+ t.Skip("uses sleep")119+ }120+ start := time.Now()121+ _, _, err := ResolveKey(context.Background(), KeySources{Command: "sleep 5", Timeout: 100 * time.Millisecond})122+ if err == nil || !strings.Contains(err.Error(), "didn't finish in 100ms") {123+ t.Errorf("timeout: %v", err)124+ }125+ if d := time.Since(start); d > 3*time.Second {126+ t.Errorf("took %s", d)127+ }128+}129+130+func TestDotEnvHasKey(t *testing.T) {131+ dir := t.TempDir()132+ if DotEnvHasKey(dir) {133+ t.Error("no .env")134+ }135+ os.WriteFile(filepath.Join(dir, ".env"), []byte("# x\nTYPESAFE_BASE_URL=https://a\n"), 0o600)136+ if DotEnvHasKey(dir) {137+ t.Error("no key in .env")138+ }139+ os.WriteFile(filepath.Join(dir, ".env"), []byte("export TYPESAFE_API_KEY=\"x\"\n"), 0o600)140+ if !DotEnvHasKey(dir) {141+ t.Error("key in .env not noticed")142+ }143+}144+145+func TestNewClientChecksBaseURL(t *testing.T) {146+ if _, err := NewClient(Config{APIKey: "k", BaseURL: "http://evil.example"}); err == nil {147+ t.Error("http base URL accepted")148+ }149+ if _, err := NewClient(Config{APIKey: "k", BaseURL: "http://127.0.0.1:8799"}); err != nil {150+ t.Errorf("localhost: %v", err)151+ }152+}@@ -5,79 +5,37 @@
55 package jev66 77 import (8- "bufio"98 "context"109 "fmt"11- "os"12- "path/filepath"13- "strings"1410 "sync"1511 1612 typesafe "github.com/FelineStateMachine/typesafe-go"1713 14+ "github.com/FelineStateMachine/012/internal/config"1815 "github.com/FelineStateMachine/012/internal/sheet"1916 )2017 21-// Config is how to reach the service.18+// Config is how to reach the service. The key comes from ResolveKey;19+// the base URL and model from the config file or environment20+// (internal/config), never from files next to a sheet.2221 type Config struct {2322 APIKey, BaseURL, Model string2423 }2524 26-// LoadConfig reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and27-// TYPESAFE_DEFAULT_MODEL from the environment, falling back to .env files28-// in dirs, first found wins. It reports false when there is no key, which29-// turns JEV functions off.30-func LoadConfig(dirs ...string) (Config, bool) {31- vals := map[string]string{}32- for _, dir := range dirs {33- for k, v := range readDotEnv(filepath.Join(dir, ".env")) {34- if _, seen := vals[k]; !seen {35- vals[k] = v36- }37- }38- }39- get := func(k string) string {40- if v := strings.TrimSpace(os.Getenv(k)); v != "" {41- return v42- }43- return vals[k]44- }45- c := Config{APIKey: get("TYPESAFE_API_KEY"), BaseURL: get("TYPESAFE_BASE_URL"), Model: get("TYPESAFE_DEFAULT_MODEL")}46- return c, c.APIKey != ""47-}48-49-// readDotEnv reads the TYPESAFE_ settings from a .env file: KEY=value50-// lines, optionally quoted, ignoring comments and anything else.51-func readDotEnv(path string) map[string]string {52- f, err := os.Open(path)53- if err != nil {54- return nil55- }56- defer f.Close()57- out := map[string]string{}58- sc := bufio.NewScanner(f)59- for sc.Scan() {60- line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sc.Text()), "export "))61- k, v, ok := strings.Cut(line, "=")62- if !ok || !strings.HasPrefix(k, "TYPESAFE_") {63- continue64- }65- v = strings.TrimSpace(v)66- if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {67- v = v[1 : len(v)-1]68- }69- out[strings.TrimSpace(k)] = v70- }71- return out72-}73-7425 // Client is the part of the TypeSafe SDK used here, so tests can fake it.7526 type Client interface {7627 SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error)7728 }7829 79-// NewClient connects to the service with c.30+// NewClient connects to the service with c. The base URL must be https31+// (or http on this machine), so the key is never sent in the clear or to32+// an address a stray file chose.8033 func NewClient(c Config) (Client, error) {34+ if c.BaseURL != "" {35+ if err := config.CheckBaseURL(c.BaseURL); err != nil {36+ return nil, err37+ }38+ }8139 opts := []typesafe.Option{typesafe.WithAPIKey(c.APIKey)}8240 if c.BaseURL != "" {8341 opts = append(opts, typesafe.WithBaseURL(c.BaseURL))@@ -4,8 +4,6 @@ import (
44 "context"55 "errors"66 "fmt"7- "os"8- "path/filepath"97 "strings"108 "testing"119 @@ -14,27 +12,6 @@ import (
1412 "github.com/FelineStateMachine/012/internal/sheet"1513 )1614 17-func TestLoadConfig(t *testing.T) {18- dir := t.TempDir()19- os.WriteFile(filepath.Join(dir, ".env"), []byte("# settings\nOTHER=x\nexport TYPESAFE_API_KEY=\"from-file\"\nTYPESAFE_DEFAULT_MODEL=jev-small\n"), 0o600)20- t.Setenv("TYPESAFE_API_KEY", "")21- t.Setenv("TYPESAFE_BASE_URL", "")22- t.Setenv("TYPESAFE_DEFAULT_MODEL", "")23-24- c, ok := LoadConfig(dir)25- if !ok || c.APIKey != "from-file" || c.Model != "jev-small" {26- t.Errorf("from .env: %+v %v", c, ok)27- }28- t.Setenv("TYPESAFE_API_KEY", "from-env")29- if c, _ := LoadConfig(dir); c.APIKey != "from-env" {30- t.Errorf("environment should win: %q", c.APIKey)31- }32- t.Setenv("TYPESAFE_API_KEY", "")33- if _, ok := LoadConfig(t.TempDir()); ok {34- t.Error("no key anywhere, but JEV is on")35- }36-}37-3815 func TestCacheQueuesOnce(t *testing.T) {3916 m := NewCache()4017 q := sheet.RemoteCall{Kind: "noul", State: "x", Instructions: "Q", Criteria: [2]string{}}@@ -10,13 +10,13 @@ import (
1010 )1111 1212 // TestLive asks the real service one question of each kind. It only runs13-// with JEV_LIVE_TEST=1 and a key in the environment or the repo's .env.13+// with JEV_LIVE_TEST=1 and TYPESAFE_API_KEY in the environment.1414 func TestLive(t *testing.T) {1515 if os.Getenv("JEV_LIVE_TEST") != "1" {1616 t.Skip("set JEV_LIVE_TEST=1 to call the live JEV service")1717 }18- cfg, ok := LoadConfig("../..")19- if !ok {18+ cfg := Config{APIKey: os.Getenv(KeyEnv), BaseURL: os.Getenv("TYPESAFE_BASE_URL"), Model: os.Getenv("TYPESAFE_DEFAULT_MODEL")}19+ if cfg.APIKey == "" {2020 t.Skip("no TYPESAFE_API_KEY")2121 }2222 client, err := NewClient(cfg)@@ -32,8 +32,13 @@ type terminal struct {
3232 palette map[int]color.RGBA3333 sent map[int]string // image id -> what was sent, to send only changes3434 blurred bool // the terminal window doesn't have focus35+ noImages bool // chart-images = false36+ noNotify bool // notifications = false3537 }3638 39+// images reports whether charts are drawn as images.40+func (t *terminal) images() bool { return t.kitty && !t.noImages }41+3742 func newTerminal() terminal {3843 return terminal{tmux: os.Getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}}3944 }@@ -128,20 +133,28 @@ func (t *terminal) color(i int) color.RGBA {
128133 return xtermColors[i%16]129134 }130135 131-// chartPalette is the theme's series colors as the terminal draws them.136+// chartPalette is the theme's series colors as the terminal draws them,137+// or as the theme's color scheme has them.132138 func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {139+ col := t.color140+ if th.Palette != nil {141+ col = func(i int) color.RGBA {142+ r, g, b, _ := th.Palette.ANSI[i%16].RGBA()143+ return color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}144+ }145+ }133146 var p chart.Palette134147 for i, idx := range th.SeriesANSI {135- p.Series[i] = t.color(idx)148+ p.Series[i] = col(idx)136149 }137- p.Grid = t.color(8) // bright black, like the text axes150+ p.Grid = col(8) // bright black, like the text axes138151 p.Grid.A = 90139152 return p140153 }141154 142155 // chartOptions are the drawing options for charts on this terminal.143156 func (t *terminal) chartOptions() chart.Options {144- return chart.Options{Image: t.kitty, CellW: t.cellW, CellH: t.cellH}157+ return chart.Options{Image: t.images(), CellW: t.cellW, CellH: t.cellH}145158 }146159 147160 // syncImages sends the images of charts that changed since they were last@@ -150,8 +163,8 @@ func (t *terminal) chartOptions() chart.Options {
150163 // after every message, so images follow edits, recalculation, resizing151164 // and the theme.152165 func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *theme.Theme) tea.Cmd {153- if !t.kitty {154- return nil166+ if !t.images() {167+ return t.freeImages()155168 }156169 var out strings.Builder157170 o := t.chartOptions()@@ -201,6 +214,19 @@ func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *th
201214 return tea.Raw(out.String())202215 }203216 217+// freeImages frees every image sent, when images are turned off.218+func (t *terminal) freeImages() tea.Cmd {219+ if len(t.sent) == 0 {220+ return nil221+ }222+ var b strings.Builder223+ for id := range t.sent {224+ b.WriteString(chart.Delete(id, t.wrap))225+ delete(t.sent, id)226+ }227+ return tea.Raw(b.String())228+}229+204230 // release undoes the startup modes and frees the chart images.205231 func (t *terminal) release() string {206232 var b strings.Builder@@ -215,7 +241,7 @@ func (t *terminal) release() string {
215241 // notification (OSC 9) when the terminal window isn't focused. When it is,216242 // the screen already shows the result.217243 func (t *terminal) notify(msg string) tea.Cmd {218- if !t.blurred {244+ if !t.blurred || t.noNotify {219245 return nil220246 }221247 msg = strings.Map(func(r rune) rune {@@ -136,7 +136,7 @@ func (j *jevRunner) line(th *theme.Theme, calls []sheet.RemoteCall) string {
136136 return ""137137 }138138 if j == nil {139- return th.Warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")139+ return th.Warning.Render("JEV functions need an API key: File > Settings > JEV API key")140140 }141141 var parts []string142142 for _, c := range calls {@@ -98,7 +98,7 @@ func TestJEVPendingAndBusy(t *testing.T) {
9898 func TestJEVWithoutKeyExplains(t *testing.T) {9999 m := tallModel()100100 press(t, m, `=JEV.TEST("x", "Q")`, "<enter>", "<up>")101- if !strings.Contains(line(m, contextLine), "TYPESAFE_API_KEY") {101+ if !strings.Contains(line(m, contextLine), "Settings > JEV API key") {102102 t.Errorf("context line %q", line(m, contextLine))103103 }104104 if commands["jev.refresh"].enabled(m) {@@ -34,7 +34,11 @@ var menuBar = []menuDef{
3434 {title: "File", accel: 'f', items: []menuItem{3535 {cmd: "file.new"}, {cmd: "file.open"}, {cmd: "file.import"}, sep,3636 {cmd: "file.save"}, {cmd: "file.saveas"}, {title: "Download", items: downloadItems()}, sep,37- {title: "Settings", items: []menuItem{{cmd: "settings.decimal"}}}, sep,37+ {title: "Settings", items: []menuItem{38+ {cmd: "settings.decimal"}, sep,39+ {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,40+ {cmd: "settings.config_edit"}, {cmd: "settings.config_reload"},41+ }}, sep,3842 {cmd: "quit"},3943 }},4044 {title: "Edit", accel: 'e', items: []menuItem{@@ -82,6 +82,7 @@ type Model struct {
8282 xfer transfer // imports and downloads: transfer.go8383 jev *jevRunner // answers JEV functions; nil without an API key: jev.go8484 term terminal // what the terminal supports: graphics.go85+ prefs prefs // the settings in effect and the theme chosen: prefs.go8586 8687 keyAt time.Time // when the key the next frame answers was pressed, for telemetry8788 @@ -90,7 +91,8 @@ type Model struct {
9091 9192 // New returns a model editing s. filename may be empty.9293 func New(s *sheet.Sheet, filename string) *Model {93- return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1}}94+ return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(),95+ charts: chartState{last: -1}, prefs: prefs{dark: true}}9496 }9597 9698 // Init implements tea.Model. It asks the terminal for its background color@@ -121,8 +123,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121123 case tea.WindowSizeMsg:122124 m.width, m.height = msg.Width, msg.Height123125 beforeMode = -1 // keep the focus visible after a resize124- case tea.BackgroundColorMsg:125- m.th = theme.New(msg.IsDark())126126 case tea.KeyPressMsg:127127 cmd = m.handleKey(msg)128128 case tea.PasteMsg:@@ -153,7 +153,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153153 case importedMsg, importTickMsg, exportedMsg:154154 cmd = m.handleTransfer(msg)155155 default:156- cmd = m.term.handle(msg)156+ if !m.handlePrefs(msg) {157+ cmd = m.term.handle(msg)158+ }157159 }158160 // Scroll only when the focus moves, so the mouse wheel can look around159161 // without the view snapping back, as in Sheets.@@ -9,6 +9,7 @@ import (
99 "time"1010 1111 tea "charm.land/bubbletea/v2"12+ "charm.land/lipgloss/v2"1213 "github.com/charmbracelet/x/ansi"1314 1415 "github.com/FelineStateMachine/012/internal/sheet"@@ -38,11 +39,13 @@ func (m *Model) View() tea.View {
3839 for i, l := range lines {3940 lines[i] = ansi.Truncate(l, m.width, "")4041 }42+ m.drawBars(lines)4143 4244 content := strings.Join(lines, "\n")4345 if boxes := m.floating(); len(boxes) > 0 || hasCharts(m) {4446 content = m.compose(content, boxes)4547 }48+ content = m.fillScreen(content)4649 v := tea.NewView(content)4750 v.AltScreen = true4851 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go@@ -65,6 +68,46 @@ func (m *Model) View() tea.View {
6568 return v6669 }6770 71+// drawBars draws the menu bar, formula bar, context line, column header72+// row and status line as full-width bands in the theme's bar roles,73+// before overlays are composited over them.74+func (m *Model) drawBars(lines []string) {75+ bands := []struct {76+ line int77+ role lipgloss.Style78+ }{79+ {menuLine, m.th.MenuBarRow}, {formulaLine, m.th.FormulaBarRow}, {contextLine, m.th.ContextRow},80+ {headerLine, m.th.ColumnHeaderRow}, {len(lines) - 1, m.th.StatusBarRow},81+ }82+ for _, b := range bands {83+ if b.line < len(lines) {84+ lines[b.line] = theme.Fill(lines[b.line], m.width, b.role)85+ }86+ }87+}88+89+// fillScreen draws everything else on the theme's background, out to the90+// edges, when the theme has one of its own.91+func (m *Model) fillScreen(content string) string {92+ if isEmpty(m.th.Screen) {93+ return content94+ }95+ lines := strings.Split(content, "\n")96+ for len(lines) < m.height {97+ lines = append(lines, "")98+ }99+ for i, l := range lines {100+ lines[i] = theme.Fill(l, m.width, m.th.Screen)101+ }102+ return strings.Join(lines, "\n")103+}104+105+func isEmpty(s lipgloss.Style) bool {106+ _, noBg := s.GetBackground().(lipgloss.NoColor)107+ _, noFg := s.GetForeground().(lipgloss.NoColor)108+ return noBg && noFg109+}110+68111 // timeFrame reports a frame begun at start to telemetry, with how long69112 // ago the key press it answers came in.70113 func (m *Model) timeFrame(start time.Time) {@@ -213,7 +256,7 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
213256 case m.mode == modeEnter, m.mode == modeEdit:214257 return formulaBarTextX() + ansi.StringWidth(m.line.head()), formulaLine, true215258 case m.mode == modePrompt && !m.pointing():216- return ansi.StringWidth(m.prompt.prefix() + m.line.head()), contextLine, true259+ return ansi.StringWidth(m.prompt.prefix() + m.prompt.head(m)), contextLine, true217260 }218261 return 0, 0, false219262 }@@ -0,0 +1,202 @@
1+package ui2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "os"8+ "strings"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/config"14+ "github.com/FelineStateMachine/012/internal/jev"15+ "github.com/FelineStateMachine/012/internal/keyring"16+ "github.com/FelineStateMachine/012/internal/ui/theme"17+)18+19+// Settings are what the app takes from outside the workbook: the config20+// file's options, and the credential store the JEV API key lives in.21+// cmd/012 passes them to Configure; tests pass fakes, so the user's own22+// config and keychain are never touched.23+type Settings struct {24+ Config *config.Config25+ Reload func() *config.Config // reads the config file again; nil disables Reload config26+ ThemesDir string // the user's theme files27+ Keys keyring.Store // where JEV API key stores the key; nil disables it28+ // Connect makes a JEV client for the configured service with key.29+ Connect func(key string) (jev.Client, error)30+ Notes []string // said on the context line at startup, e.g. config warnings31+}32+33+// prefs is the settings in effect: the component behind File >34+// Settings, the theme picker and the API key prompt.35+type prefs struct {36+ Settings37+ dark bool // the terminal's background is dark38+ preview string // a theme being previewed by the picker39+}40+41+// Configure applies settings: the theme, chart images, notifications,42+// and notes for the context line.43+func (m *Model) Configure(s Settings) {44+ m.prefs.Settings = s45+ notes := s.Notes46+ if p := m.applyConfig(); p != "" {47+ notes = append(notes, p)48+ }49+ if len(notes) > 0 {50+ m.note = m.th.Warning.Render(strings.Join(notes, " "))51+ }52+}53+54+// applyConfig applies the options that take effect live and reports55+// problems it finds, such as an unknown theme.56+func (m *Model) applyConfig() string {57+ c := m.prefs.Config58+ m.term.noImages = !c.Bool("chart-images")59+ m.term.noNotify = !c.Bool("notifications")60+ return m.applyTheme()61+}62+63+// applyTheme draws with the theme chosen for the terminal's background,64+// or the one being previewed. It returns why a theme couldn't be used.65+func (m *Model) applyTheme() string {66+ name := m.prefs.preview67+ if name == "" {68+ name = m.prefs.Config.Theme().Pick(m.prefs.dark)69+ }70+ th, err := theme.Resolve(name, m.prefs.dark, m.prefs.ThemesDir)71+ m.th = th72+ if err != nil {73+ return err.Error()74+ }75+ return ""76+}77+78+func init() {79+ register(80+ &command{id: "settings.theme", title: "Theme",81+ desc: "Pick a color theme, previewing each as you move; Esc keeps the one you had",82+ run: func(m *Model) tea.Cmd { m.openOverlay(newThemePicker(m)); return nil }},83+ &command{id: "settings.config_edit", title: "Open config file",84+ desc: "Edit the settings file in $VISUAL or $EDITOR; 012 reloads it when you close the editor",85+ enabled: func(m *Model) bool { return m.prefs.Config != nil && m.prefs.Config.Path != "" },86+ run: (*Model).editConfig},87+ &command{id: "settings.config_reload", title: "Reload config",88+ desc: "Read the settings file again and apply the theme and display options",89+ enabled: func(m *Model) bool { return m.prefs.Reload != nil },90+ run: func(m *Model) tea.Cmd { m.reloadConfig(); return nil }},91+ &command{id: "settings.jev_key", title: "JEV API key",92+ desc: "Store the TypeSafe API key for JEV functions in the OS credential store",93+ enabled: func(m *Model) bool { return m.prefs.Keys != nil },94+ run: (*Model).askKey},95+ )96+}97+98+// configEditedMsg says the editor opened by Open config file exited.99+type configEditedMsg struct{ err error }100+101+func (m *Model) editConfig() tea.Cmd {102+ path := m.prefs.Config.Path103+ if err := config.EnsureFile(path); err != nil {104+ m.fail("Can't create " + path + ": " + err.Error())105+ return nil106+ }107+ cmd, err := config.Editor(path, os.Getenv)108+ if err != nil {109+ m.fail("Can't start the editor: " + err.Error())110+ return nil111+ }112+ return tea.ExecProcess(cmd, func(err error) tea.Msg { return configEditedMsg{err} })113+}114+115+// reloadConfig reads the config file again, applies what can change116+// live, and says what happened.117+func (m *Model) reloadConfig() {118+ c := m.prefs.Reload()119+ m.prefs.Config = c120+ var problems []string121+ for _, w := range c.Warnings {122+ problems = append(problems, w.String())123+ }124+ if p := m.applyConfig(); p != "" {125+ problems = append(problems, p)126+ }127+ if len(problems) > 0 {128+ m.note = m.th.Warning.Render("Config reloaded with problems: " + strings.Join(problems, "; "))129+ return130+ }131+ m.note = "Config reloaded from " + c.Path132+}133+134+// keySavedMsg reports storing the API key, and the client made with it.135+type keySavedMsg struct {136+ client jev.Client137+ err error138+}139+140+// askKey asks for the API key on the context line, masked, and stores it.141+func (m *Model) askKey() tea.Cmd {142+ m.openPrompt(&prompt{kind: promptText, label: "TypeSafe API key:", indicator: "KEY", secret: true,143+ onText: func(m *Model, key string) tea.Cmd {144+ if key == "" {145+ m.note = "No key entered; nothing changed"146+ return nil147+ }148+ m.note = "Saving the key in the " + m.prefs.Keys.Name() + "…"149+ store, connect := m.prefs.Keys, m.prefs.Connect150+ return func() tea.Msg {151+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)152+ defer cancel()153+ if err := store.Set(ctx, key); err != nil {154+ return keySavedMsg{err: err}155+ }156+ if connect == nil {157+ return keySavedMsg{err: errors.New("JEV isn't available in this session")}158+ }159+ client, err := connect(key)160+ return keySavedMsg{client: client, err: err}161+ }162+ }}, "")163+ return nil164+}165+166+// keySaved turns JEV on with the new key.167+func (m *Model) keySaved(msg keySavedMsg) {168+ if msg.err != nil {169+ m.fail("Couldn't save the API key: " + msg.err.Error())170+ return171+ }172+ if m.jev == nil {173+ m.EnableJEV(msg.client, jev.NewCache())174+ } else {175+ m.jev.client = msg.client176+ }177+ m.sheet.RecalcVolatile()178+ m.note = fmt.Sprintf("API key saved in the %s; JEV functions are on", m.prefs.Keys.Name())179+}180+181+// handlePrefs handles the messages settings get: the terminal's182+// background (which picks the theme), the editor closing, the key saved.183+func (m *Model) handlePrefs(msg tea.Msg) bool {184+ switch msg := msg.(type) {185+ case tea.BackgroundColorMsg:186+ m.prefs.dark = msg.IsDark()187+ if p := m.applyTheme(); p != "" && m.note == "" {188+ m.note = m.th.Warning.Render(p)189+ }190+ case configEditedMsg:191+ if msg.err != nil {192+ m.fail("The editor failed: " + msg.err.Error())193+ } else if m.prefs.Reload != nil {194+ m.reloadConfig()195+ }196+ case keySavedMsg:197+ m.keySaved(msg)198+ default:199+ return false200+ }201+ return true202+}@@ -0,0 +1,221 @@
1+package ui2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+ uv "github.com/charmbracelet/ultraviolet"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/config"16+ "github.com/FelineStateMachine/012/internal/jev"17+ "github.com/FelineStateMachine/012/internal/keyring"18+)19+20+// configured returns a model with settings from a config file in a21+// temporary directory and a credential store in memory.22+func configured(t *testing.T, file string) (*Model, string, *keyring.Memory) {23+ t.Helper()24+ path := filepath.Join(t.TempDir(), "config")25+ if file != "" {26+ os.WriteFile(path, []byte(file), 0o600)27+ }28+ load := func() *config.Config { return config.Load(path, func(string) string { return "" }, nil) }29+ store := &keyring.Memory{}30+ m := newModel()31+ m.Configure(Settings{Config: load(), Reload: load, Keys: store,32+ Connect: func(key string) (jev.Client, error) { return &fakeJEV{}, nil }})33+ return m, path, store34+}35+36+func TestThemeFromConfig(t *testing.T) {37+ m, _, _ := configured(t, "theme = Dracula\nbogus = 1\n")38+ if m.th.Name != "Dracula" {39+ t.Errorf("theme %q", m.th.Name)40+ }41+ m, _, _ = configured(t, "theme = light:Builtin Solarized Light,dark:Dracula\n")42+ if m.th.Name != "Dracula" {43+ t.Errorf("dark terminal: %q", m.th.Name)44+ }45+ send(m, tea.BackgroundColorMsg{Color: ansi.White})46+ if m.th.Name != "Builtin Solarized Light" {47+ t.Errorf("light terminal: %q", m.th.Name)48+ }49+ m, _, _ = configured(t, "theme = No Such Theme\n")50+ if m.th.Name != "terminal" || !strings.Contains(line(m, contextLine), `no theme "No Such Theme"`) {51+ t.Errorf("unknown theme: %q, %q", m.th.Name, line(m, contextLine))52+ }53+}54+55+// With a color scheme every line is drawn out to the terminal's edge,56+// at odd widths too, and with an overlay open.57+func TestBarsFillTheWidth(t *testing.T) {58+ m, _, _ := configured(t, "theme = 1-2-3 Classic\n")59+ for _, w := range []int{61, 80, 97} {60+ send(m, tea.WindowSizeMsg{Width: w, Height: 17})61+ checkFilled(t, m, "no overlay")62+ press(t, m, "<alt+f>")63+ checkFilled(t, m, "menu open")64+ press(t, m, "<esc>")65+ }66+}67+68+// checkFilled fails unless every line is the terminal's width and every69+// cell has a background: the bars' or the scheme's.70+func checkFilled(t *testing.T, m *Model, when string) {71+ t.Helper()72+ content := m.View().Content73+ lines := strings.Split(content, "\n")74+ if len(lines) != m.height {75+ t.Fatalf("%s: %d lines, height %d", when, len(lines), m.height)76+ }77+ for i, l := range lines {78+ if got := ansi.StringWidth(l); got != m.width {79+ t.Errorf("%s, width %d: line %d is %d wide", when, m.width, i, got)80+ }81+ }82+ scr := uv.NewScreenBuffer(m.width, m.height)83+ uv.NewStyledString(content).Draw(scr, scr.Bounds())84+ for y := range m.height {85+ for x := range m.width {86+ if c := scr.CellAt(x, y); c != nil && c.Width > 0 && c.Style.Bg == nil {87+ t.Fatalf("%s, width %d: no background at %d,%d", when, m.width, x, y)88+ }89+ }90+ }91+}92+93+func TestTerminalThemeUnchanged(t *testing.T) {94+ m, _, _ := configured(t, "")95+ if strings.Contains(m.View().Content, "48;2;") {96+ t.Error("the terminal theme uses true color")97+ }98+}99+100+func TestThemePicker(t *testing.T) {101+ m, path, _ := configured(t, "# mine\ntheme = Nord\n")102+ press(t, m, "<alt+f>")103+ openSettingsItem(t, m, "Theme")104+ if m.th.Name != "nord" || !strings.Contains(screen(m), "│ nord") {105+ t.Fatalf("picker opens on the current theme: %q\n%s", m.th.Name, screen(m))106+ }107+ press(t, m, "Dracula")108+ if m.th.Name != "Dracula" {109+ t.Errorf("preview: %q", m.th.Name)110+ }111+ press(t, m, "<esc>")112+ if m.th.Name != "nord" || m.overlay != nil {113+ t.Errorf("Esc restores: %q", m.th.Name)114+ }115+ press(t, m, "<alt+f>")116+ openSettingsItem(t, m, "Theme")117+ press(t, m, "1-2-3 Classic", "<enter>")118+ if m.th.Name != "1-2-3 Classic" || !strings.Contains(line(m, contextLine), "saved in") {119+ t.Errorf("Enter keeps it: %q, %q", m.th.Name, line(m, contextLine))120+ }121+ data, _ := os.ReadFile(path)122+ if string(data) != "# mine\ntheme = 1-2-3 Classic\n" {123+ t.Errorf("config file: %q", data)124+ }125+}126+127+// openSettingsItem opens File > Settings and runs the item called title.128+func openSettingsItem(t *testing.T, m *Model, title string) {129+ t.Helper()130+ for range 30 {131+ if selectedTitle(m) == "Settings" {132+ break133+ }134+ press(t, m, "<down>")135+ }136+ press(t, m, "<right>")137+ for range 10 {138+ if selectedTitle(m) == title {139+ press(t, m, "<enter>")140+ return141+ }142+ press(t, m, "<down>")143+ }144+ t.Fatalf("no %s in Settings:\n%s", title, screen(m))145+}146+147+// selectedTitle is the highlighted item of the open menu.148+func selectedTitle(m *Model) string {149+ mo, ok := m.overlay.(*menuOverlay)150+ if !ok {151+ return ""152+ }153+ l := mo.top()154+ if l.sel < 0 {155+ return ""156+ }157+ return l.items[l.sel].label()158+}159+160+func TestAPIKeyPrompt(t *testing.T) {161+ const key = "ts-secret-9876"162+ m, _, store := configured(t, "")163+ m.runCommand("settings.jev_key")164+ press(t, m, key)165+ ctx := line(m, contextLine)166+ if strings.Contains(screen(m), key) || !strings.Contains(ctx, "TypeSafe API key: "+strings.Repeat("•", len(key))) {167+ t.Fatalf("key shown: %q", ctx)168+ }169+ if x, y, _ := m.cursorPos(); y != contextLine || x != len("TypeSafe API key: ")+len(key) {170+ t.Errorf("cursor at %d,%d", x, y)171+ }172+ press(t, m, "<enter>")173+ if got, _ := store.Get(context.Background()); got != key {174+ t.Errorf("stored %q", got)175+ }176+ if m.jev == nil || !strings.Contains(line(m, contextLine), "API key saved in the test store") {177+ t.Errorf("JEV not on: %q", line(m, contextLine))178+ }179+ if strings.Contains(screen(m), key) || m.line.text() != "" {180+ t.Error("the key is still around")181+ }182+183+ store.Err = errors.New("keychain locked")184+ m.runCommand("settings.jev_key")185+ press(t, m, "x", "<enter>")186+ if !strings.Contains(screen(m), "Couldn't save the API key: keychain locked") {187+ t.Errorf("failure not shown:\n%s", screen(m))188+ }189+}190+191+func TestKeyNeedsAStore(t *testing.T) {192+ m := newModel()193+ if commands["settings.jev_key"].available(m) || commands["settings.config_reload"].available(m) || commands["settings.config_edit"].available(m) {194+ t.Error("settings that need a config or store are available without one")195+ }196+}197+198+func TestReloadConfig(t *testing.T) {199+ m, path, _ := configured(t, "theme = nord\n")200+ os.WriteFile(path, []byte("theme = Dracula\nchart-images = false\nnotifications = false\n"), 0o600)201+ m.runCommand("settings.config_reload")202+ if m.th.Name != "Dracula" || !m.term.noImages || !m.term.noNotify || !strings.Contains(line(m, contextLine), "Config reloaded") {203+ t.Errorf("reload: %q images off %v, %q", m.th.Name, m.term.noImages, line(m, contextLine))204+ }205+ os.WriteFile(path, []byte("wat = 1\n"), 0o600)206+ m.runCommand("settings.config_reload")207+ if !strings.Contains(line(m, contextLine), "problems") {208+ t.Errorf("warnings: %q", line(m, contextLine))209+ }210+ if m.th.Name != "terminal" {211+ t.Errorf("theme back to the default: %q", m.th.Name)212+ }213+}214+215+func TestChartImagesOff(t *testing.T) {216+ m, _, _ := configured(t, "chart-images = false\n")217+ m.term.kitty = true218+ if m.term.images() || m.term.chartOptions().Image {219+ t.Error("images drawn although chart-images = false")220+ }221+}@@ -31,6 +31,7 @@ type prompt struct {
3131 label string3232 indicator string3333 fresh bool // the default is still showing; typing replaces it34+ secret bool // the answer is shown masked, e.g. an API key3435 typing bool // range prompts: the user is typing instead of pointing3536 onText func(m *Model, text string) tea.Cmd3637 onRange func(m *Model, r sheet.Rect) tea.Cmd@@ -177,6 +178,8 @@ func (p *prompt) line(m *Model) (left, right string) {
177178 return p.prefix() + m.line.text(), m.th.Muted.Render(strings.Join(p.files, " "))178179 case p.kind == promptWidth:179180 return p.prefix() + m.line.text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")181+ case p.secret:182+ return p.prefix() + mask(len(m.line.buf)), m.th.KeyHints("Enter", "save", "Esc", "cancel")180183 }181184 return p.prefix() + m.line.text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")182185 }@@ -185,6 +188,17 @@ func (p *prompt) prefix() string {
185188 return p.label + " "186189 }187190 191+// mask stands for n typed characters of a secret.192+func mask(n int) string { return strings.Repeat("•", n) }193+194+// head is the answer before the caret, as shown.195+func (p *prompt) head(m *Model) string {196+ if p.secret {197+ return mask(m.line.pos)198+ }199+ return m.line.head()200+}201+188202 func isDigits(s string) bool {189203 return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })190204 }@@ -0,0 +1,138 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/config"9+ "github.com/FelineStateMachine/012/internal/ui/theme"10+)11+12+// themePicker is File > Settings > Theme: every theme in a picker, fuzzy13+// searched by name, "dark" or "light". The highlighted theme is drawn14+// live; Enter keeps it and saves it to the config file, Esc goes back to15+// the theme there was.16+type themePicker struct {17+ *picker18+ shownName string // the theme being previewed19+}20+21+func newThemePicker(m *Model) *themePicker {22+ current := m.prefs.Config.Theme().Pick(m.prefs.dark)23+ entries := theme.List(m.prefs.ThemesDir)24+ items := make([]pickItem, 0, len(entries))25+ sel := 026+ for _, e := range entries {27+ kind := "light"28+ if e.Dark {29+ kind = "dark"30+ }31+ desc := "A " + kind + " color scheme, drawn in its own colors with solid bars"32+ switch {33+ case e.Name == theme.Terminal:34+ kind, desc = "your terminal's colors", "The terminal's own 16-color palette, following its light or dark background"35+ case e.User:36+ kind += ", your file"37+ }38+ if strings.EqualFold(e.Name, current) {39+ sel = len(items)40+ }41+ name := e.Name42+ items = append(items, pickItem{title: name, name: len(name), detail: kind, desc: desc,43+ pick: func(m *Model) tea.Cmd { m.keepTheme(name); return nil }})44+ }45+ tp := &themePicker{picker: newPicker(m, "Theme", "Type a name, dark or light", 64, items)}46+ tp.action = "keep"47+ tp.sel = sel48+ tp.preview(m)49+ return tp50+}51+52+func (tp *themePicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {53+ if k.String() == "esc" {54+ tp.restore(m)55+ }56+ cmd := tp.picker.key(m, k)57+ if m.overlay == tp {58+ tp.preview(m)59+ }60+ return cmd61+}62+63+func (tp *themePicker) mouse(m *Model, e mouseEvent) tea.Cmd {64+ if e.box != pickerID && e.kind == mousePress {65+ tp.restore(m)66+ }67+ cmd := tp.picker.mouse(m, e)68+ if m.overlay == tp {69+ tp.preview(m)70+ }71+ return cmd72+}73+74+func (tp *themePicker) changed(m *Model) {75+ tp.picker.changed(m)76+ tp.preview(m)77+}78+79+// preview draws the highlighted theme.80+func (tp *themePicker) preview(m *Model) {81+ if tp.sel >= len(tp.shown) {82+ return83+ }84+ name := tp.shown[tp.sel].item.title85+ if name == tp.shownName {86+ return87+ }88+ tp.shownName = name89+ m.prefs.preview = name90+ m.applyTheme()91+}92+93+// restore goes back to the configured theme.94+func (tp *themePicker) restore(m *Model) {95+ m.prefs.preview = ""96+ m.applyTheme()97+}98+99+// keepTheme makes name the theme and saves it in the config file. When100+// the config picks a theme by the terminal's background, only the one101+// for the current background changes.102+func (m *Model) keepTheme(name string) {103+ m.closeOverlay()104+ m.prefs.preview = ""105+ choice := m.prefs.Config.Theme()106+ if choice.Light != choice.Dark && m.prefs.dark {107+ choice.Dark = name108+ } else if choice.Light != choice.Dark {109+ choice.Light = name110+ } else {111+ choice = config.ThemeChoice{Light: name, Dark: name}112+ }113+ c := m.prefs.Config114+ if c == nil {115+ c = config.Load("", nil, nil)116+ m.prefs.Config = c117+ }118+ // Until the next start, the choice wins over the file, the119+ // environment and flags alike.120+ was := c.Get("theme").Src121+ c.Override("theme", choice.String(), "File > Settings > Theme")122+ if p := m.applyTheme(); p != "" {123+ m.note = m.th.Warning.Render(p)124+ return125+ }126+ if c.Path == "" {127+ m.note = "Theme " + name + " (not saved: no config file)"128+ return129+ }130+ if err := config.SetInFile(c.Path, "theme", choice.String()); err != nil {131+ m.fail("Couldn't save the theme: " + err.Error())132+ return133+ }134+ m.note = "Theme " + name + ", saved in " + c.Path135+ if was.Kind == config.FromEnv || was.Kind == config.FromFlag {136+ m.note += " (" + was.String() + " overrides it at startup)"137+ }138+}@@ -121,7 +121,7 @@ func (m *Model) gridRow(row int) string {
121121 }122122 focus := m.active()123123 sel, selecting := m.highlight()124- hdr := m.th.Header124+ hdr := m.th.RowHeader125125 switch {126126 case row == focus.Row:127127 hdr = m.th.HeaderActive