Eric Bower
·
2026-08-24
1package tui
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "math"
8 "strings"
9
10 "git.sr.ht/~rockorager/vaxis"
11 "git.sr.ht/~rockorager/vaxis/vxfw"
12 "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
13 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
14 "github.com/picosh/pico/pkg/db"
15 "github.com/picosh/pico/pkg/pssh"
16 "github.com/picosh/pico/pkg/shared"
17)
18
19const (
20 adminPrefix = "admin__"
21 HOME = "dash"
22)
23
24type SharedModel struct {
25 Logger *slog.Logger
26 Session *pssh.SSHServerConnSession
27 Cfg *shared.ConfigSite
28 Dbpool db.DB
29 PgsDB pgsdb.PgsDB
30 User *db.User
31 PlusFeatureFlag *db.FeatureFlag
32 BouncerFeatureFlag *db.FeatureFlag
33 PgsFeatureFlag *db.FeatureFlag
34 Impersonator string
35 App *vxfw.App
36}
37
38type Navigate struct{ To string }
39type PageIn struct{}
40type PageOut struct{}
41
42var fuschia = vaxis.HexColor(0xEE6FF8)
43var cream = vaxis.HexColor(0xFFFDF5)
44var green = vaxis.HexColor(0x04B575)
45var grey = vaxis.HexColor(0x5C5C5C)
46var red = vaxis.HexColor(0xED567A)
47
48// var white = vaxis.HexColor(0xFFFFFF).
49var oj = vaxis.HexColor(0xFFCA80)
50var purp = vaxis.HexColor(0xBD93F9)
51var black = vaxis.HexColor(0x282A36)
52
53func createDrawCtx(ctx vxfw.DrawContext, h uint16) vxfw.DrawContext {
54 // setting some sane defaults to prevent exceptions
55 var height uint16 = 1
56 if h >= height {
57 height = h
58 }
59 var width uint16 = 80
60 if ctx.Max.Width < math.MaxUint16 {
61 width = ctx.Max.Width
62 }
63 return vxfw.DrawContext{
64 Characters: ctx.Characters,
65 Max: vxfw.Size{
66 Width: width,
67 Height: height,
68 },
69 }
70}
71
72type App struct {
73 shared *SharedModel
74 pages map[string]vxfw.Widget
75 page string
76}
77
78func (app *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
79 switch msg := ev.(type) {
80 case vaxis.Key:
81 if msg.Matches('c', vaxis.ModCtrl) {
82 return vxfw.QuitCmd{}, nil
83 }
84 if msg.Matches(vaxis.KeyEsc) {
85 if app.page == "signup" || app.page == HOME {
86 return nil, nil
87 }
88 app.shared.App.PostEvent(Navigate{To: HOME})
89 }
90 }
91 return nil, nil
92}
93
94type WidgetFooter interface {
95 Footer() []Shortcut
96}
97
98func (app *App) GetCurPage() vxfw.Widget {
99 return app.pages[app.page]
100}
101
102func (app *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
103 switch msg := ev.(type) {
104 case vxfw.Init:
105 page := HOME
106 // no user? kick them to the create account page
107 if app.shared.User == nil {
108 page = "signup"
109 }
110 app.shared.App.PostEvent(Navigate{To: page})
111 return nil, nil
112 case Navigate:
113 cmds := []vxfw.Command{}
114 cur := app.GetCurPage()
115 if cur != nil {
116 // send event to page notifying that we are leaving
117 cmd, _ := cur.(vxfw.EventHandler).HandleEvent(PageOut{}, vxfw.TargetPhase)
118 if cmd != nil {
119 cmds = append(cmds, cmd)
120 }
121 }
122
123 // switch the page
124 app.page = msg.To
125
126 next := app.GetCurPage()
127 if next != nil {
128 // send event to page notifying that we are entering
129 cmd, _ := next.(vxfw.EventHandler).HandleEvent(PageIn{}, vxfw.TargetPhase)
130 if cmd != nil {
131 cmds = append(cmds, cmd)
132 }
133 }
134
135 cmds = append(
136 cmds,
137 vxfw.RedrawCmd{},
138 )
139 return vxfw.BatchCmd(cmds), nil
140 }
141 return nil, nil
142}
143
144func (app *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
145 w := ctx.Max.Width
146 h := ctx.Max.Height
147 root := vxfw.NewSurface(w, ctx.Min.Height, app)
148
149 ah := 1
150 header := NewHeaderWdt(app.shared, app.page)
151 headerSurf, _ := header.Draw(vxfw.DrawContext{
152 Max: vxfw.Size{Width: w, Height: 2},
153 Characters: ctx.Characters,
154 })
155 root.AddChild(1, ah, headerSurf)
156 ah += int(headerSurf.Size.Height)
157
158 cur := app.GetCurPage()
159 if cur != nil {
160 pageCtx := vxfw.DrawContext{
161 Characters: ctx.Characters,
162 Max: vxfw.Size{Width: ctx.Max.Width - 1, Height: h - 2 - uint16(ah)},
163 }
164 surface, _ := app.GetCurPage().Draw(pageCtx)
165 root.AddChild(1, ah, surface)
166 }
167
168 wdgt, ok := cur.(WidgetFooter)
169 segs := []Shortcut{
170 {Shortcut: "^c", Text: "quit"},
171 {Shortcut: "esc", Text: "prev page"},
172 }
173 if ok {
174 segs = append(segs, wdgt.Footer()...)
175 }
176 footer := NewFooterWdt(app.shared, segs)
177 footerSurf, _ := footer.Draw(vxfw.DrawContext{
178 Max: vxfw.Size{Width: w, Height: 2},
179 Characters: ctx.Characters,
180 })
181 root.AddChild(1, int(ctx.Max.Height)-2, footerSurf)
182
183 return root, nil
184}
185
186type HeaderWdgt struct {
187 shared *SharedModel
188
189 page string
190}
191
192func NewHeaderWdt(shrd *SharedModel, page string) *HeaderWdgt {
193 return &HeaderWdgt{
194 shared: shrd,
195 page: page,
196 }
197}
198
199func (m *HeaderWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
200 return nil, nil
201}
202
203func (m *HeaderWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
204 logoTxt := "pico.sh"
205 ff := m.shared.PlusFeatureFlag
206 if ff != nil && ff.IsValid() {
207 logoTxt = "pico+"
208 }
209
210 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
211 // header
212 wdgt := richtext.New([]vaxis.Segment{
213 {Text: " " + logoTxt + " ", Style: vaxis.Style{Background: purp, Foreground: black}},
214 {Text: " • " + m.page, Style: vaxis.Style{Foreground: green}},
215 })
216 surf, _ := wdgt.Draw(ctx)
217 root.AddChild(0, 0, surf)
218
219 if m.shared.User != nil {
220 user := richtext.New([]vaxis.Segment{
221 {Text: "~" + m.shared.User.Name, Style: vaxis.Style{Foreground: cream}},
222 })
223 surf, _ = user.Draw(ctx)
224 root.AddChild(int(ctx.Max.Width)-int(surf.Size.Width)-1, 0, surf)
225 }
226
227 return root, nil
228}
229
230type Shortcut struct {
231 Text string
232 Shortcut string
233}
234
235type FooterWdgt struct {
236 shared *SharedModel
237
238 cmds []Shortcut
239}
240
241func NewFooterWdt(shrd *SharedModel, cmds []Shortcut) *FooterWdgt {
242 return &FooterWdgt{
243 shared: shrd,
244 cmds: cmds,
245 }
246}
247
248func (m *FooterWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
249 return nil, nil
250}
251
252func (m *FooterWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
253 segs := []vaxis.Segment{}
254 for idx, shortcut := range m.cmds {
255 segs = append(
256 segs,
257 vaxis.Segment{Text: shortcut.Shortcut, Style: vaxis.Style{Foreground: fuschia}},
258 vaxis.Segment{Text: " " + shortcut.Text},
259 )
260 if idx < len(m.cmds)-1 {
261 segs = append(segs, vaxis.Segment{Text: " • "})
262 }
263 }
264 wdgt := richtext.New(segs)
265 return wdgt.Draw(ctx)
266}
267
268func initData(shrd *SharedModel) error {
269 user, err := FindUser(shrd)
270 if err != nil {
271 return err
272 }
273 shrd.User = user
274
275 ff, _ := FindFeatureFlag(shrd, "plus")
276 shrd.PlusFeatureFlag = ff
277
278 bff, _ := FindFeatureFlag(shrd, "bouncer")
279 shrd.BouncerFeatureFlag = bff
280
281 pff, _ := FindFeatureFlag(shrd, "pgs")
282 shrd.PgsFeatureFlag = pff
283 return nil
284}
285
286func FindUser(shrd *SharedModel) (*db.User, error) {
287 logger := shrd.Cfg.Logger
288 var user *db.User
289 usr := shrd.Session.User()
290
291 if shrd.Session.PublicKey() == nil {
292 return nil, fmt.Errorf("unable to find public key")
293 }
294
295 key := shared.KeyForKeyText(shrd.Session.PublicKey())
296
297 user, err := shrd.Dbpool.FindUserByKey(usr, key)
298 if err != nil {
299 logger.Error("no user found for public key", "err", err.Error())
300 // we only want to throw an error for specific cases
301 if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
302 return nil, err
303 }
304 // no user and not error indicates we need to create an account
305 return nil, nil
306 }
307
308 origUserName := user.Name
309
310 // impersonation
311 if strings.HasPrefix(usr, adminPrefix) {
312 hasFeature := shrd.Dbpool.HasFeatureByUser(user.ID, "admin")
313 if hasFeature {
314 impersonate := strings.TrimPrefix(usr, adminPrefix)
315 user, err = shrd.Dbpool.FindUserByName(impersonate)
316 if err == nil {
317 shrd.Impersonator = origUserName
318 }
319 }
320 }
321
322 return user, nil
323}
324
325func FindFeatureFlag(shrd *SharedModel, name string) (*db.FeatureFlag, error) {
326 if shrd.User == nil {
327 return nil, nil
328 }
329
330 ff, err := shrd.Dbpool.FindFeature(shrd.User.ID, name)
331 if err != nil {
332 return nil, err
333 }
334
335 return ff, nil
336}
337
338func NewTui(opts vaxis.Options, shrd *SharedModel) error {
339 err := initData(shrd)
340 if err != nil {
341 return err
342 }
343
344 app, err := vxfw.NewApp(opts)
345 if err != nil {
346 return err
347 }
348
349 shrd.App = app
350 pages := map[string]vxfw.Widget{
351 HOME: NewMenuPage(shrd),
352 "pubkeys": NewPubkeysPage(shrd),
353 "add-pubkey": NewAddPubkeyPage(shrd),
354 "tokens": NewTokensPage(shrd),
355 "add-token": NewAddTokenPage(shrd),
356 "signup": NewSignupPage(shrd),
357 "pico+": NewPlusPage(shrd),
358 "logs": NewLogsPage(shrd),
359 "analytics": NewAnalyticsPage(shrd),
360 "chat": NewChatPage(shrd),
361 "tuns": NewTunsPage(shrd),
362 "access_logs": NewAccessLogsPage(shrd),
363 "pages": NewPagesPage(shrd),
364 }
365 root := &App{
366 shared: shrd,
367 pages: pages,
368 }
369
370 return app.Run(root)
371}