- commit
- 50895e2
- parent
- 1941f3e
- author
- Eric Bower
- date
- 2024-05-16 19:35:12 +0000 UTC
refactor(tui): reorganize into pages
M
go.mod
+1,
-2
1@@ -20,13 +20,11 @@ require (
2 github.com/charmbracelet/wish v1.4.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.70
9 github.com/mmcdole/gofeed v1.3.0
10 github.com/muesli/reflow v0.3.0
11- github.com/muesli/termenv v0.15.2
12 github.com/neurosnap/go-exif-remove v0.0.0-20221010134343-50d1e3c35577
13 github.com/picosh/pobj v0.0.0-20240417140600-2071618b61c5
14 github.com/picosh/ptun v0.0.0-20240417140706-811cc2b70d9a
15@@ -108,6 +106,7 @@ require (
16 github.com/modern-go/reflect2 v1.0.2 // indirect
17 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
18 github.com/muesli/cancelreader v0.2.2 // indirect
19+ github.com/muesli/termenv v0.15.2 // indirect
20 github.com/neurosnap/go-jpeg-image-structure v0.0.0-20221010133817-70b1c1ff679e // indirect
21 github.com/olekukonko/tablewriter v0.0.5 // indirect
22 github.com/philhofer/fwd v1.1.2 // indirect
M
go.sum
+0,
-3
1@@ -55,7 +55,6 @@ github.com/charmbracelet/x/errors v0.0.0-20240507171223-71e9351b56e7 h1:kYsUiL7Z
2 github.com/charmbracelet/x/errors v0.0.0-20240507171223-71e9351b56e7/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
3 github.com/charmbracelet/x/exp/term v0.0.0-20240507171223-71e9351b56e7 h1:ATeHxDzJnkCWPCNhTPZUMxyD7AE94ATJDKHN3wZNRUY=
4 github.com/charmbracelet/x/exp/term v0.0.0-20240507171223-71e9351b56e7/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c=
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@@ -156,8 +155,6 @@ github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
10 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
11 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
12 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
13-github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
14-github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
15 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
16 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
17 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+0,
-277
1@@ -1,277 +0,0 @@
2-package account
3-
4-import (
5- "errors"
6- "fmt"
7- "strings"
8-
9- input "github.com/charmbracelet/bubbles/textinput"
10- tea "github.com/charmbracelet/bubbletea"
11- "github.com/charmbracelet/ssh"
12- "github.com/picosh/pico/db"
13- "github.com/picosh/pico/shared"
14- "github.com/picosh/pico/tui/common"
15-)
16-
17-type state int
18-
19-const (
20- ready state = iota
21- submitting
22-)
23-
24-// index specifies the UI element that's in focus.
25-type index int
26-
27-const (
28- textInput index = iota
29- okButton
30- cancelButton
31-)
32-
33-type CreateAccountMsg *db.User
34-
35-// NameTakenMsg is sent when the requested username has already been taken.
36-type NameTakenMsg struct{}
37-
38-// NameInvalidMsg is sent when the requested username has failed validation.
39-type NameInvalidMsg struct{}
40-
41-type errMsg struct{ err error }
42-
43-func (e errMsg) Error() string { return e.err.Error() }
44-
45-var deny = strings.Join(db.DenyList, ", ")
46-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)
47-
48-// Model holds the state of the username UI.
49-type CreateModel struct {
50- Done bool // true when it's time to exit this view
51- Quit bool // true when the user wants to quit the whole program
52-
53- dbpool db.DB
54- publicKey ssh.PublicKey
55- styles common.Styles
56- state state
57- newName string
58- index index
59- errMsg string
60- input input.Model
61-}
62-
63-// updateFocus updates the focused states in the model based on the current
64-// focus index.
65-func (m *CreateModel) updateFocus() {
66- if m.index == textInput && !m.input.Focused() {
67- m.input.Focus()
68- m.input.Prompt = m.styles.FocusedPrompt.String()
69- } else if m.index != textInput && m.input.Focused() {
70- m.input.Blur()
71- m.input.Prompt = m.styles.Prompt.String()
72- }
73-}
74-
75-// Move the focus index one unit forward.
76-func (m *CreateModel) indexForward() {
77- m.index++
78- if m.index > cancelButton {
79- m.index = textInput
80- }
81-
82- m.updateFocus()
83-}
84-
85-// Move the focus index one unit backwards.
86-func (m *CreateModel) indexBackward() {
87- m.index--
88- if m.index < textInput {
89- m.index = cancelButton
90- }
91-
92- m.updateFocus()
93-}
94-
95-// NewModel returns a new username model in its initial state.
96-func NewCreateModel(styles common.Styles, dbpool db.DB, publicKey ssh.PublicKey) CreateModel {
97- im := input.New()
98- im.Cursor.Style = styles.Cursor
99- im.Placeholder = "enter username"
100- im.Prompt = styles.FocusedPrompt.String()
101- im.CharLimit = 50
102- im.Focus()
103-
104- return CreateModel{
105- Done: false,
106- Quit: false,
107- dbpool: dbpool,
108- styles: styles,
109- state: ready,
110- newName: "",
111- index: textInput,
112- errMsg: "",
113- input: im,
114- publicKey: publicKey,
115- }
116-}
117-
118-// Init is the Bubble Tea initialization function.
119-func Init(styles common.Styles, dbpool db.DB, publicKey ssh.PublicKey) func() (CreateModel, tea.Cmd) {
120- return func() (CreateModel, tea.Cmd) {
121- m := NewCreateModel(styles, dbpool, publicKey)
122- return m, InitialCmd()
123- }
124-}
125-
126-// InitialCmd returns the initial command.
127-func InitialCmd() tea.Cmd {
128- return input.Blink
129-}
130-
131-// Update is the Bubble Tea update loop.
132-func Update(msg tea.Msg, m CreateModel) (CreateModel, tea.Cmd) {
133- switch msg := msg.(type) {
134- case tea.KeyMsg:
135- switch msg.Type {
136- case tea.KeyCtrlC, tea.KeyEscape:
137- m.Quit = true
138- return m, nil
139-
140- default:
141- // Ignore keys if we're submitting
142- if m.state == submitting {
143- return m, nil
144- }
145-
146- switch msg.String() {
147- case "tab":
148- m.indexForward()
149- case "shift+tab":
150- m.indexBackward()
151- case "l", "k", "right":
152- if m.index != textInput {
153- m.indexForward()
154- }
155- case "h", "j", "left":
156- if m.index != textInput {
157- m.indexBackward()
158- }
159- case "up", "down":
160- if m.index == textInput {
161- m.indexForward()
162- } else {
163- m.index = textInput
164- m.updateFocus()
165- }
166- case "enter":
167- switch m.index {
168- case textInput:
169- fallthrough
170- case okButton: // Submit the form
171- m.state = submitting
172- m.errMsg = ""
173- m.newName = strings.TrimSpace(m.input.Value())
174-
175- return m, createAccount(m)
176- case cancelButton: // Exit
177- m.Quit = true
178- return m, nil
179- }
180- }
181-
182- // Pass messages through to the input element if that's the element
183- // in focus
184- if m.index == textInput {
185- var cmd tea.Cmd
186- m.input, cmd = m.input.Update(msg)
187-
188- return m, cmd
189- }
190-
191- return m, nil
192- }
193-
194- case NameTakenMsg:
195- m.state = ready
196- m.errMsg = m.styles.Subtle.Render("Sorry, ") +
197- m.styles.Error.Render(m.newName) +
198- m.styles.Subtle.Render(" is taken.")
199-
200- return m, nil
201-
202- case NameInvalidMsg:
203- m.state = ready
204- head := m.styles.Error.Render("Invalid name. ")
205- body := m.styles.Subtle.Render(helpMsg)
206- m.errMsg = m.styles.Wrap.Render(head + body)
207-
208- return m, nil
209-
210- case errMsg:
211- m.state = ready
212- head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
213- body := m.styles.Subtle.Render(msg.Error())
214- m.errMsg = m.styles.Wrap.Render(head + body)
215-
216- return m, nil
217-
218- default:
219- var cmd tea.Cmd
220- m.input, cmd = m.input.Update(msg) // Do we still need this?
221-
222- return m, cmd
223- }
224-}
225-
226-// View renders current view from the model.
227-func View(m CreateModel) string {
228- intro := "To create an account, enter a username.\n\n"
229- intro += "After that, go to https://pico.sh/getting-started#next-steps"
230- s := fmt.Sprintf("%s\n\n%s\n\n", "hacker labs", intro)
231- s += fmt.Sprintf("Public Key: %s\n\n", shared.KeyForSha256(m.publicKey))
232- s += m.input.View() + "\n\n"
233-
234- if m.state == submitting {
235- s += spinnerView(m)
236- } else {
237- s += common.OKButtonView(m.styles, m.index == 1, true)
238- s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
239- if m.errMsg != "" {
240- s += "\n\n" + m.errMsg
241- }
242- }
243- s += fmt.Sprintf("\n\n%s\n", helpMsg)
244-
245- return s
246-}
247-
248-func spinnerView(m CreateModel) string {
249- return "Creating account..."
250-}
251-
252-func createAccount(m CreateModel) tea.Cmd {
253- return func() tea.Msg {
254- if m.newName == "" {
255- return NameInvalidMsg{}
256- }
257-
258- key, err := shared.KeyForKeyText(m.publicKey)
259- if err != nil {
260- return errMsg{err}
261- }
262-
263- user, err := m.dbpool.RegisterUser(m.newName, key, "")
264- if err != nil {
265- if errors.Is(err, db.ErrNameTaken) {
266- return NameTakenMsg{}
267- } else if errors.Is(err, db.ErrNameInvalid) {
268- return NameInvalidMsg{}
269- } else if errors.Is(err, db.ErrNameDenied) {
270- return NameInvalidMsg{}
271- } else {
272- return errMsg{err}
273- }
274- }
275-
276- return CreateAccountMsg(user)
277- }
278-}
+0,
-460
1@@ -1,460 +0,0 @@
2-package tui
3-
4-import (
5- "errors"
6- "io"
7-
8- tea "github.com/charmbracelet/bubbletea"
9- "github.com/charmbracelet/ssh"
10- "github.com/charmbracelet/wish"
11- bm "github.com/charmbracelet/wish/bubbletea"
12- "github.com/muesli/reflow/indent"
13- "github.com/muesli/reflow/wordwrap"
14- "github.com/muesli/reflow/wrap"
15- "github.com/picosh/pico/db"
16- "github.com/picosh/pico/db/postgres"
17- "github.com/picosh/pico/shared"
18- "github.com/picosh/pico/tui/account"
19- "github.com/picosh/pico/tui/common"
20- "github.com/picosh/pico/tui/info"
21- "github.com/picosh/pico/tui/keys"
22- "github.com/picosh/pico/tui/notifications"
23- "github.com/picosh/pico/tui/plus"
24- "github.com/picosh/pico/tui/tokens"
25-)
26-
27-type status int
28-
29-const (
30- statusInit status = iota
31- statusReady
32- statusNoAccount
33- statusBrowsingKeys
34- statusBrowsingTokens
35- statusBrowsingNotifications
36- statusBrowsingPlus
37- statusChat
38- statusQuitting
39-)
40-
41-func (s status) String() string {
42- return [...]string{
43- "initializing",
44- "ready",
45- "browsing keys",
46- "quitting",
47- "error",
48- }[s]
49-}
50-
51-// menuChoice represents a chosen menu item.
52-type menuChoice int
53-
54-// menu choices.
55-const (
56- keysChoice menuChoice = iota
57- tokensChoice
58- notificationsChoice
59- plusChoice
60- chatChoice
61- exitChoice
62- unsetChoice // set when no choice has been made
63-)
64-
65-// menu text corresponding to menu choices. these are presented to the user.
66-var menuChoices = map[menuChoice]string{
67- keysChoice: "Manage keys",
68- tokensChoice: "Manage tokens",
69- notificationsChoice: "Notifications",
70- plusChoice: "Pico+",
71- chatChoice: "Chat",
72- exitChoice: "Exit",
73-}
74-
75-type GotDBMsg db.DB
76-
77-func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
78- return func(sesh ssh.Session) (tea.Model, []tea.ProgramOption) {
79- logger := cfg.Logger
80-
81- _, _, active := sesh.Pty()
82- if !active {
83- logger.Info("no active terminal, skipping")
84- return nil, nil
85- }
86-
87- sshUser := sesh.User()
88- dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
89- renderer := bm.MakeRenderer(sesh)
90- styles := common.DefaultStyles(renderer)
91-
92- m := model{
93- session: sesh,
94- cfg: cfg,
95- publicKey: sesh.PublicKey(),
96- dbpool: dbpool,
97- sshUser: sshUser,
98- status: statusInit,
99- menuChoice: unsetChoice,
100- styles: styles,
101- terminalSize: tea.WindowSizeMsg{
102- Width: 80,
103- Height: 24,
104- },
105- }
106-
107- user, err := m.findUser()
108- if err != nil {
109- wish.Errorln(sesh, err)
110- return nil, nil
111- }
112- m.user = user
113-
114- ff, _ := m.findPlusFeatureFlag()
115- m.plusFeatureFlag = ff
116-
117- opts := bm.MakeOptions(sesh)
118- opts = append(opts, tea.WithAltScreen())
119- return m, opts
120- }
121-}
122-
123-// Just a generic tea.Model to demo terminal information of ssh.
124-type model struct {
125- cfg *shared.ConfigSite
126- publicKey ssh.PublicKey
127- dbpool db.DB
128- user *db.User
129- plusFeatureFlag *db.FeatureFlag
130- err error
131- sshUser string
132- status status
133- menuIndex int
134- menuChoice menuChoice
135- styles common.Styles
136- info info.Model
137- keys keys.Model
138- tokens tokens.Model
139- plus plus.Model
140- notifications notifications.Model
141- createAccount account.CreateModel
142- terminalSize tea.WindowSizeMsg
143- session ssh.Session
144-}
145-
146-func (m model) Init() tea.Cmd {
147- return nil
148-}
149-
150-func (m model) findUser() (*db.User, error) {
151- logger := m.cfg.Logger
152- var user *db.User
153-
154- if m.sshUser == "new" {
155- logger.Info("user requesting to register account")
156- return nil, nil
157- }
158-
159- key, err := shared.KeyForKeyText(m.publicKey)
160- if err != nil {
161- return nil, err
162- }
163-
164- user, err = m.dbpool.FindUserForKey(m.sshUser, key)
165- if err != nil {
166- logger.Error("no user found for public key", "err", err.Error())
167- // we only want to throw an error for specific cases
168- if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
169- return nil, err
170- }
171- // no user and not error indicates we need to create an account
172- return nil, nil
173- }
174-
175- return user, nil
176-}
177-
178-func (m model) findPlusFeatureFlag() (*db.FeatureFlag, error) {
179- if m.user == nil {
180- return nil, nil
181- }
182-
183- ff, err := m.dbpool.FindFeatureForUser(m.user.ID, "plus")
184- if err != nil {
185- return nil, err
186- }
187-
188- return ff, nil
189-}
190-
191-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192- var (
193- cmds []tea.Cmd
194- )
195-
196- switch msg := msg.(type) {
197- case tea.WindowSizeMsg:
198- m.terminalSize = msg
199- var cmd tea.Cmd
200- m.plus, cmd = plus.Update(msg, m.plus, m.terminalSize)
201- cmds = append(cmds, cmd)
202- m.notifications, cmd = notifications.Update(msg, m.notifications, m.terminalSize)
203- cmds = append(cmds, cmd)
204- case tea.KeyMsg:
205- switch msg.Type {
206- case tea.KeyCtrlC:
207- m.dbpool.Close()
208- return m, tea.Quit
209- }
210-
211- if m.status == statusReady { // Process keys for the menu
212- switch msg.String() {
213- // Quit
214- case "q", "esc":
215- m.status = statusQuitting
216- m.dbpool.Close()
217- return m, tea.Quit
218-
219- // Prev menu item
220- case "up", "k":
221- m.menuIndex--
222- if m.menuIndex < 0 {
223- m.menuIndex = len(menuChoices) - 1
224- }
225-
226- // Select menu item
227- case "enter":
228- m.menuChoice = menuChoice(m.menuIndex)
229-
230- // Next menu item
231- case "down", "j":
232- m.menuIndex++
233- if m.menuIndex >= len(menuChoices) {
234- m.menuIndex = 0
235- }
236- }
237- }
238- case account.CreateAccountMsg:
239- m.status = statusReady
240- m.info.User = msg
241- m.user = msg
242- m.info = info.NewModel(m.styles, m.user, m.plusFeatureFlag)
243- m.keys = keys.NewModel(m.styles, m.cfg.Logger, m.dbpool, m.user)
244- m.tokens = tokens.NewModel(m.styles, m.dbpool, m.user)
245- m.createAccount = account.NewCreateModel(m.styles, m.dbpool, m.publicKey)
246- m.plus = plus.NewModel(m.styles, m.user, m.session, m.terminalSize)
247- m.notifications = notifications.NewModel(m.styles, m.dbpool, m.user, m.terminalSize)
248- }
249-
250- switch m.status {
251- case statusInit:
252- m.info = info.NewModel(m.styles, m.user, m.plusFeatureFlag)
253- m.keys = keys.NewModel(m.styles, m.cfg.Logger, m.dbpool, m.user)
254- m.tokens = tokens.NewModel(m.styles, m.dbpool, m.user)
255- m.createAccount = account.NewCreateModel(m.styles, m.dbpool, m.publicKey)
256- m.plus = plus.NewModel(m.styles, m.user, m.session, m.terminalSize)
257- m.notifications = notifications.NewModel(m.styles, m.dbpool, m.user, m.terminalSize)
258- // no user found? go to create account screen
259- if m.user == nil {
260- m.status = statusNoAccount
261- } else {
262- m.status = statusReady
263- }
264- }
265-
266- var cmd tea.Cmd
267- m, cmd = updateChildren(msg, m)
268- if cmd != nil {
269- cmds = append(cmds, cmd)
270- }
271-
272- return m, tea.Batch(cmds...)
273-}
274-
275-func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
276- var cmd tea.Cmd
277-
278- switch m.status {
279- case statusBrowsingKeys:
280- newModel, newCmd := m.keys.Update(msg)
281- keysModel, ok := newModel.(keys.Model)
282- if !ok {
283- panic("could not perform assertion on keys model")
284- }
285- m.keys = keysModel
286- cmd = newCmd
287-
288- if m.keys.Exit {
289- m.keys = keys.NewModel(m.styles, m.cfg.Logger, m.dbpool, m.user)
290- m.status = statusReady
291- } else if m.keys.Quit {
292- m.status = statusQuitting
293- return m, tea.Quit
294- }
295- case statusBrowsingTokens:
296- newModel, newCmd := m.tokens.Update(msg)
297- tokensModel, ok := newModel.(tokens.Model)
298- if !ok {
299- panic("could not perform assertion on posts model")
300- }
301- m.tokens = tokensModel
302- cmd = newCmd
303-
304- if m.tokens.Exit {
305- m.tokens = tokens.NewModel(m.styles, m.dbpool, m.user)
306- m.status = statusReady
307- } else if m.tokens.Quit {
308- m.status = statusQuitting
309- return m, tea.Quit
310- }
311- case statusBrowsingPlus:
312- m.plus, cmd = plus.Update(msg, m.plus, m.terminalSize)
313- if m.plus.Done {
314- m.plus = plus.NewModel(m.styles, m.user, m.session, m.terminalSize)
315- m.status = statusReady
316- } else if m.plus.Quit {
317- m.status = statusQuitting
318- return m, tea.Quit
319- }
320- case statusBrowsingNotifications:
321- m.notifications, cmd = notifications.Update(msg, m.notifications, m.terminalSize)
322- if m.notifications.Done {
323- m.notifications = notifications.NewModel(m.styles, m.dbpool, m.user, m.terminalSize)
324- m.status = statusReady
325- } else if m.notifications.Quit {
326- m.status = statusQuitting
327- return m, tea.Quit
328- }
329- case statusNoAccount:
330- m.createAccount, cmd = account.Update(msg, m.createAccount)
331- if m.createAccount.Done {
332- m.createAccount = account.NewCreateModel(m.styles, m.dbpool, m.publicKey) // reset the state
333- m.status = statusReady
334- } else if m.createAccount.Quit {
335- m.status = statusQuitting
336- return m, tea.Quit
337- }
338- }
339-
340- // Handle the menu
341- switch m.menuChoice {
342- case keysChoice:
343- m.status = statusBrowsingKeys
344- m.menuChoice = unsetChoice
345- cmd = keys.LoadKeys(m.keys)
346- case tokensChoice:
347- m.status = statusBrowsingTokens
348- m.menuChoice = unsetChoice
349- cmd = tokens.LoadKeys(m.tokens)
350- case plusChoice:
351- m.status = statusBrowsingPlus
352- m.menuChoice = unsetChoice
353- case notificationsChoice:
354- m.status = statusBrowsingNotifications
355- m.menuChoice = unsetChoice
356- case chatChoice:
357- m.status = statusChat
358- m.menuChoice = unsetChoice
359- cmd = m.loadChat()
360- case exitChoice:
361- m.status = statusQuitting
362- m.dbpool.Close()
363- cmd = tea.Quit
364- }
365-
366- return m, cmd
367-}
368-
369-type SenpaiCmd struct {
370- user *db.User
371- session ssh.Session
372- dbpool db.DB
373-}
374-
375-func (m *SenpaiCmd) Run() error {
376- pass, err := m.dbpool.UpsertToken(m.user.ID, "pico-chat")
377- if err != nil {
378- return err
379- }
380- app, err := shared.NewSenpaiApp(m.session, m.user.Name, pass)
381- if err != nil {
382- return err
383- }
384- app.Run()
385- app.Close()
386- return nil
387-}
388-
389-func (m *SenpaiCmd) SetStdin(io.Reader) {}
390-func (m *SenpaiCmd) SetStdout(io.Writer) {}
391-func (m *SenpaiCmd) SetStderr(io.Writer) {}
392-
393-func (m model) loadChat() tea.Cmd {
394- sp := &SenpaiCmd{
395- session: m.session,
396- dbpool: m.dbpool,
397- user: m.user,
398- }
399- return tea.Exec(sp, func(err error) tea.Msg {
400- return tea.Quit()
401- })
402-}
403-
404-func (m model) menuView() string {
405- var s string
406- for i := 0; i < len(menuChoices); i++ {
407- e := " "
408- menuItem := menuChoices[menuChoice(i)]
409- if i == m.menuIndex {
410- e = m.styles.SelectionMarker.String() +
411- m.styles.SelectedMenuItem.Render(menuItem)
412- } else {
413- e += menuItem
414- }
415- if i < len(menuChoices)-1 {
416- e += "\n"
417- }
418- s += e
419- }
420-
421- return s
422-}
423-
424-func footerView(m model) string {
425- if m.err != nil {
426- return m.errorView(m.err)
427- }
428- return "\n\n" + common.HelpView(m.styles, "j/k, ↑/↓: choose", "enter: select")
429-}
430-
431-func (m model) errorView(err error) string {
432- head := m.styles.Error.Render("Error: ")
433- body := m.styles.Subtle.Render(err.Error())
434- msg := m.styles.Wrap.Render(head + body)
435- return "\n\n" + indent.String(msg, 2)
436-}
437-
438-func (m model) View() string {
439- w := m.terminalSize.Width - m.styles.App.GetHorizontalFrameSize()
440- s := m.styles.Logo.SetString("pico.sh").String() + "\n\n"
441- switch m.status {
442- case statusNoAccount:
443- s += account.View(m.createAccount)
444- case statusReady:
445- s += m.info.View()
446- s += "\n\n" + m.menuView()
447- s += footerView(m)
448- case statusBrowsingKeys:
449- s += m.keys.View()
450- case statusBrowsingTokens:
451- s += m.tokens.View()
452- case statusBrowsingPlus:
453- s = plus.View(m.plus)
454- return s
455- case statusBrowsingNotifications:
456- s = notifications.View(m.notifications)
457- return s
458- }
459- str := wrap.String(wordwrap.String(s, w), w)
460- return m.styles.App.Render(str)
461-}
+21,
-0
1@@ -0,0 +1,21 @@
2+package common
3+
4+import (
5+ "log/slog"
6+
7+ "github.com/charmbracelet/ssh"
8+ "github.com/picosh/pico/db"
9+ "github.com/picosh/pico/shared"
10+)
11+
12+type SharedModel struct {
13+ Logger *slog.Logger
14+ Session ssh.Session
15+ Cfg *shared.ConfigSite
16+ Dbpool db.DB
17+ User *db.User
18+ PlusFeatureFlag *db.FeatureFlag
19+ Width int
20+ Height int
21+ Styles Styles
22+}
+1,
-1
1@@ -58,7 +58,7 @@ func DefaultStyles(renderer *lipgloss.Renderer) Styles {
2 s.Subtle = renderer.NewStyle().
3 Foreground(Grey)
4 s.Error = renderer.NewStyle().Foreground(Red)
5- s.Prompt = renderer.NewStyle().MarginRight(1).SetString("•")
6+ s.Prompt = renderer.NewStyle().MarginRight(1).SetString(">")
7 s.FocusedPrompt = s.Prompt.Copy().Foreground(Fuschia)
8 s.Note = renderer.NewStyle().Foreground(Green)
9 s.Delete = s.Error.Copy()
+251,
-0
1@@ -0,0 +1,251 @@
2+package createaccount
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "strings"
8+
9+ input "github.com/charmbracelet/bubbles/textinput"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/picosh/pico/db"
12+ "github.com/picosh/pico/shared"
13+ "github.com/picosh/pico/tui/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+type CreateAccountMsg *db.User
33+
34+// NameTakenMsg is sent when the requested username has already been taken.
35+type NameTakenMsg struct{}
36+
37+// NameInvalidMsg is sent when the requested username has failed validation.
38+type NameInvalidMsg struct{}
39+
40+type errMsg struct{ err error }
41+
42+func (e errMsg) Error() string { return e.err.Error() }
43+
44+var deny = strings.Join(db.DenyList, ", ")
45+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)
46+
47+// Model holds the state of the username UI.
48+type Model struct {
49+ shared common.SharedModel
50+
51+ state state
52+ newName string
53+ index index
54+ errMsg string
55+ input input.Model
56+}
57+
58+// NewModel returns a new username model in its initial state.
59+func NewModel(shared common.SharedModel) Model {
60+ im := input.New()
61+ im.Cursor.Style = shared.Styles.Cursor
62+ im.Placeholder = "enter username"
63+ im.Prompt = shared.Styles.FocusedPrompt.String()
64+ im.CharLimit = 50
65+ im.Focus()
66+
67+ return Model{
68+ shared: shared,
69+ state: ready,
70+ newName: "",
71+ index: textInput,
72+ errMsg: "",
73+ input: im,
74+ }
75+}
76+
77+// updateFocus updates the focused states in the model based on the current
78+// focus index.
79+func (m *Model) updateFocus() {
80+ if m.index == textInput && !m.input.Focused() {
81+ m.input.Focus()
82+ m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
83+ } else if m.index != textInput && m.input.Focused() {
84+ m.input.Blur()
85+ m.input.Prompt = m.shared.Styles.Prompt.String()
86+ }
87+}
88+
89+// Move the focus index one unit forward.
90+func (m *Model) indexForward() {
91+ m.index++
92+ if m.index > cancelButton {
93+ m.index = textInput
94+ }
95+
96+ m.updateFocus()
97+}
98+
99+// Move the focus index one unit backwards.
100+func (m *Model) indexBackward() {
101+ m.index--
102+ if m.index < textInput {
103+ m.index = cancelButton
104+ }
105+
106+ m.updateFocus()
107+}
108+
109+// Init is the Bubble Tea initialization function.
110+func (m Model) Init() tea.Cmd {
111+ return input.Blink
112+}
113+
114+// Update is the Bubble Tea update loop.
115+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116+ switch msg := msg.(type) {
117+ case tea.KeyMsg:
118+ // Ignore keys if we're submitting
119+ if m.state == submitting {
120+ return m, nil
121+ }
122+
123+ switch msg.String() {
124+ case "tab":
125+ m.indexForward()
126+ case "shift+tab":
127+ m.indexBackward()
128+ case "l", "k", "right":
129+ if m.index != textInput {
130+ m.indexForward()
131+ }
132+ case "h", "j", "left":
133+ if m.index != textInput {
134+ m.indexBackward()
135+ }
136+ case "up", "down":
137+ if m.index == textInput {
138+ m.indexForward()
139+ } else {
140+ m.index = textInput
141+ m.updateFocus()
142+ }
143+ case "enter":
144+ switch m.index {
145+ case textInput:
146+ fallthrough
147+ case okButton: // Submit the form
148+ m.state = submitting
149+ m.errMsg = ""
150+ m.newName = strings.TrimSpace(m.input.Value())
151+
152+ return m, m.createAccount()
153+ case cancelButton: // Exit
154+ return m, tea.Quit
155+ }
156+ }
157+
158+ // Pass messages through to the input element if that's the element
159+ // in focus
160+ if m.index == textInput {
161+ var cmd tea.Cmd
162+ m.input, cmd = m.input.Update(msg)
163+
164+ return m, cmd
165+ }
166+
167+ return m, nil
168+
169+ case NameTakenMsg:
170+ m.state = ready
171+ m.errMsg = m.shared.Styles.Subtle.Render("Sorry, ") +
172+ m.shared.Styles.Error.Render(m.newName) +
173+ m.shared.Styles.Subtle.Render(" is taken.")
174+
175+ return m, nil
176+
177+ case NameInvalidMsg:
178+ m.state = ready
179+ head := m.shared.Styles.Error.Render("Invalid name.")
180+ m.errMsg = m.shared.Styles.Wrap.Render(head)
181+
182+ return m, nil
183+
184+ case errMsg:
185+ m.state = ready
186+ head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
187+ body := m.shared.Styles.Subtle.Render(msg.Error())
188+ m.errMsg = m.shared.Styles.Wrap.Render(head + body)
189+
190+ return m, nil
191+
192+ default:
193+ var cmd tea.Cmd
194+ m.input, cmd = m.input.Update(msg) // Do we still need this?
195+
196+ return m, cmd
197+ }
198+}
199+
200+// View renders current view from the model.
201+func (m Model) View() string {
202+ intro := "To create an account, enter a username.\n\n"
203+ intro += "After that, go to https://pico.sh/getting-started#next-steps"
204+ s := fmt.Sprintf("%s\n\n%s\n\n", "hacker labs", intro)
205+ s += fmt.Sprintf("Public Key: %s\n\n", shared.KeyForSha256(m.shared.Session.PublicKey()))
206+ s += m.input.View() + "\n\n"
207+
208+ if m.state == submitting {
209+ s += m.spinnerView()
210+ } else {
211+ s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
212+ s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
213+ if m.errMsg != "" {
214+ s += "\n\n" + m.errMsg
215+ }
216+ }
217+ s += fmt.Sprintf("\n\n%s\n", helpMsg)
218+
219+ return s
220+}
221+
222+func (m Model) spinnerView() string {
223+ return "Creating account..."
224+}
225+
226+func (m *Model) createAccount() tea.Cmd {
227+ return func() tea.Msg {
228+ if m.newName == "" {
229+ return NameInvalidMsg{}
230+ }
231+
232+ key, err := shared.KeyForKeyText(m.shared.Session.PublicKey())
233+ if err != nil {
234+ return errMsg{err}
235+ }
236+
237+ user, err := m.shared.Dbpool.RegisterUser(m.newName, key, "")
238+ if err != nil {
239+ if errors.Is(err, db.ErrNameTaken) {
240+ return NameTakenMsg{}
241+ } else if errors.Is(err, db.ErrNameInvalid) {
242+ return NameInvalidMsg{}
243+ } else if errors.Is(err, db.ErrNameDenied) {
244+ return NameInvalidMsg{}
245+ } else {
246+ return errMsg{err}
247+ }
248+ }
249+
250+ return CreateAccountMsg(user)
251+ }
252+}
+36,
-39
1@@ -9,6 +9,7 @@ import (
2 "github.com/picosh/pico/db"
3 "github.com/picosh/pico/shared"
4 "github.com/picosh/pico/tui/common"
5+ "github.com/picosh/pico/tui/pages"
6 "golang.org/x/crypto/ssh"
7 )
8
9@@ -39,12 +40,8 @@ type errMsg struct {
10 func (e errMsg) Error() string { return e.err.Error() }
11
12 type Model struct {
13- Done bool
14- Quit bool
15+ shared common.SharedModel
16
17- dbpool db.DB
18- user *db.User
19- styles common.Styles
20 state state
21 newKey string
22 index index
23@@ -57,10 +54,10 @@ type Model struct {
24 func (m *Model) updateFocus() {
25 if m.index == textInput && !m.input.Focused() {
26 m.input.Focus()
27- m.input.Prompt = m.styles.FocusedPrompt.String()
28+ m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
29 } else if m.index != textInput && m.input.Focused() {
30 m.input.Blur()
31- m.input.Prompt = m.styles.Prompt.String()
32+ m.input.Prompt = m.shared.Styles.Prompt.String()
33 }
34 }
35
36@@ -85,20 +82,17 @@ func (m *Model) indexBackward() {
37 }
38
39 // NewModel returns a new username model in its initial state.
40-func NewModel(styles common.Styles, dbpool db.DB, user *db.User) Model {
41+func NewModel(shared common.SharedModel) Model {
42 im := input.New()
43- im.Cursor.Style = styles.Cursor
44+ im.Cursor.Style = shared.Styles.Cursor
45 im.Placeholder = "ssh-ed25519 AAAA..."
46- im.Prompt = styles.FocusedPrompt.String()
47+ im.Prompt = shared.Styles.FocusedPrompt.String()
48 im.CharLimit = 2049
49 im.Focus()
50
51 return Model{
52- Done: false,
53- Quit: false,
54- dbpool: dbpool,
55- user: user,
56- styles: styles,
57+ shared: shared,
58+
59 state: ready,
60 newKey: "",
61 index: textInput,
62@@ -117,12 +111,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
63 switch msg := msg.(type) {
64 case tea.KeyMsg:
65 switch msg.Type {
66- case tea.KeyCtrlC: // quit
67- m.Quit = true
68- return m, nil
69 case tea.KeyEscape: // exit this mini-app
70- m.Done = true
71- return m, nil
72+ return m, pages.Navigate(pages.PubkeysPage)
73
74 default:
75 // Ignore keys if we're submitting
76@@ -159,10 +149,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77 m.errMsg = ""
78 m.newKey = strings.TrimSpace(m.input.Value())
79
80- return m, addPublicKey(m)
81- case cancelButton: // Exit this mini-app
82- m.Done = true
83- return m, nil
84+ return m, m.addPublicKey()
85+ case cancelButton:
86+ return m, pages.Navigate(pages.PubkeysPage)
87 }
88 }
89
90@@ -178,32 +167,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 return m, nil
92 }
93
94+ case KeySetMsg:
95+ return m, pages.Navigate(pages.PubkeysPage)
96+
97 case KeyInvalidMsg:
98 m.state = ready
99- head := m.styles.Error.Render("Invalid public key. ")
100+ head := m.shared.Styles.Error.Render("Invalid public key. ")
101 helpMsg := "Public keys must but in the correct format"
102- body := m.styles.Subtle.Render(helpMsg)
103- m.errMsg = m.styles.Wrap.Render(head + body)
104+ body := m.shared.Styles.Subtle.Render(helpMsg)
105+ m.errMsg = m.shared.Styles.Wrap.Render(head + body)
106
107 return m, nil
108
109 case KeyTakenMsg:
110 m.state = ready
111- head := m.styles.Error.Render("Invalid public key. ")
112+ head := m.shared.Styles.Error.Render("Invalid public key. ")
113 helpMsg := "Public key is associated with another user"
114- body := m.styles.Subtle.Render(helpMsg)
115- m.errMsg = m.styles.Wrap.Render(head + body)
116+ body := m.shared.Styles.Subtle.Render(helpMsg)
117+ m.errMsg = m.shared.Styles.Wrap.Render(head + body)
118
119 return m, nil
120
121 case errMsg:
122 m.state = ready
123- head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
124- body := m.styles.Subtle.Render(msg.Error())
125- m.errMsg = m.styles.Wrap.Render(head + body)
126+ head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
127+ body := m.shared.Styles.Subtle.Render(msg.Error())
128+ m.errMsg = m.shared.Styles.Wrap.Render(head + body)
129
130 return m, nil
131
132+ // leaving page so reset model
133+ case pages.NavigateMsg:
134+ next := NewModel(m.shared)
135+ return next, next.Init()
136+
137 default:
138 var cmd tea.Cmd
139 m.input, cmd = m.input.Update(msg) // Do we still need this?
140@@ -218,10 +215,10 @@ func (m Model) View() string {
141 s += m.input.View() + "\n\n"
142
143 if m.state == submitting {
144- s += spinnerView(m)
145+ s += m.spinnerView()
146 } else {
147- s += common.OKButtonView(m.styles, m.index == 1, true)
148- s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
149+ s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
150+ s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
151 if m.errMsg != "" {
152 s += "\n\n" + m.errMsg
153 }
154@@ -230,11 +227,11 @@ func (m Model) View() string {
155 return s
156 }
157
158-func spinnerView(m Model) string {
159+func (m Model) spinnerView() string {
160 return "Submitting..."
161 }
162
163-func addPublicKey(m Model) tea.Cmd {
164+func (m *Model) addPublicKey() tea.Cmd {
165 return func() tea.Msg {
166 pk, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(m.newKey))
167 if err != nil {
168@@ -245,7 +242,7 @@ func addPublicKey(m Model) tea.Cmd {
169 if err != nil {
170 return KeyInvalidMsg{}
171 }
172- err = m.dbpool.InsertPublicKey(m.user.ID, key, comment, nil)
173+ err = m.shared.Dbpool.InsertPublicKey(m.shared.User.ID, key, comment, nil)
174 if err != nil {
175 if errors.Is(err, db.ErrPublicKeyTaken) {
176 return KeyTakenMsg{}
+93,
-109
1@@ -6,8 +6,8 @@ import (
2
3 input "github.com/charmbracelet/bubbles/textinput"
4 tea "github.com/charmbracelet/bubbletea"
5- "github.com/picosh/pico/db"
6 "github.com/picosh/pico/tui/common"
7+ "github.com/picosh/pico/tui/pages"
8 )
9
10 type state int
11@@ -26,8 +26,6 @@ const (
12 cancelButton
13 )
14
15-type TokenDismissed int
16-
17 type TokenSetMsg struct {
18 token string
19 }
20@@ -39,12 +37,8 @@ type errMsg struct {
21 func (e errMsg) Error() string { return e.err.Error() }
22
23 type Model struct {
24- Done bool
25- Quit bool
26+ shared common.SharedModel
27
28- dbpool db.DB
29- user *db.User
30- styles common.Styles
31 state state
32 tokenName string
33 token string
34@@ -53,21 +47,42 @@ type Model struct {
35 input input.Model
36 }
37
38+// NewModel returns a new username model in its initial state.
39+func NewModel(shared common.SharedModel) Model {
40+ im := input.New()
41+ im.Cursor.Style = shared.Styles.Cursor
42+ im.Placeholder = "A name used for your reference"
43+ im.Prompt = shared.Styles.FocusedPrompt.String()
44+ im.CharLimit = 256
45+ im.Focus()
46+
47+ return Model{
48+ shared: shared,
49+
50+ state: ready,
51+ tokenName: "",
52+ token: "",
53+ index: textInput,
54+ errMsg: "",
55+ input: im,
56+ }
57+}
58+
59 // updateFocus updates the focused states in the model based on the current
60 // focus index.
61 func (m *Model) updateFocus() {
62 if m.index == textInput && !m.input.Focused() {
63 m.input.Focus()
64- m.input.Prompt = m.styles.FocusedPrompt.String()
65+ m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
66 } else if m.index != textInput && m.input.Focused() {
67 m.input.Blur()
68- m.input.Prompt = m.styles.Prompt.String()
69+ m.input.Prompt = m.shared.Styles.Prompt.String()
70 }
71 }
72
73 // Move the focus index one unit forward.
74 func (m *Model) indexForward() {
75- m.index++
76+ m.index += 1
77 if m.index > cancelButton {
78 m.index = textInput
79 }
80@@ -77,7 +92,7 @@ func (m *Model) indexForward() {
81
82 // Move the focus index one unit backwards.
83 func (m *Model) indexBackward() {
84- m.index--
85+ m.index -= 1
86 if m.index < textInput {
87 m.index = cancelButton
88 }
89@@ -85,30 +100,6 @@ func (m *Model) indexBackward() {
90 m.updateFocus()
91 }
92
93-// NewModel returns a new username model in its initial state.
94-func NewModel(styles common.Styles, dbpool db.DB, user *db.User) Model {
95- im := input.New()
96- im.Cursor.Style = styles.Cursor
97- im.Placeholder = "A name used for your reference"
98- im.Prompt = styles.FocusedPrompt.String()
99- im.CharLimit = 256
100- im.Focus()
101-
102- return Model{
103- Done: false,
104- Quit: false,
105- dbpool: dbpool,
106- user: user,
107- styles: styles,
108- state: ready,
109- tokenName: "",
110- token: "",
111- index: textInput,
112- errMsg: "",
113- input: im,
114- }
115-}
116-
117 // Init is the Bubble Tea initialization function.
118 func (m Model) Init() tea.Cmd {
119 return input.Blink
120@@ -118,74 +109,64 @@ func (m Model) Init() tea.Cmd {
121 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122 switch msg := msg.(type) {
123 case tea.KeyMsg:
124- switch msg.Type {
125- case tea.KeyCtrlC: // quit
126- m.Quit = true
127- return m, dismiss
128- case tea.KeyEscape: // exit this mini-app
129- m.Done = true
130- return m, dismiss
131-
132- default:
133- // Ignore keys if we're submitting
134- if m.state == submitting {
135- return m, nil
136- }
137+ // Ignore keys if we're submitting
138+ if m.state == submitting {
139+ return m, nil
140+ }
141
142- switch msg.String() {
143- case "tab":
144+ switch msg.String() {
145+ case "q", "esc":
146+ return m, pages.Navigate(pages.TokensPage)
147+ case "tab":
148+ m.indexForward()
149+ case "shift+tab":
150+ m.indexBackward()
151+ case "l", "k", "right":
152+ if m.index != textInput {
153 m.indexForward()
154- case "shift+tab":
155+ }
156+ case "h", "j", "left":
157+ if m.index != textInput {
158 m.indexBackward()
159- case "l", "k", "right":
160- if m.index != textInput {
161- m.indexForward()
162- }
163- case "h", "j", "left":
164- if m.index != textInput {
165- m.indexBackward()
166- }
167- case "up", "down":
168- if m.index == textInput {
169- m.indexForward()
170- } else {
171- m.index = textInput
172- m.updateFocus()
173- }
174- case "enter":
175- switch m.index {
176- case textInput:
177- fallthrough
178- case okButton: // Submit the form
179- // form already submitted so ok button exits
180- if m.state == submitted {
181- m.Done = true
182- return m, dismiss
183- }
184-
185- m.state = submitting
186- m.errMsg = ""
187- m.tokenName = strings.TrimSpace(m.input.Value())
188-
189- return m, addToken(m)
190- case cancelButton: // Exit this mini-app
191- m.Done = true
192- return m, dismiss
193- }
194 }
195-
196- // Pass messages through to the input element if that's the element
197- // in focus
198+ case "up", "down":
199 if m.index == textInput {
200- var cmd tea.Cmd
201- m.input, cmd = m.input.Update(msg)
202+ m.indexForward()
203+ } else {
204+ m.index = textInput
205+ m.updateFocus()
206+ }
207+ case "enter":
208+ switch m.index {
209+ case textInput:
210+ fallthrough
211+ case okButton: // Submit the form
212+ // form already submitted so ok button exits
213+ if m.state == submitted {
214+ return m, pages.Navigate(pages.TokensPage)
215+ }
216+
217+ m.state = submitting
218+ m.errMsg = ""
219+ m.tokenName = strings.TrimSpace(m.input.Value())
220
221- return m, cmd
222+ return m, m.addToken()
223+ case cancelButton:
224+ return m, pages.Navigate(pages.TokensPage)
225 }
226+ }
227
228- return m, nil
229+ // Pass messages through to the input element if that's the element
230+ // in focus
231+ if m.index == textInput {
232+ var cmd tea.Cmd
233+ m.input, cmd = m.input.Update(msg)
234+
235+ return m, cmd
236 }
237
238+ return m, nil
239+
240 case TokenSetMsg:
241 m.state = submitted
242 m.token = msg.token
243@@ -193,12 +174,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244
245 case errMsg:
246 m.state = ready
247- head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
248- body := m.styles.Subtle.Render(msg.Error())
249- m.errMsg = m.styles.Wrap.Render(head + body)
250+ head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
251+ body := m.shared.Styles.Subtle.Render(msg.Error())
252+ m.errMsg = m.shared.Styles.Wrap.Render(head + body)
253
254 return m, nil
255
256+ // leaving page so reset model
257+ case pages.NavigateMsg:
258+ next := NewModel(m.shared)
259+ return next, next.Init()
260+
261 default:
262 var cmd tea.Cmd
263 m.input, cmd = m.input.Update(msg) // Do we still need this?
264@@ -213,14 +199,14 @@ func (m Model) View() string {
265 s += m.input.View() + "\n\n"
266
267 if m.state == submitting {
268- s += spinnerView(m)
269+ s += spinnerView()
270 } else if m.state == submitted {
271 s = fmt.Sprintf("Save this token:\n%s\n\n", m.token)
272 s += "After you exit this screen you will *not* be able to see it again.\n\n"
273- s += common.OKButtonView(m.styles, m.index == 1, true)
274+ s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
275 } else {
276- s += common.OKButtonView(m.styles, m.index == 1, true)
277- s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
278+ s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
279+ s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
280 if m.errMsg != "" {
281 s += "\n\n" + m.errMsg
282 }
283@@ -229,15 +215,9 @@ func (m Model) View() string {
284 return s
285 }
286
287-func spinnerView(m Model) string {
288- return "Submitting..."
289-}
290-
291-func dismiss() tea.Msg { return TokenDismissed(1) }
292-
293-func addToken(m Model) tea.Cmd {
294+func (m *Model) addToken() tea.Cmd {
295 return func() tea.Msg {
296- token, err := m.dbpool.InsertToken(m.user.ID, m.tokenName)
297+ token, err := m.shared.Dbpool.InsertToken(m.shared.User.ID, m.tokenName)
298 if err != nil {
299 return errMsg{err}
300 }
301@@ -245,3 +225,7 @@ func addToken(m Model) tea.Cmd {
302 return TokenSetMsg{token}
303 }
304 }
305+
306+func spinnerView() string {
307+ return "Submitting..."
308+}
+0,
-94
1@@ -1,94 +0,0 @@
2-package info
3-
4-import (
5- tea "github.com/charmbracelet/bubbletea"
6- "github.com/picosh/pico/db"
7- "github.com/picosh/pico/tui/common"
8-)
9-
10-type errMsg struct {
11- err error
12-}
13-
14-// Error satisfies the error interface.
15-func (e errMsg) Error() string {
16- return e.err.Error()
17-}
18-
19-// Model stores the state of the info user interface.
20-type Model struct {
21- Quit bool // signals it's time to exit the whole application
22- Err error
23- User *db.User
24- PlusFeatureFlag *db.FeatureFlag
25- styles common.Styles
26-}
27-
28-// NewModel returns a new Model in its initial state.
29-func NewModel(styles common.Styles, user *db.User, ff *db.FeatureFlag) Model {
30- return Model{
31- Quit: false,
32- User: user,
33- styles: styles,
34- PlusFeatureFlag: ff,
35- }
36-}
37-
38-// Update is the Bubble Tea update loop.
39-func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
40- var cmd tea.Cmd
41-
42- switch msg := msg.(type) {
43- case tea.KeyMsg:
44- switch msg.String() {
45- case "ctrl+c", "esc", "q":
46- m.Quit = true
47- return m, nil
48- }
49- case errMsg:
50- // If there's an error we print the error and exit
51- m.Err = msg
52- m.Quit = true
53- return m, nil
54- }
55-
56- return m, cmd
57-}
58-
59-// View renders the current view from the model.
60-func (m Model) View() string {
61- if m.Err != nil {
62- return "error: " + m.Err.Error()
63- } else if m.User == nil {
64- return " Authenticating..."
65- }
66- return m.bioView()
67-}
68-
69-func (m Model) bioView() string {
70- var username string
71- if m.User.Name != "" {
72- username = m.User.Name
73- } else {
74- username = m.styles.Subtle.Render("(none set)")
75- }
76-
77- plus := "No"
78- expires := ""
79- if m.PlusFeatureFlag != nil {
80- plus = "Yes"
81- expires = m.PlusFeatureFlag.ExpiresAt.Format("02 Jan 2006")
82- }
83-
84- vals := []string{
85- "Username", username,
86- "Joined", m.User.CreatedAt.Format("02 Jan 2006"),
87- "Pico+", plus,
88- }
89-
90- if expires != "" {
91- vals = append(vals, "Pico+ Expires At", expires)
92- }
93-
94- return common.KeyValueView(m.styles, vals...)
95-}
+42,
-0
1@@ -0,0 +1,42 @@
2+package tui
3+
4+import (
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/ssh"
7+ bm "github.com/charmbracelet/wish/bubbletea"
8+ "github.com/picosh/pico/db/postgres"
9+ "github.com/picosh/pico/shared"
10+ "github.com/picosh/pico/tui/common"
11+)
12+
13+func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
14+ return func(sesh ssh.Session) (tea.Model, []tea.ProgramOption) {
15+ logger := cfg.Logger
16+
17+ _, _, active := sesh.Pty()
18+ if !active {
19+ logger.Info("no active terminal, skipping")
20+ return nil, nil
21+ }
22+
23+ dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
24+ renderer := bm.MakeRenderer(sesh)
25+ styles := common.DefaultStyles(renderer)
26+
27+ shrd := common.SharedModel{
28+ Session: sesh,
29+ Cfg: cfg,
30+ Dbpool: dbpool,
31+ Styles: styles,
32+ Width: 80,
33+ Height: 24,
34+ Logger: logger,
35+ }
36+
37+ m := NewUI(shrd)
38+
39+ opts := bm.MakeOptions(sesh)
40+ opts = append(opts, tea.WithAltScreen())
41+ return m, opts
42+ }
43+}
1@@ -0,0 +1,172 @@
2+package menu
3+
4+import (
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/muesli/reflow/indent"
7+ "github.com/picosh/pico/tui/common"
8+)
9+
10+// menuChoice represents a chosen menu item.
11+type menuChoice int
12+
13+type MenuChoiceMsg struct {
14+ MenuChoice menuChoice
15+}
16+
17+const (
18+ KeysChoice menuChoice = iota
19+ TokensChoice
20+ NotificationsChoice
21+ PlusChoice
22+ ChatChoice
23+ ExitChoice
24+ UnsetChoice // set when no choice has been made
25+)
26+
27+// menu text corresponding to menu choices. these are presented to the user.
28+var menuChoices = map[menuChoice]string{
29+ KeysChoice: "Manage pubkeys",
30+ TokensChoice: "Manage tokens",
31+ NotificationsChoice: "Notifications",
32+ PlusChoice: "Pico+",
33+ ChatChoice: "Chat",
34+ ExitChoice: "Exit",
35+}
36+
37+type Model struct {
38+ shared common.SharedModel
39+ err error
40+ menuIndex int
41+ menuChoice menuChoice
42+}
43+
44+func NewModel(shared common.SharedModel) Model {
45+ return Model{
46+ shared: shared,
47+ menuChoice: UnsetChoice,
48+ }
49+}
50+
51+func (m Model) Init() tea.Cmd {
52+ return nil
53+}
54+
55+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56+ var (
57+ cmds []tea.Cmd
58+ )
59+
60+ switch msg := msg.(type) {
61+ case tea.KeyMsg:
62+ switch msg.Type {
63+ case tea.KeyCtrlC:
64+ m.shared.Dbpool.Close()
65+ return m, tea.Quit
66+ }
67+
68+ switch msg.String() {
69+ case "q", "esc":
70+ m.shared.Dbpool.Close()
71+ return m, tea.Quit
72+
73+ case "up", "k":
74+ m.menuIndex--
75+ if m.menuIndex < 0 {
76+ m.menuIndex = len(menuChoices) - 1
77+ }
78+
79+ case "enter":
80+ m.menuChoice = menuChoice(m.menuIndex)
81+ cmds = append(cmds, MenuMsg(m.menuChoice))
82+
83+ case "down", "j":
84+ m.menuIndex++
85+ if m.menuIndex >= len(menuChoices) {
86+ m.menuIndex = 0
87+ }
88+ }
89+ }
90+
91+ return m, tea.Batch(cmds...)
92+}
93+
94+func MenuMsg(choice menuChoice) tea.Cmd {
95+ return func() tea.Msg {
96+ return MenuChoiceMsg{
97+ MenuChoice: choice,
98+ }
99+ }
100+}
101+
102+func (m Model) bioView() string {
103+ if m.shared.User == nil {
104+ return "Loading user info..."
105+ }
106+
107+ var username string
108+ if m.shared.User.Name != "" {
109+ username = m.shared.User.Name
110+ } else {
111+ username = m.shared.Styles.Subtle.Render("(none set)")
112+ }
113+
114+ plus := "No"
115+ expires := ""
116+ if m.shared.PlusFeatureFlag != nil {
117+ plus = "Yes"
118+ expires = m.shared.PlusFeatureFlag.ExpiresAt.Format("02 Jan 2006")
119+ }
120+
121+ vals := []string{
122+ "Username", username,
123+ "Joined", m.shared.User.CreatedAt.Format("02 Jan 2006"),
124+ "Pico+", plus,
125+ }
126+
127+ if expires != "" {
128+ vals = append(vals, "Pico+ Expires At", expires)
129+ }
130+
131+ return common.KeyValueView(m.shared.Styles, vals...)
132+}
133+
134+func (m Model) menuView() string {
135+ var s string
136+ for i := 0; i < len(menuChoices); i++ {
137+ e := " "
138+ menuItem := menuChoices[menuChoice(i)]
139+ if i == m.menuIndex {
140+ e = m.shared.Styles.SelectionMarker.String() +
141+ m.shared.Styles.SelectedMenuItem.Render(menuItem)
142+ } else {
143+ e += menuItem
144+ }
145+ if i < len(menuChoices)-1 {
146+ e += "\n"
147+ }
148+ s += e
149+ }
150+
151+ return s
152+}
153+
154+func (m Model) errorView(err error) string {
155+ head := m.shared.Styles.Error.Render("Error: ")
156+ body := m.shared.Styles.Subtle.Render(err.Error())
157+ msg := m.shared.Styles.Wrap.Render(head + body)
158+ return "\n\n" + indent.String(msg, 2)
159+}
160+
161+func (m Model) footerView() string {
162+ if m.err != nil {
163+ return m.errorView(m.err)
164+ }
165+ return "\n\n" + common.HelpView(m.shared.Styles, "j/k, ↑/↓: choose", "enter: select")
166+}
167+
168+func (m Model) View() string {
169+ s := m.bioView()
170+ s += "\n\n" + m.menuView()
171+ s += m.footerView()
172+ return s
173+}
+26,
-35
1@@ -8,6 +8,7 @@ import (
2 "github.com/charmbracelet/glamour"
3 "github.com/picosh/pico/db"
4 "github.com/picosh/pico/tui/common"
5+ "github.com/picosh/pico/tui/pages"
6 )
7
8 func NotificationsView(dbpool db.DB, userID string) string {
9@@ -53,11 +54,11 @@ Create a feeds file (e.g. pico.txt):`, url)
10
11 // Model holds the state of the username UI.
12 type Model struct {
13+ shared common.SharedModel
14+
15 Done bool // true when it's time to exit this view
16 Quit bool // true when the user wants to quit the whole program
17
18- styles common.Styles
19- user *db.User
20 viewport viewport.Model
21 }
22
23@@ -69,56 +70,46 @@ func headerWidth(w int) int {
24 return w - 2
25 }
26
27-// NewModel returns a new username model in its initial state.
28-func NewModel(styles common.Styles, dbpool db.DB, user *db.User, termSize tea.WindowSizeMsg) Model {
29- hh := headerHeight(styles)
30- viewport := viewport.New(headerWidth(termSize.Width), termSize.Height-hh)
31+func NewModel(shared common.SharedModel) Model {
32+ hh := headerHeight(shared.Styles)
33+ viewport := viewport.New(headerWidth(shared.Width), shared.Height-hh)
34 viewport.YPosition = hh
35- if user != nil {
36- viewport.SetContent(NotificationsView(dbpool, user.ID))
37+ if shared.User != nil {
38+ viewport.SetContent(NotificationsView(shared.Dbpool, shared.User.ID))
39 }
40
41 return Model{
42+ shared: shared,
43+
44 Done: false,
45 Quit: false,
46- styles: styles,
47- user: user,
48 viewport: viewport,
49 }
50 }
51
52-// Update is the Bubble Tea update loop.
53-func Update(msg tea.Msg, m Model, termSize tea.WindowSizeMsg) (Model, tea.Cmd) {
54- var cmd tea.Cmd
55- var cmds []tea.Cmd
56+func (m Model) Init() tea.Cmd {
57+ return nil
58+}
59
60+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61 switch msg := msg.(type) {
62+ case tea.WindowSizeMsg:
63+ m.viewport.Width = headerWidth(m.shared.Width)
64+ hh := headerHeight(m.shared.Styles)
65+ m.viewport.Height = m.shared.Height - hh
66+
67 case tea.KeyMsg:
68- switch msg.Type {
69- case tea.KeyCtrlC:
70- m.Quit = true
71- case tea.KeyEscape:
72- m.Done = true
73-
74- default:
75- switch msg.String() {
76- case "q":
77- m.Done = true
78- }
79+ switch msg.String() {
80+ case "q", "esc":
81+ return m, pages.Navigate(pages.MenuPage)
82 }
83 }
84
85- m.viewport.Width = headerWidth(termSize.Width)
86- hh := headerHeight(m.styles)
87- m.viewport.Height = termSize.Height - hh
88+ var cmd tea.Cmd
89 m.viewport, cmd = m.viewport.Update(msg)
90- cmds = append(cmds, cmd)
91-
92- return m, tea.Batch(cmds...)
93+ return m, cmd
94 }
95
96-// View renders current view from the model.
97-func View(m Model) string {
98- s := m.viewport.View()
99- return s
100+func (m Model) View() string {
101+ return m.viewport.View()
102 }
+24,
-0
1@@ -0,0 +1,24 @@
2+package pages
3+
4+import tea "github.com/charmbracelet/bubbletea"
5+
6+type Page int
7+
8+const (
9+ MenuPage Page = iota
10+ CreateAccountPage
11+ CreatePubkeyPage
12+ CreateTokenPage
13+ PubkeysPage
14+ TokensPage
15+ NotificationsPage
16+ PlusPage
17+)
18+
19+type NavigateMsg struct{ Page }
20+
21+func Navigate(page Page) tea.Cmd {
22+ return func() tea.Msg {
23+ return NavigateMsg{page}
24+ }
25+}
+24,
-40
1@@ -7,9 +7,8 @@ import (
2 "github.com/charmbracelet/bubbles/viewport"
3 tea "github.com/charmbracelet/bubbletea"
4 "github.com/charmbracelet/glamour"
5- "github.com/charmbracelet/ssh"
6- "github.com/picosh/pico/db"
7 "github.com/picosh/pico/tui/common"
8+ "github.com/picosh/pico/tui/pages"
9 )
10
11 func PlusView(username string) string {
12@@ -77,11 +76,7 @@ we plan on continuing to include more services as we build them.`, url)
13
14 // Model holds the state of the username UI.
15 type Model struct {
16- Done bool // true when it's time to exit this view
17- Quit bool // true when the user wants to quit the whole program
18-
19- styles common.Styles
20- user *db.User
21+ shared common.SharedModel
22 viewport viewport.Model
23 }
24
25@@ -94,55 +89,44 @@ func headerWidth(w int) int {
26 }
27
28 // NewModel returns a new username model in its initial state.
29-func NewModel(styles common.Styles, user *db.User, session ssh.Session, termSize tea.WindowSizeMsg) Model {
30- hh := headerHeight(styles)
31- viewport := viewport.New(headerWidth(termSize.Width), termSize.Height-hh)
32+func NewModel(shared common.SharedModel) Model {
33+ hh := headerHeight(shared.Styles)
34+ viewport := viewport.New(headerWidth(shared.Width), shared.Height-hh)
35 viewport.YPosition = hh
36- if user != nil {
37- viewport.SetContent(PlusView(user.Name))
38+ if shared.User != nil {
39+ viewport.SetContent(PlusView(shared.User.Name))
40 }
41
42 return Model{
43- Done: false,
44- Quit: false,
45- styles: styles,
46- user: user,
47+ shared: shared,
48 viewport: viewport,
49 }
50 }
51
52-// Update is the Bubble Tea update loop.
53-func Update(msg tea.Msg, m Model, termSize tea.WindowSizeMsg) (Model, tea.Cmd) {
54- var cmd tea.Cmd
55- var cmds []tea.Cmd
56+func (m Model) Init() tea.Cmd {
57+ return nil
58+}
59
60+// Update is the Bubble Tea update loop.
61+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62 switch msg := msg.(type) {
63+ case tea.WindowSizeMsg:
64+ m.viewport.Width = headerWidth(m.shared.Width)
65+ hh := headerHeight(m.shared.Styles)
66+ m.viewport.Height = m.shared.Height - hh
67 case tea.KeyMsg:
68- switch msg.Type {
69- case tea.KeyCtrlC:
70- m.Quit = true
71- case tea.KeyEscape:
72- m.Done = true
73-
74- default:
75- switch msg.String() {
76- case "q":
77- m.Done = true
78- }
79+ switch msg.String() {
80+ case "q", "esc":
81+ return m, pages.Navigate(pages.MenuPage)
82 }
83 }
84
85- m.viewport.Width = headerWidth(termSize.Width)
86- hh := headerHeight(m.styles)
87- m.viewport.Height = termSize.Height - hh
88+ var cmd tea.Cmd
89 m.viewport, cmd = m.viewport.Update(msg)
90- cmds = append(cmds, cmd)
91-
92- return m, tea.Batch(cmds...)
93+ return m, cmd
94 }
95
96 // View renders current view from the model.
97-func View(m Model) string {
98- s := m.viewport.View()
99- return s
100+func (m Model) View() string {
101+ return m.viewport.View()
102 }
R tui/keys/keys.go =>
tui/pubkeys/keys.go
+97,
-159
1@@ -1,13 +1,11 @@
2-package keys
3+package pubkeys
4
5 import (
6- "log/slog"
7-
8 pager "github.com/charmbracelet/bubbles/paginator"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/picosh/pico/db"
11 "github.com/picosh/pico/tui/common"
12- "github.com/picosh/pico/tui/createkey"
13+ "github.com/picosh/pico/tui/pages"
14 )
15
16 const keysPerPage = 4
17@@ -20,7 +18,6 @@ const (
18 stateDeletingKey
19 stateDeletingActiveKey
20 stateDeletingAccount
21- stateCreateKey
22 stateQuitting
23 )
24
25@@ -45,19 +42,34 @@ type (
26
27 // Model is the Tea state model for this user interface.
28 type Model struct {
29- logger *slog.Logger
30- dbpool db.DB
31- user *db.User
32- styles common.Styles
33- pager pager.Model
34+ shared common.SharedModel
35+
36 state state
37 err error
38 activeKeyIndex int // index of the key in the below slice which is currently in use
39 keys []*db.PublicKey // keys linked to user's account
40 index int // index of selected key in relation to the current page
41- Exit bool
42- Quit bool
43- createKey createkey.Model
44+
45+ pager pager.Model
46+}
47+
48+// NewModel creates a new model with defaults.
49+func NewModel(shared common.SharedModel) Model {
50+ p := pager.New()
51+ p.PerPage = keysPerPage
52+ p.Type = pager.Dots
53+ p.InactiveDot = shared.Styles.InactivePagination.Render("•")
54+
55+ return Model{
56+ shared: shared,
57+
58+ pager: p,
59+ state: stateLoading,
60+ err: nil,
61+ activeKeyIndex: -1,
62+ keys: []*db.PublicKey{},
63+ index: 0,
64+ }
65 }
66
67 // getSelectedIndex returns the index of the cursor in relation to the total
68@@ -78,105 +90,71 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
69 m.index = min(m.index, numItems-1)
70 }
71
72-// NewModel creates a new model with defaults.
73-func NewModel(styles common.Styles, logger *slog.Logger, dbpool db.DB, user *db.User) Model {
74- p := pager.New()
75- p.PerPage = keysPerPage
76- p.Type = pager.Dots
77- p.InactiveDot = styles.InactivePagination.Render("•")
78-
79- return Model{
80- logger: logger,
81- dbpool: dbpool,
82- user: user,
83- styles: styles,
84- pager: p,
85- state: stateLoading,
86- err: nil,
87- activeKeyIndex: -1,
88- keys: []*db.PublicKey{},
89- index: 0,
90- Exit: false,
91- Quit: false,
92- }
93-}
94-
95 // Init is the Tea initialization function.
96 func (m Model) Init() tea.Cmd {
97- return nil
98+ return FetchKeys(m.shared)
99 }
100
101 // Update is the tea update function which handles incoming messages.
102 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 var (
104 cmds []tea.Cmd
105- cmd tea.Cmd
106 )
107
108 switch msg := msg.(type) {
109 case tea.KeyMsg:
110- switch msg.Type {
111- case tea.KeyCtrlC:
112- m.Exit = true
113+ switch msg.String() {
114+ case "q", "esc":
115+ return m, pages.Navigate(pages.MenuPage)
116+ case "up", "k":
117+ m.index -= 1
118+ if m.index < 0 && m.pager.Page > 0 {
119+ m.index = m.pager.PerPage - 1
120+ m.pager.PrevPage()
121+ }
122+ m.index = max(0, m.index)
123+ case "down", "j":
124+ itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
125+ m.index += 1
126+ if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
127+ m.index = 0
128+ m.pager.NextPage()
129+ }
130+ m.index = min(itemsOnPage-1, m.index)
131+
132+ case "n":
133+ return m, pages.Navigate(pages.CreatePubkeyPage)
134+
135+ // Delete
136+ case "x":
137+ m.state = stateDeletingKey
138+ m.UpdatePaging(msg)
139 return m, nil
140- }
141
142- if m.state != stateCreateKey {
143- switch msg.String() {
144- case "q", "esc":
145- m.Exit = true
146- return m, nil
147- case "up", "k":
148- m.index--
149- if m.index < 0 && m.pager.Page > 0 {
150- m.index = m.pager.PerPage - 1
151- m.pager.PrevPage()
152+ // Confirm Delete
153+ case "y":
154+ switch m.state {
155+ case stateDeletingKey:
156+ if len(m.keys) == 1 {
157+ // The user is about to delete her account. Double confirm.
158+ m.state = stateDeletingAccount
159+ return m, nil
160 }
161- m.index = max(0, m.index)
162- case "down", "j":
163- itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
164- m.index++
165- if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
166- m.index = 0
167- m.pager.NextPage()
168- }
169- m.index = min(itemsOnPage-1, m.index)
170-
171- case "n":
172- m.state = stateCreateKey
173- return m, nil
174-
175- // Delete
176- case "x":
177- m.state = stateDeletingKey
178- m.UpdatePaging(msg)
179- return m, nil
180-
181- // Confirm Delete
182- case "y":
183- switch m.state {
184- case stateDeletingKey:
185- if len(m.keys) == 1 {
186- // The user is about to delete her account. Double confirm.
187- m.state = stateDeletingAccount
188- return m, nil
189- }
190- if m.getSelectedIndex() == m.activeKeyIndex {
191- // The user is going to delete her active key. Double confirm.
192- m.state = stateDeletingActiveKey
193- return m, nil
194- }
195- m.state = stateNormal
196- return m, unlinkKey(m)
197- case stateDeletingActiveKey:
198- m.state = stateQuitting
199- // Active key will be deleted. Remove the key and exit.
200- return m, unlinkKey(m)
201- case stateDeletingAccount:
202- // Account will be deleted. Remove the key and exit.
203- m.state = stateQuitting
204- return m, deleteAccount(m)
205+ if m.getSelectedIndex() == m.activeKeyIndex {
206+ // The user is going to delete her active key. Double confirm.
207+ m.state = stateDeletingActiveKey
208+ return m, nil
209 }
210+ m.state = stateNormal
211+ return m, m.unlinkKey()
212+ case stateDeletingActiveKey:
213+ m.state = stateQuitting
214+ // Active key will be deleted. Remove the key and exit.
215+ return m, m.unlinkKey()
216+ case stateDeletingAccount:
217+ // Account will be deleted. Remove the key and exit.
218+ m.state = stateQuitting
219+ return m, m.deleteAccount()
220 }
221 }
222
223@@ -189,7 +167,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
224 m.index = 0
225 m.keys = msg
226 for i, key := range m.keys {
227- if key.Key == m.user.PublicKey.Key {
228+ if key.Key == m.shared.User.PublicKey.Key {
229 m.activeKeyIndex = i
230 }
231 }
232@@ -210,22 +188,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
233 // Update cursor
234 m.index = min(m.index, m.pager.ItemsOnPage(len(m.keys)-1))
235 for i, key := range m.keys {
236- if key.Key == m.user.PublicKey.Key {
237+ if key.Key == m.shared.User.PublicKey.Key {
238 m.activeKeyIndex = i
239 }
240 }
241
242 return m, nil
243
244- case createkey.KeySetMsg:
245- m.state = stateNormal
246- return m, fetchKeys(m.dbpool, m.user)
247-
248+ // leaving page so reset model
249+ case pages.NavigateMsg:
250+ next := NewModel(m.shared)
251+ return next, next.Init()
252 }
253
254 switch m.state {
255- case stateNormal:
256- m.createKey = createkey.NewModel(m.styles, m.dbpool, m.user)
257 case stateDeletingKey:
258 // If an item is being confirmed for delete, any key (other than the key
259 // used for confirmation above) cancels the deletion
260@@ -236,40 +212,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
261 }
262
263 m.UpdatePaging(msg)
264-
265- m, cmd = updateChildren(msg, m)
266- if cmd != nil {
267- cmds = append(cmds, cmd)
268- }
269-
270 return m, tea.Batch(cmds...)
271 }
272
273-func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
274- var cmd tea.Cmd
275-
276- switch m.state {
277- case stateCreateKey:
278- newModel, newCmd := m.createKey.Update(msg)
279- createKeyModel, ok := newModel.(createkey.Model)
280- if !ok {
281- panic("could not perform assertion on posts model")
282- }
283- m.createKey = createKeyModel
284- cmd = newCmd
285- if m.createKey.Done {
286- m.createKey = createkey.NewModel(m.styles, m.dbpool, m.user) // reset the state
287- m.state = stateNormal
288- } else if m.createKey.Quit {
289- m.state = stateQuitting
290- return m, tea.Quit
291- }
292-
293- }
294-
295- return m, cmd
296-}
297-
298 // View renders the current UI into a string.
299 func (m Model) View() string {
300 if m.err != nil {
301@@ -283,13 +228,11 @@ func (m Model) View() string {
302 s = "Loading...\n\n"
303 case stateQuitting:
304 s = "Thanks for using pico.sh!\n"
305- case stateCreateKey:
306- s = m.createKey.View()
307 default:
308 s = "Here are the keys linked to your pico.sh account.\n\n"
309
310 // Keys
311- s += keysView(m)
312+ s += m.keysView()
313 if m.pager.TotalPages > 1 {
314 s += m.pager.View()
315 }
316@@ -303,14 +246,14 @@ func (m Model) View() string {
317 case stateDeletingAccount:
318 s += m.promptView("Sure? This will delete your account. Are you absolutely positive?")
319 default:
320- s += "\n\n" + helpView(m)
321+ s += "\n\n" + m.helpView()
322 }
323 }
324
325 return s
326 }
327
328-func keysView(m Model) string {
329+func (m *Model) keysView() string {
330 var (
331 s string
332 state keyState
333@@ -332,7 +275,7 @@ func keysView(m Model) string {
334 } else {
335 state = keyNormal
336 }
337- s += m.newStyledKey(m.styles, key, i+start == m.activeKeyIndex).render(state)
338+ s += m.newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
339 }
340
341 // If there aren't enough keys to fill the view, fill the missing parts
342@@ -346,7 +289,7 @@ func keysView(m Model) string {
343 return s
344 }
345
346-func helpView(m Model) string {
347+func (m *Model) helpView() string {
348 var items []string
349 if len(m.keys) > 1 {
350 items = append(items, "j/k, ↑/↓: choose")
351@@ -355,24 +298,19 @@ func helpView(m Model) string {
352 items = append(items, "h/l, ←/→: page")
353 }
354 items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
355- return common.HelpView(m.styles, items...)
356+ return common.HelpView(m.shared.Styles, items...)
357 }
358
359-func (m Model) promptView(prompt string) string {
360- st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
361+func (m *Model) promptView(prompt string) string {
362+ st := m.shared.Styles.Delete.Copy().MarginTop(2).MarginRight(1)
363 return st.Render(prompt) +
364- m.styles.Delete.Render("(y/N)")
365-}
366-
367-// LoadKeys returns the command necessary for loading the keys.
368-func LoadKeys(m Model) tea.Cmd {
369- return fetchKeys(m.dbpool, m.user)
370+ m.shared.Styles.Delete.Render("(y/N)")
371 }
372
373-// fetchKeys loads the current set of keys via the charm client.
374-func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
375+// FetchKeys loads the current set of keys via the charm client.
376+func FetchKeys(shrd common.SharedModel) tea.Cmd {
377 return func() tea.Msg {
378- ak, err := dbpool.FindKeysForUser(user)
379+ ak, err := shrd.Dbpool.FindKeysForUser(shrd.User)
380 if err != nil {
381 return errMsg{err}
382 }
383@@ -381,10 +319,10 @@ func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
384 }
385
386 // unlinkKey deletes the selected key.
387-func unlinkKey(m Model) tea.Cmd {
388+func (m *Model) unlinkKey() tea.Cmd {
389 return func() tea.Msg {
390 id := m.keys[m.getSelectedIndex()].ID
391- err := m.dbpool.RemoveKeys([]string{id})
392+ err := m.shared.Dbpool.RemoveKeys([]string{id})
393 if err != nil {
394 return errMsg{err}
395 }
396@@ -392,11 +330,11 @@ func unlinkKey(m Model) tea.Cmd {
397 }
398 }
399
400-func deleteAccount(m Model) tea.Cmd {
401+func (m *Model) deleteAccount() tea.Cmd {
402 return func() tea.Msg {
403 id := m.keys[m.getSelectedIndex()].UserID
404- m.logger.Info("user requested account deletion", "user", m.user.Name, "id", id)
405- err := m.dbpool.RemoveUsers([]string{id})
406+ m.shared.Logger.Info("user requested account deletion", "user", m.shared.User.Name, "id", id)
407+ err := m.shared.Dbpool.RemoveUsers([]string{id})
408 if err != nil {
409 return errMsg{err}
410 }
R tui/keys/keyview.go =>
tui/pubkeys/keyview.go
+2,
-2
1@@ -1,4 +1,4 @@
2-package keys
3+package pubkeys
4
5 import (
6 "fmt"
7@@ -93,7 +93,7 @@ func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool
8
9 var note string
10 if active {
11- note = m.styles.Note.Render("• ") + m.styles.Note.Render("Current Key")
12+ note = m.shared.Styles.Note.Render("• ") + m.shared.Styles.Note.Render("Current Key")
13 }
14
15 // Default state
+40,
-0
1@@ -0,0 +1,40 @@
2+package tui
3+
4+import (
5+ "io"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/picosh/pico/shared"
9+ "github.com/picosh/pico/tui/common"
10+)
11+
12+type SenpaiCmd struct {
13+ shared common.SharedModel
14+}
15+
16+func (m *SenpaiCmd) Run() error {
17+ pass, err := m.shared.Dbpool.UpsertToken(m.shared.User.ID, "pico-chat")
18+ if err != nil {
19+ return err
20+ }
21+ app, err := shared.NewSenpaiApp(m.shared.Session, m.shared.User.Name, pass)
22+ if err != nil {
23+ return err
24+ }
25+ app.Run()
26+ app.Close()
27+ return nil
28+}
29+
30+func (m *SenpaiCmd) SetStdin(io.Reader) {}
31+func (m *SenpaiCmd) SetStdout(io.Writer) {}
32+func (m *SenpaiCmd) SetStderr(io.Writer) {}
33+
34+func LoadChat(shrd common.SharedModel) tea.Cmd {
35+ sp := &SenpaiCmd{
36+ shared: shrd,
37+ }
38+ return tea.Exec(sp, func(err error) tea.Msg {
39+ return tea.Quit()
40+ })
41+}
+59,
-117
1@@ -5,7 +5,7 @@ import (
2 tea "github.com/charmbracelet/bubbletea"
3 "github.com/picosh/pico/db"
4 "github.com/picosh/pico/tui/common"
5- "github.com/picosh/pico/tui/createtoken"
6+ "github.com/picosh/pico/tui/pages"
7 )
8
9 const keysPerPage = 4
10@@ -16,7 +16,6 @@ const (
11 stateLoading state = iota
12 stateNormal
13 stateDeletingKey
14- stateCreateKey
15 stateQuitting
16 )
17
18@@ -41,18 +40,15 @@ type (
19
20 // Model is the Tea state model for this user interface.
21 type Model struct {
22- dbpool db.DB
23- user *db.User
24- styles common.Styles
25- pager pager.Model
26+ shared common.SharedModel
27+
28 state state
29 err error
30 activeKeyIndex int // index of the key in the below slice which is currently in use
31 tokens []*db.Token // keys linked to user's account
32 index int // index of selected key in relation to the current page
33- Exit bool
34- Quit bool
35- createKey createtoken.Model
36+
37+ pager pager.Model
38 }
39
40 // getSelectedIndex returns the index of the cursor in relation to the total
41@@ -74,85 +70,72 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
42 }
43
44 // NewModel creates a new model with defaults.
45-func NewModel(styles common.Styles, dbpool db.DB, user *db.User) Model {
46+func NewModel(shared common.SharedModel) Model {
47 p := pager.New()
48 p.PerPage = keysPerPage
49 p.Type = pager.Dots
50- p.InactiveDot = styles.InactivePagination.Render("•")
51+ p.InactiveDot = shared.Styles.InactivePagination.Render("•")
52
53 return Model{
54- dbpool: dbpool,
55- user: user,
56- styles: styles,
57- pager: p,
58+ shared: shared,
59+
60 state: stateLoading,
61 err: nil,
62 activeKeyIndex: -1,
63 tokens: []*db.Token{},
64 index: 0,
65- Exit: false,
66- Quit: false,
67+
68+ pager: p,
69 }
70 }
71
72 // Init is the Tea initialization function.
73 func (m Model) Init() tea.Cmd {
74- return nil
75+ return FetchTokens(m.shared)
76 }
77
78 // Update is the tea update function which handles incoming messages.
79 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 var (
81 cmds []tea.Cmd
82- cmd tea.Cmd
83 )
84
85 switch msg := msg.(type) {
86 case tea.KeyMsg:
87- switch msg.Type {
88- case tea.KeyCtrlC:
89- m.Exit = true
90+ switch msg.String() {
91+ case "q", "esc":
92+ return m, pages.Navigate(pages.MenuPage)
93+ case "up", "k":
94+ m.index--
95+ if m.index < 0 && m.pager.Page > 0 {
96+ m.index = m.pager.PerPage - 1
97+ m.pager.PrevPage()
98+ }
99+ m.index = max(0, m.index)
100+ case "down", "j":
101+ itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
102+ m.index++
103+ if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
104+ m.index = 0
105+ m.pager.NextPage()
106+ }
107+ m.index = min(itemsOnPage-1, m.index)
108+
109+ case "n":
110+ return m, pages.Navigate(pages.CreateTokenPage)
111+
112+ // Delete
113+ case "x":
114+ m.state = stateDeletingKey
115+ m.UpdatePaging(msg)
116 return m, nil
117- }
118
119- if m.state != stateCreateKey {
120- switch msg.String() {
121- case "q", "esc":
122- m.Exit = true
123- return m, nil
124- case "up", "k":
125- m.index--
126- if m.index < 0 && m.pager.Page > 0 {
127- m.index = m.pager.PerPage - 1
128- m.pager.PrevPage()
129- }
130- m.index = max(0, m.index)
131- case "down", "j":
132- itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
133- m.index++
134- if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
135- m.index = 0
136- m.pager.NextPage()
137- }
138- m.index = min(itemsOnPage-1, m.index)
139-
140- case "n":
141- m.state = stateCreateKey
142- return m, nil
143-
144- // Delete
145- case "x":
146- m.state = stateDeletingKey
147- m.UpdatePaging(msg)
148- return m, nil
149-
150- // Confirm Delete
151- case "y":
152- switch m.state {
153- case stateDeletingKey:
154- m.state = stateNormal
155- return m, unlinkKey(m)
156- }
157+ // Confirm Delete
158+ case "y":
159+ switch m.state {
160+ case stateDeletingKey:
161+ m.state = stateNormal
162+ return m, m.unlinkKey()
163 }
164 }
165
166@@ -183,15 +166,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
167
168 return m, nil
169
170- case createtoken.TokenDismissed:
171- m.state = stateNormal
172- return m, fetchKeys(m.dbpool, m.user)
173-
174+ // leaving page so reset model
175+ case pages.NavigateMsg:
176+ next := NewModel(m.shared)
177+ return next, next.Init()
178 }
179
180 switch m.state {
181- case stateNormal:
182- m.createKey = createtoken.NewModel(m.styles, m.dbpool, m.user)
183 case stateDeletingKey:
184 // If an item is being confirmed for delete, any key (other than the key
185 // used for confirmation above) cancels the deletion
186@@ -202,40 +183,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187 }
188
189 m.UpdatePaging(msg)
190-
191- m, cmd = updateChildren(msg, m)
192- if cmd != nil {
193- cmds = append(cmds, cmd)
194- }
195-
196 return m, tea.Batch(cmds...)
197 }
198
199-func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
200- var cmd tea.Cmd
201-
202- switch m.state {
203- case stateCreateKey:
204- newModel, newCmd := m.createKey.Update(msg)
205- createKeyModel, ok := newModel.(createtoken.Model)
206- if !ok {
207- panic("could not perform assertion on posts model")
208- }
209- m.createKey = createKeyModel
210- cmd = newCmd
211- if m.createKey.Done {
212- m.createKey = createtoken.NewModel(m.styles, m.dbpool, m.user) // reset the state
213- m.state = stateNormal
214- } else if m.createKey.Quit {
215- m.state = stateQuitting
216- return m, tea.Quit
217- }
218-
219- }
220-
221- return m, cmd
222-}
223-
224 // View renders the current UI into a string.
225 func (m Model) View() string {
226 if m.err != nil {
227@@ -249,8 +199,6 @@ func (m Model) View() string {
228 s = "Loading...\n\n"
229 case stateQuitting:
230 s = "Thanks for using pico.sh!\n"
231- case stateCreateKey:
232- s = m.createKey.View()
233 default:
234 s = "Here are the tokens linked to your account.\n\n"
235 s += "A token can be used for connecting to our\nIRC bouncer from your client.\n\n"
236@@ -266,7 +214,7 @@ func (m Model) View() string {
237 case stateDeletingKey:
238 s += m.promptView("Delete this key?")
239 default:
240- s += "\n\n" + helpView(m)
241+ s += "\n\n" + m.helpView()
242 }
243 }
244
245@@ -292,7 +240,7 @@ func keysView(m Model) string {
246 } else {
247 state = keyNormal
248 }
249- s += m.newStyledKey(m.styles, key, i+start == m.activeKeyIndex).render(state)
250+ s += newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
251 }
252
253 // If there aren't enough keys to fill the view, fill the missing parts
254@@ -306,7 +254,7 @@ func keysView(m Model) string {
255 return s
256 }
257
258-func helpView(m Model) string {
259+func (m *Model) helpView() string {
260 var items []string
261 if len(m.tokens) > 1 {
262 items = append(items, "j/k, ↑/↓: choose")
263@@ -315,24 +263,18 @@ func helpView(m Model) string {
264 items = append(items, "h/l, ←/→: page")
265 }
266 items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
267- return common.HelpView(m.styles, items...)
268+ return common.HelpView(m.shared.Styles, items...)
269 }
270
271-func (m Model) promptView(prompt string) string {
272- st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
273+func (m *Model) promptView(prompt string) string {
274+ st := m.shared.Styles.Delete.Copy().MarginTop(2).MarginRight(1)
275 return st.Render(prompt) +
276- m.styles.Delete.Render("(y/N)")
277-}
278-
279-// LoadKeys returns the command necessary for loading the keys.
280-func LoadKeys(m Model) tea.Cmd {
281- return fetchKeys(m.dbpool, m.user)
282+ m.shared.Styles.Delete.Render("(y/N)")
283 }
284
285-// fetchKeys loads the current set of keys via the charm client.
286-func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
287+func FetchTokens(shrd common.SharedModel) tea.Cmd {
288 return func() tea.Msg {
289- ak, err := dbpool.FindTokensForUser(user.ID)
290+ ak, err := shrd.Dbpool.FindTokensForUser(shrd.User.ID)
291 if err != nil {
292 return errMsg{err}
293 }
294@@ -341,10 +283,10 @@ func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
295 }
296
297 // unlinkKey deletes the selected key.
298-func unlinkKey(m Model) tea.Cmd {
299+func (m *Model) unlinkKey() tea.Cmd {
300 return func() tea.Msg {
301 id := m.tokens[m.getSelectedIndex()].ID
302- err := m.dbpool.RemoveToken(id)
303+ err := m.shared.Dbpool.RemoveToken(id)
304 if err != nil {
305 return errMsg{err}
306 }
+1,
-1
1@@ -17,7 +17,7 @@ type styledKey struct {
2 dateVal string
3 }
4
5-func (m Model) newStyledKey(styles common.Styles, token *db.Token, active bool) styledKey {
6+func newStyledKey(styles common.Styles, token *db.Token, active bool) styledKey {
7 date := token.CreatedAt.Format("02 Jan 2006")
8
9 // Default state
+194,
-0
1@@ -0,0 +1,194 @@
2+package tui
3+
4+import (
5+ "errors"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/wish"
9+ "github.com/muesli/reflow/wordwrap"
10+ "github.com/muesli/reflow/wrap"
11+ "github.com/picosh/pico/db"
12+ "github.com/picosh/pico/shared"
13+ "github.com/picosh/pico/tui/common"
14+ "github.com/picosh/pico/tui/createaccount"
15+ "github.com/picosh/pico/tui/createkey"
16+ "github.com/picosh/pico/tui/createtoken"
17+ "github.com/picosh/pico/tui/menu"
18+ "github.com/picosh/pico/tui/notifications"
19+ "github.com/picosh/pico/tui/pages"
20+ "github.com/picosh/pico/tui/plus"
21+ "github.com/picosh/pico/tui/pubkeys"
22+ "github.com/picosh/pico/tui/tokens"
23+)
24+
25+type state int
26+
27+const (
28+ initState state = iota
29+ readyState
30+)
31+
32+// Just a generic tea.Model to demo terminal information of ssh.
33+type UI struct {
34+ shared common.SharedModel
35+
36+ state state
37+ activePage pages.Page
38+ pages []tea.Model
39+}
40+
41+func NewUI(shared common.SharedModel) *UI {
42+ m := &UI{
43+ shared: shared,
44+ state: initState,
45+ pages: make([]tea.Model, 8),
46+ }
47+ return m
48+}
49+
50+func (m *UI) updateActivePage(msg tea.Msg) tea.Cmd {
51+ nm, cmd := m.pages[m.activePage].Update(msg)
52+ m.pages[m.activePage] = nm
53+ return cmd
54+}
55+
56+func (m *UI) updateModels(msg tea.Msg) tea.Cmd {
57+ cmds := []tea.Cmd{}
58+ for i, page := range m.pages {
59+ if page == nil {
60+ continue
61+ }
62+ nm, cmd := page.Update(msg)
63+ m.pages[i] = nm
64+ cmds = append(cmds, cmd)
65+ }
66+ return tea.Batch(cmds...)
67+}
68+
69+func (m *UI) Init() tea.Cmd {
70+ user, err := findUser(m.shared)
71+ if err != nil {
72+ wish.Errorln(m.shared.Session, err)
73+ return tea.Quit
74+ }
75+ m.shared.User = user
76+
77+ ff, _ := findPlusFeatureFlag(m.shared)
78+ m.shared.PlusFeatureFlag = ff
79+
80+ m.pages[pages.MenuPage] = menu.NewModel(m.shared)
81+ m.pages[pages.CreateAccountPage] = createaccount.NewModel(m.shared)
82+ m.pages[pages.CreatePubkeyPage] = createkey.NewModel(m.shared)
83+ m.pages[pages.CreateTokenPage] = createtoken.NewModel(m.shared)
84+ m.pages[pages.CreateAccountPage] = createaccount.NewModel(m.shared)
85+ m.pages[pages.PubkeysPage] = pubkeys.NewModel(m.shared)
86+ m.pages[pages.TokensPage] = tokens.NewModel(m.shared)
87+ m.pages[pages.NotificationsPage] = notifications.NewModel(m.shared)
88+ m.pages[pages.PlusPage] = plus.NewModel(m.shared)
89+ if m.shared.User == nil {
90+ m.activePage = pages.CreateAccountPage
91+ } else {
92+ m.activePage = pages.MenuPage
93+ }
94+ m.state = readyState
95+ return nil
96+}
97+
98+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99+ var cmds []tea.Cmd
100+
101+ switch msg := msg.(type) {
102+ case tea.WindowSizeMsg:
103+ m.shared.Width = msg.Width
104+ m.shared.Height = msg.Height
105+ return m, m.updateModels(msg)
106+
107+ case tea.KeyMsg:
108+ switch msg.Type {
109+ case tea.KeyCtrlC:
110+ m.shared.Dbpool.Close()
111+ return m, tea.Quit
112+ }
113+
114+ case pages.NavigateMsg:
115+ // send message to the active page so it can teardown
116+ // and reset itself
117+ cmds = append(cmds, m.updateActivePage(msg))
118+ m.activePage = msg.Page
119+
120+ // user created account
121+ case createaccount.CreateAccountMsg:
122+ return m, m.Init()
123+
124+ case menu.MenuChoiceMsg:
125+ switch msg.MenuChoice {
126+ case menu.KeysChoice:
127+ m.activePage = pages.PubkeysPage
128+ case menu.TokensChoice:
129+ m.activePage = pages.TokensPage
130+ case menu.PlusChoice:
131+ m.activePage = pages.PlusPage
132+ case menu.NotificationsChoice:
133+ m.activePage = pages.NotificationsPage
134+ case menu.ChatChoice:
135+ return m, LoadChat(m.shared)
136+ case menu.ExitChoice:
137+ m.shared.Dbpool.Close()
138+ return m, tea.Quit
139+ }
140+
141+ cmds = append(cmds, m.pages[m.activePage].Init())
142+ }
143+
144+ cmd := m.updateActivePage(msg)
145+ cmds = append(cmds, cmd)
146+
147+ return m, tea.Batch(cmds...)
148+}
149+
150+func (m *UI) View() string {
151+ w := m.shared.Width - m.shared.Styles.App.GetHorizontalFrameSize()
152+ s := m.shared.Styles.Logo.SetString("pico.sh").String() + "\n\n"
153+ if m.pages[m.activePage] != nil {
154+ s += m.pages[m.activePage].View()
155+ }
156+ str := wrap.String(wordwrap.String(s, w), w)
157+ return m.shared.Styles.App.Render(str)
158+}
159+
160+func findUser(shrd common.SharedModel) (*db.User, error) {
161+ logger := shrd.Cfg.Logger
162+ var user *db.User
163+ usr := shrd.Session.User()
164+
165+ key, err := shared.KeyForKeyText(shrd.Session.PublicKey())
166+ if err != nil {
167+ return nil, err
168+ }
169+
170+ user, err = shrd.Dbpool.FindUserForKey(usr, key)
171+ if err != nil {
172+ logger.Error("no user found for public key", "err", err.Error())
173+ // we only want to throw an error for specific cases
174+ if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
175+ return nil, err
176+ }
177+ // no user and not error indicates we need to create an account
178+ return nil, nil
179+ }
180+
181+ return user, nil
182+}
183+
184+func findPlusFeatureFlag(shrd common.SharedModel) (*db.FeatureFlag, error) {
185+ if shrd.User == nil {
186+ return nil, nil
187+ }
188+
189+ ff, err := shrd.Dbpool.FindFeatureForUser(shrd.User.ID, "plus")
190+ if err != nil {
191+ return nil, err
192+ }
193+
194+ return ff, nil
195+}