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