- commit
- 65ca2e2
- parent
- 886ea6e
- author
- Eric Bower
- date
- 2024-02-29 16:04:49 +0000 UTC
fix: empty username refactor: ssh styles feat: add pico+ info
M
db/db.go
+7,
-6
1@@ -1,6 +1,7 @@
2 package db
3
4 import (
5+ "database/sql"
6 "database/sql/driver"
7 "encoding/json"
8 "errors"
9@@ -255,19 +256,19 @@ var DenyList = []string{
10 }
11
12 type DB interface {
13- AddUser() (string, error)
14+ RegisterUser(name, pubkey string) (*User, error)
15 RemoveUsers(userIDs []string) error
16- LinkUserKey(userID string, key string) error
17- FindPublicKeyForKey(key string) (*PublicKey, error)
18+ LinkUserKey(userID string, pubkey string, tx *sql.Tx) error
19+ FindPublicKeyForKey(pubkey string) (*PublicKey, error)
20 FindKeysForUser(user *User) ([]*PublicKey, error)
21- RemoveKeys(keyIDs []string) error
22+ RemoveKeys(pubkeyIDs []string) error
23
24 FindSiteAnalytics(space string) (*Analytics, error)
25
26 FindUsers() ([]*User, error)
27 FindUserForName(name string) (*User, error)
28- FindUserForNameAndKey(name string, key string) (*User, error)
29- FindUserForKey(name string, key string) (*User, error)
30+ FindUserForNameAndKey(name string, pubkey string) (*User, error)
31+ FindUserForKey(name string, pubkey string) (*User, error)
32 FindUser(userID string) (*User, error)
33 ValidateName(name string) (bool, error)
34 SetUserName(userID string, name string) error
+46,
-9
1@@ -234,7 +234,7 @@ const (
2 file_size, mime_type, shasum, data, expires_at, updated_at)
3 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
4 RETURNING id`
5- sqlInsertUser = `INSERT INTO app_users DEFAULT VALUES returning id`
6+ sqlInsertUser = `INSERT INTO app_users (name) VALUES($1) returning id`
7 sqlInsertTag = `INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`
8 sqlInsertAliases = `INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`
9 sqlInsertFeedItems = `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`
10@@ -363,13 +363,45 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
11 return d
12 }
13
14-func (me *PsqlDB) AddUser() (string, error) {
15+func (me *PsqlDB) RegisterUser(username string, pubkey string) (*db.User, error) {
16+ lowerName := strings.ToLower(username)
17+ valid, err := me.ValidateName(lowerName)
18+ if !valid {
19+ return nil, err
20+ }
21+
22+ ctx := context.Background()
23+ tx, err := me.Db.BeginTx(ctx, nil)
24+ if err != nil {
25+ return nil, err
26+ }
27+ defer func() {
28+ err = tx.Rollback()
29+ }()
30+
31+ stmt, err := tx.Prepare(sqlInsertUser)
32+ if err != nil {
33+ return nil, err
34+ }
35+ defer stmt.Close()
36+
37 var id string
38- err := me.Db.QueryRow(sqlInsertUser).Scan(&id)
39+ err = stmt.QueryRow(lowerName).Scan(&id)
40 if err != nil {
41- return "", err
42+ return nil, err
43 }
44- return id, nil
45+
46+ err = me.LinkUserKey(id, pubkey, tx)
47+ if err != nil {
48+ return nil, err
49+ }
50+
51+ err = tx.Commit()
52+ if err != nil {
53+ return nil, err
54+ }
55+
56+ return me.FindUserForKey(username, pubkey)
57 }
58
59 func (me *PsqlDB) RemoveUsers(userIDs []string) error {
60@@ -378,12 +410,17 @@ func (me *PsqlDB) RemoveUsers(userIDs []string) error {
61 return err
62 }
63
64-func (me *PsqlDB) LinkUserKey(userID string, key string) error {
65+func (me *PsqlDB) LinkUserKey(userID string, key string, tx *sql.Tx) error {
66 pk, _ := me.FindPublicKeyForKey(key)
67 if pk != nil {
68 return db.ErrPublicKeyTaken
69 }
70- _, err := me.Db.Exec(sqlInsertPublicKey, userID, key)
71+ var err error
72+ if tx != nil {
73+ _, err = tx.Exec(sqlInsertPublicKey, userID, key)
74+ } else {
75+ _, err = me.Db.Exec(sqlInsertPublicKey, userID, key)
76+ }
77 return err
78 }
79
80@@ -556,7 +593,7 @@ func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
81 func (me *PsqlDB) ValidateName(name string) (bool, error) {
82 lower := strings.ToLower(name)
83 if slices.Contains(db.DenyList, lower) {
84- return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameDenied)
85+ return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
86 }
87 v := db.NameValidator.MatchString(lower)
88 if !v {
89@@ -566,7 +603,7 @@ func (me *PsqlDB) ValidateName(name string) (bool, error) {
90 if user == nil {
91 return true, nil
92 }
93- return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameTaken)
94+ return false, fmt.Errorf("%s already taken: %w", lower, db.ErrNameTaken)
95 }
96
97 func (me *PsqlDB) FindUserForName(name string) (*db.User, error) {
M
go.mod
+2,
-0
1@@ -20,6 +20,7 @@ require (
2 github.com/charmbracelet/wish v1.3.0
3 github.com/google/go-cmp v0.6.0
4 github.com/gorilla/feeds v1.1.2
5+ github.com/kr/pty v1.1.8
6 github.com/lib/pq v1.10.9
7 github.com/microcosm-cc/bluemonday v1.0.26
8 github.com/minio/minio-go/v7 v7.0.63
9@@ -54,6 +55,7 @@ require (
10 github.com/charmbracelet/keygen v0.5.0 // indirect
11 github.com/charmbracelet/log v0.3.1 // indirect
12 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
13+ github.com/creack/pty v1.1.21 // indirect
14 github.com/dlclark/regexp2 v1.10.0 // indirect
15 github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
16 github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
M
go.sum
+5,
-0
1@@ -41,6 +41,9 @@ github.com/charmbracelet/wish v1.2.0 h1:h5Wj9pr97IQz/l4gM5Xep2lXcY/YM+6O2RC2o3x0
2 github.com/charmbracelet/wish v1.2.0/go.mod h1:JX3fC+178xadJYAhPu6qWtVDpJTwpnFvpdjz9RKJlUE=
3 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
4 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
5+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
6+github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
7+github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11@@ -122,6 +125,8 @@ github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
12 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
13 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
14 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
15+github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
16+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
17 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
18 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
19 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+23,
-25
1@@ -17,24 +17,21 @@ import (
2 "github.com/picosh/pico/wish/cms/ui/common"
3 )
4
5-var re = lipgloss.NewRenderer(os.Stdout)
6-var baseStyle = re.NewStyle().Padding(0, 1)
7-var headerStyle = baseStyle.Copy().Foreground(common.Indigo).Bold(true)
8-var borderStyle = re.NewStyle().Foreground(lipgloss.Color("238"))
9-
10-func styleRows(row, col int) lipgloss.Style {
11- if row == 0 {
12- return headerStyle
13- }
14+func styleRows(styles common.Styles) func(row, col int) lipgloss.Style {
15+ return func(row, col int) lipgloss.Style {
16+ if row == 0 {
17+ return styles.CliHeader
18+ }
19
20- even := row%2 == 0
21- if even {
22- return baseStyle.Copy().Foreground(lipgloss.Color("245"))
23+ even := row%2 == 0
24+ if even {
25+ return styles.CliPadding.Copy().Foreground(lipgloss.Color("245"))
26+ }
27+ return styles.CliPadding.Copy().Foreground(lipgloss.Color("252"))
28 }
29- return baseStyle.Copy().Foreground(lipgloss.Color("252"))
30 }
31
32-func projectTable(projects []*db.Project) *table.Table {
33+func projectTable(styles common.Styles, projects []*db.Project) *table.Table {
34 headers := []string{
35 "Name",
36 "Last Updated",
37@@ -62,16 +59,16 @@ func projectTable(projects []*db.Project) *table.Table {
38
39 t := table.New().
40 Border(lipgloss.NormalBorder()).
41- BorderStyle(borderStyle).
42+ BorderStyle(styles.CliBorder).
43 Headers(headers...).
44 Rows(data...).
45- StyleFunc(styleRows)
46+ StyleFunc(styleRows(styles))
47 return t
48 }
49
50-func getHelpText(userName string) string {
51+func getHelpText(styles common.Styles, userName string) string {
52 helpStr := "Commands: [help, stats, ls, rm, link, unlink, prune, retain, depends, acl]\n\n"
53- helpStr += "NOTICE: *must* append with `--write` for the changes to persist.\n\n"
54+ helpStr += styles.Note.Render("NOTICE:") + " *must* append with `--write` for the changes to persist.\n\n"
55
56 projectName := "projA"
57 headers := []string{"Cmd", "Description"}
58@@ -120,10 +117,10 @@ func getHelpText(userName string) string {
59
60 t := table.New().
61 Border(lipgloss.NormalBorder()).
62- BorderStyle(borderStyle).
63+ BorderStyle(styles.CliBorder).
64 Headers(headers...).
65 Rows(data...).
66- StyleFunc(styleRows)
67+ StyleFunc(styleRows(styles))
68
69 helpStr += t.String()
70 return helpStr
71@@ -165,6 +162,7 @@ type Cmd struct {
72 Store storage.StorageServe
73 Dbpool db.DB
74 Write bool
75+ Styles common.Styles
76 }
77
78 func (c *Cmd) output(out string) {
79@@ -236,7 +234,7 @@ func (c *Cmd) RmProjectAssets(projectName string) error {
80 }
81
82 func (c *Cmd) help() {
83- c.output(getHelpText(c.User.Name))
84+ c.output(getHelpText(c.Styles, c.User.Name))
85 }
86
87 func (c *Cmd) stats(cfgMaxSize uint64) error {
88@@ -274,10 +272,10 @@ func (c *Cmd) stats(cfgMaxSize uint64) error {
89
90 t := table.New().
91 Border(lipgloss.NormalBorder()).
92- BorderStyle(borderStyle).
93+ BorderStyle(c.Styles.CliBorder).
94 Headers(headers...).
95 Rows(data).
96- StyleFunc(styleRows)
97+ StyleFunc(styleRows(c.Styles))
98 c.output(t.String())
99
100 return nil
101@@ -293,7 +291,7 @@ func (c *Cmd) ls() error {
102 c.output("no projects found")
103 }
104
105- t := projectTable(projects)
106+ t := projectTable(c.Styles, projects)
107 c.output(t.String())
108
109 return nil
110@@ -379,7 +377,7 @@ func (c *Cmd) depends(projectName string) error {
111 return nil
112 }
113
114- t := projectTable(projects)
115+ t := projectTable(c.Styles, projects)
116 c.output(t.String())
117
118 return nil
+57,
-49
1@@ -22,7 +22,6 @@ import (
2 "github.com/picosh/pico/wish/cms/ui/info"
3 "github.com/picosh/pico/wish/cms/ui/keys"
4 "github.com/picosh/pico/wish/cms/ui/tokens"
5- "github.com/picosh/pico/wish/cms/ui/username"
6 )
7
8 type status int
9@@ -64,15 +63,10 @@ var menuChoices = map[menuChoice]string{
10 exitChoice: "Exit",
11 }
12
13-var (
14- spinnerStyle = lipgloss.NewStyle().
15- Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
16-)
17-
18-func NewSpinner() spinner.Model {
19+func NewSpinner(styles common.Styles) spinner.Model {
20 s := spinner.New()
21 s.Spinner = spinner.Dot
22- s.Style = spinnerStyle
23+ s.Style = styles.Spinner
24 return s
25 }
26
27@@ -90,6 +84,7 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
28 key, err := shared.KeyText(s)
29 if err != nil {
30 logger.Error(err.Error())
31+ return nil, nil
32 }
33
34 sshUser := s.User()
35@@ -107,6 +102,10 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
36 logger.Error(err.Error())
37 }
38
39+ renderer := lipgloss.NewRenderer(s)
40+ renderer.SetOutput(common.OutputFromSession(s))
41+ styles := common.DefaultStyles(renderer)
42+
43 m := model{
44 cfg: cfg,
45 urls: urls,
46@@ -116,8 +115,8 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
47 sshUser: sshUser,
48 status: statusInit,
49 menuChoice: unsetChoice,
50- styles: common.DefaultStyles(),
51- spinner: common.NewSpinner(),
52+ styles: styles,
53+ spinner: common.NewSpinner(styles),
54 terminalSize: tea.WindowSizeMsg{
55 Width: 80,
56 Height: 24,
57@@ -131,31 +130,34 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
58 }
59 m.user = user
60
61+ ff, _ := m.findPlusFeatureFlag()
62+ m.plusFeatureFlag = ff
63+
64 return m, []tea.ProgramOption{tea.WithAltScreen()}
65 }
66 }
67
68 // Just a generic tea.Model to demo terminal information of ssh.
69 type model struct {
70- cfg *config.ConfigCms
71- urls config.ConfigURL
72- publicKey string
73- dbpool db.DB
74- st storage.StorageServe
75- user *db.User
76- err error
77- sshUser string
78- status status
79- menuIndex int
80- menuChoice menuChoice
81- styles common.Styles
82- info info.Model
83- spinner spinner.Model
84- username username.Model
85- keys keys.Model
86- tokens tokens.Model
87- createAccount account.CreateModel
88- terminalSize tea.WindowSizeMsg
89+ cfg *config.ConfigCms
90+ urls config.ConfigURL
91+ publicKey string
92+ dbpool db.DB
93+ st storage.StorageServe
94+ user *db.User
95+ plusFeatureFlag *db.FeatureFlag
96+ err error
97+ sshUser string
98+ status status
99+ menuIndex int
100+ menuChoice menuChoice
101+ styles common.Styles
102+ info info.Model
103+ spinner spinner.Model
104+ keys keys.Model
105+ tokens tokens.Model
106+ createAccount account.CreateModel
107+ terminalSize tea.WindowSizeMsg
108 }
109
110 func (m model) Init() tea.Cmd {
111@@ -174,7 +176,7 @@ func (m model) findUser() (*db.User, error) {
112 user, err := m.dbpool.FindUserForKey(m.sshUser, m.publicKey)
113
114 if err != nil {
115- logger.Error(err.Error())
116+ logger.Error("no user found for public key", "err", err.Error())
117 // we only want to throw an error for specific cases
118 if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
119 return nil, err
120@@ -185,6 +187,19 @@ func (m model) findUser() (*db.User, error) {
121 return user, nil
122 }
123
124+func (m model) findPlusFeatureFlag() (*db.FeatureFlag, error) {
125+ if m.user == nil {
126+ return nil, nil
127+ }
128+
129+ ff, err := m.dbpool.FindFeatureForUser(m.user.ID, "pgs")
130+ if err != nil {
131+ return nil, err
132+ }
133+
134+ return ff, nil
135+}
136+
137 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138 var (
139 cmds []tea.Cmd
140@@ -228,29 +243,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141 }
142 }
143 }
144- case username.NameSetMsg:
145- m.status = statusReady
146- m.info.User.Name = string(msg)
147- m.user = m.info.User
148- m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
149 case account.CreateAccountMsg:
150 m.status = statusReady
151 m.info.User = msg
152 m.user = msg
153- m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
154- m.info = info.NewModel(m.cfg, m.urls, m.user)
155- m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
156- m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
157- m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
158+ m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
159+ m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
160+ m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
161+ m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
162 }
163
164 switch m.status {
165 case statusInit:
166- m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
167- m.info = info.NewModel(m.cfg, m.urls, m.user)
168- m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
169- m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
170- m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
171+ m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
172+ m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
173+ m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
174+ m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
175 if m.user == nil {
176 m.status = statusNoAccount
177 } else {
178@@ -280,7 +288,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
179 cmd = newCmd
180
181 if m.keys.Exit {
182- m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
183+ m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
184 m.status = statusReady
185 } else if m.keys.Quit {
186 m.status = statusQuitting
187@@ -296,7 +304,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
188 cmd = newCmd
189
190 if m.tokens.Exit {
191- m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
192+ m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
193 m.status = statusReady
194 } else if m.tokens.Quit {
195 m.status = statusQuitting
196@@ -305,7 +313,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
197 case statusNoAccount:
198 m.createAccount, cmd = account.Update(msg, m.createAccount)
199 if m.createAccount.Done {
200- m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey) // reset the state
201+ m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey) // reset the state
202 m.status = statusReady
203 } else if m.createAccount.Quit {
204 m.status = statusQuitting
205@@ -356,7 +364,7 @@ func footerView(m model) string {
206 if m.err != nil {
207 return m.errorView(m.err)
208 }
209- return "\n\n" + common.HelpView("j/k, ↑/↓: choose", "enter: select")
210+ return "\n\n" + common.HelpView(m.styles, "j/k, ↑/↓: choose", "enter: select")
211 }
212
213 func (m model) errorView(err error) string {
+1,
-1
1@@ -42,7 +42,7 @@ func NewConfigSite() *shared.ConfigSite {
2 MinioURL: minioURL,
3 MinioUser: minioUser,
4 MinioPass: minioPass,
5- Description: "hacker labs",
6+ Description: "A zero-install static site hosting service for hackers",
7 IntroText: intro,
8 Space: "pgs",
9 // IMPORTANT: make sure `shared.GetMimeType` has the extensions being
+8,
-0
1@@ -6,11 +6,13 @@ import (
2 "slices"
3 "strings"
4
5+ "github.com/charmbracelet/lipgloss"
6 "github.com/charmbracelet/ssh"
7 "github.com/charmbracelet/wish"
8 "github.com/picosh/pico/db"
9 uploadassets "github.com/picosh/pico/filehandlers/assets"
10 "github.com/picosh/pico/shared"
11+ "github.com/picosh/pico/wish/cms/ui/common"
12 "github.com/picosh/send/send/utils"
13 )
14
15@@ -83,6 +85,11 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
16
17 args := sesh.Command()
18
19+ renderer := lipgloss.NewRenderer(sesh)
20+ // this might be dangerous but going with it for now
21+ // renderer.SetColorProfile(termenv.ANSI256)
22+ styles := common.DefaultStyles(renderer)
23+
24 opts := Cmd{
25 Session: sesh,
26 User: user,
27@@ -90,6 +97,7 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
28 Log: log,
29 Dbpool: dbpool,
30 Write: false,
31+ Styles: styles,
32 }
33
34 cmd := strings.TrimSpace(args[0])
+61,
-53
1@@ -24,7 +24,6 @@ import (
2 "github.com/picosh/pico/wish/cms/ui/keys"
3 "github.com/picosh/pico/wish/cms/ui/posts"
4 "github.com/picosh/pico/wish/cms/ui/tokens"
5- "github.com/picosh/pico/wish/cms/ui/username"
6 )
7
8 type status int
9@@ -70,15 +69,10 @@ var menuChoices = map[menuChoice]string{
10 exitChoice: "Exit",
11 }
12
13-var (
14- spinnerStyle = lipgloss.NewStyle().
15- Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
16-)
17-
18-func NewSpinner() spinner.Model {
19+func NewSpinner(styles common.Styles) spinner.Model {
20 s := spinner.New()
21 s.Spinner = spinner.Dot
22- s.Style = spinnerStyle
23+ s.Style = styles.Spinner
24 return s
25 }
26
27@@ -113,6 +107,10 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
28 logger.Error(err.Error())
29 }
30
31+ renderer := lipgloss.NewRenderer(s)
32+ renderer.SetOutput(common.OutputFromSession(s))
33+ styles := common.DefaultStyles(renderer)
34+
35 m := model{
36 cfg: cfg,
37 urls: urls,
38@@ -122,8 +120,8 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
39 sshUser: sshUser,
40 status: statusInit,
41 menuChoice: unsetChoice,
42- styles: common.DefaultStyles(),
43- spinner: common.NewSpinner(),
44+ styles: styles,
45+ spinner: common.NewSpinner(styles),
46 terminalSize: tea.WindowSizeMsg{
47 Width: 80,
48 Height: 24,
49@@ -137,32 +135,35 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
50 }
51 m.user = user
52
53+ ff, _ := m.findPlusFeatureFlag()
54+ m.plusFeatureFlag = ff
55+
56 return m, []tea.ProgramOption{tea.WithAltScreen()}
57 }
58 }
59
60 // Just a generic tea.Model to demo terminal information of ssh.
61 type model struct {
62- cfg *config.ConfigCms
63- urls config.ConfigURL
64- publicKey string
65- dbpool db.DB
66- st storage.StorageServe
67- user *db.User
68- err error
69- sshUser string
70- status status
71- menuIndex int
72- menuChoice menuChoice
73- terminalSize tea.WindowSizeMsg
74- styles common.Styles
75- info info.Model
76- spinner spinner.Model
77- username username.Model
78- posts posts.Model
79- keys keys.Model
80- tokens tokens.Model
81- createAccount account.CreateModel
82+ cfg *config.ConfigCms
83+ urls config.ConfigURL
84+ publicKey string
85+ dbpool db.DB
86+ st storage.StorageServe
87+ user *db.User
88+ plusFeatureFlag *db.FeatureFlag
89+ err error
90+ sshUser string
91+ status status
92+ menuIndex int
93+ menuChoice menuChoice
94+ terminalSize tea.WindowSizeMsg
95+ styles common.Styles
96+ info info.Model
97+ spinner spinner.Model
98+ posts posts.Model
99+ keys keys.Model
100+ tokens tokens.Model
101+ createAccount account.CreateModel
102 }
103
104 func (m model) Init() tea.Cmd {
105@@ -192,6 +193,19 @@ func (m model) findUser() (*db.User, error) {
106 return user, nil
107 }
108
109+func (m model) findPlusFeatureFlag() (*db.FeatureFlag, error) {
110+ if m.user == nil {
111+ return nil, nil
112+ }
113+
114+ ff, err := m.dbpool.FindFeatureForUser(m.user.ID, "pgs")
115+ if err != nil {
116+ return nil, err
117+ }
118+
119+ return ff, nil
120+}
121+
122 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123 var (
124 cmds []tea.Cmd
125@@ -235,32 +249,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126 }
127 }
128 }
129- case username.NameSetMsg:
130- m.status = statusReady
131- m.info.User.Name = string(msg)
132- m.user = m.info.User
133- m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
134+
135 case account.CreateAccountMsg:
136 m.status = statusReady
137 m.info.User = msg
138 m.user = msg
139- m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
140- m.info = info.NewModel(m.cfg, m.urls, m.user)
141- m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
142- m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
143- m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
144+ m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
145+ m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
146+ m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
147+ m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
148
149 perPage := math.Floor(float64(m.terminalSize.Height) / 10.0)
150- m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
151+ m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
152 }
153
154 switch m.status {
155 case statusInit:
156- m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
157- m.info = info.NewModel(m.cfg, m.urls, m.user)
158- m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
159- m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
160- m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
161+ m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
162+ m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
163+ m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
164+ m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
165 if m.user == nil {
166 m.status = statusNoAccount
167 } else {
168@@ -268,7 +276,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169 }
170
171 perPage := math.Floor(float64(m.terminalSize.Height) / 10.0)
172- m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
173+ m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
174 }
175
176 m, cmd = updateChildren(msg, m)
177@@ -294,9 +302,9 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
178
179 if m.posts.Exit {
180 perPage := math.Floor(float64(m.terminalSize.Height) / 10.0)
181- m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
182+ m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
183
184- m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
185+ m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
186 m.status = statusReady
187 } else if m.posts.Quit {
188 m.status = statusQuitting
189@@ -312,7 +320,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
190 cmd = newCmd
191
192 if m.keys.Exit {
193- m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
194+ m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
195 m.status = statusReady
196 } else if m.keys.Quit {
197 m.status = statusQuitting
198@@ -328,7 +336,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
199 cmd = newCmd
200
201 if m.tokens.Exit {
202- m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
203+ m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
204 m.status = statusReady
205 } else if m.tokens.Quit {
206 m.status = statusQuitting
207@@ -337,7 +345,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
208 case statusNoAccount:
209 m.createAccount, cmd = account.Update(msg, m.createAccount)
210 if m.createAccount.Done {
211- m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey) // reset the state
212+ m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey) // reset the state
213 m.status = statusReady
214 } else if m.createAccount.Quit {
215 m.status = statusQuitting
216@@ -392,7 +400,7 @@ func footerView(m model) string {
217 if m.err != nil {
218 return m.errorView(m.err)
219 }
220- return "\n\n" + common.HelpView("j/k, ↑/↓: choose", "enter: select")
221+ return "\n\n" + common.HelpView(m.styles, "j/k, ↑/↓: choose", "enter: select")
222 }
223
224 func (m model) errorView(err error) string {
+24,
-57
1@@ -41,6 +41,9 @@ type errMsg struct{ err error }
2
3 func (e errMsg) Error() string { return e.err.Error() }
4
5+var deny = strings.Join(db.DenyList, ", ")
6+var helpMsg = fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
7+
8 // Model holds the state of the username UI.
9 type CreateModel struct {
10 Done bool // true when it's time to exit this view
11@@ -91,13 +94,11 @@ func (m *CreateModel) indexBackward() {
12 }
13
14 // NewModel returns a new username model in its initial state.
15-func NewCreateModel(cfg *config.ConfigCms, dbpool db.DB, publicKey string) CreateModel {
16- st := common.DefaultStyles()
17-
18+func NewCreateModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, publicKey string) CreateModel {
19 im := input.New()
20- im.Cursor.Style = st.Cursor
21- im.Placeholder = "erock"
22- im.Prompt = st.FocusedPrompt.String()
23+ im.Cursor.Style = styles.Cursor
24+ im.Placeholder = "enter username"
25+ im.Prompt = styles.FocusedPrompt.String()
26 im.CharLimit = 50
27 im.Focus()
28
29@@ -106,21 +107,21 @@ func NewCreateModel(cfg *config.ConfigCms, dbpool db.DB, publicKey string) Creat
30 Done: false,
31 Quit: false,
32 dbpool: dbpool,
33- styles: st,
34+ styles: styles,
35 state: ready,
36 newName: "",
37 index: textInput,
38 errMsg: "",
39 input: im,
40- spinner: common.NewSpinner(),
41+ spinner: common.NewSpinner(styles),
42 publicKey: publicKey,
43 }
44 }
45
46 // Init is the Bubble Tea initialization function.
47-func Init(cfg *config.ConfigCms, dbpool db.DB, publicKey string) func() (CreateModel, tea.Cmd) {
48+func Init(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, publicKey string) func() (CreateModel, tea.Cmd) {
49 return func() (CreateModel, tea.Cmd) {
50- m := NewCreateModel(cfg, dbpool, publicKey)
51+ m := NewCreateModel(styles, cfg, dbpool, publicKey)
52 return m, InitialCmd()
53 }
54 }
55@@ -207,8 +208,6 @@ func Update(msg tea.Msg, m CreateModel) (CreateModel, tea.Cmd) {
56 case NameInvalidMsg:
57 m.state = ready
58 head := m.styles.Error.Render("Invalid name. ")
59- deny := strings.Join(db.DenyList, ", ")
60- helpMsg := fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
61 body := m.styles.Subtle.Render(helpMsg)
62 m.errMsg = m.styles.Wrap.Render(head + body)
63
64@@ -242,19 +241,20 @@ func View(m CreateModel) string {
65 return "Registration is closed for this service. Press 'esc' to exit."
66 }
67
68- s := fmt.Sprintf("%s\n\n%s\n\n", m.cfg.Description, m.cfg.IntroText)
69- s += "Enter a username\n\n"
70+ s := fmt.Sprintf("%s\n\n%s\n", "hacker labs", m.cfg.IntroText)
71+ s += fmt.Sprintf("Public Key: %s\n\n", m.publicKey)
72 s += m.input.View() + "\n\n"
73
74 if m.state == submitting {
75 s += spinnerView(m)
76 } else {
77- s += common.OKButtonView(m.index == 1, true)
78- s += " " + common.CancelButtonView(m.index == 2, false)
79+ s += common.OKButtonView(m.styles, m.index == 1, true)
80+ s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
81 if m.errMsg != "" {
82 s += "\n\n" + m.errMsg
83 }
84 }
85+ s += fmt.Sprintf("\n\n%s\n", helpMsg)
86
87 return s
88 }
89@@ -263,59 +263,26 @@ func spinnerView(m CreateModel) string {
90 return m.spinner.View() + " Creating account..."
91 }
92
93-func registerUser(m CreateModel) (*db.User, error) {
94- userID, err := m.dbpool.AddUser()
95- if err != nil {
96- return nil, err
97- }
98-
99- err = m.dbpool.LinkUserKey(userID, m.publicKey)
100- if err != nil {
101- return nil, err
102- }
103-
104- user, err := m.dbpool.FindUser(userID)
105- if err != nil {
106- return nil, err
107- }
108-
109- return user, nil
110-
111-}
112-
113-// Attempt to update the username on the server.
114 func createAccount(m CreateModel) tea.Cmd {
115 return func() tea.Msg {
116 if m.newName == "" {
117 return NameInvalidMsg{}
118 }
119
120- valid, err := m.dbpool.ValidateName(m.newName)
121- // Validate before resetting the session to potentially save some
122- // network traffic and keep things feeling speedy.
123- if !valid {
124+ user, err := m.dbpool.RegisterUser(m.newName, m.publicKey)
125+ fmt.Println(err)
126+ if err != nil {
127 if errors.Is(err, db.ErrNameTaken) {
128 return NameTakenMsg{}
129- } else {
130+ } else if errors.Is(err, db.ErrNameInvalid) {
131+ return NameInvalidMsg{}
132+ } else if errors.Is(err, db.ErrNameDenied) {
133 return NameInvalidMsg{}
134+ } else {
135+ return errMsg{err}
136 }
137 }
138
139- user, err := registerUser(m)
140- if err != nil {
141- return errMsg{err}
142- }
143-
144- err = m.dbpool.SetUserName(user.ID, m.newName)
145- if err != nil {
146- return errMsg{err}
147- }
148-
149- user, err = m.dbpool.FindUserForKey(m.newName, m.publicKey)
150- if err != nil {
151- return errMsg{err}
152- }
153-
154 return CreateAccountMsg(user)
155 }
156 }
+113,
-22
1@@ -1,9 +1,72 @@
2 package common
3
4 import (
5+ "fmt"
6+ "log"
7+ "os"
8+ "strings"
9+
10 "github.com/charmbracelet/lipgloss"
11+ "github.com/charmbracelet/ssh"
12+ "github.com/kr/pty"
13+ "github.com/muesli/termenv"
14 )
15
16+// Bridge Wish and Termenv so we can query for a user's terminal capabilities.
17+type sshOutput struct {
18+ ssh.Session
19+ tty *os.File
20+}
21+
22+func (s *sshOutput) Write(p []byte) (int, error) {
23+ return s.Session.Write(p)
24+}
25+
26+func (s *sshOutput) Read(p []byte) (int, error) {
27+ return s.Session.Read(p)
28+}
29+
30+func (s *sshOutput) Fd() uintptr {
31+ return s.tty.Fd()
32+}
33+
34+type sshEnviron struct {
35+ environ []string
36+}
37+
38+func (s *sshEnviron) Getenv(key string) string {
39+ for _, v := range s.environ {
40+ if strings.HasPrefix(v, key+"=") {
41+ return v[len(key)+1:]
42+ }
43+ }
44+ return ""
45+}
46+
47+func (s *sshEnviron) Environ() []string {
48+ return s.environ
49+}
50+
51+// Create a termenv.Output from the session.
52+func OutputFromSession(sess ssh.Session) *termenv.Output {
53+ sshPty, _, _ := sess.Pty()
54+ _, tty, err := pty.Open()
55+ if err != nil {
56+ // TODO: FIX
57+ log.Fatal(err)
58+ }
59+ o := &sshOutput{
60+ Session: sess,
61+ tty: tty,
62+ }
63+ environ := sess.Environ()
64+ environ = append(environ, fmt.Sprintf("TERM=%s", sshPty.Term))
65+ e := &sshEnviron{environ: environ}
66+ // We need to use unsafe mode here because the ssh session is not running
67+ // locally and we already know that the session is a TTY.
68+ return termenv.NewOutput(o, termenv.WithUnsafe(), termenv.WithEnvironment(e))
69+}
70+
71 // Color definitions.
72 var (
73 Indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
74@@ -38,49 +101,77 @@ type Styles struct {
75 SelectedMenuItem,
76 Checkmark,
77 Logo,
78+ BlurredButtonStyle,
79+ FocusedButtonStyle,
80+ HelpSection,
81+ HelpDivider,
82+ Spinner,
83+ CliPadding,
84+ CliBorder,
85+ CliHeader,
86 App lipgloss.Style
87+ Renderer *lipgloss.Renderer
88 }
89
90-func DefaultStyles() Styles {
91- s := Styles{}
92+func DefaultStyles(renderer *lipgloss.Renderer) Styles {
93+ s := Styles{
94+ Renderer: renderer,
95+ }
96
97- s.Cursor = lipgloss.NewStyle().Foreground(Fuschia)
98- s.Wrap = lipgloss.NewStyle().Width(58)
99- s.Keyword = lipgloss.NewStyle().Foreground(Green)
100+ s.Cursor = renderer.NewStyle().Foreground(Fuschia)
101+ s.Wrap = renderer.NewStyle().Width(58)
102+ s.Keyword = renderer.NewStyle().Foreground(Green)
103 s.Paragraph = s.Wrap.Copy().Margin(1, 0, 0, 2)
104- s.Code = lipgloss.NewStyle().
105+ s.Code = renderer.NewStyle().
106 Foreground(lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}).
107 Background(lipgloss.AdaptiveColor{Light: "#EBE5EC", Dark: "#2B2A2A"}).
108 Padding(0, 1)
109- s.Subtle = lipgloss.NewStyle().
110+ s.Subtle = renderer.NewStyle().
111 Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
112- s.Error = lipgloss.NewStyle().Foreground(Red)
113- s.Prompt = lipgloss.NewStyle().MarginRight(1).SetString(">")
114+ s.Error = renderer.NewStyle().Foreground(Red)
115+ s.Prompt = renderer.NewStyle().MarginRight(1).SetString(">")
116 s.FocusedPrompt = s.Prompt.Copy().Foreground(Fuschia)
117- s.Note = lipgloss.NewStyle().Foreground(Green)
118- s.NoteDim = lipgloss.NewStyle().
119+ s.Note = renderer.NewStyle().Foreground(Green)
120+ s.NoteDim = renderer.NewStyle().
121 Foreground(lipgloss.AdaptiveColor{Light: "#ABE5D1", Dark: "#2B4A3F"})
122 s.Delete = s.Error.Copy()
123- s.DeleteDim = lipgloss.NewStyle().Foreground(FaintRed)
124- s.Label = lipgloss.NewStyle().Foreground(Fuschia)
125- s.LabelDim = lipgloss.NewStyle().Foreground(Indigo)
126- s.ListKey = lipgloss.NewStyle().Foreground(Indigo)
127- s.ListDim = lipgloss.NewStyle().Foreground(SubtleIndigo)
128- s.InactivePagination = lipgloss.NewStyle().
129+ s.DeleteDim = renderer.NewStyle().Foreground(FaintRed)
130+ s.Label = renderer.NewStyle().Foreground(Fuschia)
131+ s.LabelDim = renderer.NewStyle().Foreground(Indigo)
132+ s.ListKey = renderer.NewStyle().Foreground(Indigo)
133+ s.ListDim = renderer.NewStyle().Foreground(SubtleIndigo)
134+ s.InactivePagination = renderer.NewStyle().
135 Foreground(lipgloss.AdaptiveColor{Light: "#CACACA", Dark: "#4F4F4F"})
136- s.SelectionMarker = lipgloss.NewStyle().
137+ s.SelectionMarker = renderer.NewStyle().
138 Foreground(Fuschia).
139 PaddingRight(1).
140 SetString(">")
141- s.Checkmark = lipgloss.NewStyle().
142+ s.Checkmark = renderer.NewStyle().
143 SetString("✔").
144 Foreground(Green)
145- s.SelectedMenuItem = lipgloss.NewStyle().Foreground(Fuschia)
146- s.Logo = lipgloss.NewStyle().
147+ s.SelectedMenuItem = renderer.NewStyle().Foreground(Fuschia)
148+ s.Logo = renderer.NewStyle().
149 Foreground(Cream).
150 Background(lipgloss.Color("#5A56E0")).
151 Padding(0, 1)
152- s.App = lipgloss.NewStyle().Margin(1, 0, 1, 2)
153+ s.BlurredButtonStyle = renderer.NewStyle().
154+ Foreground(Cream).
155+ Background(lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}).
156+ Padding(0, 3)
157+ s.FocusedButtonStyle = s.BlurredButtonStyle.Copy().
158+ Background(Fuschia)
159+ s.HelpDivider = renderer.NewStyle().
160+ Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}).
161+ Padding(0, 1).
162+ SetString("•")
163+ s.HelpSection = renderer.NewStyle().
164+ Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
165+ s.App = renderer.NewStyle().Margin(1, 0, 1, 2)
166+ s.Spinner = renderer.NewStyle().
167+ Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
168+ s.CliPadding = renderer.NewStyle().Padding(0, 1)
169+ s.CliHeader = s.CliPadding.Copy().Foreground(Indigo).Bold(true)
170+ s.CliBorder = renderer.NewStyle().Foreground(lipgloss.Color("238"))
171
172 return s
173 }
+19,
-41
1@@ -28,30 +28,15 @@ var lineColors = map[State]lipgloss.TerminalColor{
2 }
3
4 // VerticalLine return a vertical line colored according to the given state.
5-func VerticalLine(state State) string {
6- return lipgloss.NewStyle().
7+func VerticalLine(renderer *lipgloss.Renderer, state State) string {
8+ return renderer.NewStyle().
9 SetString("│").
10 Foreground(lineColors[state]).
11 String()
12 }
13
14-var valStyle = lipgloss.NewStyle().Foreground(Indigo)
15-
16-var (
17- spinnerStyle = lipgloss.NewStyle().
18- Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
19-
20- blurredButtonStyle = lipgloss.NewStyle().
21- Foreground(Cream).
22- Background(lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}).
23- Padding(0, 3)
24-
25- focusedButtonStyle = blurredButtonStyle.Copy().
26- Background(Fuschia)
27-)
28-
29 // KeyValueView renders key-value pairs.
30-func KeyValueView(stuff ...string) string {
31+func KeyValueView(styles Styles, stuff ...string) string {
32 if len(stuff) == 0 {
33 return ""
34 }
35@@ -63,11 +48,11 @@ func KeyValueView(stuff ...string) string {
36 for i := 0; i < len(stuff); i++ {
37 if i%2 == 0 {
38 // even: key
39- s += fmt.Sprintf("%s %s: ", VerticalLine(StateNormal), stuff[i])
40+ s += fmt.Sprintf("%s %s: ", VerticalLine(styles.Renderer, StateNormal), stuff[i])
41 continue
42 }
43 // odd: value
44- s += valStyle.Render(stuff[i])
45+ s += styles.LabelDim.Render(stuff[i])
46 s += "\n"
47 index++
48 }
49@@ -76,7 +61,10 @@ func KeyValueView(stuff ...string) string {
50 }
51
52 // NewSpinner returns a spinner model.
53-func NewSpinner() spinner.Model {
54+func NewSpinner(styles Styles) spinner.Model {
55+ spinnerStyle := styles.Renderer.NewStyle().
56+ Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
57+
58 s := spinner.New()
59 s.Spinner = spinner.Dot
60 s.Style = spinnerStyle
61@@ -84,21 +72,21 @@ func NewSpinner() spinner.Model {
62 }
63
64 // OKButtonView returns a button reading "OK".
65-func OKButtonView(focused bool, defaultButton bool) string {
66- return styledButton("OK", defaultButton, focused)
67+func OKButtonView(styles Styles, focused bool, defaultButton bool) string {
68+ return styledButton(styles, "OK", defaultButton, focused)
69 }
70
71 // CancelButtonView returns a button reading "Cancel.".
72-func CancelButtonView(focused bool, defaultButton bool) string {
73- return styledButton("Cancel", defaultButton, focused)
74+func CancelButtonView(styles Styles, focused bool, defaultButton bool) string {
75+ return styledButton(styles, "Cancel", defaultButton, focused)
76 }
77
78-func styledButton(str string, underlined, focused bool) string {
79+func styledButton(styles Styles, str string, underlined, focused bool) string {
80 var st lipgloss.Style
81 if focused {
82- st = focusedButtonStyle.Copy()
83+ st = styles.FocusedButtonStyle.Copy()
84 } else {
85- st = blurredButtonStyle.Copy()
86+ st = styles.BlurredButtonStyle.Copy()
87 }
88 if underlined {
89 st = st.Underline(true)
90@@ -106,28 +94,18 @@ func styledButton(str string, underlined, focused bool) string {
91 return st.Render(str)
92 }
93
94-var (
95- helpDivider = lipgloss.NewStyle().
96- Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}).
97- Padding(0, 1).
98- Render("•")
99-
100- helpSection = lipgloss.NewStyle().
101- Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
102-)
103-
104 // HelpView renders text intended to display at help text, often at the
105 // bottom of a view.
106-func HelpView(sections ...string) string {
107+func HelpView(styles Styles, sections ...string) string {
108 var s string
109 if len(sections) == 0 {
110 return s
111 }
112
113 for i := 0; i < len(sections); i++ {
114- s += helpSection.Render(sections[i])
115+ s += styles.HelpSection.Render(sections[i])
116 if i < len(sections)-1 {
117- s += helpDivider
118+ s += styles.HelpDivider.Render()
119 }
120 }
121
+8,
-10
1@@ -87,13 +87,11 @@ func (m *Model) indexBackward() {
2 }
3
4 // NewModel returns a new username model in its initial state.
5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
6- st := common.DefaultStyles()
7-
8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
9 im := input.New()
10- im.Cursor.Style = st.Cursor
11+ im.Cursor.Style = styles.Cursor
12 im.Placeholder = "ssh-ed25519 AAAA..."
13- im.Prompt = st.FocusedPrompt.String()
14+ im.Prompt = styles.FocusedPrompt.String()
15 im.CharLimit = 2049
16 im.Focus()
17
18@@ -102,13 +100,13 @@ func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
19 Quit: false,
20 dbpool: dbpool,
21 user: user,
22- styles: st,
23+ styles: styles,
24 state: ready,
25 newKey: "",
26 index: textInput,
27 errMsg: "",
28 input: im,
29- spinner: common.NewSpinner(),
30+ spinner: common.NewSpinner(styles),
31 }
32 }
33
34@@ -234,8 +232,8 @@ func (m Model) View() string {
35 if m.state == submitting {
36 s += spinnerView(m)
37 } else {
38- s += common.OKButtonView(m.index == 1, true)
39- s += " " + common.CancelButtonView(m.index == 2, false)
40+ s += common.OKButtonView(m.styles, m.index == 1, true)
41+ s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
42 if m.errMsg != "" {
43 s += "\n\n" + m.errMsg
44 }
45@@ -279,7 +277,7 @@ func addPublicKey(m Model) tea.Cmd {
46 }
47
48 key := sanitizeKey(m.newKey)
49- err := m.dbpool.LinkUserKey(m.user.ID, key)
50+ err := m.dbpool.LinkUserKey(m.user.ID, key, nil)
51 if err != nil {
52 if errors.Is(err, db.ErrPublicKeyTaken) {
53 return KeyTakenMsg{}
+8,
-10
1@@ -89,13 +89,11 @@ func (m *Model) indexBackward() {
2 }
3
4 // NewModel returns a new username model in its initial state.
5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
6- st := common.DefaultStyles()
7-
8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
9 im := input.New()
10- im.Cursor.Style = st.Cursor
11+ im.Cursor.Style = styles.Cursor
12 im.Placeholder = "A name used for your reference"
13- im.Prompt = st.FocusedPrompt.String()
14+ im.Prompt = styles.FocusedPrompt.String()
15 im.CharLimit = 256
16 im.Focus()
17
18@@ -104,14 +102,14 @@ func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
19 Quit: false,
20 dbpool: dbpool,
21 user: user,
22- styles: st,
23+ styles: styles,
24 state: ready,
25 tokenName: "",
26 token: "",
27 index: textInput,
28 errMsg: "",
29 input: im,
30- spinner: common.NewSpinner(),
31+ spinner: common.NewSpinner(styles),
32 }
33 }
34
35@@ -232,10 +230,10 @@ func (m Model) View() string {
36 } else if m.state == submitted {
37 s = fmt.Sprintf("Save this token:\n%s\n\n", m.token)
38 s += "After you exit this screen you will *not* be able to see it again.\n\n"
39- s += common.OKButtonView(m.index == 1, true)
40+ s += common.OKButtonView(m.styles, m.index == 1, true)
41 } else {
42- s += common.OKButtonView(m.index == 1, true)
43- s += " " + common.CancelButtonView(m.index == 2, false)
44+ s += common.OKButtonView(m.styles, m.index == 1, true)
45+ s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
46 if m.errMsg != "" {
47 s += "\n\n" + m.errMsg
48 }
+30,
-16
1@@ -18,22 +18,24 @@ func (e errMsg) Error() string {
2
3 // Model stores the state of the info user interface.
4 type Model struct {
5- cfg *config.ConfigCms
6- urls config.ConfigURL
7- Quit bool // signals it's time to exit the whole application
8- Err error
9- User *db.User
10- styles common.Styles
11+ cfg *config.ConfigCms
12+ urls config.ConfigURL
13+ Quit bool // signals it's time to exit the whole application
14+ Err error
15+ User *db.User
16+ PlusFeatureFlag *db.FeatureFlag
17+ styles common.Styles
18 }
19
20 // NewModel returns a new Model in its initial state.
21-func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, user *db.User) Model {
22+func NewModel(styles common.Styles, cfg *config.ConfigCms, urls config.ConfigURL, user *db.User, ff *db.FeatureFlag) Model {
23 return Model{
24- Quit: false,
25- User: user,
26- styles: common.DefaultStyles(),
27- cfg: cfg,
28- urls: urls,
29+ Quit: false,
30+ User: user,
31+ styles: styles,
32+ cfg: cfg,
33+ urls: urls,
34+ PlusFeatureFlag: ff,
35 }
36 }
37
38@@ -76,10 +78,22 @@ func (m Model) bioView() string {
39 username = m.styles.Subtle.Render("(none set)")
40 }
41
42- return common.KeyValueView(
43+ plus := "No"
44+ expires := ""
45+ if m.PlusFeatureFlag != nil {
46+ plus = "Yes"
47+ expires = m.PlusFeatureFlag.ExpiresAt.Format("02 Jan 2006")
48+ }
49+
50+ vals := []string{
51 "Username", username,
52- "URL", m.urls.BlogURL(username),
53- "Public key", m.User.PublicKey.Key,
54 "Joined", m.User.CreatedAt.Format("02 Jan 2006"),
55- )
56+ "Pico+", plus,
57+ }
58+
59+ if expires != "" {
60+ vals = append(vals, "Pico+ Expires At", expires)
61+ }
62+
63+ return common.KeyValueView(m.styles, vals...)
64 }
+7,
-9
1@@ -82,26 +82,24 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
2 }
3
4 // NewModel creates a new model with defaults.
5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
6- st := common.DefaultStyles()
7-
8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
9 p := pager.New()
10 p.PerPage = keysPerPage
11 p.Type = pager.Dots
12- p.InactiveDot = st.InactivePagination.Render("•")
13+ p.InactiveDot = styles.InactivePagination.Render("•")
14
15 return Model{
16 cfg: cfg,
17 dbpool: dbpool,
18 user: user,
19- styles: st,
20+ styles: styles,
21 pager: p,
22 state: stateLoading,
23 err: nil,
24 activeKeyIndex: -1,
25 keys: []*db.PublicKey{},
26 index: 0,
27- spinner: common.NewSpinner(),
28+ spinner: common.NewSpinner(styles),
29 Exit: false,
30 Quit: false,
31 }
32@@ -238,7 +236,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33
34 switch m.state {
35 case stateNormal:
36- m.createKey = createkey.NewModel(m.cfg, m.dbpool, m.user)
37+ m.createKey = createkey.NewModel(m.styles, m.cfg, m.dbpool, m.user)
38 case stateDeletingKey:
39 // If an item is being confirmed for delete, any key (other than the key
40 // used for confirmation above) cancels the deletion
41@@ -271,7 +269,7 @@ func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
42 m.createKey = createKeyModel
43 cmd = newCmd
44 if m.createKey.Done {
45- m.createKey = createkey.NewModel(m.cfg, m.dbpool, m.user) // reset the state
46+ m.createKey = createkey.NewModel(m.styles, m.cfg, m.dbpool, m.user) // reset the state
47 m.state = stateNormal
48 } else if m.createKey.Quit {
49 m.state = stateQuitting
50@@ -368,7 +366,7 @@ func helpView(m Model) string {
51 items = append(items, "h/l, ←/→: page")
52 }
53 items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
54- return common.HelpView(items...)
55+ return common.HelpView(m.styles, items...)
56 }
57
58 func (m Model) promptView(prompt string) string {
+8,
-8
1@@ -9,8 +9,6 @@ import (
2 "golang.org/x/crypto/ssh"
3 )
4
5-var styles = common.DefaultStyles()
6-
7 func algo(keyType string) string {
8 if idx := strings.Index(keyType, "@"); idx > 0 {
9 return algo(keyType[0:idx])
10@@ -29,20 +27,21 @@ type Fingerprint struct {
11 Type string
12 Value string
13 Algorithm string
14+ Styles common.Styles
15 }
16
17 // String outputs a string representation of the fingerprint.
18 func (f Fingerprint) String() string {
19 return fmt.Sprintf(
20 "%s %s",
21- styles.ListDim.Render(strings.ToUpper(f.Algorithm)),
22- styles.ListKey.Render(f.Type+":"+f.Value),
23+ f.Styles.ListDim.Render(strings.ToUpper(f.Algorithm)),
24+ f.Styles.ListKey.Render(f.Type+":"+f.Value),
25 )
26 }
27
28 // FingerprintSHA256 returns the algorithm and SHA256 fingerprint for the given
29 // key.
30-func FingerprintSHA256(k *db.PublicKey) (Fingerprint, error) {
31+func FingerprintSHA256(styles common.Styles, k *db.PublicKey) (Fingerprint, error) {
32 key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key))
33 if err != nil {
34 return Fingerprint{}, fmt.Errorf("failed to parse public key: %w", err)
35@@ -52,6 +51,7 @@ func FingerprintSHA256(k *db.PublicKey) (Fingerprint, error) {
36 Algorithm: algo(key.Type()),
37 Type: "SHA256",
38 Value: strings.TrimPrefix(ssh.FingerprintSHA256(key), "SHA256:"),
39+ Styles: styles,
40 }, nil
41 }
42
43@@ -84,7 +84,7 @@ type styledKey struct {
44
45 func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool) styledKey {
46 date := key.CreatedAt.Format("02 Jan 2006 15:04:05 MST")
47- fp, err := FingerprintSHA256(key)
48+ fp, err := FingerprintSHA256(styles, key)
49 if err != nil {
50 fp = Fingerprint{Value: "[error generating fingerprint]"}
51 }
52@@ -109,14 +109,14 @@ func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool
53
54 // Selected state.
55 func (k *styledKey) selected() {
56- k.gutter = common.VerticalLine(common.StateSelected)
57+ k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
58 k.keyLabel = k.styles.Label.Render("Key:")
59 k.dateLabel = k.styles.Label.Render("Added:")
60 }
61
62 // Deleting state.
63 func (k *styledKey) deleting() {
64- k.gutter = common.VerticalLine(common.StateDeleting)
65+ k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
66 k.keyLabel = k.styles.Delete.Render("Key:")
67 k.dateLabel = k.styles.Delete.Render("Added:")
68 k.dateVal = k.styles.DeleteDim.Render(k.date)
+2,
-2
1@@ -54,7 +54,7 @@ func (m Model) newStyledKey(styles common.Styles, post *db.Post, urls config.Con
2
3 // Selected state.
4 func (k *styledKey) selected() {
5- k.gutter = common.VerticalLine(common.StateSelected)
6+ k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
7 k.postLabel = k.styles.Label.Render("post:")
8 k.dateLabel = k.styles.Label.Render("publish_at:")
9 k.viewsLabel = k.styles.Label.Render("views:")
10@@ -64,7 +64,7 @@ func (k *styledKey) selected() {
11
12 // Deleting state.
13 func (k *styledKey) deleting() {
14- k.gutter = common.VerticalLine(common.StateDeleting)
15+ k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
16 k.postLabel = k.styles.Delete.Render("post:")
17 k.dateLabel = k.styles.Delete.Render("publish_at:")
18 k.urlLabel = k.styles.Delete.Render("url:")
+5,
-6
1@@ -83,14 +83,13 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
2 }
3
4 // NewModel creates a new model with defaults.
5-func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *db.User, stor storage.StorageServe, perPage int) Model {
6+func NewModel(styles common.Styles, cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *db.User, stor storage.StorageServe, perPage int) Model {
7 logger := cfg.Logger
8- st := common.DefaultStyles()
9
10 p := pager.New()
11 p.PerPage = keysPerPage
12 p.Type = pager.Dots
13- p.InactiveDot = st.InactivePagination.Render("•")
14+ p.InactiveDot = styles.InactivePagination.Render("•")
15
16 if perPage > 0 {
17 p.PerPage = perPage
18@@ -101,13 +100,13 @@ func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *
19 dbpool: dbpool,
20 st: stor,
21 user: user,
22- styles: st,
23+ styles: styles,
24 pager: p,
25 state: stateLoading,
26 err: nil,
27 posts: []*db.Post{},
28 index: 0,
29- spinner: common.NewSpinner(),
30+ spinner: common.NewSpinner(styles),
31 Exit: false,
32 Quit: false,
33 logger: logger,
34@@ -298,7 +297,7 @@ func helpView(m Model) string {
35 items = append(items, "x: delete")
36 }
37 items = append(items, "esc: exit")
38- return common.HelpView(items...)
39+ return common.HelpView(m.styles, items...)
40 }
41
42 func (m Model) promptView(prompt string) string {
+7,
-9
1@@ -80,26 +80,24 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
2 }
3
4 // NewModel creates a new model with defaults.
5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
6- st := common.DefaultStyles()
7-
8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
9 p := pager.New()
10 p.PerPage = keysPerPage
11 p.Type = pager.Dots
12- p.InactiveDot = st.InactivePagination.Render("•")
13+ p.InactiveDot = styles.InactivePagination.Render("•")
14
15 return Model{
16 cfg: cfg,
17 dbpool: dbpool,
18 user: user,
19- styles: st,
20+ styles: styles,
21 pager: p,
22 state: stateLoading,
23 err: nil,
24 activeKeyIndex: -1,
25 tokens: []*db.Token{},
26 index: 0,
27- spinner: common.NewSpinner(),
28+ spinner: common.NewSpinner(styles),
29 Exit: false,
30 Quit: false,
31 }
32@@ -209,7 +207,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33
34 switch m.state {
35 case stateNormal:
36- m.createKey = createtoken.NewModel(m.cfg, m.dbpool, m.user)
37+ m.createKey = createtoken.NewModel(m.styles, m.cfg, m.dbpool, m.user)
38 case stateDeletingKey:
39 // If an item is being confirmed for delete, any key (other than the key
40 // used for confirmation above) cancels the deletion
41@@ -242,7 +240,7 @@ func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
42 m.createKey = createKeyModel
43 cmd = newCmd
44 if m.createKey.Done {
45- m.createKey = createtoken.NewModel(m.cfg, m.dbpool, m.user) // reset the state
46+ m.createKey = createtoken.NewModel(m.styles, m.cfg, m.dbpool, m.user) // reset the state
47 m.state = stateNormal
48 } else if m.createKey.Quit {
49 m.state = stateQuitting
50@@ -333,7 +331,7 @@ func helpView(m Model) string {
51 items = append(items, "h/l, ←/→: page")
52 }
53 items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
54- return common.HelpView(items...)
55+ return common.HelpView(m.styles, items...)
56 }
57
58 func (m Model) promptView(prompt string) string {
+2,
-2
1@@ -39,7 +39,7 @@ func (m Model) newStyledKey(styles common.Styles, token *db.Token, active bool)
2
3 // Selected state.
4 func (k *styledKey) selected() {
5- k.gutter = common.VerticalLine(common.StateSelected)
6+ k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
7 k.nameLabel = k.styles.Label.Render("Name:")
8 k.dateLabel = k.styles.Label.Render("Added:")
9 k.expiresLabel = k.styles.Label.Render("Expires:")
10@@ -47,7 +47,7 @@ func (k *styledKey) selected() {
11
12 // Deleting state.
13 func (k *styledKey) deleting() {
14- k.gutter = common.VerticalLine(common.StateDeleting)
15+ k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
16 k.nameLabel = k.styles.Delete.Render("Name:")
17 k.dateLabel = k.styles.Delete.Render("Added:")
18 k.dateVal = k.styles.DeleteDim.Render(k.date)
+0,
-284
1@@ -1,284 +0,0 @@
2-package username
3-
4-import (
5- "errors"
6- "fmt"
7- "strings"
8-
9- "github.com/charmbracelet/bubbles/spinner"
10- input "github.com/charmbracelet/bubbles/textinput"
11- tea "github.com/charmbracelet/bubbletea"
12- "github.com/picosh/pico/db"
13- "github.com/picosh/pico/wish/cms/ui/common"
14-)
15-
16-type state int
17-
18-const (
19- ready state = iota
20- submitting
21-)
22-
23-// index specifies the UI element that's in focus.
24-type index int
25-
26-const (
27- textInput index = iota
28- okButton
29- cancelButton
30-)
31-
32-// NameSetMsg is sent when a new name has been set successfully. It contains
33-// the new name.
34-type NameSetMsg string
35-
36-// NameTakenMsg is sent when the requested username has already been taken.
37-type NameTakenMsg struct{}
38-
39-// NameInvalidMsg is sent when the requested username has failed validation.
40-type NameInvalidMsg struct{}
41-
42-type errMsg struct{ err error }
43-
44-func (e errMsg) Error() string { return e.err.Error() }
45-
46-// Model holds the state of the username UI.
47-type Model struct {
48- Done bool // true when it's time to exit this view
49- Quit bool // true when the user wants to quit the whole program
50-
51- dbpool db.DB
52- user *db.User
53- styles common.Styles
54- state state
55- newName string
56- index index
57- errMsg string
58- input input.Model
59- spinner spinner.Model
60-}
61-
62-// updateFocus updates the focused states in the model based on the current
63-// focus index.
64-func (m *Model) updateFocus() {
65- if m.index == textInput && !m.input.Focused() {
66- m.input.Focus()
67- m.input.Prompt = m.styles.FocusedPrompt.String()
68- } else if m.index != textInput && m.input.Focused() {
69- m.input.Blur()
70- m.input.Prompt = m.styles.Prompt.String()
71- }
72-}
73-
74-// Move the focus index one unit forward.
75-func (m *Model) indexForward() {
76- m.index++
77- if m.index > cancelButton {
78- m.index = textInput
79- }
80-
81- m.updateFocus()
82-}
83-
84-// Move the focus index one unit backwards.
85-func (m *Model) indexBackward() {
86- m.index--
87- if m.index < textInput {
88- m.index = cancelButton
89- }
90-
91- m.updateFocus()
92-}
93-
94-// NewModel returns a new username model in its initial state.
95-func NewModel(dbpool db.DB, user *db.User, sshUser string) Model {
96- st := common.DefaultStyles()
97-
98- im := input.New()
99- im.Cursor.Style = st.Cursor
100- im.Placeholder = sshUser
101- im.Prompt = st.FocusedPrompt.String()
102- im.CharLimit = 50
103- im.Focus()
104-
105- return Model{
106- Done: false,
107- Quit: false,
108- dbpool: dbpool,
109- user: user,
110- styles: st,
111- state: ready,
112- newName: "",
113- index: textInput,
114- errMsg: "",
115- input: im,
116- spinner: common.NewSpinner(),
117- }
118-}
119-
120-// Init is the Bubble Tea initialization function.
121-func Init(dbpool db.DB, user *db.User, sshUser string) func() (Model, tea.Cmd) {
122- return func() (Model, tea.Cmd) {
123- m := NewModel(dbpool, user, sshUser)
124- return m, InitialCmd()
125- }
126-}
127-
128-// InitialCmd returns the initial command.
129-func InitialCmd() tea.Cmd {
130- return input.Blink
131-}
132-
133-// Update is the Bubble Tea update loop.
134-func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
135- switch msg := msg.(type) {
136- case tea.KeyMsg:
137- switch msg.Type {
138- case tea.KeyCtrlC: // quit
139- m.Quit = true
140- return m, nil
141- case tea.KeyEscape: // exit this mini-app
142- m.Done = true
143- return m, nil
144-
145- default:
146- // Ignore keys if we're submitting
147- if m.state == submitting {
148- return m, nil
149- }
150-
151- switch msg.String() {
152- case "tab":
153- m.indexForward()
154- case "shift+tab":
155- m.indexBackward()
156- case "l", "k", "right":
157- if m.index != textInput {
158- m.indexForward()
159- }
160- case "h", "j", "left":
161- if m.index != textInput {
162- m.indexBackward()
163- }
164- case "up", "down":
165- if m.index == textInput {
166- m.indexForward()
167- } else {
168- m.index = textInput
169- m.updateFocus()
170- }
171- case "enter":
172- switch m.index {
173- case textInput:
174- fallthrough
175- case okButton: // Submit the form
176- m.state = submitting
177- m.errMsg = ""
178- m.newName = strings.TrimSpace(m.input.Value())
179-
180- return m, tea.Batch(
181- setName(m), // fire off the command, too
182- m.spinner.Tick,
183- )
184- case cancelButton: // Exit this mini-app
185- m.Done = true
186- return m, nil
187- }
188- }
189-
190- // Pass messages through to the input element if that's the element
191- // in focus
192- if m.index == textInput {
193- var cmd tea.Cmd
194- m.input, cmd = m.input.Update(msg)
195-
196- return m, cmd
197- }
198-
199- return m, nil
200- }
201-
202- case NameTakenMsg:
203- m.state = ready
204- m.errMsg = m.styles.Subtle.Render("Sorry, ") +
205- m.styles.Error.Render(m.newName) +
206- m.styles.Subtle.Render(" is taken.")
207-
208- return m, nil
209-
210- case NameInvalidMsg:
211- m.state = ready
212- head := m.styles.Error.Render("Invalid name. ")
213- deny := strings.Join(db.DenyList, ", ")
214- helpMsg := fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
215- body := m.styles.Subtle.Render(helpMsg)
216- m.errMsg = m.styles.Wrap.Render(head + body)
217-
218- return m, nil
219-
220- case errMsg:
221- m.state = ready
222- head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
223- body := m.styles.Subtle.Render(msg.Error())
224- m.errMsg = m.styles.Wrap.Render(head + body)
225-
226- return m, nil
227-
228- case spinner.TickMsg:
229- var cmd tea.Cmd
230- m.spinner, cmd = m.spinner.Update(msg)
231-
232- return m, cmd
233-
234- default:
235- var cmd tea.Cmd
236- m.input, cmd = m.input.Update(msg) // Do we still need this?
237-
238- return m, cmd
239- }
240-}
241-
242-// View renders current view from the model.
243-func View(m Model) string {
244- s := "Enter a new username\n\n"
245- s += m.input.View() + "\n\n"
246-
247- if m.state == submitting {
248- s += spinnerView(m)
249- } else {
250- s += common.OKButtonView(m.index == 1, true)
251- s += " " + common.CancelButtonView(m.index == 2, false)
252- if m.errMsg != "" {
253- s += "\n\n" + m.errMsg
254- }
255- }
256-
257- return s
258-}
259-
260-func spinnerView(m Model) string {
261- return m.spinner.View() + " Submitting..."
262-}
263-
264-// Attempt to update the username on the server.
265-func setName(m Model) tea.Cmd {
266- return func() tea.Msg {
267- valid, err := m.dbpool.ValidateName(m.newName)
268- // Validate before resetting the session to potentially save some
269- // network traffic and keep things feeling speedy.
270- if !valid {
271- if errors.Is(err, db.ErrNameTaken) {
272- return NameTakenMsg{}
273- } else {
274- return NameInvalidMsg{}
275- }
276- }
277-
278- err = m.dbpool.SetUserName(m.user.ID, m.newName)
279- if err != nil {
280- return errMsg{err}
281- }
282-
283- return NameSetMsg(m.newName)
284- }
285-}