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