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/pkg/access"
12 "github.com/charmbracelet/soft-serve/pkg/backend"
13 "github.com/charmbracelet/soft-serve/pkg/ui/common"
14 "github.com/charmbracelet/soft-serve/pkg/ui/components/code"
15 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
16 "github.com/charmbracelet/soft-serve/pkg/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.UseGlamour = true
66 readme.NoContentStyle = c.Styles.NoContent.Copy().
67 SetString(defaultNoContent)
68 selector := selector.New(c,
69 []selector.IdentifiableItem{},
70 NewItemDelegate(&c, &sel.activePane))
71 selector.SetShowTitle(false)
72 selector.SetShowHelp(false)
73 selector.SetShowStatusBar(false)
74 selector.DisableQuitKeybindings()
75 sel.selector = selector
76 sel.readme = readme
77 return sel
78}
79
80func (s *Selection) getMargins() (wm, hm int) {
81 wm = 0
82 hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
83 s.common.Styles.Tabs.GetHeight()
84 if s.activePane == selectorPane && s.IsFiltering() {
85 // hide tabs when filtering
86 hm = 0
87 }
88 return
89}
90
91// FilterState returns the current filter state.
92func (s *Selection) FilterState() list.FilterState {
93 return s.selector.FilterState()
94}
95
96// SetSize implements common.Component.
97func (s *Selection) SetSize(width, height int) {
98 s.common.SetSize(width, height)
99 wm, hm := s.getMargins()
100 s.tabs.SetSize(width, height-hm)
101 s.selector.SetSize(width-wm, height-hm)
102 s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line
103}
104
105// IsFiltering returns true if the selector is currently filtering.
106func (s *Selection) IsFiltering() bool {
107 return s.FilterState() == list.Filtering
108}
109
110// ShortHelp implements help.KeyMap.
111func (s *Selection) ShortHelp() []key.Binding {
112 k := s.selector.KeyMap
113 kb := make([]key.Binding, 0)
114 kb = append(kb,
115 s.common.KeyMap.UpDown,
116 s.common.KeyMap.Section,
117 )
118 if s.activePane == selectorPane {
119 copyKey := s.common.KeyMap.Copy
120 copyKey.SetHelp("c", "copy command")
121 kb = append(kb,
122 s.common.KeyMap.Select,
123 k.Filter,
124 k.ClearFilter,
125 copyKey,
126 )
127 }
128 return kb
129}
130
131// FullHelp implements help.KeyMap.
132func (s *Selection) FullHelp() [][]key.Binding {
133 b := [][]key.Binding{
134 {
135 s.common.KeyMap.Section,
136 },
137 }
138 switch s.activePane {
139 case readmePane:
140 k := s.readme.KeyMap
141 b = append(b, []key.Binding{
142 k.PageDown,
143 k.PageUp,
144 })
145 b = append(b, []key.Binding{
146 k.HalfPageDown,
147 k.HalfPageUp,
148 })
149 b = append(b, []key.Binding{
150 k.Down,
151 k.Up,
152 })
153 case selectorPane:
154 copyKey := s.common.KeyMap.Copy
155 copyKey.SetHelp("c", "copy command")
156 k := s.selector.KeyMap
157 if !s.IsFiltering() {
158 b[0] = append(b[0],
159 s.common.KeyMap.Select,
160 copyKey,
161 )
162 }
163 b = append(b, []key.Binding{
164 k.CursorUp,
165 k.CursorDown,
166 })
167 b = append(b, []key.Binding{
168 k.NextPage,
169 k.PrevPage,
170 k.GoToStart,
171 k.GoToEnd,
172 })
173 b = append(b, []key.Binding{
174 k.Filter,
175 k.ClearFilter,
176 k.CancelWhileFiltering,
177 k.AcceptWhileFiltering,
178 })
179 }
180 return b
181}
182
183// Init implements tea.Model.
184func (s *Selection) Init() tea.Cmd {
185 var readmeCmd tea.Cmd
186 cfg := s.common.Config()
187 if cfg == nil {
188 return nil
189 }
190
191 ctx := s.common.Context()
192 be := s.common.Backend()
193 pk := s.common.PublicKey()
194 if pk == nil && !be.AllowKeyless(ctx) {
195 return nil
196 }
197
198 repos, err := be.Repositories(ctx)
199 if err != nil {
200 return common.ErrorCmd(err)
201 }
202 sortedItems := make(Items, 0)
203 for _, r := range repos {
204 if r.Name() == ".soft-serve" {
205 readme, path, err := backend.Readme(r, nil)
206 if err != nil {
207 continue
208 }
209
210 readmeCmd = s.readme.SetContent(readme, path)
211 }
212
213 if r.IsHidden() {
214 continue
215 }
216 al := be.AccessLevelByPublicKey(ctx, r.Name(), pk)
217 if al >= access.ReadOnlyAccess {
218 item, err := NewItem(r, cfg)
219 if err != nil {
220 s.common.Logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err)
221 continue
222 }
223 sortedItems = append(sortedItems, item)
224 }
225 }
226 sort.Sort(sortedItems)
227 items := make([]selector.IdentifiableItem, len(sortedItems))
228 for i, it := range sortedItems {
229 items[i] = it
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 switch s.activePane {
291 case selectorPane:
292 ss := lipgloss.NewStyle().
293 Width(s.common.Width - wm).
294 Height(s.common.Height - hm)
295 view = ss.Render(s.selector.View())
296 case readmePane:
297 rs := lipgloss.NewStyle().
298 Height(s.common.Height - hm)
299 status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
300 readmeStatus := lipgloss.NewStyle().
301 Align(lipgloss.Right).
302 Width(s.common.Width - wm).
303 Foreground(s.common.Styles.InactiveBorderColor).
304 Render(status)
305 view = rs.Render(lipgloss.JoinVertical(lipgloss.Left,
306 s.readme.View(),
307 readmeStatus,
308 ))
309 }
310 if s.activePane != selectorPane || s.FilterState() != list.Filtering {
311 tabs := s.common.Styles.Tabs.Render(s.tabs.View())
312 view = lipgloss.JoinVertical(lipgloss.Left,
313 tabs,
314 view,
315 )
316 }
317 return lipgloss.JoinVertical(
318 lipgloss.Left,
319 view,
320 )
321}