1package repo
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "path/filepath"
8
9 "github.com/charmbracelet/bubbles/key"
10 tea "github.com/charmbracelet/bubbletea"
11 ggit "github.com/charmbracelet/soft-serve/git"
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/git"
16)
17
18type filesView int
19
20const (
21 filesViewFiles filesView = iota
22 filesViewContent
23)
24
25var (
26 errNoFileSelected = errors.New("no file selected")
27 errBinaryFile = errors.New("binary file")
28 errFileTooLarge = errors.New("file is too large")
29 errInvalidFile = errors.New("invalid file")
30)
31
32// FileItemsMsg is a message that contains a list of files.
33type FileItemsMsg []selector.IdentifiableItem
34
35// FileContentMsg is a message that contains the content of a file.
36type FileContentMsg struct {
37 content string
38 ext string
39}
40
41// Files is the model for the files view.
42type Files struct {
43 common common.Common
44 selector *selector.Selector
45 ref *ggit.Reference
46 activeView filesView
47 repo git.GitRepo
48 code *code.Code
49 path string
50 currentItem *FileItem
51 currentContent FileContentMsg
52 lastSelected []int
53}
54
55// NewFiles creates a new files model.
56func NewFiles(common common.Common) *Files {
57 f := &Files{
58 common: common,
59 code: code.New(common, "", ""),
60 activeView: filesViewFiles,
61 lastSelected: make([]int, 0),
62 }
63 selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{common.Styles})
64 selector.SetShowFilter(false)
65 selector.SetShowHelp(false)
66 selector.SetShowPagination(false)
67 selector.SetShowStatusBar(false)
68 selector.SetShowTitle(false)
69 selector.SetFilteringEnabled(false)
70 selector.DisableQuitKeybindings()
71 selector.KeyMap.NextPage = common.KeyMap.NextPage
72 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
73 f.selector = selector
74 return f
75}
76
77// SetSize implements common.Component.
78func (f *Files) SetSize(width, height int) {
79 f.common.SetSize(width, height)
80 f.selector.SetSize(width, height)
81 f.code.SetSize(width, height)
82}
83
84// ShortHelp implements help.KeyMap.
85func (f *Files) ShortHelp() []key.Binding {
86 k := f.selector.KeyMap
87 switch f.activeView {
88 case filesViewFiles:
89 return []key.Binding{
90 f.common.KeyMap.SelectItem,
91 f.common.KeyMap.BackItem,
92 k.CursorUp,
93 k.CursorDown,
94 }
95 case filesViewContent:
96 return []key.Binding{
97 f.common.KeyMap.UpDown,
98 f.common.KeyMap.BackItem,
99 }
100 default:
101 return []key.Binding{}
102 }
103}
104
105// FullHelp implements help.KeyMap.
106func (f *Files) FullHelp() [][]key.Binding {
107 b := make([][]key.Binding, 0)
108 switch f.activeView {
109 case filesViewFiles:
110 k := f.selector.KeyMap
111 b = append(b, []key.Binding{
112 f.common.KeyMap.SelectItem,
113 f.common.KeyMap.BackItem,
114 })
115 b = append(b, [][]key.Binding{
116 {},
117 {
118 k.CursorUp,
119 k.CursorDown,
120 },
121 {
122 k.NextPage,
123 k.PrevPage,
124 },
125 {
126 k.GoToStart,
127 k.GoToEnd,
128 },
129 }...)
130 case filesViewContent:
131 k := f.code.KeyMap
132 b = append(b, []key.Binding{
133 f.common.KeyMap.BackItem,
134 })
135 b = append(b, [][]key.Binding{
136 {
137 k.PageDown,
138 k.PageUp,
139 },
140 {
141 k.HalfPageDown,
142 k.HalfPageUp,
143 },
144 {
145 k.Down,
146 k.Up,
147 },
148 }...)
149 }
150 return b
151}
152
153// Init implements tea.Model.
154func (f *Files) Init() tea.Cmd {
155 f.path = ""
156 f.currentItem = nil
157 f.activeView = filesViewFiles
158 f.lastSelected = make([]int, 0)
159 f.selector.Select(0)
160 return f.updateFilesCmd
161}
162
163// Update implements tea.Model.
164func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165 cmds := make([]tea.Cmd, 0)
166 switch msg := msg.(type) {
167 case RepoMsg:
168 f.repo = git.GitRepo(msg)
169 cmds = append(cmds, f.Init())
170 case RefMsg:
171 f.ref = msg
172 cmds = append(cmds, f.Init())
173 case FileItemsMsg:
174 cmds = append(cmds,
175 f.selector.SetItems(msg),
176 updateStatusBarCmd,
177 )
178 case FileContentMsg:
179 f.activeView = filesViewContent
180 f.currentContent = msg
181 f.code.SetContent(msg.content, msg.ext)
182 f.code.GotoTop()
183 cmds = append(cmds, updateStatusBarCmd)
184 case selector.SelectMsg:
185 switch sel := msg.IdentifiableItem.(type) {
186 case FileItem:
187 f.currentItem = &sel
188 f.path = filepath.Join(f.path, sel.entry.Name())
189 log.Printf("selected index %d", f.selector.Index())
190 if sel.entry.IsTree() {
191 cmds = append(cmds, f.selectTreeCmd)
192 } else {
193 cmds = append(cmds, f.selectFileCmd)
194 }
195 }
196 case tea.KeyMsg:
197 switch f.activeView {
198 case filesViewFiles:
199 switch msg.String() {
200 case "l", "right":
201 cmds = append(cmds, f.selector.SelectItem)
202 case "h", "left":
203 cmds = append(cmds, f.deselectItemCmd)
204 }
205 case filesViewContent:
206 switch msg.String() {
207 case "h", "left":
208 cmds = append(cmds, f.deselectItemCmd)
209 }
210 }
211 case tea.WindowSizeMsg:
212 switch f.activeView {
213 case filesViewFiles:
214 if f.repo != nil {
215 cmds = append(cmds, f.updateFilesCmd)
216 }
217 case filesViewContent:
218 if f.currentContent.content != "" {
219 m, cmd := f.code.Update(msg)
220 f.code = m.(*code.Code)
221 if cmd != nil {
222 cmds = append(cmds, cmd)
223 }
224 }
225 }
226 }
227 switch f.activeView {
228 case filesViewFiles:
229 m, cmd := f.selector.Update(msg)
230 f.selector = m.(*selector.Selector)
231 if cmd != nil {
232 cmds = append(cmds, cmd)
233 }
234 case filesViewContent:
235 m, cmd := f.code.Update(msg)
236 f.code = m.(*code.Code)
237 if cmd != nil {
238 cmds = append(cmds, cmd)
239 }
240 }
241 return f, tea.Batch(cmds...)
242}
243
244// View implements tea.Model.
245func (f *Files) View() string {
246 switch f.activeView {
247 case filesViewFiles:
248 return f.selector.View()
249 case filesViewContent:
250 return f.code.View()
251 default:
252 return ""
253 }
254}
255
256// StatusBarValue returns the status bar value.
257func (f *Files) StatusBarValue() string {
258 p := f.path
259 if p == "." {
260 return ""
261 }
262 return p
263}
264
265// StatusBarInfo returns the status bar info.
266func (f *Files) StatusBarInfo() string {
267 switch f.activeView {
268 case filesViewFiles:
269 return fmt.Sprintf("%d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
270 case filesViewContent:
271 return fmt.Sprintf("%.f%%", f.code.ScrollPercent()*100)
272 default:
273 return ""
274 }
275}
276
277func (f *Files) updateFilesCmd() tea.Msg {
278 files := make([]selector.IdentifiableItem, 0)
279 dirs := make([]selector.IdentifiableItem, 0)
280 t, err := f.repo.Tree(f.ref, f.path)
281 if err != nil {
282 return common.ErrorMsg(err)
283 }
284 ents, err := t.Entries()
285 if err != nil {
286 return common.ErrorMsg(err)
287 }
288 ents.Sort()
289 for _, e := range ents {
290 if e.IsTree() {
291 dirs = append(dirs, FileItem{e})
292 } else {
293 files = append(files, FileItem{e})
294 }
295 }
296 return FileItemsMsg(append(dirs, files...))
297}
298
299func (f *Files) selectTreeCmd() tea.Msg {
300 if f.currentItem != nil && f.currentItem.entry.IsTree() {
301 f.lastSelected = append(f.lastSelected, f.selector.Index())
302 f.selector.Select(0)
303 return f.updateFilesCmd()
304 }
305 return common.ErrorMsg(errNoFileSelected)
306}
307
308func (f *Files) selectFileCmd() tea.Msg {
309 i := f.currentItem
310 if i != nil && !i.entry.IsTree() {
311 fi := i.entry.File()
312 if i.Mode().IsDir() || f == nil {
313 return common.ErrorMsg(errInvalidFile)
314 }
315 bin, err := fi.IsBinary()
316 if err != nil {
317 f.path = filepath.Dir(f.path)
318 return common.ErrorMsg(err)
319 }
320 if bin {
321 f.path = filepath.Dir(f.path)
322 return common.ErrorMsg(errBinaryFile)
323 }
324 c, err := fi.Bytes()
325 if err != nil {
326 f.path = filepath.Dir(f.path)
327 return common.ErrorMsg(err)
328 }
329 f.lastSelected = append(f.lastSelected, f.selector.Index())
330 return FileContentMsg{string(c), i.entry.Name()}
331 }
332 return common.ErrorMsg(errNoFileSelected)
333}
334
335func (f *Files) deselectItemCmd() tea.Msg {
336 f.path = filepath.Dir(f.path)
337 f.activeView = filesViewFiles
338 msg := f.updateFilesCmd()
339 index := 0
340 if len(f.lastSelected) > 0 {
341 index = f.lastSelected[len(f.lastSelected)-1]
342 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
343 }
344 log.Printf("deselect %d", index)
345 f.selector.Select(index)
346 return msg
347}