files.go

  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}