1package selection
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/list"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/soft-serve/config"
12 "github.com/charmbracelet/soft-serve/ui/common"
13 "github.com/charmbracelet/soft-serve/ui/components/code"
14 "github.com/charmbracelet/soft-serve/ui/components/selector"
15 "github.com/charmbracelet/soft-serve/ui/components/tabs"
16 "github.com/charmbracelet/soft-serve/ui/git"
17 wgit "github.com/charmbracelet/wish/git"
18 "github.com/gliderlabs/ssh"
19)
20
21type pane int
22
23const (
24 selectorPane pane = iota
25 readmePane
26 lastPane
27)
28
29func (p pane) String() string {
30 return []string{
31 "Repositories",
32 "About",
33 }[p]
34}
35
36// Selection is the model for the selection screen/page.
37type Selection struct {
38 cfg *config.Config
39 pk ssh.PublicKey
40 common common.Common
41 readme *code.Code
42 readmeHeight int
43 selector *selector.Selector
44 activePane pane
45 tabs *tabs.Tabs
46}
47
48// New creates a new selection model.
49func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
50 ts := make([]string, lastPane)
51 for i, b := range []pane{selectorPane, readmePane} {
52 ts[i] = b.String()
53 }
54 t := tabs.New(common, ts)
55 t.TabSeparator = lipgloss.NewStyle()
56 t.TabInactive = common.Styles.TopLevelNormalTab.Copy()
57 t.TabActive = common.Styles.TopLevelActiveTab.Copy()
58 t.TabDot = common.Styles.TopLevelActiveTabDot.Copy()
59 t.UseDot = true
60 sel := &Selection{
61 cfg: cfg,
62 pk: pk,
63 common: common,
64 activePane: selectorPane, // start with the selector focused
65 tabs: t,
66 }
67 readme := code.New(common, "", "")
68 readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
69 selector := selector.New(common,
70 []selector.IdentifiableItem{},
71 ItemDelegate{&common, &sel.activePane})
72 selector.SetShowTitle(false)
73 selector.SetShowHelp(false)
74 selector.SetShowStatusBar(false)
75 selector.DisableQuitKeybindings()
76 sel.selector = selector
77 sel.readme = readme
78 return sel
79}
80
81func (s *Selection) getMargins() (wm, hm int) {
82 wm = 0
83 hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
84 s.common.Styles.Tabs.GetHeight() +
85 2 // tabs margin see View()
86 if s.activePane == readmePane {
87 hm += 1 // readme statusbar
88 }
89 return
90}
91
92// FilterState returns the current filter state.
93func (s *Selection) FilterState() list.FilterState {
94 return s.selector.FilterState()
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.activePane == selectorPane {
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.activePane {
130 case readmePane:
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 selectorPane:
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.activePane = pane(msg)
268 }
269 switch s.activePane {
270 case readmePane:
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 selectorPane:
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.activePane {
292 case selectorPane:
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 readmePane:
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.Left,
307 s.readme.View(),
308 readmeStatus,
309 ))
310 }
311 return lipgloss.JoinVertical(
312 lipgloss.Left,
313 s.common.Styles.Tabs.Render(s.tabs.View()),
314 view,
315 )
316}