feat: display commit diffs

Ayman Bagabas created

Change summary

ui/components/selector/selector.go |  19 +-
ui/components/viewport/viewport.go |   5 
ui/pages/repo/log.go               | 194 ++++++++++++++++++++++++++++++-
ui/pages/repo/repo.go              |  56 ++++++++
ui/pages/selection/item.go         |   4 
ui/pages/selection/selection.go    |   5 
ui/styles/styles.go                |   8 
ui/ui.go                           |  11 +
8 files changed, 270 insertions(+), 32 deletions(-)

Detailed changes

ui/components/selector/selector.go 🔗

@@ -22,10 +22,10 @@ type IdentifiableItem interface {
 }
 
 // SelectMsg is a message that is sent when an item is selected.
-type SelectMsg string
+type SelectMsg struct{ IdentifiableItem }
 
 // ActiveMsg is a message that is sent when an item is active but not selected.
-type ActiveMsg string
+type ActiveMsg struct{ IdentifiableItem }
 
 // New creates a new selector.
 func New(common common.Common, items []IdentifiableItem, delegate list.ItemDelegate) *Selector {
@@ -166,22 +166,27 @@ func (s *Selector) View() string {
 	return s.Model.View()
 }
 
+// SelectItem is a command that selects the currently active item.
+func (s *Selector) SelectItem() tea.Msg {
+	return s.selectCmd()
+}
+
 func (s *Selector) selectCmd() tea.Msg {
 	item := s.Model.SelectedItem()
 	i, ok := item.(IdentifiableItem)
 	if !ok {
-		return SelectMsg("")
+		return SelectMsg{}
 	}
-	return SelectMsg(i.ID())
+	return SelectMsg{i}
 }
 
 func (s *Selector) activeCmd() tea.Msg {
 	item := s.Model.SelectedItem()
 	i, ok := item.(IdentifiableItem)
 	if !ok {
-		return ActiveMsg("")
+		return ActiveMsg{}
 	}
-	return ActiveMsg(i.ID())
+	return ActiveMsg{i}
 }
 
 func (s *Selector) activeFilterCmd() tea.Msg {
@@ -197,5 +202,5 @@ func (s *Selector) activeFilterCmd() tea.Msg {
 	if !ok {
 		return nil
 	}
-	return ActiveMsg(i.ID())
+	return ActiveMsg{i}
 }

ui/components/viewport/viewport.go 🔗

@@ -41,6 +41,11 @@ func (v *Viewport) View() string {
 	return v.Viewport.View()
 }
 
+// SetContent sets the viewport's content.
+func (v *Viewport) SetContent(content string) {
+	v.Viewport.SetContent(content)
+}
+
 // GotoTop moves the viewport to the top of the log.
 func (v *Viewport) GotoTop() {
 	v.Viewport.GotoTop()

ui/pages/repo/log.go 🔗

@@ -2,14 +2,22 @@ package repo
 
 import (
 	"fmt"
+	"strings"
+	"time"
 
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/glamour"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/lipgloss"
 	ggit "github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/ui/components/viewport"
 	"github.com/charmbracelet/soft-serve/ui/git"
+	"github.com/muesli/reflow/wrap"
+	"github.com/muesli/termenv"
 )
 
 type view int
@@ -23,15 +31,21 @@ type LogCountMsg int64
 
 type LogItemsMsg []list.Item
 
+type LogCommitMsg *ggit.Commit
+
+type LogDiffMsg *ggit.Diff
+
 type Log struct {
-	common     common.Common
-	selector   *selector.Selector
-	vp         *viewport.Viewport
-	activeView view
-	repo       git.GitRepo
-	ref        *ggit.Reference
-	count      int64
-	nextPage   int
+	common         common.Common
+	selector       *selector.Selector
+	vp             *viewport.Viewport
+	activeView     view
+	repo           git.GitRepo
+	ref            *ggit.Reference
+	count          int64
+	nextPage       int
+	selectedCommit *ggit.Commit
+	currentDiff    *ggit.Diff
 }
 
 func NewLog(common common.Common) *Log {
@@ -60,6 +74,40 @@ func (l *Log) SetSize(width, height int) {
 	l.vp.SetSize(width, height)
 }
 
+func (l *Log) ShortHelp() []key.Binding {
+	switch l.activeView {
+	case logView:
+		return []key.Binding{
+			key.NewBinding(
+				key.WithKeys(
+					"l",
+					"right",
+				),
+				key.WithHelp(
+					"→",
+					"select",
+				),
+			),
+		}
+	case commitView:
+		return []key.Binding{
+			l.common.KeyMap.UpDown,
+			key.NewBinding(
+				key.WithKeys(
+					"h",
+					"left",
+				),
+				key.WithHelp(
+					"←",
+					"back",
+				),
+			),
+		}
+	default:
+		return []key.Binding{}
+	}
+}
+
 func (l *Log) Init() tea.Cmd {
 	cmds := make([]tea.Cmd, 0)
 	cmds = append(cmds, l.updateCommitsCmd)
@@ -73,6 +121,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		l.count = 0
 		l.selector.Select(0)
 		l.nextPage = 0
+		l.activeView = 0
 		l.repo = git.GitRepo(msg)
 	case RefMsg:
 		l.ref = msg
@@ -85,8 +134,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		l.selector.SetPage(l.nextPage)
 		l.SetSize(l.common.Width, l.common.Height)
 	case tea.KeyMsg, tea.MouseMsg:
-		// This is a hack for loading commits on demand based on list.Pagination.
-		if l.activeView == logView {
+		switch l.activeView {
+		case logView:
+			switch key := msg.(type) {
+			case tea.KeyMsg:
+				switch key.String() {
+				case "l", "right":
+					cmds = append(cmds, l.selector.SelectItem)
+				}
+			}
+			// This is a hack for loading commits on demand based on list.Pagination.
 			curPage := l.selector.Page()
 			s, cmd := l.selector.Update(msg)
 			m := s.(*selector.Selector)
@@ -97,6 +154,47 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, l.updateCommitsCmd)
 			}
 			cmds = append(cmds, cmd)
+		case commitView:
+			switch key := msg.(type) {
+			case tea.KeyMsg:
+				switch key.String() {
+				case "h", "left":
+					l.activeView = logView
+				}
+			}
+		}
+	case selector.SelectMsg:
+		switch sel := msg.IdentifiableItem.(type) {
+		case LogItem:
+			cmds = append(cmds, l.selectCommitCmd(sel.Commit))
+		}
+	case LogCommitMsg:
+		l.selectedCommit = msg
+		cmds = append(cmds, l.loadDiffCmd)
+	case LogDiffMsg:
+		l.currentDiff = msg
+		l.vp.SetContent(
+			lipgloss.JoinVertical(lipgloss.Top,
+				l.renderCommit(l.selectedCommit),
+				l.renderSummary(msg),
+				l.renderDiff(msg),
+			),
+		)
+		l.vp.GotoTop()
+		l.activeView = commitView
+		cmds = append(cmds, updateStatusBarCmd)
+	case tea.WindowSizeMsg:
+		if l.selectedCommit != nil && l.currentDiff != nil {
+			l.vp.SetContent(
+				lipgloss.JoinVertical(lipgloss.Top,
+					l.renderCommit(l.selectedCommit),
+					l.renderSummary(l.currentDiff),
+					l.renderDiff(l.currentDiff),
+				),
+			)
+		}
+		if l.repo != nil {
+			cmds = append(cmds, l.updateCommitsCmd)
 		}
 	}
 	switch l.activeView {
@@ -127,6 +225,8 @@ func (l *Log) StatusBarInfo() string {
 		// We're using l.nextPage instead of l.selector.Paginator.Page because
 		// of the paginator hack above.
 		return fmt.Sprintf("%d/%d", l.nextPage+1, l.selector.TotalPages())
+	case commitView:
+		return fmt.Sprintf("%.f%%", l.vp.ScrollPercent()*100)
 	default:
 		return ""
 	}
@@ -168,3 +268,77 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 	}
 	return LogItemsMsg(items)
 }
+
+func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
+	return func() tea.Msg {
+		return LogCommitMsg(commit)
+	}
+}
+
+func (l *Log) loadDiffCmd() tea.Msg {
+	diff, err := l.repo.Diff(l.selectedCommit)
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	return LogDiffMsg(diff)
+}
+
+func styleConfig() gansi.StyleConfig {
+	noColor := ""
+	s := glamour.DarkStyleConfig
+	s.Document.StylePrimitive.Color = &noColor
+	s.CodeBlock.Chroma.Text.Color = &noColor
+	s.CodeBlock.Chroma.Name.Color = &noColor
+	return s
+}
+
+func renderCtx() gansi.RenderContext {
+	return gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       styleConfig(),
+	})
+}
+
+func (l *Log) renderCommit(c *ggit.Commit) string {
+	s := strings.Builder{}
+	// FIXME: lipgloss prints empty lines when CRLF is used
+	// sanitize commit message from CRLF
+	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
+	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
+		l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
+		l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
+		l.common.Styles.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
+		l.common.Styles.LogCommitBody.Render(msg),
+	))
+	return wrap.String(s.String(), l.common.Width-2)
+}
+
+func (l *Log) renderSummary(diff *ggit.Diff) string {
+	stats := strings.Split(diff.Stats().String(), "\n")
+	for i, line := range stats {
+		ch := strings.Split(line, "|")
+		if len(ch) > 1 {
+			adddel := ch[len(ch)-1]
+			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
+			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
+			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
+		}
+	}
+	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
+}
+
+func (l *Log) renderDiff(diff *ggit.Diff) string {
+	var s strings.Builder
+	var pr strings.Builder
+	diffChroma := &gansi.CodeBlockElement{
+		Code:     diff.Patch(),
+		Language: "diff",
+	}
+	err := diffChroma.Render(&pr, renderCtx())
+	if err != nil {
+		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
+	} else {
+		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
+	}
+	return wrap.String(s.String(), l.common.Width-2)
+}

ui/pages/repo/repo.go 🔗

@@ -9,9 +9,11 @@ import (
 	ggit "github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/code"
+	"github.com/charmbracelet/soft-serve/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/ui/components/statusbar"
 	"github.com/charmbracelet/soft-serve/ui/components/tabs"
 	"github.com/charmbracelet/soft-serve/ui/git"
+	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 )
 
 type tab int
@@ -24,6 +26,8 @@ const (
 	tagsTab
 )
 
+type UpdateStatusBarMsg struct{}
+
 // RepoMsg is a message that contains a git.Repository.
 type RepoMsg git.GitRepo
 
@@ -35,6 +39,7 @@ type Repo struct {
 	common       common.Common
 	rs           git.GitRepoSource
 	selectedRepo git.GitRepo
+	selectedItem selection.Item
 	activeTab    tab
 	tabs         *tabs.Tabs
 	statusbar    *statusbar.StatusBar
@@ -68,7 +73,8 @@ func (r *Repo) SetSize(width, height int) {
 		r.common.Styles.RepoHeader.GetHeight() +
 		r.common.Styles.RepoHeader.GetVerticalFrameSize() +
 		r.common.Styles.StatusBar.GetHeight() +
-		r.common.Styles.Tabs.GetHeight()
+		r.common.Styles.Tabs.GetHeight() +
+		r.common.Styles.Tabs.GetVerticalFrameSize()
 	r.tabs.SetSize(width, height-hm)
 	r.statusbar.SetSize(width, height-hm)
 	r.readme.SetSize(width, height-hm)
@@ -80,8 +86,16 @@ func (r *Repo) ShortHelp() []key.Binding {
 	b := make([]key.Binding, 0)
 	tab := r.common.KeyMap.Section
 	tab.SetHelp("tab", "switch tab")
-	b = append(b, r.common.KeyMap.Back)
+	back := r.common.KeyMap.Back
+	back.SetHelp("esc", "repos")
+	b = append(b, back)
 	b = append(b, tab)
+	switch r.activeTab {
+	case readmeTab:
+		b = append(b, r.common.KeyMap.UpDown)
+	case commitsTab:
+		b = append(b, r.log.ShortHelp()...)
+	}
 	return b
 }
 
@@ -100,6 +114,11 @@ func (r *Repo) Init() tea.Cmd {
 func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
+	case selector.SelectMsg:
+		switch msg.IdentifiableItem.(type) {
+		case selection.Item:
+			r.selectedItem = msg.IdentifiableItem.(selection.Item)
+		}
 	case RepoMsg:
 		r.activeTab = 0
 		r.selectedRepo = git.GitRepo(msg)
@@ -142,6 +161,19 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+	case UpdateStatusBarMsg:
+		cmds = append(cmds, r.updateStatusBarCmd)
+	case tea.WindowSizeMsg:
+		b, cmd := r.readme.Update(msg)
+		r.readme = b.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		l, cmd := r.log.Update(msg)
+		r.log = l.(*Log)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	}
 	t, cmd := r.tabs.Update(msg)
 	r.tabs = t.(*tabs.Tabs)
@@ -183,7 +215,8 @@ func (r *Repo) View() string {
 		r.common.Styles.RepoHeader.GetHeight() +
 		r.common.Styles.RepoHeader.GetVerticalFrameSize() +
 		r.common.Styles.StatusBar.GetHeight() +
-		r.common.Styles.Tabs.GetHeight()
+		r.common.Styles.Tabs.GetHeight() +
+		r.common.Styles.Tabs.GetVerticalFrameSize()
 	mainStyle := repoBodyStyle.
 		Height(r.common.Height - hm)
 	main := mainStyle.Render("")
@@ -196,6 +229,7 @@ func (r *Repo) View() string {
 	}
 	view := lipgloss.JoinVertical(lipgloss.Top,
 		r.headerView(),
+		r.tabs.View(),
 		main,
 		r.statusbar.View(),
 	)
@@ -206,12 +240,18 @@ func (r *Repo) headerView() string {
 	if r.selectedRepo == nil {
 		return ""
 	}
-	name := r.common.Styles.RepoHeaderName.Render(r.selectedRepo.Name())
+	name := r.common.Styles.RepoHeaderName.Render(r.selectedItem.Title())
+	// TODO move this into a style.
+	url := lipgloss.NewStyle().MarginLeft(2).Render(r.selectedItem.URL())
+	desc := r.common.Styles.RepoHeaderDesc.Render(r.selectedItem.Description())
 	style := r.common.Styles.RepoHeader.Copy().Width(r.common.Width)
 	return style.Render(
 		lipgloss.JoinVertical(lipgloss.Top,
-			name,
-			r.tabs.View(),
+			lipgloss.JoinHorizontal(lipgloss.Left,
+				name,
+				url,
+			),
+			desc,
 		),
 	)
 }
@@ -258,3 +298,7 @@ func (r *Repo) updateRefCmd() tea.Msg {
 	}
 	return RefMsg(head)
 }
+
+func updateStatusBarCmd() tea.Msg {
+	return UpdateStatusBarMsg{}
+}

ui/pages/selection/item.go 🔗

@@ -37,6 +37,10 @@ func (i Item) Description() string { return i.desc }
 // FilterValue implements list.Item.
 func (i Item) FilterValue() string { return i.name }
 
+func (i Item) URL() string {
+	return i.url.View()
+}
+
 // ItemDelegate is the delegate for the item.
 type ItemDelegate struct {
 	styles    *styles.Styles

ui/pages/selection/selection.go 🔗

@@ -132,7 +132,7 @@ func (s *Selection) Init() tea.Cmd {
 	}
 	items := make([]list.Item, 0)
 	cfg := s.s.Config()
-	// TODO clean up this
+	// TODO clean up this and move style to its own var.
 	yank := func(text string) *yankable.Yankable {
 		return yankable.New(
 			session,
@@ -205,7 +205,6 @@ func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, s.changeActive(msg))
 		// reset readme position when active item change
 		s.readme.GotoTop()
-	case selector.SelectMsg:
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, s.common.KeyMap.Section):
@@ -251,7 +250,7 @@ func (s *Selection) View() string {
 
 func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
 	cfg := s.s.Config()
-	r, err := cfg.Source.GetRepo(string(msg))
+	r, err := cfg.Source.GetRepo(msg.ID())
 	if err != nil {
 		return common.ErrorCmd(err)
 	}

ui/styles/styles.go 🔗

@@ -36,6 +36,7 @@ type Styles struct {
 	RepoBody       lipgloss.Style
 	RepoHeader     lipgloss.Style
 	RepoHeaderName lipgloss.Style
+	RepoHeaderDesc lipgloss.Style
 
 	Footer      lipgloss.Style
 	Branch      lipgloss.Style
@@ -196,6 +197,7 @@ func DefaultStyles() *Styles {
 		Margin(1, 0)
 
 	s.RepoHeader = lipgloss.NewStyle().
+		Height(2).
 		Border(lipgloss.NormalBorder(), false, false, true, false).
 		BorderForeground(lipgloss.Color("241"))
 
@@ -203,6 +205,9 @@ func DefaultStyles() *Styles {
 		Foreground(lipgloss.Color("15")).
 		Bold(true)
 
+	s.RepoHeaderDesc = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("15"))
+
 	s.Footer = lipgloss.NewStyle().
 		Height(1)
 
@@ -348,8 +353,7 @@ func DefaultStyles() *Styles {
 		Background(lipgloss.Color("#6E6ED8")).
 		Foreground(lipgloss.Color("#F1F1F1"))
 
-	s.Tabs = lipgloss.NewStyle().
-		Height(1)
+	s.Tabs = lipgloss.NewStyle()
 
 	s.TabInactive = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("15"))

ui/ui.go 🔗

@@ -106,7 +106,7 @@ func (ui *UI) Init() tea.Cmd {
 // Update implements tea.Model.
 // TODO show full help
 func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	log.Printf("%T", msg)
+	log.Printf("msg: %T", msg)
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
@@ -143,9 +143,12 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		ui.state = errorState
 		return ui, nil
 	case selector.SelectMsg:
-		if ui.activePage == 0 {
-			ui.activePage = (ui.activePage + 1) % 2
-			cmds = append(cmds, ui.setRepoCmd(string(msg)))
+		switch msg.IdentifiableItem.(type) {
+		case selection.Item:
+			if ui.activePage == 0 {
+				ui.activePage = 1
+				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
+			}
 		}
 	}
 	m, cmd := ui.pages[ui.activePage].Update(msg)