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