1package repo
2
3import (
4 "errors"
5 "fmt"
6 "path/filepath"
7
8 "github.com/alecthomas/chroma/lexers"
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
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 *ggit.Reference
53 activeView filesView
54 repo git.GitRepo
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 switch f.activeView {
134 case filesViewFiles:
135 copyKey := f.common.KeyMap.Copy
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 := f.common.KeyMap.Copy
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 copyKey,
174 }
175 lexer := lexers.Match(f.currentContent.ext)
176 lang := ""
177 if lexer != nil && lexer.Config() != nil {
178 lang = lexer.Config().Name
179 }
180 if lang != "markdown" {
181 lc = append(lc, lineNo)
182 }
183 b = append(b, lc)
184 }
185 return b
186}
187
188// Init implements tea.Model.
189func (f *Files) Init() tea.Cmd {
190 f.path = ""
191 f.currentItem = nil
192 f.activeView = filesViewFiles
193 f.lastSelected = make([]int, 0)
194 f.selector.Select(0)
195 return f.updateFilesCmd
196}
197
198// Update implements tea.Model.
199func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200 cmds := make([]tea.Cmd, 0)
201 switch msg := msg.(type) {
202 case RepoMsg:
203 f.repo = git.GitRepo(msg)
204 cmds = append(cmds, f.Init())
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 tea.KeyMsg:
231 switch f.activeView {
232 case filesViewFiles:
233 switch msg.String() {
234 case "l", "right":
235 cmds = append(cmds, f.selector.SelectItem)
236 case "h", "left":
237 cmds = append(cmds, f.deselectItemCmd)
238 }
239 case filesViewContent:
240 keyStr := msg.String()
241 switch {
242 case keyStr == "h", keyStr == "left":
243 cmds = append(cmds, f.deselectItemCmd)
244 case key.Matches(msg, f.common.KeyMap.Copy):
245 f.common.Copy.Copy(f.currentContent.content)
246 case key.Matches(msg, lineNo):
247 f.lineNumber = !f.lineNumber
248 f.code.SetShowLineNumber(f.lineNumber)
249 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
250 }
251 }
252 case tea.WindowSizeMsg:
253 switch f.activeView {
254 case filesViewFiles:
255 if f.repo != nil {
256 cmds = append(cmds, f.updateFilesCmd)
257 }
258 case filesViewContent:
259 if f.currentContent.content != "" {
260 m, cmd := f.code.Update(msg)
261 f.code = m.(*code.Code)
262 if cmd != nil {
263 cmds = append(cmds, cmd)
264 }
265 }
266 }
267 }
268 switch f.activeView {
269 case filesViewFiles:
270 m, cmd := f.selector.Update(msg)
271 f.selector = m.(*selector.Selector)
272 if cmd != nil {
273 cmds = append(cmds, cmd)
274 }
275 case filesViewContent:
276 m, cmd := f.code.Update(msg)
277 f.code = m.(*code.Code)
278 if cmd != nil {
279 cmds = append(cmds, cmd)
280 }
281 }
282 return f, tea.Batch(cmds...)
283}
284
285// View implements tea.Model.
286func (f *Files) View() string {
287 switch f.activeView {
288 case filesViewFiles:
289 return f.selector.View()
290 case filesViewContent:
291 return f.code.View()
292 default:
293 return ""
294 }
295}
296
297// StatusBarValue returns the status bar value.
298func (f *Files) StatusBarValue() string {
299 p := f.path
300 if p == "." {
301 return ""
302 }
303 return p
304}
305
306// StatusBarInfo returns the status bar info.
307func (f *Files) StatusBarInfo() string {
308 switch f.activeView {
309 case filesViewFiles:
310 return fmt.Sprintf(" %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
311 case filesViewContent:
312 return fmt.Sprintf("☰ %.f%%", f.code.ScrollPercent()*100)
313 default:
314 return ""
315 }
316}
317
318func (f *Files) updateFilesCmd() tea.Msg {
319 files := make([]selector.IdentifiableItem, 0)
320 dirs := make([]selector.IdentifiableItem, 0)
321 t, err := f.repo.Tree(f.ref, f.path)
322 if err != nil {
323 return common.ErrorMsg(err)
324 }
325 ents, err := t.Entries()
326 if err != nil {
327 return common.ErrorMsg(err)
328 }
329 ents.Sort()
330 for _, e := range ents {
331 if e.IsTree() {
332 dirs = append(dirs, FileItem{entry: e})
333 } else {
334 files = append(files, FileItem{entry: e})
335 }
336 }
337 return FileItemsMsg(append(dirs, files...))
338}
339
340func (f *Files) selectTreeCmd() tea.Msg {
341 if f.currentItem != nil && f.currentItem.entry.IsTree() {
342 f.lastSelected = append(f.lastSelected, f.selector.Index())
343 f.selector.Select(0)
344 return f.updateFilesCmd()
345 }
346 return common.ErrorMsg(errNoFileSelected)
347}
348
349func (f *Files) selectFileCmd() tea.Msg {
350 i := f.currentItem
351 if i != nil && !i.entry.IsTree() {
352 fi := i.entry.File()
353 if i.Mode().IsDir() || f == nil {
354 return common.ErrorMsg(errInvalidFile)
355 }
356 bin, err := fi.IsBinary()
357 if err != nil {
358 f.path = filepath.Dir(f.path)
359 return common.ErrorMsg(err)
360 }
361 if bin {
362 f.path = filepath.Dir(f.path)
363 return common.ErrorMsg(errBinaryFile)
364 }
365 c, err := fi.Bytes()
366 if err != nil {
367 f.path = filepath.Dir(f.path)
368 return common.ErrorMsg(err)
369 }
370 f.lastSelected = append(f.lastSelected, f.selector.Index())
371 return FileContentMsg{string(c), i.entry.Name()}
372 }
373 return common.ErrorMsg(errNoFileSelected)
374}
375
376func (f *Files) deselectItemCmd() tea.Msg {
377 f.path = filepath.Dir(f.path)
378 f.activeView = filesViewFiles
379 msg := f.updateFilesCmd()
380 index := 0
381 if len(f.lastSelected) > 0 {
382 index = f.lastSelected[len(f.lastSelected)-1]
383 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
384 }
385 f.selector.Select(index)
386 return msg
387}