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 k.NextPage,
138 k.PrevPage,
139 },
140 {
141 k.GoToStart,
142 k.GoToEnd,
143 copyKey,
144 },
145 }...)
146 case filesViewContent:
147 copyKey := f.common.KeyMap.Copy
148 copyKey.SetHelp("c", "copy content")
149 k := f.code.KeyMap
150 b = append(b, []key.Binding{
151 f.common.KeyMap.BackItem,
152 })
153 b = append(b, [][]key.Binding{
154 {
155 k.PageDown,
156 k.PageUp,
157 k.HalfPageDown,
158 k.HalfPageUp,
159 },
160 {
161 k.Down,
162 k.Up,
163 copyKey,
164 lineNo,
165 },
166 }...)
167 }
168 return b
169}
170
171// Init implements tea.Model.
172func (f *Files) Init() tea.Cmd {
173 f.path = ""
174 f.currentItem = nil
175 f.activeView = filesViewFiles
176 f.lastSelected = make([]int, 0)
177 f.selector.Select(0)
178 return f.updateFilesCmd
179}
180
181// Update implements tea.Model.
182func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183 cmds := make([]tea.Cmd, 0)
184 switch msg := msg.(type) {
185 case RepoMsg:
186 f.repo = git.GitRepo(msg)
187 cmds = append(cmds, f.Init())
188 case RefMsg:
189 f.ref = msg
190 cmds = append(cmds, f.Init())
191 case FileItemsMsg:
192 cmds = append(cmds,
193 f.selector.SetItems(msg),
194 updateStatusBarCmd,
195 )
196 case FileContentMsg:
197 f.activeView = filesViewContent
198 f.currentContent = msg
199 f.code.SetContent(msg.content, msg.ext)
200 f.code.GotoTop()
201 cmds = append(cmds, updateStatusBarCmd)
202 case selector.SelectMsg:
203 switch sel := msg.IdentifiableItem.(type) {
204 case FileItem:
205 f.currentItem = &sel
206 f.path = filepath.Join(f.path, sel.entry.Name())
207 if sel.entry.IsTree() {
208 cmds = append(cmds, f.selectTreeCmd)
209 } else {
210 cmds = append(cmds, f.selectFileCmd)
211 }
212 }
213 case tea.KeyMsg:
214 switch f.activeView {
215 case filesViewFiles:
216 switch msg.String() {
217 case "l", "right":
218 cmds = append(cmds, f.selector.SelectItem)
219 case "h", "left":
220 cmds = append(cmds, f.deselectItemCmd)
221 }
222 case filesViewContent:
223 keyStr := msg.String()
224 switch {
225 case keyStr == "h", keyStr == "left":
226 cmds = append(cmds, f.deselectItemCmd)
227 case key.Matches(msg, f.common.KeyMap.Copy):
228 f.common.Copy.Copy(f.currentContent.content)
229 case key.Matches(msg, lineNo):
230 f.lineNumber = !f.lineNumber
231 f.code.SetShowLineNumber(f.lineNumber)
232 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
233 }
234 }
235 case tea.WindowSizeMsg:
236 switch f.activeView {
237 case filesViewFiles:
238 if f.repo != nil {
239 cmds = append(cmds, f.updateFilesCmd)
240 }
241 case filesViewContent:
242 if f.currentContent.content != "" {
243 m, cmd := f.code.Update(msg)
244 f.code = m.(*code.Code)
245 if cmd != nil {
246 cmds = append(cmds, cmd)
247 }
248 }
249 }
250 }
251 switch f.activeView {
252 case filesViewFiles:
253 m, cmd := f.selector.Update(msg)
254 f.selector = m.(*selector.Selector)
255 if cmd != nil {
256 cmds = append(cmds, cmd)
257 }
258 case filesViewContent:
259 m, cmd := f.code.Update(msg)
260 f.code = m.(*code.Code)
261 if cmd != nil {
262 cmds = append(cmds, cmd)
263 }
264 }
265 return f, tea.Batch(cmds...)
266}
267
268// View implements tea.Model.
269func (f *Files) View() string {
270 switch f.activeView {
271 case filesViewFiles:
272 return f.selector.View()
273 case filesViewContent:
274 return f.code.View()
275 default:
276 return ""
277 }
278}
279
280// StatusBarValue returns the status bar value.
281func (f *Files) StatusBarValue() string {
282 p := f.path
283 if p == "." {
284 return ""
285 }
286 return p
287}
288
289// StatusBarInfo returns the status bar info.
290func (f *Files) StatusBarInfo() string {
291 switch f.activeView {
292 case filesViewFiles:
293 return fmt.Sprintf(" %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
294 case filesViewContent:
295 return fmt.Sprintf("☰ %.f%%", f.code.ScrollPercent()*100)
296 default:
297 return ""
298 }
299}
300
301func (f *Files) updateFilesCmd() tea.Msg {
302 files := make([]selector.IdentifiableItem, 0)
303 dirs := make([]selector.IdentifiableItem, 0)
304 t, err := f.repo.Tree(f.ref, f.path)
305 if err != nil {
306 return common.ErrorMsg(err)
307 }
308 ents, err := t.Entries()
309 if err != nil {
310 return common.ErrorMsg(err)
311 }
312 ents.Sort()
313 for _, e := range ents {
314 if e.IsTree() {
315 dirs = append(dirs, FileItem{entry: e})
316 } else {
317 files = append(files, FileItem{entry: e})
318 }
319 }
320 return FileItemsMsg(append(dirs, files...))
321}
322
323func (f *Files) selectTreeCmd() tea.Msg {
324 if f.currentItem != nil && f.currentItem.entry.IsTree() {
325 f.lastSelected = append(f.lastSelected, f.selector.Index())
326 f.selector.Select(0)
327 return f.updateFilesCmd()
328 }
329 return common.ErrorMsg(errNoFileSelected)
330}
331
332func (f *Files) selectFileCmd() tea.Msg {
333 i := f.currentItem
334 if i != nil && !i.entry.IsTree() {
335 fi := i.entry.File()
336 if i.Mode().IsDir() || f == nil {
337 return common.ErrorMsg(errInvalidFile)
338 }
339 bin, err := fi.IsBinary()
340 if err != nil {
341 f.path = filepath.Dir(f.path)
342 return common.ErrorMsg(err)
343 }
344 if bin {
345 f.path = filepath.Dir(f.path)
346 return common.ErrorMsg(errBinaryFile)
347 }
348 c, err := fi.Bytes()
349 if err != nil {
350 f.path = filepath.Dir(f.path)
351 return common.ErrorMsg(err)
352 }
353 f.lastSelected = append(f.lastSelected, f.selector.Index())
354 return FileContentMsg{string(c), i.entry.Name()}
355 }
356 return common.ErrorMsg(errNoFileSelected)
357}
358
359func (f *Files) deselectItemCmd() tea.Msg {
360 f.path = filepath.Dir(f.path)
361 f.activeView = filesViewFiles
362 msg := f.updateFilesCmd()
363 index := 0
364 if len(f.lastSelected) > 0 {
365 index = f.lastSelected[len(f.lastSelected)-1]
366 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
367 }
368 f.selector.Select(index)
369 return msg
370}