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