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 if f.currentContent.content != "" {
143 m, cmd := f.code.Update(msg)
144 f.code = m.(*code.Code)
145 if cmd != nil {
146 cmds = append(cmds, cmd)
147 }
148 }
149 if f.repo != nil {
150 cmds = append(cmds, f.updateFilesCmd)
151 }
152 }
153 switch f.activeView {
154 case filesViewFiles:
155 m, cmd := f.selector.Update(msg)
156 f.selector = m.(*selector.Selector)
157 if cmd != nil {
158 cmds = append(cmds, cmd)
159 }
160 case filesViewContent:
161 m, cmd := f.code.Update(msg)
162 f.code = m.(*code.Code)
163 if cmd != nil {
164 cmds = append(cmds, cmd)
165 }
166 }
167 return f, tea.Batch(cmds...)
168}
169
170// View implements tea.Model.
171func (f *Files) View() string {
172 switch f.activeView {
173 case filesViewFiles:
174 return f.selector.View()
175 case filesViewContent:
176 return f.code.View()
177 default:
178 return ""
179 }
180}
181
182// StatusBarValue returns the status bar value.
183func (f *Files) StatusBarValue() string {
184 p := f.path
185 if p == "." {
186 return ""
187 }
188 return p
189}
190
191// StatusBarInfo returns the status bar info.
192func (f *Files) StatusBarInfo() string {
193 switch f.activeView {
194 case filesViewFiles:
195 return fmt.Sprintf("%d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
196 case filesViewContent:
197 return fmt.Sprintf("%.f%%", f.code.ScrollPercent()*100)
198 default:
199 return ""
200 }
201}
202
203func (f *Files) updateFilesCmd() tea.Msg {
204 files := make([]selector.IdentifiableItem, 0)
205 dirs := make([]selector.IdentifiableItem, 0)
206 t, err := f.repo.Tree(f.ref, f.path)
207 if err != nil {
208 return common.ErrorMsg(err)
209 }
210 ents, err := t.Entries()
211 if err != nil {
212 return common.ErrorMsg(err)
213 }
214 ents.Sort()
215 for _, e := range ents {
216 if e.IsTree() {
217 dirs = append(dirs, FileItem{e})
218 } else {
219 files = append(files, FileItem{e})
220 }
221 }
222 return FileItemsMsg(append(dirs, files...))
223}
224
225func (f *Files) selectTreeCmd() tea.Msg {
226 if f.currentItem != nil && f.currentItem.entry.IsTree() {
227 f.lastSelected = append(f.lastSelected, f.selector.Index())
228 f.selector.Select(0)
229 return f.updateFilesCmd()
230 }
231 return common.ErrorMsg(errNoFileSelected)
232}
233
234func (f *Files) selectFileCmd() tea.Msg {
235 i := f.currentItem
236 if i != nil && !i.entry.IsTree() {
237 fi := i.entry.File()
238 if i.Mode().IsDir() || f == nil {
239 return common.ErrorMsg(errInvalidFile)
240 }
241 bin, err := fi.IsBinary()
242 if err != nil {
243 f.path = filepath.Dir(f.path)
244 return common.ErrorMsg(err)
245 }
246 if bin {
247 f.path = filepath.Dir(f.path)
248 return common.ErrorMsg(errBinaryFile)
249 }
250 c, err := fi.Bytes()
251 if err != nil {
252 f.path = filepath.Dir(f.path)
253 return common.ErrorMsg(err)
254 }
255 f.lastSelected = append(f.lastSelected, f.selector.Index())
256 return FileContentMsg{string(c), i.entry.Name()}
257 }
258 return common.ErrorMsg(errNoFileSelected)
259}
260
261func (f *Files) deselectItemCmd() tea.Msg {
262 f.path = filepath.Dir(f.path)
263 f.activeView = filesViewFiles
264 msg := f.updateFilesCmd()
265 index := 0
266 if len(f.lastSelected) > 0 {
267 index = f.lastSelected[len(f.lastSelected)-1]
268 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
269 }
270 log.Printf("deselect %d", index)
271 f.selector.Select(index)
272 return msg
273}