1package repo
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "path/filepath"
8
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.Styles})
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// Init implements tea.Model.
84func (f *Files) Init() tea.Cmd {
85 f.path = ""
86 f.currentItem = nil
87 f.activeView = filesViewFiles
88 f.lastSelected = make([]int, 0)
89 return f.updateFilesCmd
90}
91
92// Update implements tea.Model.
93func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94 cmds := make([]tea.Cmd, 0)
95 switch msg := msg.(type) {
96 case RepoMsg:
97 f.selector.Select(0)
98 f.repo = git.GitRepo(msg)
99 cmds = append(cmds, f.Init())
100 case RefMsg:
101 f.ref = msg
102 cmds = append(cmds, f.Init())
103 case FileItemsMsg:
104 cmds = append(cmds,
105 f.selector.SetItems(msg),
106 updateStatusBarCmd,
107 )
108 case FileContentMsg:
109 f.activeView = filesViewContent
110 f.currentContent = msg
111 f.code.SetContent(msg.content, msg.ext)
112 f.code.GotoTop()
113 cmds = append(cmds, updateStatusBarCmd)
114 case selector.SelectMsg:
115 switch sel := msg.IdentifiableItem.(type) {
116 case FileItem:
117 f.currentItem = &sel
118 f.path = filepath.Join(f.path, sel.entry.Name())
119 log.Printf("selected index %d", f.selector.Index())
120 if sel.entry.IsTree() {
121 cmds = append(cmds, f.selectTreeCmd)
122 } else {
123 cmds = append(cmds, f.selectFileCmd)
124 }
125 }
126 case tea.KeyMsg:
127 switch f.activeView {
128 case filesViewFiles:
129 switch msg.String() {
130 case "l", "right":
131 cmds = append(cmds, f.selector.SelectItem)
132 case "h", "left":
133 cmds = append(cmds, f.deselectItemCmd)
134 }
135 case filesViewContent:
136 switch msg.String() {
137 case "h", "left":
138 cmds = append(cmds, f.deselectItemCmd)
139 }
140 }
141 case tea.WindowSizeMsg:
142 switch f.activeView {
143 case filesViewFiles:
144 if f.repo != nil {
145 cmds = append(cmds, f.updateFilesCmd)
146 }
147 case filesViewContent:
148 if f.currentContent.content != "" {
149 m, cmd := f.code.Update(msg)
150 f.code = m.(*code.Code)
151 if cmd != nil {
152 cmds = append(cmds, cmd)
153 }
154 }
155 }
156 }
157 switch f.activeView {
158 case filesViewFiles:
159 m, cmd := f.selector.Update(msg)
160 f.selector = m.(*selector.Selector)
161 if cmd != nil {
162 cmds = append(cmds, cmd)
163 }
164 case filesViewContent:
165 m, cmd := f.code.Update(msg)
166 f.code = m.(*code.Code)
167 if cmd != nil {
168 cmds = append(cmds, cmd)
169 }
170 }
171 return f, tea.Batch(cmds...)
172}
173
174// View implements tea.Model.
175func (f *Files) View() string {
176 switch f.activeView {
177 case filesViewFiles:
178 return f.selector.View()
179 case filesViewContent:
180 return f.code.View()
181 default:
182 return ""
183 }
184}
185
186// StatusBarValue returns the status bar value.
187func (f *Files) StatusBarValue() string {
188 p := f.path
189 if p == "." {
190 return ""
191 }
192 return p
193}
194
195// StatusBarInfo returns the status bar info.
196func (f *Files) StatusBarInfo() string {
197 switch f.activeView {
198 case filesViewFiles:
199 return fmt.Sprintf("%d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
200 case filesViewContent:
201 return fmt.Sprintf("%.f%%", f.code.ScrollPercent()*100)
202 default:
203 return ""
204 }
205}
206
207func (f *Files) updateFilesCmd() tea.Msg {
208 files := make([]selector.IdentifiableItem, 0)
209 dirs := make([]selector.IdentifiableItem, 0)
210 t, err := f.repo.Tree(f.ref, f.path)
211 if err != nil {
212 return common.ErrorMsg(err)
213 }
214 ents, err := t.Entries()
215 if err != nil {
216 return common.ErrorMsg(err)
217 }
218 ents.Sort()
219 for _, e := range ents {
220 if e.IsTree() {
221 dirs = append(dirs, FileItem{e})
222 } else {
223 files = append(files, FileItem{e})
224 }
225 }
226 return FileItemsMsg(append(dirs, files...))
227}
228
229func (f *Files) selectTreeCmd() tea.Msg {
230 if f.currentItem != nil && f.currentItem.entry.IsTree() {
231 f.lastSelected = append(f.lastSelected, f.selector.Index())
232 f.selector.Select(0)
233 return f.updateFilesCmd()
234 }
235 return common.ErrorMsg(errNoFileSelected)
236}
237
238func (f *Files) selectFileCmd() tea.Msg {
239 i := f.currentItem
240 if i != nil && !i.entry.IsTree() {
241 fi := i.entry.File()
242 if i.Mode().IsDir() || f == nil {
243 return common.ErrorMsg(errInvalidFile)
244 }
245 bin, err := fi.IsBinary()
246 if err != nil {
247 f.path = filepath.Dir(f.path)
248 return common.ErrorMsg(err)
249 }
250 if bin {
251 f.path = filepath.Dir(f.path)
252 return common.ErrorMsg(errBinaryFile)
253 }
254 c, err := fi.Bytes()
255 if err != nil {
256 f.path = filepath.Dir(f.path)
257 return common.ErrorMsg(err)
258 }
259 f.lastSelected = append(f.lastSelected, f.selector.Index())
260 return FileContentMsg{string(c), i.entry.Name()}
261 }
262 return common.ErrorMsg(errNoFileSelected)
263}
264
265func (f *Files) deselectItemCmd() tea.Msg {
266 f.path = filepath.Dir(f.path)
267 f.activeView = filesViewFiles
268 msg := f.updateFilesCmd()
269 index := 0
270 if len(f.lastSelected) > 0 {
271 index = f.lastSelected[len(f.lastSelected)-1]
272 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
273 }
274 log.Printf("deselect %d", index)
275 f.selector.Select(index)
276 return msg
277}