From 37950203a7110c0061aed869deaec1cf3b486c2f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 2 Mar 2022 14:23:47 -0500 Subject: [PATCH] feat: show spinner when loading a large commit diff --- internal/git/git.go | 37 ++++++ internal/tui/bubbles/git/log/bubble.go | 164 ++++++++++++++----------- internal/tui/bubbles/git/types/git.go | 3 + internal/tui/style/style.go | 6 + 4 files changed, 137 insertions(+), 73 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index cf2b09d37bfdbb3bcea2ae0ec3daab12e26aa222..7cbd469849e185d641c700fae01a0a74a72c8e2e 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,6 +1,7 @@ package git import ( + "context" "errors" "log" "os" @@ -30,6 +31,7 @@ type Repo struct { refs []*plumbing.Reference trees map[plumbing.Hash]*object.Tree commits map[plumbing.Hash]*object.Commit + patch map[plumbing.Hash]*object.Patch } // GetName returns the name of the repository. @@ -105,6 +107,40 @@ func (r *Repo) commitForHash(hash plumbing.Hash) (*object.Commit, error) { return co, nil } +func (r *Repo) PatchCtx(ctx context.Context, commit *object.Commit) (*object.Patch, error) { + hash := commit.Hash + p, ok := r.patch[hash] + if !ok { + c, err := r.commitForHash(hash) + if err != nil { + return nil, err + } + // Using commit trees fixes the issue when generating diff for the first commit + // https://github.com/go-git/go-git/issues/281 + tree, err := r.treeForHash(c.TreeHash) + if err != nil { + return nil, err + } + var parent *object.Commit + parentTree := &object.Tree{} + if c.NumParents() > 0 { + parent, err = r.commitForHash(c.ParentHashes[0]) + if err != nil { + return nil, err + } + parentTree, err = r.treeForHash(parent.TreeHash) + if err != nil { + return nil, err + } + } + p, err = parentTree.PatchContext(ctx, tree) + if err != nil { + return nil, err + } + } + return p, nil +} + // GetCommits returns the commits for a repository. func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) { hash, err := r.targetHash(ref) @@ -264,6 +300,7 @@ func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) { r := &Repo{ name: name, repository: rg, + patch: make(map[plumbing.Hash]*object.Patch), } r.commits = make(map[plumbing.Hash]*object.Commit) r.trees = make(map[plumbing.Hash]*object.Tree) diff --git a/internal/tui/bubbles/git/log/bubble.go b/internal/tui/bubbles/git/log/bubble.go index 600011791a4424921cd0a12d8f4daf6ef0e3bf0c..95e6b7b5598a937b3a7c1b762be43f0b8150d1fa 100644 --- a/internal/tui/bubbles/git/log/bubble.go +++ b/internal/tui/bubbles/git/log/bubble.go @@ -9,6 +9,7 @@ import ( "time" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" gansi "github.com/charmbracelet/glamour/ansi" @@ -26,21 +27,17 @@ var ( Code: "", Language: "diff", } + waitBeforeLoading = time.Millisecond * 300 ) -type commitMsg struct { - commit *object.Commit - parent *object.Commit - tree *object.Tree - parentTree *object.Tree - patch *object.Patch -} +type commitMsg *object.Commit type sessionState int const ( logState sessionState = iota commitState + loadingState errorState ) @@ -100,6 +97,7 @@ type Bubble struct { height int heightMargin int error types.ErrMsg + spinner spinner.Model } func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { @@ -113,6 +111,9 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height l.DisableQuitKeybindings() l.KeyMap.NextPage = types.NextPage l.KeyMap.PrevPage = types.PrevPage + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = styles.Spinner b := &Bubble{ commitViewport: &vp.ViewportBubble{ Viewport: &viewport.Model{}, @@ -126,6 +127,7 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height heightMargin: heightMargin, list: l, ref: repo.GetHEAD(), + spinner: s, } b.SetSize(width, height) return b @@ -193,12 +195,19 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { b.state = errorState return b, nil case commitMsg: - content := b.renderCommit(msg) - b.state = commitState - b.commitViewport.Viewport.SetContent(content) - b.GotoTop() + if b.state == loadingState { + cmds = append(cmds, b.spinner.Tick) + } case refs.RefMsg: b.ref = msg + case spinner.TickMsg: + if b.state == loadingState { + s, cmd := b.spinner.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + b.spinner = s + } } switch b.state { @@ -215,83 +224,77 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return b, tea.Batch(cmds...) } +func (b *Bubble) loadPatch(c *object.Commit) error { + var patch strings.Builder + style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize()) + ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait) + defer cancel() + p, err := b.repo.PatchCtx(ctx, c) + if err != nil { + return err + } + patch.WriteString(b.renderCommit(c)) + fpl := len(p.FilePatches()) + if fpl > types.MaxDiffFiles { + patch.WriteString("\n" + types.ErrDiffFilesTooLong.Error()) + } else { + patch.WriteString("\n" + b.renderStats(p.Stats())) + } + if fpl <= types.MaxDiffFiles { + ps := p.String() + if len(strings.Split(ps, "\n")) > types.MaxDiffLines { + patch.WriteString("\n" + types.ErrDiffTooLong.Error()) + } else { + patch.WriteString("\n" + b.renderDiff(ps)) + } + } + content := style.Render(patch.String()) + b.commitViewport.Viewport.SetContent(content) + b.GotoTop() + return nil +} + func (b *Bubble) loadCommit() tea.Cmd { + var err error + done := make(chan struct{}, 1) + i := b.list.SelectedItem() + if i == nil { + return nil + } + c, ok := i.(item) + if !ok { + return nil + } + go func() { + err = b.loadPatch(c.Commit) + done <- struct{}{} + b.state = commitState + }() return func() tea.Msg { - i := b.list.SelectedItem() - if i == nil { - return nil - } - c, ok := i.(item) - if !ok { - return nil + select { + case <-done: + case <-time.After(waitBeforeLoading): + b.state = loadingState } - // Using commit trees fixes the issue when generating diff for the first commit - // https://github.com/go-git/go-git/issues/281 - tree, err := c.Tree() if err != nil { return types.ErrMsg{Err: err} } - var parent *object.Commit - parentTree := &object.Tree{} - if c.NumParents() > 0 { - parent, err = c.Parents().Next() - if err != nil { - return types.ErrMsg{Err: err} - } - parentTree, err = parent.Tree() - if err != nil { - return types.ErrMsg{Err: err} - } - } - ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait) - defer cancel() - patch, err := parentTree.PatchContext(ctx, tree) - if err != nil { - return types.ErrMsg{Err: err} - } - return commitMsg{ - commit: c.Commit, - tree: tree, - parent: parent, - parentTree: parentTree, - patch: patch, - } + return commitMsg(c.Commit) } } -func (b *Bubble) renderCommit(m commitMsg) string { +func (b *Bubble) renderCommit(c *object.Commit) string { s := strings.Builder{} - st := b.style - c := m.commit // 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", - st.LogCommitHash.Render("commit "+c.Hash.String()), - st.LogCommitAuthor.Render("Author: "+c.Author.String()), - st.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), - st.LogCommitBody.Render(msg), + b.style.LogCommitHash.Render("commit "+c.Hash.String()), + b.style.LogCommitAuthor.Render("Author: "+c.Author.String()), + b.style.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), + b.style.LogCommitBody.Render(msg), )) - stats := m.patch.Stats() - if len(stats) > types.MaxDiffFiles { - s.WriteString("\n" + types.ErrDiffFilesTooLong.Error()) - } else { - s.WriteString("\n" + b.renderStats(stats)) - } - ps := m.patch.String() - if len(strings.Split(ps, "\n")) > types.MaxDiffLines { - s.WriteString("\n" + types.ErrDiffTooLong.Error()) - } else { - p := strings.Builder{} - diffChroma.Code = ps - err := diffChroma.Render(&p, types.RenderCtx) - if err != nil { - s.WriteString(fmt.Sprintf("\n%s", err.Error())) - } else { - s.WriteString(fmt.Sprintf("\n%s", p.String())) - } - } - return st.LogCommit.Copy().Width(b.width - b.widthMargin - st.LogCommit.GetHorizontalFrameSize()).Render(s.String()) + return s.String() } func (b *Bubble) renderStats(fileStats object.FileStats) string { @@ -381,10 +384,25 @@ func (b *Bubble) renderStats(fileStats object.FileStats) string { return output.String() } +func (b *Bubble) renderDiff(diff string) string { + var s strings.Builder + pr := strings.Builder{} + diffChroma.Code = diff + err := diffChroma.Render(&pr, types.RenderCtx) + if err != nil { + s.WriteString(fmt.Sprintf("\n%s", err.Error())) + } else { + s.WriteString(fmt.Sprintf("\n%s", pr.String())) + } + return s.String() +} + func (b *Bubble) View() string { switch b.state { case logState: return b.list.View() + case loadingState: + return fmt.Sprintf("%s loading commit", b.spinner.View()) case errorState: return b.error.ViewWithPrefix(b.style, "Error") case commitState: diff --git a/internal/tui/bubbles/git/types/git.go b/internal/tui/bubbles/git/types/git.go index 758f9d126eb39b2018ba03c4f70e869df4b555b8..05a1c26114c178ee6e08286c3687c7a187c6b95c 100644 --- a/internal/tui/bubbles/git/types/git.go +++ b/internal/tui/bubbles/git/types/git.go @@ -1,6 +1,8 @@ package types import ( + "context" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" @@ -15,6 +17,7 @@ type Repo interface { GetCommits(*plumbing.Reference) (Commits, error) Repository() *git.Repository Tree(*plumbing.Reference, string) (*object.Tree, error) + PatchCtx(context.Context, *object.Commit) (*object.Patch, error) } type Commits []*object.Commit diff --git a/internal/tui/style/style.go b/internal/tui/style/style.go index 1f57cb5b763cd83ea39c5add97521545ab2ac6eb..bc997ed31d03c6d46ad65931995f307bdca62393 100644 --- a/internal/tui/style/style.go +++ b/internal/tui/style/style.go @@ -65,6 +65,8 @@ type Styles struct { TreeFileMode lipgloss.Style TreeFileSize lipgloss.Style TreeFileContent lipgloss.Style + + Spinner lipgloss.Style } // DefaultStyles returns default styles for the TUI. @@ -252,5 +254,9 @@ func DefaultStyles() *Styles { s.TreeFileContent = lipgloss.NewStyle() + s.Spinner = lipgloss.NewStyle(). + MarginLeft(1). + Foreground(lipgloss.Color("205")) + return s }