1package selection
2
3import (
4 "fmt"
5 "sort"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/list"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 lipgloss "github.com/charmbracelet/lipgloss/v2"
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
56 t.TabActive = c.Styles.TopLevelActiveTab
57 t.TabDot = c.Styles.TopLevelActiveTabDot
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.
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 case lastPane:
180 // lastPane is not a real pane, used for bounds checking
181 }
182 return b
183}
184
185// Init implements tea.Model.
186func (s *Selection) Init() tea.Cmd {
187 var readmeCmd tea.Cmd
188 cfg := s.common.Config()
189 if cfg == nil {
190 return nil
191 }
192
193 ctx := s.common.Context()
194 be := s.common.Backend()
195 pk := s.common.PublicKey()
196 if pk == nil && !be.AllowKeyless(ctx) {
197 return nil
198 }
199
200 repos, err := be.Repositories(ctx)
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, nil)
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 := be.AccessLevelByPublicKey(ctx, r.Name(), pk)
219 if al >= access.ReadOnlyAccess {
220 item, err := NewItem(s.common, r)
221 if err != nil {
222 s.common.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.KeyPressMsg, tea.MouseMsg:
256 switch msg := msg.(type) {
257 case tea.KeyPressMsg:
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 case lastPane:
285 // lastPane is not a real pane, no updates needed
286 }
287 return s, tea.Batch(cmds...)
288}
289
290// View implements tea.Model.
291func (s *Selection) View() string {
292 var view string
293 wm, hm := s.getMargins()
294 switch s.activePane {
295 case selectorPane:
296 ss := lipgloss.NewStyle().
297 Width(s.common.Width - wm).
298 Height(s.common.Height - hm)
299 view = ss.Render(s.selector.View())
300 case readmePane:
301 rs := lipgloss.NewStyle().
302 Height(s.common.Height - hm)
303 status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
304 readmeStatus := lipgloss.NewStyle().
305 Align(lipgloss.Right).
306 Width(s.common.Width - wm).
307 Foreground(s.common.Styles.InactiveBorderColor).
308 Render(status)
309 view = rs.Render(lipgloss.JoinVertical(lipgloss.Left,
310 s.readme.View(),
311 readmeStatus,
312 ))
313 case lastPane:
314 // lastPane is not a real pane, no view
315 }
316 if s.activePane != selectorPane || s.FilterState() != list.Filtering {
317 tabs := s.common.Styles.Tabs.Render(s.tabs.View())
318 view = lipgloss.JoinVertical(lipgloss.Left,
319 tabs,
320 view,
321 )
322 }
323 return lipgloss.JoinVertical(
324 lipgloss.Left,
325 view,
326 )
327}