repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / tui / tokens
Antonio Mika · 31 May 24

tokens.go

  1package tokens
  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)
 20
 21type keyState int
 22
 23const (
 24	keyNormal keyState = iota
 25	keySelected
 26	keyDeleting
 27)
 28
 29type errMsg struct {
 30	err error
 31}
 32
 33func (e errMsg) Error() string { return e.err.Error() }
 34
 35type (
 36	keysLoadedMsg  []*db.Token
 37	unlinkedKeyMsg int
 38)
 39
 40// Model is the Tea state model for this user interface.
 41type Model struct {
 42	shared common.SharedModel
 43
 44	state          state
 45	err            error
 46	activeKeyIndex int         // index of the key in the below slice which is currently in use
 47	tokens         []*db.Token // keys linked to user's account
 48	index          int         // index of selected key in relation to the current page
 49
 50	pager pager.Model
 51}
 52
 53// getSelectedIndex returns the index of the cursor in relation to the total
 54// number of items.
 55func (m *Model) getSelectedIndex() int {
 56	return m.index + m.pager.Page*m.pager.PerPage
 57}
 58
 59// UpdatePaging runs an update against the underlying pagination model as well
 60// as performing some related tasks on this model.
 61func (m *Model) UpdatePaging(msg tea.Msg) {
 62	// Handle paging
 63	m.pager.SetTotalPages(len(m.tokens))
 64	m.pager, _ = m.pager.Update(msg)
 65
 66	// If selected item is out of bounds, put it in bounds
 67	numItems := m.pager.ItemsOnPage(len(m.tokens))
 68	m.index = min(m.index, numItems-1)
 69}
 70
 71// NewModel creates a new model with defaults.
 72func NewModel(shared common.SharedModel) Model {
 73	p := pager.New()
 74	p.PerPage = keysPerPage
 75	p.Type = pager.Dots
 76	p.InactiveDot = shared.Styles.InactivePagination.Render("•")
 77
 78	return Model{
 79		shared: shared,
 80
 81		state:          stateLoading,
 82		err:            nil,
 83		activeKeyIndex: -1,
 84		tokens:         []*db.Token{},
 85		index:          0,
 86
 87		pager: p,
 88	}
 89}
 90
 91// Init is the Tea initialization function.
 92func (m Model) Init() tea.Cmd {
 93	return FetchTokens(m.shared)
 94}
 95
 96// Update is the tea update function which handles incoming messages.
 97func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 98	var (
 99		cmds []tea.Cmd
100	)
101
102	switch msg := msg.(type) {
103	case tea.KeyMsg:
104		switch msg.String() {
105		case "q", "esc":
106			return m, pages.Navigate(pages.MenuPage)
107		case "up", "k":
108			m.index--
109			if m.index < 0 && m.pager.Page > 0 {
110				m.index = m.pager.PerPage - 1
111				m.pager.PrevPage()
112			}
113			m.index = max(0, m.index)
114		case "down", "j":
115			itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
116			m.index++
117			if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
118				m.index = 0
119				m.pager.NextPage()
120			}
121			m.index = min(itemsOnPage-1, m.index)
122
123		case "n":
124			return m, pages.Navigate(pages.CreateTokenPage)
125
126		// Delete
127		case "x":
128			m.state = stateDeletingKey
129			m.UpdatePaging(msg)
130			return m, nil
131
132		// Confirm Delete
133		case "y":
134			switch m.state {
135			case stateDeletingKey:
136				m.state = stateNormal
137				return m, m.unlinkKey()
138			}
139		}
140
141	case errMsg:
142		m.err = msg.err
143		return m, nil
144
145	case keysLoadedMsg:
146		m.state = stateNormal
147		m.index = 0
148		m.tokens = msg
149
150	case unlinkedKeyMsg:
151		i := m.getSelectedIndex()
152
153		// Remove key from array
154		m.tokens = append(m.tokens[:i], m.tokens[i+1:]...)
155
156		// Update pagination
157		m.pager.SetTotalPages(len(m.tokens))
158		m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
159
160		// Update cursor
161		m.index = min(m.index, m.pager.ItemsOnPage(len(m.tokens)-1))
162
163		return m, nil
164
165	// leaving page so reset model
166	case pages.NavigateMsg:
167		next := NewModel(m.shared)
168		return next, next.Init()
169	}
170
171	switch m.state {
172	case stateDeletingKey:
173		// If an item is being confirmed for delete, any key (other than the key
174		// used for confirmation above) cancels the deletion
175		k, ok := msg.(tea.KeyMsg)
176		if ok && k.String() != "y" {
177			m.state = stateNormal
178		}
179	}
180
181	m.UpdatePaging(msg)
182	return m, tea.Batch(cmds...)
183}
184
185// View renders the current UI into a string.
186func (m Model) View() string {
187	if m.err != nil {
188		return m.err.Error()
189	}
190
191	var s string
192
193	switch m.state {
194	case stateLoading:
195		s = "Loading...\n\n"
196	default:
197		s = "Here are the tokens linked to your account. An API token can be used for connecting to our IRC bouncer or your pico RSS feed.\n\n"
198
199		// Keys
200		s += keysView(m)
201		if m.pager.TotalPages > 1 {
202			s += m.pager.View()
203		}
204
205		// Footer
206		switch m.state {
207		case stateDeletingKey:
208			s += m.promptView("Delete this key?")
209		default:
210			s += "\n\n" + m.helpView()
211		}
212	}
213
214	return s
215}
216
217func keysView(m Model) string {
218	var (
219		s          string
220		state      keyState
221		start, end = m.pager.GetSliceBounds(len(m.tokens))
222		slice      = m.tokens[start:end]
223	)
224
225	destructiveState := m.state == stateDeletingKey
226
227	// Render key info
228	for i, key := range slice {
229		if destructiveState && m.index == i {
230			state = keyDeleting
231		} else if m.index == i {
232			state = keySelected
233		} else {
234			state = keyNormal
235		}
236		s += newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
237	}
238
239	// If there aren't enough keys to fill the view, fill the missing parts
240	// with whitespace
241	if len(slice) < m.pager.PerPage {
242		for i := len(slice); i < m.pager.PerPage; i++ {
243			s += "\n\n\n"
244		}
245	}
246
247	return s
248}
249
250func (m *Model) helpView() string {
251	var items []string
252	if len(m.tokens) > 1 {
253		items = append(items, "j/k, ↑/↓: choose")
254	}
255	if m.pager.TotalPages > 1 {
256		items = append(items, "h/l, ←/→: page")
257	}
258	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
259	return common.HelpView(m.shared.Styles, items...)
260}
261
262func (m *Model) promptView(prompt string) string {
263	st := m.shared.Styles.Delete.MarginTop(2).MarginRight(1)
264	return st.Render(prompt) +
265		m.shared.Styles.Delete.Render("(y/N)")
266}
267
268func FetchTokens(shrd common.SharedModel) tea.Cmd {
269	return func() tea.Msg {
270		ak, err := shrd.Dbpool.FindTokensForUser(shrd.User.ID)
271		if err != nil {
272			return errMsg{err}
273		}
274		return keysLoadedMsg(ak)
275	}
276}
277
278// unlinkKey deletes the selected key.
279func (m *Model) unlinkKey() tea.Cmd {
280	return func() tea.Msg {
281		id := m.tokens[m.getSelectedIndex()].ID
282		err := m.shared.Dbpool.RemoveToken(id)
283		if err != nil {
284			return errMsg{err}
285		}
286		return unlinkedKeyMsg(m.index)
287	}
288}
289
290// Utils
291
292func min(a, b int) int {
293	if a < b {
294		return a
295	}
296	return b
297}
298
299func max(a, b int) int {
300	if a > b {
301		return a
302	}
303	return b
304}