From 37deef70453cf860ca58f49ab0e8e701421f34ce Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 23 Oct 2023 17:00:08 -0400 Subject: [PATCH] feat(ui): add blame file view Fixes: https://github.com/charmbracelet/soft-serve/issues/149 --- server/ui/components/code/code.go | 126 ++++++++++++++---------- server/ui/pages/repo/files.go | 155 ++++++++++++++++++++++-------- server/ui/pages/repo/repo.go | 4 +- server/ui/styles/styles.go | 10 ++ 4 files changed, 202 insertions(+), 93 deletions(-) diff --git a/server/ui/components/code/code.go b/server/ui/components/code/code.go index 77774674e..5e8270605 100644 --- a/server/ui/components/code/code.go +++ b/server/ui/components/code/code.go @@ -1,6 +1,7 @@ package code import ( + "math" "strings" "sync" @@ -21,14 +22,15 @@ const ( // Code is a code snippet. type Code struct { *vp.Viewport - common common.Common - content string - extension string - renderContext gansi.RenderContext - renderMutex sync.Mutex - styleConfig gansi.StyleConfig - ShowLineNumber bool + common common.Common + sidenote string + content string + extension string + renderContext gansi.RenderContext + renderMutex sync.Mutex + styleConfig gansi.StyleConfig + ShowLineNumber bool NoContentStyle lipgloss.Style UseGlamour bool } @@ -65,19 +67,58 @@ func (r *Code) SetContent(c, ext string) tea.Cmd { return r.Init() } +// SetSideNote sets the sidenote of the Code. +func (r *Code) SetSideNote(s string) tea.Cmd { + r.sidenote = s + return r.Init() +} + // Init implements tea.Model. func (r *Code) Init() tea.Cmd { w := r.common.Width - c := r.content - if c == "" { + content := r.content + if content == "" { r.Viewport.Model.SetContent(r.NoContentStyle.String()) return nil } - f, err := r.renderFile(r.extension, c, w) - if err != nil { - return common.ErrorCmd(err) + + if r.UseGlamour { + md, err := r.glamourize(w, content) + if err != nil { + return common.ErrorCmd(err) + } + content = md + } else { + f, err := r.renderFile(r.extension, content, w) + if err != nil { + return common.ErrorCmd(err) + } + content = f + if r.ShowLineNumber { + var ml int + content, ml = common.FormatLineNumber(r.common.Styles, content, true) + w -= ml + } } - r.Viewport.Model.SetContent(f) + + const sideNotePercent = 0.3 + if r.sidenote != "" { + lines := strings.Split(r.sidenote, "\n") + sideNoteWidth := int(math.Ceil(float64(r.Model.Width) * sideNotePercent)) + for i, l := range lines { + lines[i] = common.TruncateString(l, sideNoteWidth) + } + content = lipgloss.JoinHorizontal(lipgloss.Left, strings.Join(lines, "\n"), content) + } + + // Fix styles after hard wrapping + // https://github.com/muesli/reflow/issues/43 + // + // TODO: solve this upstream in Glamour/Reflow. + content = lipgloss.NewStyle().Width(w).Render(content) + + r.Viewport.Model.SetContent(content) + return nil } @@ -181,43 +222,26 @@ func (r *Code) renderFile(path, content string, width int) (string, error) { if lexer != nil && lexer.Config() != nil { lang = lexer.Config().Name } - var c string - if r.UseGlamour { - md, err := r.glamourize(width, content) - if err != nil { - return "", err - } - c = md - } else { - formatter := &gansi.CodeBlockElement{ - Code: content, - Language: lang, - } - s := strings.Builder{} - rc := r.renderContext - if r.ShowLineNumber { - st := common.StyleConfig() - var m uint - st.CodeBlock.Margin = &m - rc = gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: st, - }) - } - err := formatter.Render(&s, rc) - if err != nil { - return "", err - } - c = s.String() - if r.ShowLineNumber { - var ml int - c, ml = common.FormatLineNumber(r.common.Styles, c, true) - width -= ml - } + + formatter := &gansi.CodeBlockElement{ + Code: content, + Language: lang, } - // Fix styling when after line breaks. - // https://github.com/muesli/reflow/issues/43 - // - // TODO: solve this upstream in Glamour/Reflow. - return lipgloss.NewStyle().Width(width).Render(c), nil + s := strings.Builder{} + rc := r.renderContext + if r.ShowLineNumber { + st := common.StyleConfig() + var m uint + st.CodeBlock.Margin = &m + rc = gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: st, + }) + } + err := formatter.Render(&s, rc) + if err != nil { + return "", err + } + + return s.String(), nil } diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go index bac6e40c1..a225c8e0c 100644 --- a/server/ui/pages/repo/files.go +++ b/server/ui/pages/repo/files.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "github.com/alecthomas/chroma/lexers" "github.com/charmbracelet/bubbles/key" @@ -14,6 +15,7 @@ import ( "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/code" "github.com/charmbracelet/soft-serve/server/ui/components/selector" + gitm "github.com/gogs/git-module" ) type filesView int @@ -35,6 +37,14 @@ var ( key.WithKeys("l"), key.WithHelp("l", "toggle line numbers"), ) + blameView = key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "toggle blame view"), + ) + preview = key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "toggle preview"), + ) ) // FileItemsMsg is a message that contains a list of files. @@ -46,6 +56,9 @@ type FileContentMsg struct { ext string } +// FileBlameMsg is a message that contains the blame of a file. +type FileBlameMsg *gitm.Blame + // Files is the model for the files view. type Files struct { common common.Common @@ -57,10 +70,12 @@ type Files struct { path string currentItem *FileItem currentContent FileContentMsg + currentBlame FileBlameMsg lastSelected []int lineNumber bool spinner spinner.Model cursor int + blameView bool } // NewFiles creates a new files model. @@ -107,30 +122,16 @@ func (f *Files) ShortHelp() []key.Binding { k := f.selector.KeyMap switch f.activeView { case filesViewFiles: - copyKey := f.common.KeyMap.Copy - copyKey.SetHelp("c", "copy name") return []key.Binding{ f.common.KeyMap.SelectItem, f.common.KeyMap.BackItem, k.CursorUp, k.CursorDown, - copyKey, } case filesViewContent: - copyKey := f.common.KeyMap.Copy - copyKey.SetHelp("c", "copy content") b := []key.Binding{ f.common.KeyMap.UpDown, f.common.KeyMap.BackItem, - copyKey, - } - lexer := lexers.Match(f.currentContent.ext) - lang := "" - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - if lang != "markdown" { - b = append(b, lineNo) } return b default: @@ -142,15 +143,25 @@ func (f *Files) ShortHelp() []key.Binding { func (f *Files) FullHelp() [][]key.Binding { b := make([][]key.Binding, 0) copyKey := f.common.KeyMap.Copy + actionKeys := []key.Binding{ + copyKey, + } + if !f.code.UseGlamour { + actionKeys = append(actionKeys, lineNo) + } + actionKeys = append(actionKeys, blameView) + if f.isSelectedMarkdown() && !f.blameView { + actionKeys = append(actionKeys, preview) + } switch f.activeView { case filesViewFiles: copyKey.SetHelp("c", "copy name") k := f.selector.KeyMap - b = append(b, []key.Binding{ - f.common.KeyMap.SelectItem, - f.common.KeyMap.BackItem, - }) b = append(b, [][]key.Binding{ + { + f.common.KeyMap.SelectItem, + f.common.KeyMap.BackItem, + }, { k.CursorUp, k.CursorDown, @@ -160,7 +171,6 @@ func (f *Files) FullHelp() [][]key.Binding { { k.GoToStart, k.GoToEnd, - copyKey, }, }...) case filesViewContent: @@ -176,25 +186,15 @@ func (f *Files) FullHelp() [][]key.Binding { k.HalfPageDown, k.HalfPageUp, }, + { + k.Down, + k.Up, + f.common.KeyMap.GotoTop, + f.common.KeyMap.GotoBottom, + }, }...) - lc := []key.Binding{ - k.Down, - k.Up, - f.common.KeyMap.GotoTop, - f.common.KeyMap.GotoBottom, - copyKey, - } - lexer := lexers.Match(f.currentContent.ext) - lang := "" - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - if lang != "markdown" { - lc = append(lc, lineNo) - } - b = append(b, lc) } - return b + return append(b, actionKeys) } // Init implements tea.Model. @@ -203,6 +203,9 @@ func (f *Files) Init() tea.Cmd { f.currentItem = nil f.activeView = filesViewLoading f.lastSelected = make([]int, 0) + f.blameView = false + f.currentBlame = nil + f.code.UseGlamour = false return tea.Batch(f.spinner.Tick, f.updateFilesCmd) } @@ -228,10 +231,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case FileContentMsg: f.activeView = filesViewContent f.currentContent = msg - cmds = append(cmds, - f.code.SetContent(msg.content, msg.ext), - ) + f.code.UseGlamour = f.isSelectedMarkdown() + cmds = append(cmds, f.code.SetContent(msg.content, msg.ext)) f.code.GotoTop() + case FileBlameMsg: + f.currentBlame = msg + f.activeView = filesViewContent + f.code.UseGlamour = false + f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg)) case selector.SelectMsg: switch sel := msg.IdentifiableItem.(type) { case FileItem: @@ -258,10 +265,22 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, f.deselectItemCmd()) case key.Matches(msg, f.common.KeyMap.Copy): cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard")) - case key.Matches(msg, lineNo): + case key.Matches(msg, lineNo) && !f.code.UseGlamour: f.lineNumber = !f.lineNumber f.code.ShowLineNumber = f.lineNumber cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) + case key.Matches(msg, blameView): + f.activeView = filesViewLoading + f.blameView = !f.blameView + if f.blameView { + cmds = append(cmds, f.fetchBlame) + } else { + f.activeView = filesViewContent + cmds = append(cmds, f.code.SetSideNote("")) + } + case key.Matches(msg, preview) && f.isSelectedMarkdown() && !f.blameView: + f.code.UseGlamour = !f.code.UseGlamour + cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) } } case tea.WindowSizeMsg: @@ -445,6 +464,49 @@ func (f *Files) selectFileCmd() tea.Msg { return common.ErrorMsg(errNoFileSelected) } +func (f *Files) fetchBlame() tea.Msg { + r, err := f.repo.Open() + if err != nil { + return common.ErrorMsg(err) + } + + b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path()) + if err != nil { + return common.ErrorMsg(err) + } + + return FileBlameMsg(b) +} + +func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string { + if f == nil || f.entry.IsTree() || b == nil { + return "" + } + + lines := make([]string, 0) + i := 1 + var prev string + for { + commit := b.Line(i) + if commit == nil { + break + } + line := fmt.Sprintf("%s %s", + c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]), + c.Styles.Tree.Blame.Message.Render(commit.Summary()), + ) + if line != prev { + lines = append(lines, line) + } else { + lines = append(lines, "") + } + prev = line + i++ + } + + return strings.Join(lines, "\n") +} + func (f *Files) deselectItemCmd() tea.Cmd { f.path = filepath.Dir(f.path) index := 0 @@ -454,6 +516,10 @@ func (f *Files) deselectItemCmd() tea.Cmd { } f.cursor = index f.activeView = filesViewFiles + f.code.SetSideNote("") + f.blameView = false + f.currentBlame = nil + f.code.UseGlamour = false return f.updateFilesCmd } @@ -462,3 +528,12 @@ func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd { return FileItemsMsg(items) } } + +func (f *Files) isSelectedMarkdown() bool { + lexer := lexers.Match(f.currentContent.ext) + lang := "" + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + return lang == "markdown" +} diff --git a/server/ui/pages/repo/repo.go b/server/ui/pages/repo/repo.go index 9d473fbac..86d4230ca 100644 --- a/server/ui/pages/repo/repo.go +++ b/server/ui/pages/repo/repo.go @@ -258,8 +258,8 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Must come after we've updated the active tab switch msg.(type) { case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg, - FileItemsMsg, FileContentMsg, selector.ActiveMsg, LogItemsMsg, - GoBackMsg, LogDiffMsg, EmptyRepoMsg: + FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg, + LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg: r.setStatusBarInfo() } diff --git a/server/ui/styles/styles.go b/server/ui/styles/styles.go index ca13f6cb9..6a5078072 100644 --- a/server/ui/styles/styles.go +++ b/server/ui/styles/styles.go @@ -123,6 +123,10 @@ type Styles struct { Selector lipgloss.Style FileContent lipgloss.Style Paginator lipgloss.Style + Blame struct { + Hash lipgloss.Style + Message lipgloss.Style + } } Spinner lipgloss.Style @@ -425,6 +429,12 @@ func DefaultStyles() *Styles { s.Tree.Paginator = s.Log.Paginator.Copy() + s.Tree.Blame.Hash = lipgloss.NewStyle(). + Foreground(hashColor). + Bold(true) + + s.Tree.Blame.Message = lipgloss.NewStyle() + s.Spinner = lipgloss.NewStyle(). MarginTop(1). MarginLeft(2).