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 appCfg "github.com/charmbracelet/soft-serve/config"
11 "github.com/charmbracelet/soft-serve/ui/common"
12 "github.com/charmbracelet/soft-serve/ui/components/code"
13 "github.com/charmbracelet/soft-serve/ui/components/selector"
14 "github.com/charmbracelet/soft-serve/ui/components/yankable"
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 2
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.
90// TODO implement full help on ?
91func (s *Selection) FullHelp() [][]key.Binding {
92 k := s.selector.KeyMap
93 return [][]key.Binding{
94 {
95 k.CursorUp,
96 k.CursorDown,
97 k.NextPage,
98 k.PrevPage,
99 k.GoToStart,
100 k.GoToEnd,
101 },
102 {
103 k.Filter,
104 k.ClearFilter,
105 k.CancelWhileFiltering,
106 k.AcceptWhileFiltering,
107 k.ShowFullHelp,
108 k.CloseFullHelp,
109 },
110 // Ignore the following keys:
111 // k.Quit,
112 // k.ForceQuit,
113 }
114}
115
116// Init implements tea.Model.
117func (s *Selection) Init() tea.Cmd {
118 session := s.s.Session()
119 environ := session.Environ()
120 termExists := false
121 // Add TERM using pty.Term if it's not already set.
122 for _, env := range environ {
123 if strings.HasPrefix(env, "TERM=") {
124 termExists = true
125 break
126 }
127 }
128 if !termExists {
129 pty, _, _ := session.Pty()
130 environ = append(environ, fmt.Sprintf("TERM=%s", pty.Term))
131 }
132 items := make([]selector.IdentifiableItem, 0)
133 cfg := s.s.Config()
134 // TODO clean up this and move style to its own var.
135 yank := func(text string) *yankable.Yankable {
136 return yankable.New(
137 session,
138 environ,
139 lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
140 lipgloss.NewStyle().Foreground(lipgloss.Color("168")).SetString("Copied!"),
141 text,
142 )
143 }
144 // Put configured repos first
145 for _, r := range cfg.Repos {
146 items = append(items, Item{
147 name: r.Name,
148 repo: r.Repo,
149 desc: r.Note,
150 url: yank(repoUrl(cfg, r.Repo)),
151 })
152 }
153 for _, r := range cfg.Source.AllRepos() {
154 exists := false
155 head, err := r.HEAD()
156 if err != nil {
157 return common.ErrorCmd(err)
158 }
159 lc, err := r.CommitsByPage(head, 1, 1)
160 if err != nil {
161 return common.ErrorCmd(err)
162 }
163 lastUpdate := lc[0].Committer.When
164 for _, item := range items {
165 item := item.(Item)
166 if item.repo == r.Name() {
167 exists = true
168 item.lastUpdate = lastUpdate
169 break
170 }
171 }
172 if !exists {
173 items = append(items, Item{
174 name: r.Name(),
175 repo: r.Name(),
176 desc: "",
177 lastUpdate: lastUpdate,
178 url: yank(repoUrl(cfg, r.Name())),
179 })
180 }
181 }
182 return tea.Batch(
183 s.selector.Init(),
184 s.selector.SetItems(items),
185 )
186}
187
188// Update implements tea.Model.
189func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190 cmds := make([]tea.Cmd, 0)
191 switch msg := msg.(type) {
192 case tea.WindowSizeMsg:
193 r, cmd := s.readme.Update(msg)
194 s.readme = r.(*code.Code)
195 if cmd != nil {
196 cmds = append(cmds, cmd)
197 }
198 m, cmd := s.selector.Update(msg)
199 s.selector = m.(*selector.Selector)
200 if cmd != nil {
201 cmds = append(cmds, cmd)
202 }
203 case selector.ActiveMsg:
204 cmds = append(cmds, s.changeActive(msg))
205 // reset readme position when active item change
206 s.readme.GotoTop()
207 case tea.KeyMsg:
208 switch {
209 case key.Matches(msg, s.common.KeyMap.Section):
210 s.activeBox = (s.activeBox + 1) % 2
211 }
212 }
213 switch s.activeBox {
214 case readmeBox:
215 r, cmd := s.readme.Update(msg)
216 s.readme = r.(*code.Code)
217 if cmd != nil {
218 cmds = append(cmds, cmd)
219 }
220 case selectorBox:
221 m, cmd := s.selector.Update(msg)
222 s.selector = m.(*selector.Selector)
223 if cmd != nil {
224 cmds = append(cmds, cmd)
225 }
226 }
227 return s, tea.Batch(cmds...)
228}
229
230// View implements tea.Model.
231func (s *Selection) View() string {
232 wm := s.common.Styles.SelectorBox.GetWidth() +
233 s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
234 s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
235 hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
236 rs := s.common.Styles.ReadmeBox.Copy().
237 Width(s.common.Width - wm).
238 Height(s.common.Height - hm)
239 if s.activeBox == readmeBox {
240 rs.BorderForeground(s.common.Styles.ActiveBorderColor)
241 }
242 readme := rs.Render(s.readme.View())
243 return lipgloss.JoinHorizontal(
244 lipgloss.Top,
245 readme,
246 s.selector.View(),
247 )
248}
249
250func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
251 cfg := s.s.Config()
252 r, err := cfg.Source.GetRepo(msg.ID())
253 if err != nil {
254 return common.ErrorCmd(err)
255 }
256 rm, rp := r.Readme()
257 return s.readme.SetContent(rm, rp)
258}
259
260func repoUrl(cfg *appCfg.Config, name string) string {
261 port := ""
262 if cfg.Port != 22 {
263 port += fmt.Sprintf(":%d", cfg.Port)
264 }
265 return fmt.Sprintf("git clone ssh://%s/%s", cfg.Host+port, name)
266}