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