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