1package selection
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/key"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10 "github.com/charmbracelet/soft-serve/ui/common"
11 "github.com/charmbracelet/soft-serve/ui/components/code"
12 "github.com/charmbracelet/soft-serve/ui/components/selector"
13 "github.com/charmbracelet/soft-serve/ui/components/yankable"
14 "github.com/charmbracelet/soft-serve/ui/git"
15 "github.com/charmbracelet/soft-serve/ui/session"
16)
17
18type box int
19
20const (
21 readmeBox box = iota
22 selectorBox
23)
24
25// Selection is the model for the selection screen/page.
26type Selection struct {
27 s session.Session
28 common common.Common
29 readme *code.Code
30 selector *selector.Selector
31 activeBox box
32}
33
34// New creates a new selection model.
35func New(s session.Session, common common.Common) *Selection {
36 sel := &Selection{
37 s: s,
38 common: common,
39 activeBox: selectorBox, // start with the selector focused
40 }
41 readme := code.New(common, "", "")
42 readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
43 selector := selector.New(common,
44 []selector.IdentifiableItem{},
45 ItemDelegate{common.Styles, &sel.activeBox})
46 selector.SetShowTitle(false)
47 selector.SetShowHelp(false)
48 selector.SetShowStatusBar(false)
49 selector.DisableQuitKeybindings()
50 sel.selector = selector
51 sel.readme = readme
52 return sel
53}
54
55// SetSize implements common.Component.
56func (s *Selection) SetSize(width, height int) {
57 s.common.SetSize(width, height)
58 sw := s.common.Styles.SelectorBox.GetWidth()
59 wm := sw +
60 s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
61 s.common.Styles.ReadmeBox.GetHorizontalFrameSize() +
62 // +1 to get wrapping to work.
63 // This is needed because the readme box width has to be -1 from the
64 // readme style in order for wrapping to not break.
65 1
66 hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
67 s.readme.SetSize(width-wm, height-hm)
68 s.selector.SetSize(sw, height)
69}
70
71// ShortHelp implements help.KeyMap.
72func (s *Selection) ShortHelp() []key.Binding {
73 k := s.selector.KeyMap
74 kb := make([]key.Binding, 0)
75 kb = append(kb,
76 s.common.KeyMap.UpDown,
77 s.common.KeyMap.Section,
78 )
79 if s.activeBox == selectorBox {
80 kb = append(kb,
81 s.common.KeyMap.Select,
82 k.Filter,
83 k.ClearFilter,
84 )
85 }
86 return kb
87}
88
89// FullHelp implements help.KeyMap.
90func (s *Selection) FullHelp() [][]key.Binding {
91 switch s.activeBox {
92 case readmeBox:
93 k := s.readme.KeyMap
94 return [][]key.Binding{
95 {
96 k.PageDown,
97 k.PageUp,
98 },
99 {
100 k.HalfPageDown,
101 k.HalfPageUp,
102 },
103 {
104 k.Down,
105 k.Up,
106 },
107 }
108 case selectorBox:
109 k := s.selector.KeyMap
110 return [][]key.Binding{
111 {
112 s.common.KeyMap.Select,
113 },
114 {
115 k.CursorUp,
116 k.CursorDown,
117 },
118 {
119 k.NextPage,
120 k.PrevPage,
121 },
122 {
123 k.GoToStart,
124 k.GoToEnd,
125 },
126 {
127 k.Filter,
128 k.ClearFilter,
129 k.CancelWhileFiltering,
130 k.AcceptWhileFiltering,
131 },
132 }
133 }
134 return [][]key.Binding{}
135}
136
137// Init implements tea.Model.
138func (s *Selection) Init() tea.Cmd {
139 session := s.s.Session()
140 environ := session.Environ()
141 termExists := false
142 // Add TERM using pty.Term if it's not already set.
143 for _, env := range environ {
144 if strings.HasPrefix(env, "TERM=") {
145 termExists = true
146 break
147 }
148 }
149 if !termExists {
150 pty, _, _ := session.Pty()
151 environ = append(environ, fmt.Sprintf("TERM=%s", pty.Term))
152 }
153 items := make([]selector.IdentifiableItem, 0)
154 cfg := s.s.Config()
155 // TODO clean up this and move style to its own var.
156 yank := func(text string) *yankable.Yankable {
157 return yankable.New(
158 session,
159 environ,
160 lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
161 lipgloss.NewStyle().Foreground(lipgloss.Color("168")).SetString("Copied!"),
162 text,
163 )
164 }
165 // Put configured repos first
166 for _, r := range cfg.Repos {
167 repo, err := cfg.Source.GetRepo(r.Repo)
168 if err != nil {
169 continue
170 }
171 items = append(items, Item{
172 repo: repo,
173 url: yank(git.RepoURL(cfg.Host, cfg.Port, r.Repo)),
174 })
175 }
176 for _, r := range cfg.Source.AllRepos() {
177 exists := false
178 head, err := r.HEAD()
179 if err != nil {
180 return common.ErrorCmd(err)
181 }
182 lc, err := r.CommitsByPage(head, 1, 1)
183 if err != nil {
184 return common.ErrorCmd(err)
185 }
186 lastUpdate := lc[0].Committer.When
187 for _, item := range items {
188 item := item.(Item)
189 if item.repo.Repo() == r.Repo() {
190 exists = true
191 item.lastUpdate = lastUpdate
192 break
193 }
194 }
195 if !exists {
196 items = append(items, Item{
197 repo: r,
198 lastUpdate: lastUpdate,
199 url: yank(git.RepoURL(cfg.Host, cfg.Port, r.Name())),
200 })
201 }
202 }
203 return tea.Batch(
204 s.selector.Init(),
205 s.selector.SetItems(items),
206 )
207}
208
209// Update implements tea.Model.
210func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211 cmds := make([]tea.Cmd, 0)
212 switch msg := msg.(type) {
213 case tea.WindowSizeMsg:
214 r, cmd := s.readme.Update(msg)
215 s.readme = r.(*code.Code)
216 if cmd != nil {
217 cmds = append(cmds, cmd)
218 }
219 m, cmd := s.selector.Update(msg)
220 s.selector = m.(*selector.Selector)
221 if cmd != nil {
222 cmds = append(cmds, cmd)
223 }
224 case selector.ActiveMsg:
225 cmds = append(cmds, s.changeActive(msg))
226 // reset readme position when active item change
227 s.readme.GotoTop()
228 case tea.KeyMsg:
229 switch {
230 case key.Matches(msg, s.common.KeyMap.Section):
231 s.activeBox = (s.activeBox + 1) % 2
232 case key.Matches(msg, s.common.KeyMap.Back):
233 cmds = append(cmds, s.selector.Init())
234 }
235 }
236 switch s.activeBox {
237 case readmeBox:
238 r, cmd := s.readme.Update(msg)
239 s.readme = r.(*code.Code)
240 if cmd != nil {
241 cmds = append(cmds, cmd)
242 }
243 case selectorBox:
244 m, cmd := s.selector.Update(msg)
245 s.selector = m.(*selector.Selector)
246 if cmd != nil {
247 cmds = append(cmds, cmd)
248 }
249 }
250 return s, tea.Batch(cmds...)
251}
252
253// View implements tea.Model.
254func (s *Selection) View() string {
255 wm := s.common.Styles.SelectorBox.GetWidth() +
256 s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
257 s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
258 hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
259 rs := s.common.Styles.ReadmeBox.Copy().
260 Width(s.common.Width - wm).
261 Height(s.common.Height - hm)
262 if s.activeBox == readmeBox {
263 rs.BorderForeground(s.common.Styles.ActiveBorderColor)
264 }
265 readme := rs.Render(s.readme.View())
266 return lipgloss.JoinHorizontal(
267 lipgloss.Top,
268 readme,
269 s.selector.View(),
270 )
271}
272
273func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
274 cfg := s.s.Config()
275 r, err := cfg.Source.GetRepo(msg.ID())
276 if err != nil {
277 return common.ErrorCmd(err)
278 }
279 rm, rp := r.Readme()
280 return s.readme.SetContent(rm, rp)
281}