1package ui23import (4 "context"5 "errors"6 "fmt"7 "os"8 "strings"9 "time"1011 tea "charm.land/bubbletea/v2"1213 "src.rest/cat/012/internal/config"14 "src.rest/cat/012/internal/jev"15 "src.rest/cat/012/internal/keyring"16 "src.rest/cat/012/internal/locale"17 "src.rest/cat/012/internal/sheet"18 "src.rest/cat/012/internal/ui/theme"19 "src.rest/cat/012/internal/ui/themepicker"20)2122// Settings are what the app takes from outside the workbook: the config23// file's options, and the credential store the JEV API key lives in.24// cmd/012 passes them to Configure; tests pass fakes, so the user's own25// config and keychain are never touched.26type Settings struct {27 Config *config.Config28 Reload func() *config.Config // reads the config file again; nil disables Reload config29 ThemesDir string // the user's theme files30 Keys keyring.Store // where JEV API key stores the key; nil disables it31 // Connect makes a JEV client for the configured service with key.32 Connect func(key string) (jev.Client, error)33 Notes []string // said on the context line at startup, e.g. config warnings34 // RecoveryDir is where a local session keeps its unsaved work when35 // it stops on an internal error, and finds it again: recovery.go.36 // "" keeps none; 012 serve keeps them in the served directory.37 RecoveryDir string38 // Version is 012's, which live mode's MCP server reports to agents.39 Version string40}4142// prefs is the settings in effect: the component behind File >43// Settings, the theme picker and the API key prompt.44type prefs struct {45 Settings46 light bool // the terminal's background is light (dark until it says)47 preview string // a theme being previewed by the picker48 vim bool // vim keys in READY mode (keymap = vim): see vim.go49}5051// Configure applies settings: the theme, chart images, notifications,52// and notes for the context line.53func (m *Model) Configure(s Settings) {54 m.prefs.Settings = s55 notes := s.Notes56 if p := m.applyConfig(); p != "" {57 notes = append(notes, p)58 }59 if len(notes) > 0 {60 m.note = m.th.Warning.Render(strings.Join(notes, " "))61 }62}6364// applyConfig applies the options that take effect live and reports65// problems it finds, such as an unknown theme.66func (m *Model) applyConfig() string {67 c := m.prefs.Config68 m.term.noImages = !c.Bool("chart-images")69 m.term.noNotify = !c.Bool("notifications")70 m.SetVimKeys(c.String("keymap") == "vim")71 sheet.SetMaxCells(c.Int("max-cells"))72 if l, ok := locale.Lookup(c.String("locale")); ok {73 sheet.SetDefaultLocale(l)74 }75 return m.applyTheme()76}7778// applyTheme draws with the theme chosen for the terminal's background,79// or the one being previewed. It returns why a theme couldn't be used.80func (m *Model) applyTheme() string {81 name := m.prefs.preview82 if name == "" {83 name = m.prefs.Config.Theme().Pick(!m.prefs.light)84 }85 th, err := theme.Resolve(name, !m.prefs.light, m.prefs.ThemesDir)86 m.th = th87 if err != nil {88 return err.Error()89 }90 return ""91}9293func init() {94 register(95 &command{id: "settings.theme", macro: macroNever, title: "Theme",96 desc: "Pick a color theme, previewing each as you move; Esc keeps the one you had",97 run: func(m *Model) tea.Cmd { m.openOverlay(themepicker.New(m.host())); return nil }},98 &command{id: "settings.config_edit", macro: macroNever, title: "Open config file",99 desc: "Edit the settings file in $VISUAL or $EDITOR; 012 reloads it when you close the editor",100 enabled: func(m *Model) bool { return m.prefs.Config != nil && m.prefs.Config.Path != "" },101 run: (*Model).editConfig},102 &command{id: "settings.config_reload", macro: macroNever, title: "Reload config",103 desc: "Read the settings file again and apply the theme and display options",104 enabled: func(m *Model) bool { return m.prefs.Reload != nil },105 run: func(m *Model) tea.Cmd { m.reloadConfig(); return nil }},106 &command{id: "settings.jev_key", macro: macroNever, title: "JEV API key",107 desc: "Store the TypeSafe API key for JEV functions in the OS credential store",108 enabled: func(m *Model) bool { return m.prefs.Keys != nil },109 run: (*Model).askKey},110 )111}112113// configEditedMsg says the editor opened by Open config file exited.114type configEditedMsg struct{ err error }115116func (m *Model) editConfig() tea.Cmd {117 path := m.prefs.Config.Path118 if err := config.EnsureFile(path); err != nil {119 m.fail("Can't create " + path + ": " + err.Error())120 return nil121 }122 cmd, err := config.Editor(path, os.Getenv)123 if err != nil {124 m.fail("Can't start the editor: " + err.Error())125 return nil126 }127 return tea.ExecProcess(cmd, func(err error) tea.Msg { return configEditedMsg{err} })128}129130// reloadConfig reads the config file again, applies what can change131// live, and says what happened.132func (m *Model) reloadConfig() {133 c := m.prefs.Reload()134 m.prefs.Config = c135 var problems []string136 for _, w := range c.Warnings {137 problems = append(problems, w.Short())138 }139 if p := m.applyConfig(); p != "" {140 problems = append(problems, p)141 }142 if len(problems) > 0 {143 m.note = m.th.Warning.Render("Config reloaded with problems: " + strings.Join(problems, "; "))144 return145 }146 m.note = "Config reloaded from " + config.Tilde(c.Path)147}148149// keySavedMsg reports storing the API key, the client made with it, and150// how the test call with it went.151type keySavedMsg struct {152 client jev.Client153 err error // storing it or connecting failed154 check string // why the test call failed, "" when it worked155}156157// keyCheckTimeout bounds the test call made with a new key.158const keyCheckTimeout = 15 * time.Second159160// askKey asks for the API key on the context line, masked, stores it,161// and checks it with one small test call (jev.Check).162func (m *Model) askKey() tea.Cmd {163 m.openPrompt(&prompt{kind: promptText, label: "TypeSafe API key:", indicator: "KEY", secret: true,164 onText: func(key string) tea.Cmd {165 if key == "" {166 m.note = "No key entered; nothing changed"167 return nil168 }169 m.note = "Saving the key in the " + m.prefs.Keys.Name() + " and checking it…"170 return saveKeyCmd(m.prefs.Keys, m.prefs.Connect, key)171 }}, "")172 return nil173}174175// saveKeyCmd stores key, connects with it and makes the test call.176func saveKeyCmd(store keyring.Store, connect func(string) (jev.Client, error), key string) tea.Cmd {177 return func() tea.Msg {178 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)179 defer cancel()180 if err := store.Set(ctx, key); err != nil {181 return keySavedMsg{err: err}182 }183 if connect == nil {184 return keySavedMsg{err: errors.New("JEV isn't available in this session")}185 }186 client, err := connect(key)187 if err != nil {188 return keySavedMsg{err: err}189 }190 cctx, ccancel := context.WithTimeout(context.Background(), keyCheckTimeout)191 defer ccancel()192 msg := keySavedMsg{client: client}193 if err := jev.Check(cctx, client); err != nil {194 // Whatever the service said, it never gets to show the key.195 msg.check = strings.ReplaceAll(err.Error(), key, "…")196 }197 return msg198 }199}200201// keySaved turns JEV on with the new key and says how its check went.202// A key whose check failed is kept and used: a check can fail because203// the service can't be reached, not the key.204func (m *Model) keySaved(msg keySavedMsg) {205 if msg.err != nil {206 m.fail("Couldn't save the API key: " + msg.err.Error())207 return208 }209 if m.jev == nil {210 m.EnableJEV(msg.client, jev.NewCache())211 } else {212 m.jev.client = msg.client213 }214 m.sheet.RecalcVolatile()215 if msg.check != "" {216 m.note = m.th.Warning.Render("Key saved, but the check failed: " + msg.check)217 return218 }219 m.note = fmt.Sprintf("Key saved and checked; it's in the %s and JEV functions are on", m.prefs.Keys.Name())220}221222// handlePrefs handles the messages settings get: the terminal's223// background (which picks the theme), the editor closing, the key saved.224func (m *Model) handlePrefs(msg tea.Msg) bool {225 switch msg := msg.(type) {226 case tea.BackgroundColorMsg:227 m.prefs.light = !msg.IsDark()228 m.term.bg = msg.Color229 m.term.backgroundKnown()230 if p := m.applyTheme(); p != "" && m.note == "" {231 m.note = m.th.Warning.Render(p)232 }233 case configEditedMsg:234 if msg.err != nil {235 m.fail("The editor failed: " + msg.err.Error())236 } else if m.prefs.Reload != nil {237 m.reloadConfig()238 }239 case keySavedMsg:240 m.keySaved(msg)241 default:242 return false243 }244 return true245}