1package selection
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/key"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10 "github.com/charmbracelet/soft-serve/config"
11 "github.com/charmbracelet/soft-serve/ui/common"
12 "github.com/charmbracelet/soft-serve/ui/components/code"
13 "github.com/charmbracelet/soft-serve/ui/components/selector"
14 "github.com/charmbracelet/soft-serve/ui/components/tabs"
15 "github.com/charmbracelet/soft-serve/ui/git"
16 wgit "github.com/charmbracelet/wish/git"
17 "github.com/gliderlabs/ssh"
18)
19
20type box int
21
22const (
23 readmeBox box = iota
24 selectorBox
25)
26
27// Selection is the model for the selection screen/page.
28type Selection struct {
29 cfg *config.Config
30 pk ssh.PublicKey
31 common common.Common
32 readme *code.Code
33 readmeHeight int
34 selector *selector.Selector
35 activeBox box
36 tabs *tabs.Tabs
37}
38
39// New creates a new selection model.
40func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
41 t := tabs.New(common, []string{"About", "Repositories"})
42 t.TabSeparator = lipgloss.NewStyle()
43 t.TabInactive = lipgloss.NewStyle().
44 Bold(true).
45 UnsetBackground().
46 Foreground(common.Styles.InactiveBorderColor).
47 Padding(0, 1)
48 t.TabActive = t.TabInactive.Copy().
49 Background(lipgloss.Color("62")).
50 Foreground(lipgloss.Color("230"))
51 sel := &Selection{
52 cfg: cfg,
53 pk: pk,
54 common: common,
55 activeBox: readmeBox, // start with the selector focused
56 tabs: t,
57 }
58 readme := code.New(common, "", "")
59 readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
60 selector := selector.New(common,
61 []selector.IdentifiableItem{},
62 ItemDelegate{&common, &sel.activeBox})
63 selector.SetShowTitle(false)
64 selector.SetShowHelp(false)
65 selector.SetShowStatusBar(false)
66 selector.DisableQuitKeybindings()
67 sel.selector = selector
68 sel.readme = readme
69 return sel
70}
71
72func (s *Selection) getMargins() (wm, hm int) {
73 wm = 0
74 hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
75 s.common.Styles.Tabs.GetHeight() +
76 2 // tabs margin see View()
77 switch s.activeBox {
78 case selectorBox:
79 hm += s.common.Styles.SelectorBox.GetVerticalFrameSize() +
80 s.common.Styles.SelectorBox.GetHeight()
81 case readmeBox:
82 hm += s.common.Styles.ReadmeBox.GetVerticalFrameSize() +
83 s.common.Styles.ReadmeBox.GetHeight() +
84 1 // readme statusbar
85 }
86 return
87}
88
89// SetSize implements common.Component.
90func (s *Selection) SetSize(width, height int) {
91 s.common.SetSize(width, height)
92 wm, hm := s.getMargins()
93 s.tabs.SetSize(width, height-hm)
94 s.selector.SetSize(width-wm, height-hm)
95 s.readme.SetSize(width-wm, height-hm)
96}
97
98// ShortHelp implements help.KeyMap.
99func (s *Selection) ShortHelp() []key.Binding {
100 k := s.selector.KeyMap
101 kb := make([]key.Binding, 0)
102 kb = append(kb,
103 s.common.KeyMap.UpDown,
104 s.common.KeyMap.Section,
105 )
106 if s.activeBox == selectorBox {
107 copyKey := s.common.KeyMap.Copy
108 copyKey.SetHelp("c", "copy command")
109 kb = append(kb,
110 s.common.KeyMap.Select,
111 k.Filter,
112 k.ClearFilter,
113 copyKey,
114 )
115 }
116 return kb
117}
118
119// FullHelp implements help.KeyMap.
120func (s *Selection) FullHelp() [][]key.Binding {
121 switch s.activeBox {
122 case readmeBox:
123 k := s.readme.KeyMap
124 return [][]key.Binding{
125 {
126 k.PageDown,
127 k.PageUp,
128 },
129 {
130 k.HalfPageDown,
131 k.HalfPageUp,
132 },
133 {
134 k.Down,
135 k.Up,
136 },
137 }
138 case selectorBox:
139 copyKey := s.common.KeyMap.Copy
140 copyKey.SetHelp("c", "copy command")
141 k := s.selector.KeyMap
142 return [][]key.Binding{
143 {
144 s.common.KeyMap.Select,
145 copyKey,
146 k.CursorUp,
147 k.CursorDown,
148 },
149 {
150 k.NextPage,
151 k.PrevPage,
152 k.GoToStart,
153 k.GoToEnd,
154 },
155 {
156 k.Filter,
157 k.ClearFilter,
158 k.CancelWhileFiltering,
159 k.AcceptWhileFiltering,
160 },
161 }
162 }
163 return [][]key.Binding{}
164}
165
166// Init implements tea.Model.
167func (s *Selection) Init() tea.Cmd {
168 var readmeCmd tea.Cmd
169 items := make([]selector.IdentifiableItem, 0)
170 cfg := s.cfg
171 pk := s.pk
172 // Put configured repos first
173 for _, r := range cfg.Repos {
174 if r.Private && cfg.AuthRepo(r.Repo, pk) < wgit.AdminAccess {
175 continue
176 }
177 repo, err := cfg.Source.GetRepo(r.Repo)
178 if err != nil {
179 continue
180 }
181 items = append(items, Item{
182 repo: repo,
183 cmd: git.RepoURL(cfg.Host, cfg.Port, r.Repo),
184 })
185 }
186 for _, r := range cfg.Source.AllRepos() {
187 if r.Repo() == "config" {
188 rm, rp := r.Readme()
189 s.readmeHeight = strings.Count(rm, "\n")
190 readmeCmd = s.readme.SetContent(rm, rp)
191 }
192 if r.IsPrivate() && cfg.AuthRepo(r.Repo(), pk) < wgit.AdminAccess {
193 continue
194 }
195 exists := false
196 head, err := r.HEAD()
197 if err != nil {
198 return common.ErrorCmd(err)
199 }
200 lc, err := r.CommitsByPage(head, 1, 1)
201 if err != nil {
202 return common.ErrorCmd(err)
203 }
204 lastUpdate := lc[0].Committer.When
205 if lastUpdate.IsZero() {
206 lastUpdate = lc[0].Author.When
207 }
208 for i, item := range items {
209 item := item.(Item)
210 if item.repo.Repo() == r.Repo() {
211 exists = true
212 item.lastUpdate = lastUpdate
213 items[i] = item
214 break
215 }
216 }
217 if !exists {
218 items = append(items, Item{
219 repo: r,
220 lastUpdate: lastUpdate,
221 cmd: git.RepoURL(cfg.Host, cfg.Port, r.Name()),
222 })
223 }
224 }
225 return tea.Batch(
226 s.selector.Init(),
227 s.selector.SetItems(items),
228 readmeCmd,
229 )
230}
231
232// Update implements tea.Model.
233func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
234 cmds := make([]tea.Cmd, 0)
235 switch msg := msg.(type) {
236 case tea.WindowSizeMsg:
237 r, cmd := s.readme.Update(msg)
238 s.readme = r.(*code.Code)
239 if cmd != nil {
240 cmds = append(cmds, cmd)
241 }
242 m, cmd := s.selector.Update(msg)
243 s.selector = m.(*selector.Selector)
244 if cmd != nil {
245 cmds = append(cmds, cmd)
246 }
247 case tea.KeyMsg, tea.MouseMsg:
248 switch msg := msg.(type) {
249 case tea.KeyMsg:
250 switch {
251 case key.Matches(msg, s.common.KeyMap.Back):
252 cmds = append(cmds, s.selector.Init())
253 }
254 }
255 t, cmd := s.tabs.Update(msg)
256 s.tabs = t.(*tabs.Tabs)
257 if cmd != nil {
258 cmds = append(cmds, cmd)
259 }
260 case tabs.ActiveTabMsg:
261 s.activeBox = box(msg)
262 }
263 switch s.activeBox {
264 case readmeBox:
265 r, cmd := s.readme.Update(msg)
266 s.readme = r.(*code.Code)
267 if cmd != nil {
268 cmds = append(cmds, cmd)
269 }
270 case selectorBox:
271 m, cmd := s.selector.Update(msg)
272 s.selector = m.(*selector.Selector)
273 if cmd != nil {
274 cmds = append(cmds, cmd)
275 }
276 }
277 return s, tea.Batch(cmds...)
278}
279
280// View implements tea.Model.
281func (s *Selection) View() string {
282 var view string
283 wm, hm := s.getMargins()
284 hm++ // tabs margin
285 switch s.activeBox {
286 case selectorBox:
287 ss := s.common.Styles.SelectorBox.Copy().
288 Height(s.common.Height - hm)
289 view = ss.Render(s.selector.View())
290 case readmeBox:
291 rs := s.common.Styles.ReadmeBox.Copy().
292 Height(s.common.Height - hm)
293 status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
294 readmeStatus := lipgloss.NewStyle().
295 Align(lipgloss.Right).
296 Width(s.common.Width - wm).
297 Foreground(s.common.Styles.InactiveBorderColor).
298 Render(status)
299 view = rs.Render(lipgloss.JoinVertical(lipgloss.Top,
300 s.readme.View(),
301 readmeStatus,
302 ))
303 }
304 ts := s.common.Styles.Tabs.Copy().
305 MarginBottom(1)
306 return lipgloss.JoinVertical(lipgloss.Top,
307 ts.Render(s.tabs.View()),
308 view,
309 )
310}