1package repo23import (4 "errors"5 "fmt"6 "path/filepath"7 "strings"89 "charm.land/bubbles/v2/key"10 "charm.land/bubbles/v2/spinner"11 tea "charm.land/bubbletea/v2"12 gitm "github.com/aymanbagabas/git-module"13 "github.com/charmbracelet/soft-serve/git"14 "github.com/charmbracelet/soft-serve/pkg/proto"15 "github.com/charmbracelet/soft-serve/pkg/ui/common"16 "github.com/charmbracelet/soft-serve/pkg/ui/components/code"17 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"18)1920type filesView int2122const (23 filesViewLoading filesView = iota24 filesViewFiles25 filesViewContent26)2728var (29 errNoFileSelected = errors.New("no file selected")30 errBinaryFile = errors.New("binary file")31 errInvalidFile = errors.New("invalid file")32)3334var (35 lineNo = key.NewBinding(36 key.WithKeys("l"),37 key.WithHelp("l", "toggle line numbers"),38 )39 blameView = key.NewBinding(40 key.WithKeys("b"),41 key.WithHelp("b", "toggle blame view"),42 )43 preview = key.NewBinding(44 key.WithKeys("p"),45 key.WithHelp("p", "toggle preview"),46 )47)4849// FileItemsMsg is a message that contains a list of files.50type FileItemsMsg []selector.IdentifiableItem5152// FileContentMsg is a message that contains the content of a file.53type FileContentMsg struct {54 content string55 ext string56}5758// FileBlameMsg is a message that contains the blame of a file.59type FileBlameMsg *gitm.Blame6061// Files is the model for the files view.62type Files struct {63 common common.Common64 selector *selector.Selector65 ref *git.Reference66 activeView filesView67 repo proto.Repository68 code *code.Code69 path string70 currentItem *FileItem71 currentContent FileContentMsg72 currentBlame FileBlameMsg73 lastSelected []int74 lineNumber bool75 spinner spinner.Model76 cursor int77 blameView bool78}7980// NewFiles creates a new files model.81func NewFiles(common common.Common) *Files {82 f := &Files{83 common: common,84 code: code.New(common, "", ""),85 activeView: filesViewLoading,86 lastSelected: make([]int, 0),87 lineNumber: true,88 }89 selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})90 selector.SetShowFilter(false)91 selector.SetShowHelp(false)92 selector.SetShowPagination(false)93 selector.SetShowStatusBar(false)94 selector.SetShowTitle(false)95 selector.SetFilteringEnabled(false)96 selector.DisableQuitKeybindings()97 selector.KeyMap.NextPage = common.KeyMap.NextPage98 selector.KeyMap.PrevPage = common.KeyMap.PrevPage99 f.selector = selector100 f.code.ShowLineNumber = f.lineNumber101 s := spinner.New(spinner.WithSpinner(spinner.Dot),102 spinner.WithStyle(common.Styles.Spinner))103 f.spinner = s104 return f105}106107// Path implements common.TabComponent.108func (f *Files) Path() string {109 path := f.path110 if path == "." {111 return ""112 }113 return path114}115116// TabName returns the tab name.117func (f *Files) TabName() string {118 return "Files"119}120121// SetSize implements common.Component.122func (f *Files) SetSize(width, height int) {123 f.common.SetSize(width, height)124 f.selector.SetSize(width, height)125 f.code.SetSize(width, height)126}127128// ShortHelp implements help.KeyMap.129func (f *Files) ShortHelp() []key.Binding {130 k := f.selector.KeyMap131 switch f.activeView {132 case filesViewFiles:133 return []key.Binding{134 f.common.KeyMap.SelectItem,135 f.common.KeyMap.BackItem,136 k.CursorUp,137 k.CursorDown,138 }139 case filesViewContent:140 b := []key.Binding{141 f.common.KeyMap.UpDown,142 f.common.KeyMap.BackItem,143 }144 return b145 default:146 return []key.Binding{}147 }148}149150// FullHelp implements help.KeyMap.151func (f *Files) FullHelp() [][]key.Binding {152 b := make([][]key.Binding, 0)153 copyKey := f.common.KeyMap.Copy154 actionKeys := []key.Binding{}155 switch f.activeView {156 case filesViewFiles:157 copyKey.SetHelp("c", "copy name")158 k := f.selector.KeyMap159 b = append(b, [][]key.Binding{160 {161 f.common.KeyMap.SelectItem,162 f.common.KeyMap.BackItem,163 },164 {165 k.CursorUp,166 k.CursorDown,167 k.NextPage,168 k.PrevPage,169 },170 {171 k.GoToStart,172 k.GoToEnd,173 },174 }...)175 case filesViewContent:176 if !f.code.UseGlamour {177 actionKeys = append(actionKeys, lineNo)178 }179 actionKeys = append(actionKeys, blameView)180 if common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) &&181 !f.blameView {182 actionKeys = append(actionKeys, preview)183 }184 copyKey.SetHelp("c", "copy content")185 k := f.code.KeyMap186 b = append(b, []key.Binding{187 f.common.KeyMap.BackItem,188 })189 b = append(b, [][]key.Binding{190 {191 k.PageDown,192 k.PageUp,193 k.HalfPageDown,194 k.HalfPageUp,195 },196 {197 k.Down,198 k.Up,199 f.common.KeyMap.GotoTop,200 f.common.KeyMap.GotoBottom,201 },202 }...)203 }204 actionKeys = append([]key.Binding{205 copyKey,206 }, actionKeys...)207 return append(b, actionKeys)208}209210// Init implements tea.Model.211func (f *Files) Init() tea.Cmd {212 f.path = ""213 f.currentItem = nil214 f.activeView = filesViewLoading215 f.lastSelected = make([]int, 0)216 f.blameView = false217 f.currentBlame = nil218 f.code.UseGlamour = false219 return tea.Batch(f.spinner.Tick, f.updateFilesCmd)220}221222// Update implements tea.Model.223func (f *Files) Update(msg tea.Msg) (common.Model, tea.Cmd) {224 cmds := make([]tea.Cmd, 0)225 switch msg := msg.(type) {226 case RepoMsg:227 f.repo = msg228 case RefMsg:229 f.ref = msg230 f.selector.Select(0)231 cmds = append(cmds, f.Init())232 case FileItemsMsg:233 cmds = append(cmds,234 f.selector.SetItems(msg),235 )236 f.activeView = filesViewFiles237 if f.cursor >= 0 {238 f.selector.Select(f.cursor)239 f.cursor = -1240 }241 case FileContentMsg:242 f.activeView = filesViewContent243 f.currentContent = msg244 f.code.UseGlamour = common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext)245 cmds = append(cmds, f.code.SetContent(msg.content, msg.ext))246 f.code.GotoTop()247 case FileBlameMsg:248 f.currentBlame = msg249 f.activeView = filesViewContent250 f.code.UseGlamour = false251 f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg))252 case selector.SelectMsg:253 switch sel := msg.IdentifiableItem.(type) {254 case FileItem:255 f.currentItem = &sel256 f.path = filepath.Join(f.path, sel.entry.Name())257 if sel.entry.IsTree() {258 cmds = append(cmds, f.selectTreeCmd)259 } else {260 cmds = append(cmds, f.selectFileCmd)261 }262 }263 case GoBackMsg:264 switch f.activeView {265 case filesViewFiles, filesViewContent:266 cmds = append(cmds, f.deselectItemCmd())267 }268 case tea.KeyPressMsg:269 switch f.activeView {270 case filesViewFiles:271 switch {272 case key.Matches(msg, f.common.KeyMap.SelectItem):273 cmds = append(cmds, f.selector.SelectItemCmd)274 case key.Matches(msg, f.common.KeyMap.BackItem):275 cmds = append(cmds, f.deselectItemCmd())276 }277 case filesViewContent:278 switch {279 case key.Matches(msg, f.common.KeyMap.BackItem):280 cmds = append(cmds, f.deselectItemCmd())281 case key.Matches(msg, f.common.KeyMap.Copy):282 cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))283 case key.Matches(msg, lineNo) && !f.code.UseGlamour:284 f.lineNumber = !f.lineNumber285 f.code.ShowLineNumber = f.lineNumber286 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))287 case key.Matches(msg, blameView):288 f.activeView = filesViewLoading289 f.blameView = !f.blameView290 if f.blameView {291 cmds = append(cmds, f.fetchBlame)292 } else {293 f.activeView = filesViewContent294 cmds = append(cmds, f.code.SetSideNote(""))295 }296 cmds = append(cmds, f.spinner.Tick)297 case key.Matches(msg, preview) &&298 common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView:299 f.code.UseGlamour = !f.code.UseGlamour300 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))301 }302 }303 case tea.WindowSizeMsg:304 f.SetSize(msg.Width, msg.Height)305 switch f.activeView {306 case filesViewFiles:307 if f.repo != nil {308 cmds = append(cmds, f.updateFilesCmd)309 }310 case filesViewContent:311 if f.currentContent.content != "" {312 m, cmd := f.code.Update(msg)313 f.code = m.(*code.Code)314 if cmd != nil {315 cmds = append(cmds, cmd)316 }317 }318 }319 case EmptyRepoMsg:320 f.ref = nil321 f.path = ""322 f.currentItem = nil323 f.activeView = filesViewFiles324 f.lastSelected = make([]int, 0)325 f.selector.Select(0)326 cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))327 case spinner.TickMsg:328 if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {329 s, cmd := f.spinner.Update(msg)330 f.spinner = s331 if cmd != nil {332 cmds = append(cmds, cmd)333 }334 }335 }336 switch f.activeView {337 case filesViewFiles:338 m, cmd := f.selector.Update(msg)339 f.selector = m.(*selector.Selector)340 if cmd != nil {341 cmds = append(cmds, cmd)342 }343 case filesViewContent:344 m, cmd := f.code.Update(msg)345 f.code = m.(*code.Code)346 if cmd != nil {347 cmds = append(cmds, cmd)348 }349 }350 return f, tea.Batch(cmds...)351}352353// View implements tea.Model.354func (f *Files) View() string {355 switch f.activeView {356 case filesViewLoading:357 return renderLoading(f.common, f.spinner)358 case filesViewFiles:359 return f.selector.View()360 case filesViewContent:361 return f.code.View()362 default:363 return ""364 }365}366367// SpinnerID implements common.TabComponent.368func (f *Files) SpinnerID() int {369 return f.spinner.ID()370}371372// StatusBarValue returns the status bar value.373func (f *Files) StatusBarValue() string {374 p := f.path375 if p == "." || p == "" {376 return " "377 }378 return p379}380381// StatusBarInfo returns the status bar info.382func (f *Files) StatusBarInfo() string {383 switch f.activeView {384 case filesViewFiles:385 return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))386 case filesViewContent:387 return common.ScrollPercent(f.code.ScrollPosition())388 default:389 return ""390 }391}392393func (f *Files) updateFilesCmd() tea.Msg {394 files := make([]selector.IdentifiableItem, 0)395 dirs := make([]selector.IdentifiableItem, 0)396 if f.ref == nil {397 return nil398 }399 r, err := f.repo.Open()400 if err != nil {401 return common.ErrorCmd(err)402 }403 path := f.path404 ref := f.ref405 t, err := r.TreePath(ref, path)406 if err != nil {407 return common.ErrorCmd(err)408 }409 ents, err := t.Entries()410 if err != nil {411 return common.ErrorCmd(err)412 }413 ents.Sort()414 for _, e := range ents {415 if e.IsTree() {416 dirs = append(dirs, FileItem{entry: e})417 } else {418 files = append(files, FileItem{entry: e})419 }420 }421 return FileItemsMsg(append(dirs, files...))422}423424func (f *Files) selectTreeCmd() tea.Msg {425 if f.currentItem != nil && f.currentItem.entry.IsTree() {426 f.lastSelected = append(f.lastSelected, f.selector.Index())427 f.cursor = 0428 return f.updateFilesCmd()429 }430 return common.ErrorMsg(errNoFileSelected)431}432433func (f *Files) selectFileCmd() tea.Msg {434 i := f.currentItem435 if i != nil && !i.entry.IsTree() {436 fi := i.entry.File()437 if i.Mode().IsDir() || f == nil {438 return common.ErrorMsg(errInvalidFile)439 }440441 var err error442 var bin bool443444 r, err := f.repo.Open()445 if err == nil {446 attrs, err := r.CheckAttributes(f.ref, fi.Path())447 if err == nil {448 for _, attr := range attrs {449 if (attr.Name == "binary" && attr.Value == "set") ||450 (attr.Name == "text" && attr.Value == "unset") {451 bin = true452 break453 }454 }455 }456 }457458 if !bin {459 bin, err = fi.IsBinary()460 if err != nil {461 f.path = filepath.Dir(f.path)462 return common.ErrorMsg(err)463 }464 }465466 if bin {467 f.path = filepath.Dir(f.path)468 return common.ErrorMsg(errBinaryFile)469 }470471 c, err := fi.Bytes()472 if err != nil {473 f.path = filepath.Dir(f.path)474 return common.ErrorMsg(err)475 }476477 f.lastSelected = append(f.lastSelected, f.selector.Index())478 return FileContentMsg{string(c), i.entry.Name()}479 }480481 return common.ErrorMsg(errNoFileSelected)482}483484func (f *Files) fetchBlame() tea.Msg {485 r, err := f.repo.Open()486 if err != nil {487 return common.ErrorMsg(err)488 }489490 b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())491 if err != nil {492 return common.ErrorMsg(err)493 }494495 return FileBlameMsg(b)496}497498func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {499 if f == nil || f.entry.IsTree() || b == nil {500 return ""501 }502503 lines := make([]string, 0)504 i := 1505 var prev string506 for {507 commit := b.Line(i)508 if commit == nil {509 break510 }511 who := fmt.Sprintf("%s <%s>", commit.Author.Name, commit.Author.Email)512 line := fmt.Sprintf("%s %s %s",513 c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),514 c.Styles.Tree.Blame.Message.Render(commit.Summary()),515 c.Styles.Tree.Blame.Who.Render(who),516 )517 if line != prev {518 lines = append(lines, line)519 } else {520 lines = append(lines, "")521 }522 prev = line523 i++524 }525526 return strings.Join(lines, "\n")527}528529func (f *Files) deselectItemCmd() tea.Cmd {530 f.path = filepath.Dir(f.path)531 index := 0532 if len(f.lastSelected) > 0 {533 index = f.lastSelected[len(f.lastSelected)-1]534 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]535 }536 f.cursor = index537 f.activeView = filesViewFiles538 f.code.SetSideNote("")539 f.blameView = false540 f.currentBlame = nil541 f.code.UseGlamour = false542 return f.updateFilesCmd543}544545func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {546 return func() tea.Msg {547 return FileItemsMsg(items)548 }549}