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}