Antonio Mika
·
31 May 24
keys.go
1package pubkeys
2
3import (
4 pager "github.com/charmbracelet/bubbles/paginator"
5 tea "github.com/charmbracelet/bubbletea"
6 "github.com/picosh/pico/db"
7 "github.com/picosh/pico/tui/common"
8 "github.com/picosh/pico/tui/pages"
9)
10
11const keysPerPage = 4
12
13type state int
14
15const (
16 stateLoading state = iota
17 stateNormal
18 stateDeletingKey
19 stateDeletingActiveKey
20 stateDeletingAccount
21 stateQuitting
22)
23
24type keyState int
25
26const (
27 keyNormal keyState = iota
28 keySelected
29 keyDeleting
30)
31
32type (
33 keysLoadedMsg []*db.PublicKey
34 unlinkedKeyMsg int
35)
36
37// Model is the Tea state model for this user interface.
38type Model struct {
39 shared common.SharedModel
40
41 state state
42 err error
43 activeKeyIndex int // index of the key in the below slice which is currently in use
44 keys []*db.PublicKey // keys linked to user's account
45 index int // index of selected key in relation to the current page
46
47 pager pager.Model
48}
49
50// NewModel creates a new model with defaults.
51func NewModel(shared common.SharedModel) Model {
52 p := pager.New()
53 p.PerPage = keysPerPage
54 p.Type = pager.Dots
55 p.InactiveDot = shared.Styles.InactivePagination.Render("•")
56
57 return Model{
58 shared: shared,
59
60 pager: p,
61 state: stateLoading,
62 err: nil,
63 activeKeyIndex: -1,
64 keys: []*db.PublicKey{},
65 index: 0,
66 }
67}
68
69// getSelectedIndex returns the index of the cursor in relation to the total
70// number of items.
71func (m *Model) getSelectedIndex() int {
72 return m.index + m.pager.Page*m.pager.PerPage
73}
74
75// UpdatePaging runs an update against the underlying pagination model as well
76// as performing some related tasks on this model.
77func (m *Model) UpdatePaging(msg tea.Msg) {
78 // Handle paging
79 m.pager.SetTotalPages(len(m.keys))
80 m.pager, _ = m.pager.Update(msg)
81
82 // If selected item is out of bounds, put it in bounds
83 numItems := m.pager.ItemsOnPage(len(m.keys))
84 m.index = min(m.index, numItems-1)
85}
86
87// Init is the Tea initialization function.
88func (m Model) Init() tea.Cmd {
89 return FetchKeys(m.shared)
90}
91
92// Update is the tea update function which handles incoming messages.
93func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94 var (
95 cmds []tea.Cmd
96 )
97
98 switch msg := msg.(type) {
99 case tea.KeyMsg:
100 switch msg.String() {
101 case "q", "esc":
102 return m, pages.Navigate(pages.MenuPage)
103 case "up", "k":
104 m.index -= 1
105 if m.index < 0 && m.pager.Page > 0 {
106 m.index = m.pager.PerPage - 1
107 m.pager.PrevPage()
108 }
109 m.index = max(0, m.index)
110 case "down", "j":
111 itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
112 m.index += 1
113 if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
114 m.index = 0
115 m.pager.NextPage()
116 }
117 m.index = min(itemsOnPage-1, m.index)
118
119 case "n":
120 return m, pages.Navigate(pages.CreatePubkeyPage)
121
122 // Delete
123 case "x":
124 m.state = stateDeletingKey
125 m.UpdatePaging(msg)
126 return m, nil
127
128 // Confirm Delete
129 case "y":
130 switch m.state {
131 case stateDeletingKey:
132 if len(m.keys) == 1 {
133 // The user is about to delete her account. Double confirm.
134 m.state = stateDeletingAccount
135 return m, nil
136 }
137 if m.getSelectedIndex() == m.activeKeyIndex {
138 // The user is going to delete her active key. Double confirm.
139 m.state = stateDeletingActiveKey
140 return m, nil
141 }
142 m.state = stateNormal
143 return m, m.unlinkKey()
144 case stateDeletingActiveKey:
145 m.state = stateQuitting
146 // Active key will be deleted. Remove the key and exit.
147 return m, m.unlinkKey()
148 case stateDeletingAccount:
149 // Account will be deleted. Remove the key and exit.
150 m.state = stateQuitting
151 return m, m.deleteAccount()
152 }
153 }
154
155 case common.ErrMsg:
156 m.err = msg.Err
157 return m, nil
158
159 case keysLoadedMsg:
160 m.state = stateNormal
161 m.index = 0
162 m.keys = msg
163 for i, key := range m.keys {
164 if key.Key == m.shared.User.PublicKey.Key {
165 m.activeKeyIndex = i
166 }
167 }
168
169 case unlinkedKeyMsg:
170 if m.state == stateQuitting {
171 return m, tea.Quit
172 }
173 i := m.getSelectedIndex()
174
175 // Remove key from array
176 m.keys = append(m.keys[:i], m.keys[i+1:]...)
177
178 // Update pagination
179 m.pager.SetTotalPages(len(m.keys))
180 m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
181
182 // Update cursor
183 m.index = min(m.index, m.pager.ItemsOnPage(len(m.keys)-1))
184 for i, key := range m.keys {
185 if key.Key == m.shared.User.PublicKey.Key {
186 m.activeKeyIndex = i
187 }
188 }
189
190 return m, nil
191
192 // leaving page so reset model
193 case pages.NavigateMsg:
194 next := NewModel(m.shared)
195 return next, next.Init()
196 }
197
198 switch m.state {
199 case stateDeletingKey:
200 // If an item is being confirmed for delete, any key (other than the key
201 // used for confirmation above) cancels the deletion
202 k, ok := msg.(tea.KeyMsg)
203 if ok && k.String() != "y" {
204 m.state = stateNormal
205 }
206 }
207
208 m.UpdatePaging(msg)
209 return m, tea.Batch(cmds...)
210}
211
212// View renders the current UI into a string.
213func (m Model) View() string {
214 if m.err != nil {
215 return m.err.Error()
216 }
217
218 var s string
219
220 switch m.state {
221 case stateLoading:
222 s = "Loading...\n\n"
223 case stateQuitting:
224 s = "Thanks for using pico.sh!\n"
225 default:
226 s = "Here are the pubkeys linked to your account. Add more pubkeys to be able to login with multiple SSH keypairs.\n\n"
227
228 // Keys
229 s += m.keysView()
230 if m.pager.TotalPages > 1 {
231 s += m.pager.View()
232 }
233
234 // Footer
235 switch m.state {
236 case stateDeletingKey:
237 s += m.promptView("Delete this key?")
238 case stateDeletingActiveKey:
239 s += m.promptView("This is the key currently in use. Are you, like, for-sure-for-sure?")
240 case stateDeletingAccount:
241 s += m.promptView("Sure? This will delete your account. Are you absolutely positive?")
242 default:
243 s += "\n\n" + m.helpView()
244 }
245 }
246
247 return s
248}
249
250func (m *Model) keysView() string {
251 var (
252 s string
253 state keyState
254 start, end = m.pager.GetSliceBounds(len(m.keys))
255 slice = m.keys[start:end]
256 )
257
258 destructiveState :=
259 (m.state == stateDeletingKey ||
260 m.state == stateDeletingActiveKey ||
261 m.state == stateDeletingAccount)
262
263 // Render key info
264 for i, key := range slice {
265 if destructiveState && m.index == i {
266 state = keyDeleting
267 } else if m.index == i {
268 state = keySelected
269 } else {
270 state = keyNormal
271 }
272 s += m.newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
273 }
274
275 // If there aren't enough keys to fill the view, fill the missing parts
276 // with whitespace
277 if len(slice) < m.pager.PerPage {
278 for i := len(slice); i < m.pager.PerPage; i++ {
279 s += "\n\n\n"
280 }
281 }
282
283 return s
284}
285
286func (m *Model) helpView() string {
287 var items []string
288 if len(m.keys) > 1 {
289 items = append(items, "j/k, ↑/↓: choose")
290 }
291 if m.pager.TotalPages > 1 {
292 items = append(items, "h/l, ←/→: page")
293 }
294 items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
295 return common.HelpView(m.shared.Styles, items...)
296}
297
298func (m *Model) promptView(prompt string) string {
299 st := m.shared.Styles.Delete.MarginTop(2).MarginRight(1)
300 return st.Render(prompt) +
301 m.shared.Styles.Delete.Render("(y/N)")
302}
303
304// FetchKeys loads the current set of keys via the charm client.
305func FetchKeys(shrd common.SharedModel) tea.Cmd {
306 return func() tea.Msg {
307 ak, err := shrd.Dbpool.FindKeysForUser(shrd.User)
308 if err != nil {
309 return common.ErrMsg{Err: err}
310 }
311 return keysLoadedMsg(ak)
312 }
313}
314
315// unlinkKey deletes the selected key.
316func (m *Model) unlinkKey() tea.Cmd {
317 return func() tea.Msg {
318 id := m.keys[m.getSelectedIndex()].ID
319 err := m.shared.Dbpool.RemoveKeys([]string{id})
320 if err != nil {
321 return common.ErrMsg{Err: err}
322 }
323 return unlinkedKeyMsg(m.index)
324 }
325}
326
327func (m *Model) deleteAccount() tea.Cmd {
328 return func() tea.Msg {
329 id := m.keys[m.getSelectedIndex()].UserID
330 m.shared.Logger.Info("user requested account deletion", "user", m.shared.User.Name, "id", id)
331 err := m.shared.Dbpool.RemoveUsers([]string{id})
332 if err != nil {
333 return common.ErrMsg{Err: err}
334 }
335 return unlinkedKeyMsg(m.index)
336 }
337}
338
339// Utils
340
341func min(a, b int) int {
342 if a < b {
343 return a
344 }
345 return b
346}
347
348func max(a, b int) int {
349 if a > b {
350 return a
351 }
352 return b
353}