1package repo
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "path/filepath"
8
9 "github.com/alecthomas/chroma/lexers"
10 "github.com/charmbracelet/bubbles/key"
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/charmbracelet/soft-serve/git"
13 "github.com/charmbracelet/soft-serve/server/backend"
14 "github.com/charmbracelet/soft-serve/ui/common"
15 "github.com/charmbracelet/soft-serve/ui/components/code"
16 "github.com/charmbracelet/soft-serve/ui/components/selector"
17)
18
19type filesView int
20
21const (
22 filesViewFiles filesView = iota
23 filesViewContent
24)
25
26var (
27 errNoFileSelected = errors.New("no file selected")
28 errBinaryFile = errors.New("binary file")
29 errFileTooLarge = errors.New("file is too large")
30 errInvalidFile = errors.New("invalid file")
31)
32
33var (
34 lineNo = key.NewBinding(
35 key.WithKeys("l"),
36 key.WithHelp("l", "toggle line numbers"),
37 )
38)
39
40// FileItemsMsg is a message that contains a list of files.
41type FileItemsMsg []selector.IdentifiableItem
42
43// FileContentMsg is a message that contains the content of a file.
44type FileContentMsg struct {
45 content string
46 ext string
47}
48
49// Files is the model for the files view.
50type Files struct {
51 common common.Common
52 selector *selector.Selector
53 ref *git.Reference
54 activeView filesView
55 repo backend.Repository
56 code *code.Code
57 path string
58 currentItem *FileItem
59 currentContent FileContentMsg
60 lastSelected []int
61 lineNumber bool
62}
63
64// NewFiles creates a new files model.
65func NewFiles(common common.Common) *Files {
66 f := &Files{
67 common: common,
68 code: code.New(common, "", ""),
69 activeView: filesViewFiles,
70 lastSelected: make([]int, 0),
71 lineNumber: true,
72 }
73 selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})
74 selector.SetShowFilter(false)
75 selector.SetShowHelp(false)
76 selector.SetShowPagination(false)
77 selector.SetShowStatusBar(false)
78 selector.SetShowTitle(false)
79 selector.SetFilteringEnabled(false)
80 selector.DisableQuitKeybindings()
81 selector.KeyMap.NextPage = common.KeyMap.NextPage
82 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
83 f.selector = selector
84 f.code.SetShowLineNumber(f.lineNumber)
85 return f
86}
87
88// SetSize implements common.Component.
89func (f *Files) SetSize(width, height int) {
90 f.common.SetSize(width, height)
91 f.selector.SetSize(width, height)
92 f.code.SetSize(width, height)
93}
94
95// ShortHelp implements help.KeyMap.
96func (f *Files) ShortHelp() []key.Binding {
97 k := f.selector.KeyMap
98 switch f.activeView {
99 case filesViewFiles:
100 copyKey := f.common.KeyMap.Copy
101 copyKey.SetHelp("c", "copy name")
102 return []key.Binding{
103 f.common.KeyMap.SelectItem,
104 f.common.KeyMap.BackItem,
105 k.CursorUp,
106 k.CursorDown,
107 copyKey,
108 }
109 case filesViewContent:
110 copyKey := f.common.KeyMap.Copy
111 copyKey.SetHelp("c", "copy content")
112 b := []key.Binding{
113 f.common.KeyMap.UpDown,
114 f.common.KeyMap.BackItem,
115 copyKey,
116 }
117 lexer := lexers.Match(f.currentContent.ext)
118 lang := ""
119 if lexer != nil && lexer.Config() != nil {
120 lang = lexer.Config().Name
121 }
122 if lang != "markdown" {
123 b = append(b, lineNo)
124 }
125 return b
126 default:
127 return []key.Binding{}
128 }
129}
130
131// FullHelp implements help.KeyMap.
132func (f *Files) FullHelp() [][]key.Binding {
133 b := make([][]key.Binding, 0)
134 copyKey := f.common.KeyMap.Copy
135 switch f.activeView {
136 case filesViewFiles:
137 copyKey.SetHelp("c", "copy name")
138 k := f.selector.KeyMap
139 b = append(b, []key.Binding{
140 f.common.KeyMap.SelectItem,
141 f.common.KeyMap.BackItem,
142 })
143 b = append(b, [][]key.Binding{
144 {
145 k.CursorUp,
146 k.CursorDown,
147 k.NextPage,
148 k.PrevPage,
149 },
150 {
151 k.GoToStart,
152 k.GoToEnd,
153 copyKey,
154 },
155 }...)
156 case filesViewContent:
157 copyKey.SetHelp("c", "copy content")
158 k := f.code.KeyMap
159 b = append(b, []key.Binding{
160 f.common.KeyMap.BackItem,
161 })
162 b = append(b, [][]key.Binding{
163 {
164 k.PageDown,
165 k.PageUp,
166 k.HalfPageDown,
167 k.HalfPageUp,
168 },
169 }...)
170 lc := []key.Binding{
171 k.Down,
172 k.Up,
173 f.common.KeyMap.GotoTop,
174 f.common.KeyMap.GotoBottom,
175 copyKey,
176 }
177 lexer := lexers.Match(f.currentContent.ext)
178 lang := ""
179 if lexer != nil && lexer.Config() != nil {
180 lang = lexer.Config().Name
181 }
182 if lang != "markdown" {
183 lc = append(lc, lineNo)
184 }
185 b = append(b, lc)
186 }
187 return b
188}
189
190// Init implements tea.Model.
191func (f *Files) Init() tea.Cmd {
192 f.path = ""
193 f.currentItem = nil
194 f.activeView = filesViewFiles
195 f.lastSelected = make([]int, 0)
196 f.selector.Select(0)
197 return f.updateFilesCmd
198}
199
200// Update implements tea.Model.
201func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
202 cmds := make([]tea.Cmd, 0)
203 switch msg := msg.(type) {
204 case RepoMsg:
205 f.repo = msg
206 case RefMsg:
207 f.ref = msg
208 cmds = append(cmds, f.Init())
209 case FileItemsMsg:
210 cmds = append(cmds,
211 f.selector.SetItems(msg),
212 updateStatusBarCmd,
213 )
214 case FileContentMsg:
215 f.activeView = filesViewContent
216 f.currentContent = msg
217 f.code.SetContent(msg.content, msg.ext)
218 f.code.GotoTop()
219 cmds = append(cmds, updateStatusBarCmd)
220 case selector.SelectMsg:
221 switch sel := msg.IdentifiableItem.(type) {
222 case FileItem:
223 f.currentItem = &sel
224 f.path = filepath.Join(f.path, sel.entry.Name())
225 if sel.entry.IsTree() {
226 cmds = append(cmds, f.selectTreeCmd)
227 } else {
228 cmds = append(cmds, f.selectFileCmd)
229 }
230 }
231 case BackMsg:
232 cmds = append(cmds, f.deselectItemCmd)
233 case tea.KeyMsg:
234 switch f.activeView {
235 case filesViewFiles:
236 switch {
237 case key.Matches(msg, f.common.KeyMap.SelectItem):
238 cmds = append(cmds, f.selector.SelectItem)
239 case key.Matches(msg, f.common.KeyMap.BackItem):
240 cmds = append(cmds, backCmd)
241 }
242 case filesViewContent:
243 switch {
244 case key.Matches(msg, f.common.KeyMap.BackItem):
245 cmds = append(cmds, backCmd)
246 case key.Matches(msg, f.common.KeyMap.Copy):
247 cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
248 case key.Matches(msg, lineNo):
249 f.lineNumber = !f.lineNumber
250 f.code.SetShowLineNumber(f.lineNumber)
251 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
252 }
253 }
254 case tea.WindowSizeMsg:
255 switch f.activeView {
256 case filesViewFiles:
257 if f.repo != nil {
258 cmds = append(cmds, f.updateFilesCmd)
259 }
260 case filesViewContent:
261 if f.currentContent.content != "" {
262 m, cmd := f.code.Update(msg)
263 f.code = m.(*code.Code)
264 if cmd != nil {
265 cmds = append(cmds, cmd)
266 }
267 }
268 }
269 case selector.ActiveMsg:
270 cmds = append(cmds, updateStatusBarCmd)
271 case EmptyRepoMsg:
272 f.ref = nil
273 f.path = ""
274 f.currentItem = nil
275 f.activeView = filesViewFiles
276 f.lastSelected = make([]int, 0)
277 f.selector.Select(0)
278 cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
279 }
280 switch f.activeView {
281 case filesViewFiles:
282 m, cmd := f.selector.Update(msg)
283 f.selector = m.(*selector.Selector)
284 if cmd != nil {
285 cmds = append(cmds, cmd)
286 }
287 case filesViewContent:
288 m, cmd := f.code.Update(msg)
289 f.code = m.(*code.Code)
290 if cmd != nil {
291 cmds = append(cmds, cmd)
292 }
293 }
294 return f, tea.Batch(cmds...)
295}
296
297// View implements tea.Model.
298func (f *Files) View() string {
299 switch f.activeView {
300 case filesViewFiles:
301 return f.selector.View()
302 case filesViewContent:
303 return f.code.View()
304 default:
305 return ""
306 }
307}
308
309// StatusBarValue returns the status bar value.
310func (f *Files) StatusBarValue() string {
311 p := f.path
312 if p == "." {
313 // FIXME: this is a hack to force clear the status bar value
314 return " "
315 }
316 return p
317}
318
319// StatusBarInfo returns the status bar info.
320func (f *Files) StatusBarInfo() string {
321 switch f.activeView {
322 case filesViewFiles:
323 return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
324 case filesViewContent:
325 return fmt.Sprintf("☰ %.f%%", f.code.ScrollPercent()*100)
326 default:
327 return ""
328 }
329}
330
331func (f *Files) updateFilesCmd() tea.Msg {
332 files := make([]selector.IdentifiableItem, 0)
333 dirs := make([]selector.IdentifiableItem, 0)
334 if f.ref == nil {
335 log.Printf("ui: files: ref is nil")
336 return common.ErrorMsg(errNoRef)
337 }
338 r, err := f.repo.Open()
339 if err != nil {
340 return common.ErrorMsg(err)
341 }
342 t, err := r.TreePath(f.ref, f.path)
343 if err != nil {
344 log.Printf("ui: files: error getting tree %v", err)
345 return common.ErrorMsg(err)
346 }
347 ents, err := t.Entries()
348 if err != nil {
349 log.Printf("ui: files: error listing files %v", err)
350 return common.ErrorMsg(err)
351 }
352 ents.Sort()
353 for _, e := range ents {
354 if e.IsTree() {
355 dirs = append(dirs, FileItem{entry: e})
356 } else {
357 files = append(files, FileItem{entry: e})
358 }
359 }
360 return FileItemsMsg(append(dirs, files...))
361}
362
363func (f *Files) selectTreeCmd() tea.Msg {
364 if f.currentItem != nil && f.currentItem.entry.IsTree() {
365 f.lastSelected = append(f.lastSelected, f.selector.Index())
366 f.selector.Select(0)
367 return f.updateFilesCmd()
368 }
369 log.Printf("ui: files: current item is not a tree")
370 return common.ErrorMsg(errNoFileSelected)
371}
372
373func (f *Files) selectFileCmd() tea.Msg {
374 i := f.currentItem
375 if i != nil && !i.entry.IsTree() {
376 fi := i.entry.File()
377 if i.Mode().IsDir() || f == nil {
378 log.Printf("ui: files: current item is not a file")
379 return common.ErrorMsg(errInvalidFile)
380 }
381 bin, err := fi.IsBinary()
382 if err != nil {
383 f.path = filepath.Dir(f.path)
384 log.Printf("ui: files: error checking if file is binary %v", err)
385 return common.ErrorMsg(err)
386 }
387 if bin {
388 f.path = filepath.Dir(f.path)
389 log.Printf("ui: files: file is binary")
390 return common.ErrorMsg(errBinaryFile)
391 }
392 c, err := fi.Bytes()
393 if err != nil {
394 f.path = filepath.Dir(f.path)
395 log.Printf("ui: files: error reading file %v", err)
396 return common.ErrorMsg(err)
397 }
398 f.lastSelected = append(f.lastSelected, f.selector.Index())
399 return FileContentMsg{string(c), i.entry.Name()}
400 }
401 log.Printf("ui: files: current item is not a file")
402 return common.ErrorMsg(errNoFileSelected)
403}
404
405func (f *Files) deselectItemCmd() tea.Msg {
406 f.path = filepath.Dir(f.path)
407 f.activeView = filesViewFiles
408 msg := f.updateFilesCmd()
409 index := 0
410 if len(f.lastSelected) > 0 {
411 index = f.lastSelected[len(f.lastSelected)-1]
412 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
413 }
414 f.selector.Select(index)
415 return msg
416}
417
418func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
419 return func() tea.Msg {
420 return FileItemsMsg(items)
421 }
422}