1package selection
2
3import (
4 "fmt"
5 "sort"
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/log"
12 "github.com/charmbracelet/soft-serve/server/backend"
13 "github.com/charmbracelet/soft-serve/ui/common"
14 "github.com/charmbracelet/soft-serve/ui/components/code"
15 "github.com/charmbracelet/soft-serve/ui/components/selector"
16 "github.com/charmbracelet/soft-serve/ui/components/tabs"
17)
18
19const (
20 defaultNoContent = "No readme found.\n\nCreate a `.soft-serve` repository and add a `README.md` file to display readme."
21)
22
23var (
24 logger = log.WithPrefix("ui.selection")
25)
26
27type pane int
28
29const (
30 selectorPane pane = iota
31 readmePane
32 lastPane
33)
34
35func (p pane) String() string {
36 return []string{
37 "Repositories",
38 "About",
39 }[p]
40}
41
42// Selection is the model for the selection screen/page.
43type Selection struct {
44 common common.Common
45 readme *code.Code
46 readmeHeight int
47 selector *selector.Selector
48 activePane pane
49 tabs *tabs.Tabs
50}
51
52// New creates a new selection model.
53func New(c common.Common) *Selection {
54 ts := make([]string, lastPane)
55 for i, b := range []pane{selectorPane, readmePane} {
56 ts[i] = b.String()
57 }
58 t := tabs.New(c, ts)
59 t.TabSeparator = lipgloss.NewStyle()
60 t.TabInactive = c.Styles.TopLevelNormalTab.Copy()
61 t.TabActive = c.Styles.TopLevelActiveTab.Copy()
62 t.TabDot = c.Styles.TopLevelActiveTabDot.Copy()
63 t.UseDot = true
64 sel := &Selection{
65 common: c,
66 activePane: selectorPane, // start with the selector focused
67 tabs: t,
68 }
69 readme := code.New(c, "", "")
70 readme.NoContentStyle = c.Styles.NoContent.Copy().
71 SetString(defaultNoContent)
72 selector := selector.New(c,
73 []selector.IdentifiableItem{},
74 NewItemDelegate(&c, &sel.activePane))
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 if s.activePane == selectorPane && s.IsFiltering() {
89 // hide tabs when filtering
90 hm = 0
91 }
92 return
93}
94
95// FilterState returns the current filter state.
96func (s *Selection) FilterState() list.FilterState {
97 return s.selector.FilterState()
98}
99
100// SetSize implements common.Component.
101func (s *Selection) SetSize(width, height int) {
102 s.common.SetSize(width, height)
103 wm, hm := s.getMargins()
104 s.tabs.SetSize(width, height-hm)
105 s.selector.SetSize(width-wm, height-hm)
106 s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line
107}
108
109// IsFiltering returns true if the selector is currently filtering.
110func (s *Selection) IsFiltering() bool {
111 return s.FilterState() == list.Filtering
112}
113
114// ShortHelp implements help.KeyMap.
115func (s *Selection) ShortHelp() []key.Binding {
116 k := s.selector.KeyMap
117 kb := make([]key.Binding, 0)
118 kb = append(kb,
119 s.common.KeyMap.UpDown,
120 s.common.KeyMap.Section,
121 )
122 if s.activePane == selectorPane {
123 copyKey := s.common.KeyMap.Copy
124 copyKey.SetHelp("c", "copy command")
125 kb = append(kb,
126 s.common.KeyMap.Select,
127 k.Filter,
128 k.ClearFilter,
129 copyKey,
130 )
131 }
132 return kb
133}
134
135// FullHelp implements help.KeyMap.
136func (s *Selection) FullHelp() [][]key.Binding {
137 b := [][]key.Binding{
138 {
139 s.common.KeyMap.Section,
140 },
141 }
142 switch s.activePane {
143 case readmePane:
144 k := s.readme.KeyMap
145 b = append(b, []key.Binding{
146 k.PageDown,
147 k.PageUp,
148 })
149 b = append(b, []key.Binding{
150 k.HalfPageDown,
151 k.HalfPageUp,
152 })
153 b = append(b, []key.Binding{
154 k.Down,
155 k.Up,
156 })
157 case selectorPane:
158 copyKey := s.common.KeyMap.Copy
159 copyKey.SetHelp("c", "copy command")
160 k := s.selector.KeyMap
161 if !s.IsFiltering() {
162 b[0] = append(b[0],
163 s.common.KeyMap.Select,
164 copyKey,
165 )
166 }
167 b = append(b, []key.Binding{
168 k.CursorUp,
169 k.CursorDown,
170 })
171 b = append(b, []key.Binding{
172 k.NextPage,
173 k.PrevPage,
174 k.GoToStart,
175 k.GoToEnd,
176 })
177 b = append(b, []key.Binding{
178 k.Filter,
179 k.ClearFilter,
180 k.CancelWhileFiltering,
181 k.AcceptWhileFiltering,
182 })
183 }
184 return b
185}
186
187// Init implements tea.Model.
188func (s *Selection) Init() tea.Cmd {
189 var readmeCmd tea.Cmd
190 cfg := s.common.Config()
191 if cfg == nil {
192 return nil
193 }
194
195 pk := s.common.PublicKey()
196 if pk == nil && !cfg.Backend.AllowKeyless() {
197 return nil
198 }
199
200 repos, err := cfg.Backend.Repositories()
201 if err != nil {
202 return common.ErrorCmd(err)
203 }
204 sortedItems := make(Items, 0)
205 for _, r := range repos {
206 if r.Name() == ".soft-serve" {
207 readme, path, err := backend.Readme(r)
208 if err != nil {
209 continue
210 }
211
212 readmeCmd = s.readme.SetContent(readme, path)
213 }
214
215 if r.IsHidden() {
216 continue
217 }
218 al := cfg.Backend.AccessLevelByPublicKey(r.Name(), pk)
219 if al >= backend.ReadOnlyAccess {
220 item, err := NewItem(r, cfg)
221 if err != nil {
222 logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err)
223 continue
224 }
225 sortedItems = append(sortedItems, item)
226 }
227 }
228 sort.Sort(sortedItems)
229 items := make([]selector.IdentifiableItem, len(sortedItems))
230 for i, it := range sortedItems {
231 items[i] = it
232 }
233 return tea.Batch(
234 s.selector.Init(),
235 s.selector.SetItems(items),
236 readmeCmd,
237 )
238}
239
240// Update implements tea.Model.
241func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242 cmds := make([]tea.Cmd, 0)
243 switch msg := msg.(type) {
244 case tea.WindowSizeMsg:
245 r, cmd := s.readme.Update(msg)
246 s.readme = r.(*code.Code)
247 if cmd != nil {
248 cmds = append(cmds, cmd)
249 }
250 m, cmd := s.selector.Update(msg)
251 s.selector = m.(*selector.Selector)
252 if cmd != nil {
253 cmds = append(cmds, cmd)
254 }
255 case tea.KeyMsg, tea.MouseMsg:
256 switch msg := msg.(type) {
257 case tea.KeyMsg:
258 switch {
259 case key.Matches(msg, s.common.KeyMap.Back):
260 cmds = append(cmds, s.selector.Init())
261 }
262 }
263 t, cmd := s.tabs.Update(msg)
264 s.tabs = t.(*tabs.Tabs)
265 if cmd != nil {
266 cmds = append(cmds, cmd)
267 }
268 case tabs.ActiveTabMsg:
269 s.activePane = pane(msg)
270 }
271 switch s.activePane {
272 case readmePane:
273 r, cmd := s.readme.Update(msg)
274 s.readme = r.(*code.Code)
275 if cmd != nil {
276 cmds = append(cmds, cmd)
277 }
278 case selectorPane:
279 m, cmd := s.selector.Update(msg)
280 s.selector = m.(*selector.Selector)
281 if cmd != nil {
282 cmds = append(cmds, cmd)
283 }
284 }
285 return s, tea.Batch(cmds...)
286}
287
288// View implements tea.Model.
289func (s *Selection) View() string {
290 var view string
291 wm, hm := s.getMargins()
292 switch s.activePane {
293 case selectorPane:
294 ss := lipgloss.NewStyle().
295 Width(s.common.Width - wm).
296 Height(s.common.Height - hm)
297 view = ss.Render(s.selector.View())
298 case readmePane:
299 rs := lipgloss.NewStyle().
300 Height(s.common.Height - hm)
301 status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
302 readmeStatus := lipgloss.NewStyle().
303 Align(lipgloss.Right).
304 Width(s.common.Width - wm).
305 Foreground(s.common.Styles.InactiveBorderColor).
306 Render(status)
307 view = rs.Render(lipgloss.JoinVertical(lipgloss.Left,
308 s.readme.View(),
309 readmeStatus,
310 ))
311 }
312 if s.activePane != selectorPane || s.FilterState() != list.Filtering {
313 tabs := s.common.Styles.Tabs.Render(s.tabs.View())
314 view = lipgloss.JoinVertical(lipgloss.Left,
315 tabs,
316 view,
317 )
318 }
319 return lipgloss.JoinVertical(
320 lipgloss.Left,
321 view,
322 )
323}