diff --git a/go.mod b/go.mod index 6f6ef1df4b09317e733a0b16164bfad1ed0705d7..fad69374307dd3260c3b4900262899e57d855e71 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,9 @@ module github.com/charmbracelet/soft-serve go 1.17 require ( + github.com/alecthomas/chroma v0.10.0 github.com/caarlos0/env/v6 v6.9.1 - github.com/charmbracelet/bubbles v0.10.0 + github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50 github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c github.com/charmbracelet/glamour v0.4.0 github.com/charmbracelet/lipgloss v0.4.0 @@ -15,6 +16,8 @@ require ( github.com/go-git/go-git/v5 v5.4.2 github.com/matryer/is v1.2.0 github.com/muesli/reflow v0.3.0 + github.com/muesli/termenv v0.11.0 + github.com/sergi/go-diff v1.1.0 // indirect golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce gopkg.in/yaml.v2 v2.4.0 ) @@ -23,8 +26,8 @@ require ( github.com/Microsoft/go-winio v0.5.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20220113124808-70ae35bab23f // indirect github.com/acomagu/bufpipe v1.0.3 // indirect - github.com/alecthomas/chroma v0.10.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/keygen v0.1.2 // indirect github.com/containerd/console v1.0.3 // indirect @@ -42,10 +45,9 @@ require ( github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect - github.com/muesli/termenv v0.11.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/sergi/go-diff v1.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/xanzy/ssh-agent v0.3.1 // indirect github.com/yuin/goldmark v1.4.4 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect diff --git a/go.sum b/go.sum index 5749f90ecd1569e09abab465c29ff7b08ecad062..12198bc644c5288da3d066fad5236b1f5a2240db 100644 --- a/go.sum +++ b/go.sum @@ -15,13 +15,16 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k= github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= -github.com/charmbracelet/bubbles v0.10.0 h1:ZYqBwnmFGp91HSRRbhxKq5jr6bUPsVUBdkrGGWtv0Wk= -github.com/charmbracelet/bubbles v0.10.0/go.mod h1:4tiDrWzH1MTD4t5NnrcthaedmI3MxU0FIutax7//dvk= +github.com/charmbracelet/bubbles v0.10.3-0.20220206060452-06358c35f974 h1:mdlhYomJ2+TRS+py0WxxqjDdyjvX5ox+CyrCjV2HaUM= +github.com/charmbracelet/bubbles v0.10.3-0.20220206060452-06358c35f974/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50 h1:hAsXGdqKHVoEbBlvReSfz8X605xddHMBFSxSrCaSSO4= +github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c h1:hcS4xdVQwblKo8xuA5gRO/jql+yCVfnBlOwWcZrxOmA= github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c/go.mod h1:5nPeULOIxbAMykb3ggwhw1kruS7nP+Y4Za9yEH4J27U= @@ -30,7 +33,6 @@ github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5Uy github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/keygen v0.1.2 h1:Gr/gdIOjDIxCTRVXpwa9tsXPoJPS2eGNehPoMnZLvTQ= github.com/charmbracelet/keygen v0.1.2/go.mod h1:kFQ3Cvop12fXWX1K29vxDxV9x8ujG4wBSXq//GySSSk= -github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk= github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/charmbracelet/wish v0.2.1-0.20220208182816-534842b53d2a h1:dDdOcIedpXZ13xGfwFDd1ZlTUXotX945xXtz+7rHBK8= @@ -81,12 +83,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= @@ -107,7 +109,6 @@ github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBc github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/muesli/termenv v0.11.0 h1:fwNUbu2mfWlgicwG7qYzs06aOI8Z/zKPAv8J4uKbT+o= github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= @@ -122,10 +123,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/git/git.go b/internal/git/git.go index 936cf080cac33e780cf487639679b0b7fc1b2f5e..16931421dbddafa113dff031539d5b14fc901f1a 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -25,6 +25,7 @@ type Repo struct { Repository *git.Repository Readme string LastUpdated *time.Time + commits CommitLog } // RepoCommit contains metadata for a Git commit. @@ -44,10 +45,9 @@ func (cl CommitLog) Less(i, j int) bool { // RepoSource is a reference to an on-disk repositories. type RepoSource struct { - Path string - mtx sync.Mutex - repos []*Repo - commits CommitLog + Path string + mtx sync.Mutex + repos []*Repo } // NewRepoSource creates a new RepoSource. @@ -106,14 +106,14 @@ func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) { return r, nil } -// GetCommits returns commits for the repository. -func (rs *RepoSource) GetCommits(limit int) []RepoCommit { - rs.mtx.Lock() - defer rs.mtx.Unlock() - if limit > len(rs.commits) { - limit = len(rs.commits) +func (r *Repo) GetCommits(limit int) CommitLog { + if limit <= 0 { + return r.commits + } + if limit > len(r.commits) { + limit = len(r.commits) } - return rs.commits[:limit] + return r.commits[:limit] } // LoadRepos opens Git repositories. @@ -125,7 +125,6 @@ func (rs *RepoSource) LoadRepos() error { return err } rs.repos = make([]*Repo, 0) - rs.commits = make([]RepoCommit, 0) for _, de := range rd { rn := de.Name() rg, err := git.PlainOpen(filepath.Join(rs.Path, rn)) @@ -143,6 +142,7 @@ func (rs *RepoSource) LoadRepos() error { func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) { r := &Repo{Name: name} + r.commits = make([]RepoCommit, 0) r.Repository = rg l, err := rg.Log(&git.LogOptions{All: true}) if err != nil { @@ -159,13 +159,13 @@ func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) { } } } - rs.commits = append(rs.commits, RepoCommit{Name: name, Commit: c}) + r.commits = append(r.commits, RepoCommit{Name: name, Commit: c}) return nil }) if err != nil { return nil, err } - sort.Sort(rs.commits) + sort.Sort(r.commits) return r, nil } diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go index c5071c4b836ed6e54388cb6238e42d29cab6a7ad..1adf41728ba4b9d584e30e8fb42fcf212be2a54b 100644 --- a/internal/tui/bubble.go +++ b/internal/tui/bubble.go @@ -7,12 +7,17 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/internal/config" + gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection" "github.com/charmbracelet/soft-serve/internal/tui/style" "github.com/gliderlabs/ssh" ) +const ( + repoNameMaxWidth = 32 +) + type sessionState int const ( @@ -83,14 +88,6 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return b, tea.Quit case "tab", "shift+tab": b.activeBox = (b.activeBox + 1) % 2 - case "h", "left": - if b.activeBox > 0 { - b.activeBox-- - } - case "l", "right": - if b.activeBox < len(b.boxes)-1 { - b.activeBox++ - } } case errMsg: b.error = msg.Error() @@ -112,11 +109,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case selection.SelectedMsg: b.activeBox = 1 rb := b.repoMenu[msg.Index].bubble - rb.GotoTop() b.boxes[1] = rb case selection.ActiveMsg: - rb := b.repoMenu[msg.Index].bubble - rb.GotoTop() b.boxes[1] = b.repoMenu[msg.Index].bubble cmds = append(cmds, func() tea.Msg { return b.lastResize @@ -163,27 +157,42 @@ func (b Bubble) headerView() string { func (b Bubble) footerView() string { w := &strings.Builder{} - var h []helpEntry - switch b.state { - case errorState: - h = []helpEntry{{"q", "quit"}} - default: - h = []helpEntry{ + var h []gittypes.HelpEntry + if b.state != errorState { + h = []gittypes.HelpEntry{ {"tab", "section"}, - {"↑/↓", "navigate"}, - {"q", "quit"}, } - if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok { - h = append(h[:2], helpEntry{"f/b", "pgdown/pgup"}, h[2]) + if box, ok := b.boxes[b.activeBox].(gittypes.HelpableBubble); ok { + help := box.Help() + for _, he := range help { + h = append(h, he) + } } } + h = append(h, gittypes.HelpEntry{"q", "quit"}) for i, v := range h { - fmt.Fprint(w, v.Render(b.styles)) + fmt.Fprint(w, helpEntryRender(v, b.styles)) if i != len(h)-1 { fmt.Fprint(w, b.styles.HelpDivider) } } - return b.styles.Footer.Copy().Width(b.width).Render(w.String()) + branch := "" + if b.state == loadedState { + branch = b.boxes[1].(*repo.Bubble).Reference().Short() + } + help := w.String() + branchMaxWidth := b.width - // bubble width + lipgloss.Width(help) - // help width + b.styles.App.GetHorizontalFrameSize() // App paddings + branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…")) + gap := lipgloss.NewStyle(). + Width(b.width - + lipgloss.Width(help) - + lipgloss.Width(branch) - + b.styles.App.GetHorizontalFrameSize()). + Render("") + footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch) + return b.styles.Footer.Render(footer) } func (b Bubble) errorView() string { @@ -219,11 +228,6 @@ func (b Bubble) View() string { return b.styles.App.Render(s.String()) } -type helpEntry struct { - key string - val string -} - -func (h helpEntry) Render(s *style.Styles) string { - return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val)) +func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string { + return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value)) } diff --git a/internal/tui/bubbles/commits/bubble.go b/internal/tui/bubbles/commits/bubble.go deleted file mode 100644 index 4edc6a6c21e6a0a59c90695dfc6b1dc76e7aea2c..0000000000000000000000000000000000000000 --- a/internal/tui/bubbles/commits/bubble.go +++ /dev/null @@ -1,67 +0,0 @@ -package commits - -import ( - "strings" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/internal/git" - "github.com/dustin/go-humanize" -) - -type Bubble struct { - Commits []git.RepoCommit - Height int - Width int - viewport viewport.Model -} - -func NewBubble(height int, width int, rcs []git.RepoCommit) *Bubble { - b := &Bubble{ - Commits: rcs, - viewport: viewport.Model{Height: height, Width: width}, - } - s := "" - for _, rc := range rcs { - s += b.renderCommit(rc) + "\n" - } - b.viewport.SetContent(s) - return b -} - -func (b *Bubble) Init() tea.Cmd { - return nil -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "up", "k": - b.viewport.LineUp(1) - case "down", "j": - b.viewport.LineDown(1) - } - } - return b, tea.Batch(cmds...) -} - -func (b *Bubble) renderCommit(rc git.RepoCommit) string { - s := "" - s += commitRepoNameStyle.Render(rc.Name) - s += " " - s += commitDateStyle.Render(humanize.Time(rc.Commit.Author.When)) - s += "\n" - s += commitCommentStyle.Render(strings.TrimSpace(rc.Commit.Message)) - s += "\n" - s += commitAuthorStyle.Render(rc.Commit.Author.Name) - s += " " - s += commitAuthorEmailStyle.Render(rc.Commit.Author.Email) - s += " " - return commitBoxStyle.Width(b.viewport.Width).Render(s) -} - -func (b *Bubble) View() string { - return b.viewport.View() -} diff --git a/internal/tui/bubbles/commits/style.go b/internal/tui/bubbles/commits/style.go deleted file mode 100644 index dd2543acb875d62ff769efb5ab39bc204b183d22..0000000000000000000000000000000000000000 --- a/internal/tui/bubbles/commits/style.go +++ /dev/null @@ -1,26 +0,0 @@ -package commits - -import ( - "github.com/charmbracelet/lipgloss" -) - -var commitBoxStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#670083")). - Padding(1) -var commitRepoNameStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#8922A5")) -var commitAuthorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#670083")) -var commitAuthorEmailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#781194")) -var commitDateStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#781194")) -var commitCommentStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A0A0A0")). - BorderStyle(lipgloss.Border{Left: ">"}). - BorderForeground(lipgloss.Color("#606060")). - PaddingLeft(1). - PaddingBottom(0). - Margin(0) diff --git a/internal/tui/bubbles/git/about/bubble.go b/internal/tui/bubbles/git/about/bubble.go new file mode 100644 index 0000000000000000000000000000000000000000..07fa8a8c1775e571f0266437e7152d90e1af767a --- /dev/null +++ b/internal/tui/bubbles/git/about/bubble.go @@ -0,0 +1,121 @@ +package about + +import ( + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport" + "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/muesli/reflow/wrap" +) + +type Bubble struct { + readmeViewport *vp.ViewportBubble + repo types.Repo + styles *style.Styles + height int + heightMargin int + width int + widthMargin int +} + +func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble { + b := &Bubble{ + readmeViewport: &vp.ViewportBubble{ + Viewport: &viewport.Model{}, + }, + repo: repo, + styles: styles, + widthMargin: wm, + heightMargin: hm, + } + b.SetSize(width, height) + return b +} +func (b *Bubble) Init() tea.Cmd { + return b.setupCmd +} + +func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + b.SetSize(msg.Width, msg.Height) + // XXX: if we find that longer readmes take more than a few + // milliseconds to render we may need to move Glamour rendering into a + // command. + md, err := b.glamourize(b.repo.GetReadme()) + if err != nil { + return b, nil + } + b.readmeViewport.Viewport.SetContent(md) + case tea.KeyMsg: + switch msg.String() { + case "A": + b.GotoTop() + } + } + rv, cmd := b.readmeViewport.Update(msg) + b.readmeViewport = rv.(*vp.ViewportBubble) + cmds = append(cmds, cmd) + return b, tea.Batch(cmds...) +} + +func (b *Bubble) SetSize(w, h int) { + b.width = w + b.height = h + b.readmeViewport.Viewport.Width = w - b.widthMargin + b.readmeViewport.Viewport.Height = h - b.heightMargin +} + +func (b *Bubble) GotoTop() { + b.readmeViewport.Viewport.GotoTop() +} + +func (b *Bubble) View() string { + return b.readmeViewport.View() +} + +func (b *Bubble) Help() []types.HelpEntry { + return []types.HelpEntry{ + {"f/b", "pgup/pgdown"}, + } +} + +func (b *Bubble) setupCmd() tea.Msg { + md, err := b.glamourize(b.repo.GetReadme()) + if err != nil { + return types.ErrMsg{err} + } + b.readmeViewport.Viewport.SetContent(md) + b.GotoTop() + return nil +} + +func (b *Bubble) glamourize(md string) (string, error) { + w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize() + if w > types.GlamourMaxWidth { + w = types.GlamourMaxWidth + } + tr, err := glamour.NewTermRenderer( + glamour.WithStyles(types.DefaultStyles()), + glamour.WithWordWrap(w), + ) + + if err != nil { + return "", err + } + mdt, err := tr.Render(md) + if err != nil { + return "", err + } + // For now, hard-wrap long lines in Glamour that would otherwise break the + // layout when wrapping. This may be due to #43 in Reflow, which has to do + // with a bug in the way lines longer than the given width are wrapped. + // + // https://github.com/muesli/reflow/issues/43 + // + // TODO: solve this upstream in Glamour/Reflow. + return wrap.String(mdt, w), nil +} diff --git a/internal/tui/bubbles/git/bubble.go b/internal/tui/bubbles/git/bubble.go new file mode 100644 index 0000000000000000000000000000000000000000..c6780f26ab56b0015159abb9bc8772fdab59da1c --- /dev/null +++ b/internal/tui/bubbles/git/bubble.go @@ -0,0 +1,157 @@ +package git + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/about" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/log" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/tree" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/go-git/go-git/v5/plumbing" +) + +const ( + repoNameMaxWidth = 32 +) + +type pageState int + +const ( + aboutPage pageState = iota + refsPage + logPage + treePage +) + +type Bubble struct { + state pageState + repo types.Repo + height int + heightMargin int + width int + widthMargin int + style *style.Styles + boxes []tea.Model +} + +func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble { + b := &Bubble{ + repo: repo, + state: aboutPage, + width: width, + widthMargin: wm, + height: height, + heightMargin: hm, + style: styles, + boxes: make([]tea.Model, 4), + } + heightMargin := hm + lipgloss.Height(b.headerView()) + b.boxes[aboutPage] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin) + b.boxes[refsPage] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin) + b.boxes[logPage] = log.NewBubble(repo, b.style, width, wm, height, heightMargin) + b.boxes[treePage] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin) + return b +} + +func (b *Bubble) Init() tea.Cmd { + return b.setupCmd +} + +func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "A": + b.state = aboutPage + case "R": + b.state = refsPage + case "L": + b.state = logPage + case "T": + b.state = treePage + } + case tea.WindowSizeMsg: + b.width = msg.Width + b.height = msg.Height + for i, bx := range b.boxes { + m, cmd := bx.Update(msg) + b.boxes[i] = m + if cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m, cmd := b.boxes[b.state].Update(msg) + b.boxes[b.state] = m + if cmd != nil { + cmds = append(cmds, cmd) + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + if b.state == refsPage { + b.state = treePage + cmds = append(cmds, b.boxes[b.state].Init()) + } + } + } + return b, tea.Batch(cmds...) +} + +func (b *Bubble) Help() []types.HelpEntry { + h := []types.HelpEntry{} + if b.state != treePage { + h = append(h, types.HelpEntry{"↑/↓", "navigate"}) + } + h = append(h, b.boxes[b.state].(types.HelpableBubble).Help()...) + if b.state != aboutPage { + h = append(h, types.HelpEntry{"A", "about"}) + } + if b.state != refsPage { + h = append(h, types.HelpEntry{"R", "refs"}) + } + if b.state != logPage { + h = append(h, types.HelpEntry{"L", "log"}) + } + if b.state != treePage { + h = append(h, types.HelpEntry{"T", "tree"}) + } + return h +} + +func (b *Bubble) Reference() plumbing.ReferenceName { + return b.repo.GetReference().Name() +} + +func (b *Bubble) headerView() string { + // TODO better header, tabs? + return "" +} + +func (b *Bubble) View() string { + header := b.headerView() + return header + b.boxes[b.state].View() +} + +func (b *Bubble) setupCmd() tea.Msg { + cmds := make([]tea.Cmd, 0) + for _, bx := range b.boxes { + if bx != nil { + initCmd := bx.Init() + if initCmd != nil { + msg := initCmd() + switch msg := msg.(type) { + case types.ErrMsg: + return msg + } + } + cmds = append(cmds, initCmd) + } + } + return tea.Batch(cmds...) +} diff --git a/internal/tui/bubbles/git/log/bubble.go b/internal/tui/bubbles/git/log/bubble.go new file mode 100644 index 0000000000000000000000000000000000000000..ccee7889ceb2c211982faf8815dccbd74f2c691a --- /dev/null +++ b/internal/tui/bubbles/git/log/bubble.go @@ -0,0 +1,394 @@ +package log + +import ( + "context" + "fmt" + "io" + "math" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport" + "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/dustin/go-humanize/english" + "github.com/go-git/go-git/v5/plumbing/object" +) + +var ( + diffChroma = &gansi.CodeBlockElement{ + Code: "", + Language: "diff", + } +) + +type commitMsg struct { + commit *object.Commit + parent *object.Commit + tree *object.Tree + parentTree *object.Tree + patch *object.Patch +} + +type sessionState int + +const ( + logState sessionState = iota + commitState + errorState +) + +type item struct { + *types.Commit +} + +func (i item) Title() string { + lines := strings.Split(i.Message, "\n") + if len(lines) > 0 { + return lines[0] + } + return "" +} + +func (i item) FilterValue() string { return i.Title() } + +type itemDelegate struct { + style *style.Styles +} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + leftMargin := d.style.LogItemSelector.GetMarginLeft() + + d.style.LogItemSelector.GetWidth() + + d.style.LogItemHash.GetMarginLeft() + + d.style.LogItemHash.GetWidth() + + d.style.LogItemInactive.GetMarginLeft() + title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…") + if index == m.Index() { + fmt.Fprint(w, d.style.LogItemSelector.Render(">")+ + d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+ + d.style.LogItemActive.Render(title)) + } else { + fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+ + d.style.LogItemHash.Render(i.Hash.String()[:7])+ + d.style.LogItemInactive.Render(title)) + } +} + +type Bubble struct { + repo types.Repo + list list.Model + state sessionState + commitViewport *vp.ViewportBubble + style *style.Styles + width int + widthMargin int + height int + heightMargin int + error types.ErrMsg +} + +func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { + l := list.New([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin) + l.SetShowFilter(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + l.SetShowStatusBar(false) + l.SetShowTitle(false) + l.SetFilteringEnabled(false) + l.DisableQuitKeybindings() + l.KeyMap.NextPage = types.NextPage + l.KeyMap.PrevPage = types.PrevPage + b := &Bubble{ + commitViewport: &vp.ViewportBubble{ + Viewport: &viewport.Model{}, + }, + repo: repo, + style: style, + state: logState, + width: width, + widthMargin: widthMargin, + height: height, + heightMargin: heightMargin, + list: l, + } + b.SetSize(width, height) + return b +} + +func (b *Bubble) updateItems() tea.Cmd { + items := make([]list.Item, 0) + cc, err := b.repo.GetCommits(0) + if err != nil { + return func() tea.Msg { return types.ErrMsg{err} } + } + for _, c := range cc { + items = append(items, item{c}) + } + return b.list.SetItems(items) +} + +func (b *Bubble) Help() []types.HelpEntry { + switch b.state { + case logState: + return []types.HelpEntry{ + {"enter", "select"}, + } + default: + return []types.HelpEntry{ + {"esc", "back"}, + } + } +} + +func (b *Bubble) GotoTop() { + b.commitViewport.Viewport.GotoTop() +} + +func (b *Bubble) Init() tea.Cmd { + return b.updateItems() +} + +func (b *Bubble) SetSize(width, height int) { + b.width = width + b.height = height + b.commitViewport.Viewport.Width = width - b.widthMargin + b.commitViewport.Viewport.Height = height - b.heightMargin + b.list.SetSize(width-b.widthMargin, height-b.heightMargin) +} + +func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + b.SetSize(msg.Width, msg.Height) + + case tea.KeyMsg: + switch msg.String() { + case "L": + b.state = logState + b.list.Select(0) + cmds = append(cmds, b.updateItems()) + case "enter", "right", "l": + if b.state == logState { + cmds = append(cmds, b.loadCommit()) + } + case "esc", "left", "h": + if b.state != logState { + b.state = logState + } + } + case types.ErrMsg: + b.error = msg + b.state = errorState + return b, nil + case commitMsg: + content := b.renderCommit(msg) + b.state = commitState + b.commitViewport.Viewport.SetContent(content) + b.GotoTop() + } + + switch b.state { + case commitState: + rv, cmd := b.commitViewport.Update(msg) + b.commitViewport = rv.(*vp.ViewportBubble) + cmds = append(cmds, cmd) + case logState: + l, cmd := b.list.Update(msg) + b.list = l + cmds = append(cmds, cmd) + } + + return b, tea.Batch(cmds...) +} + +func (b *Bubble) loadCommit() tea.Cmd { + return func() tea.Msg { + i := b.list.SelectedItem() + if i == nil { + return nil + } + c, ok := i.(item) + if !ok { + return nil + } + // 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} + } + var parent *object.Commit + parentTree := &object.Tree{} + if c.NumParents() > 0 { + parent, err = c.Parents().Next() + if err != nil { + return types.ErrMsg{err} + } + parentTree, err = parent.Tree() + if err != nil { + return types.ErrMsg{err} + } + } + ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait) + defer cancel() + patch, err := parentTree.PatchContext(ctx, tree) + if err != nil { + return types.ErrMsg{err} + } + return commitMsg{ + commit: c.Commit.Commit, + tree: tree, + parent: parent, + parentTree: parentTree, + patch: patch, + } + } +} + +func (b *Bubble) renderCommit(m commitMsg) 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), + )) + 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()) +} + +func (b *Bubble) renderStats(fileStats object.FileStats) string { + padLength := float64(len(" ")) + newlineLength := float64(len("\n")) + separatorLength := float64(len("|")) + // Soft line length limit. The text length calculation below excludes + // length of the change number. Adding that would take it closer to 80, + // but probably not more than 80, until it's a huge number. + lineLength := 72.0 + + // Get the longest filename and longest total change. + var longestLength float64 + var longestTotalChange float64 + for _, fs := range fileStats { + if int(longestLength) < len(fs.Name) { + longestLength = float64(len(fs.Name)) + } + totalChange := fs.Addition + fs.Deletion + if int(longestTotalChange) < totalChange { + longestTotalChange = float64(totalChange) + } + } + + // Parts of the output: + // |<+++/---> + // example: " main.go | 10 +++++++--- " + + // + leftTextLength := padLength + longestLength + padLength + + // <+++++/-----> + // Excluding number length here. + rightTextLength := padLength + padLength + newlineLength + + totalTextArea := leftTextLength + separatorLength + rightTextLength + heightOfHistogram := lineLength - totalTextArea + + // Scale the histogram. + var scaleFactor float64 + if longestTotalChange > heightOfHistogram { + // Scale down to heightOfHistogram. + scaleFactor = longestTotalChange / heightOfHistogram + } else { + scaleFactor = 1.0 + } + + taddc := 0 + tdelc := 0 + output := strings.Builder{} + for _, fs := range fileStats { + taddc += fs.Addition + tdelc += fs.Deletion + addn := float64(fs.Addition) + deln := float64(fs.Deletion) + addc := int(math.Floor(addn / scaleFactor)) + delc := int(math.Floor(deln / scaleFactor)) + if addc < 0 { + addc = 0 + } + if delc < 0 { + delc = 0 + } + adds := strings.Repeat("+", addc) + dels := strings.Repeat("-", delc) + diffLines := fmt.Sprint(fs.Addition + fs.Deletion) + totalDiffLines := fmt.Sprint(int(longestTotalChange)) + fmt.Fprintf(&output, "%s | %s %s%s\n", + fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)), + strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines, + b.style.LogCommitStatsAdd.Render(adds), + b.style.LogCommitStatsDel.Render(dels)) + } + files := len(fileStats) + fc := fmt.Sprintf("%s changed", english.Plural(files, "file", "")) + ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", "")) + dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", "")) + fmt.Fprint(&output, fc) + if taddc > 0 { + fmt.Fprintf(&output, ", %s", ins) + } + if tdelc > 0 { + fmt.Fprintf(&output, ", %s", dels) + } + fmt.Fprint(&output, "\n") + + return output.String() +} + +func (b *Bubble) View() string { + switch b.state { + case logState: + return b.list.View() + case errorState: + return b.error.ViewWithPrefix(b.style, "Error") + case commitState: + return b.commitViewport.View() + default: + return "" + } +} diff --git a/internal/tui/bubbles/git/refs/bubble.go b/internal/tui/bubbles/git/refs/bubble.go new file mode 100644 index 0000000000000000000000000000000000000000..543410b4741d81b021ddf09b90b794eb0fa24460 --- /dev/null +++ b/internal/tui/bubbles/git/refs/bubble.go @@ -0,0 +1,174 @@ +package refs + +import ( + "fmt" + "io" + "sort" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/go-git/go-git/v5/plumbing" +) + +type item struct { + *plumbing.Reference +} + +func (i item) Short() string { + return i.Name().Short() +} + +func (i item) FilterValue() string { return i.Short() } + +type items []item + +func (cl items) Len() int { return len(cl) } +func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } +func (cl items) Less(i, j int) bool { + return cl[i].Name().Short() < cl[j].Name().Short() +} + +type itemDelegate struct { + style *style.Styles +} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + s := d.style + i, ok := listItem.(item) + if !ok { + return + } + + ref := i.Short() + if i.Name().IsTag() { + ref = s.RefItemTag.Render(ref) + } + ref = s.RefItemBranch.Render(ref) + refMaxWidth := m.Width() - + s.RefItemSelector.GetMarginLeft() - + s.RefItemSelector.GetWidth() - + s.RefItemInactive.GetMarginLeft() + ref = types.TruncateString(ref, refMaxWidth, "…") + if index == m.Index() { + fmt.Fprint(w, s.RefItemSelector.Render(">")+ + s.RefItemActive.Render(ref)) + } else { + fmt.Fprint(w, s.LogItemSelector.Render(" ")+ + s.RefItemInactive.Render(ref)) + } +} + +type Bubble struct { + repo types.Repo + list list.Model + style *style.Styles + width int + widthMargin int + height int + heightMargin int +} + +func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { + l := list.NewModel([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin) + l.SetShowFilter(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + l.SetShowStatusBar(false) + l.SetShowTitle(false) + l.SetFilteringEnabled(false) + l.DisableQuitKeybindings() + b := &Bubble{ + repo: repo, + style: style, + width: width, + height: height, + widthMargin: widthMargin, + heightMargin: heightMargin, + list: l, + } + b.SetSize(width, height) + return b +} + +func (b *Bubble) SetBranch(ref *plumbing.Reference) { + b.repo.SetReference(ref) +} + +func (b *Bubble) Init() tea.Cmd { + return b.updateItems() +} + +func (b *Bubble) SetSize(width, height int) { + b.width = width + b.height = height + b.list.SetSize(width-b.widthMargin, height-b.heightMargin) +} + +func (b *Bubble) Help() []types.HelpEntry { + return []types.HelpEntry{ + {"enter", "select"}, + } +} + +func (b *Bubble) updateItems() tea.Cmd { + its := make(items, 0) + tags := make(items, 0) + ri, err := b.repo.Repository().References() + if err != nil { + return nil + } + if err = ri.ForEach(func(r *plumbing.Reference) error { + if r.Type() == plumbing.HashReference { + if r.Name().IsTag() { + tags = append(tags, item{r}) + } else { + its = append(its, item{r}) + } + } + return nil + }); err != nil { + return nil + } + sort.Sort(its) + sort.Sort(tags) + its = append(its, tags...) + itt := make([]list.Item, len(its)) + for i, it := range its { + itt[i] = it + } + return b.list.SetItems(itt) +} + +func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + b.SetSize(msg.Width, msg.Height) + + case tea.KeyMsg: + switch msg.String() { + case "R": + cmds = append(cmds, b.updateItems()) + case "enter", "right", "l": + if b.list.Index() >= 0 { + ref := b.list.SelectedItem().(item).Reference + b.SetBranch(ref) + } + } + } + + l, cmd := b.list.Update(msg) + b.list = l + cmds = append(cmds, cmd) + + return b, tea.Batch(cmds...) +} + +func (b *Bubble) View() string { + return b.list.View() +} diff --git a/internal/tui/bubbles/git/tree/bubble.go b/internal/tui/bubbles/git/tree/bubble.go new file mode 100644 index 0000000000000000000000000000000000000000..e69bb28d30bde231ec530e1abf68e8121f1553c6 --- /dev/null +++ b/internal/tui/bubbles/git/tree/bubble.go @@ -0,0 +1,355 @@ +package tree + +import ( + "fmt" + "io" + "path/filepath" + "sort" + "strings" + + "github.com/alecthomas/chroma/lexers" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport" + "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/dustin/go-humanize" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type fileMsg struct { + content string +} + +type sessionState int + +const ( + treeState sessionState = iota + fileState + errorState +) + +type item struct { + *object.TreeEntry + *object.File +} + +func (i item) Name() string { + return i.TreeEntry.Name +} + +func (i item) Mode() filemode.FileMode { + return i.TreeEntry.Mode +} + +func (i item) FilterValue() string { return i.Name() } + +type items []item + +func (cl items) Len() int { return len(cl) } +func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } +func (cl items) Less(i, j int) bool { + if cl[i].Mode() == filemode.Dir && cl[j].Mode() == filemode.Dir { + return cl[i].Name() < cl[j].Name() + } else if cl[i].Mode() == filemode.Dir { + return true + } else if cl[j].Mode() == filemode.Dir { + return false + } else { + return cl[i].Name() < cl[j].Name() + } +} + +type itemDelegate struct { + style *style.Styles +} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + s := d.style + i, ok := listItem.(item) + if !ok { + return + } + + name := i.Name() + if i.Mode() == filemode.Dir { + name = s.TreeFileDir.Render(name) + } + size := "" + if i.File != nil { + size = humanize.Bytes(uint64(i.File.Size)) + } + var cs lipgloss.Style + mode, _ := i.Mode().ToOSFileMode() + if index == m.Index() { + cs = s.TreeItemActive + fmt.Fprint(w, s.TreeItemSelector.Render(">")) + } else { + cs = s.TreeItemInactive + fmt.Fprint(w, s.TreeItemSelector.Render(" ")) + } + leftMargin := s.TreeItemSelector.GetMarginLeft() + + s.TreeItemSelector.GetWidth() + + s.TreeFileMode.GetMarginLeft() + + s.TreeFileMode.GetWidth() + + cs.GetMarginLeft() + rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size) + name = types.TruncateString(name, m.Width()-leftMargin-rightMargin, "…") + sizeStyle := s.TreeFileSize.Copy(). + Width(m.Width() - + leftMargin - + s.TreeFileSize.GetMarginLeft() - + lipgloss.Width(name)). + Align(lipgloss.Right) + fmt.Fprint(w, s.TreeFileMode.Render(mode.String())+ + cs.Render(name)+ + sizeStyle.Render(size)) +} + +type Bubble struct { + repo types.Repo + list list.Model + style *style.Styles + width int + widthMargin int + height int + heightMargin int + path string + state sessionState + error types.ErrMsg + fileViewport *vp.ViewportBubble + lastSelected []int +} + +func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { + l := list.New([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin) + l.SetShowFilter(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + l.SetShowStatusBar(false) + l.SetShowTitle(false) + l.SetFilteringEnabled(false) + l.DisableQuitKeybindings() + l.KeyMap.NextPage = types.NextPage + l.KeyMap.PrevPage = types.PrevPage + b := &Bubble{ + fileViewport: &vp.ViewportBubble{ + Viewport: &viewport.Model{}, + }, + repo: repo, + style: style, + width: width, + height: height, + widthMargin: widthMargin, + heightMargin: heightMargin, + list: l, + path: "", + state: treeState, + lastSelected: []int{}, + } + b.SetSize(width, height) + return b +} + +func (b *Bubble) Init() tea.Cmd { + b.path = "" + b.list.Select(0) + b.state = treeState + return b.updateItems() +} + +func (b *Bubble) SetSize(width, height int) { + b.width = width + b.height = height + b.fileViewport.Viewport.Width = width - b.widthMargin + b.fileViewport.Viewport.Height = height - b.heightMargin + b.list.SetSize(width-b.widthMargin, height-b.heightMargin) +} + +func (b *Bubble) Help() []types.HelpEntry { + h := []types.HelpEntry{} + switch b.state { + case errorState: + h = append(h, types.HelpEntry{"esc", "back"}) + default: + h = append(h, types.HelpEntry{"←/↑/→/↓", "navigate"}) + } + return h +} + +func (b *Bubble) updateItems() tea.Cmd { + its := make(items, 0) + t, err := b.repo.Tree(b.path) + if err != nil { + return func() tea.Msg { return types.ErrMsg{err} } + } + tw := object.NewTreeWalker(t, false, map[plumbing.Hash]bool{}) + defer tw.Close() + for { + _, e, err := tw.Next() + if err != nil { + break + } + i := item{ + TreeEntry: &e, + } + if e.Mode.IsFile() { + if f, err := t.TreeEntryFile(&e); err == nil { + i.File = f + } + } + its = append(its, i) + } + sort.Sort(its) + itt := make([]list.Item, len(its)) + for i, it := range its { + itt[i] = it + } + cmd := b.list.SetItems(itt) + b.list.Select(0) + return cmd +} + +func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + b.SetSize(msg.Width, msg.Height) + + case tea.KeyMsg: + switch msg.String() { + case "T": + b.state = treeState + b.path = "" + cmds = append(cmds, b.updateItems()) + case "enter", "right", "l": + if b.state == treeState { + index := b.list.Index() + item := b.list.SelectedItem().(item) + mode := item.Mode() + b.path = filepath.Join(b.path, item.Name()) + if mode == filemode.Dir { + b.lastSelected = append(b.lastSelected, index) + cmds = append(cmds, b.updateItems()) + } else { + b.lastSelected = append(b.lastSelected, index) + cmds = append(cmds, b.loadFile()) + } + } + case "esc", "left", "h": + if b.state != treeState { + b.state = treeState + } + p := filepath.Dir(b.path) + b.path = p + cmds = append(cmds, b.updateItems()) + index := 0 + if len(b.lastSelected) > 0 { + index = b.lastSelected[len(b.lastSelected)-1] + b.lastSelected = b.lastSelected[:len(b.lastSelected)-1] + } + b.list.Select(index) + } + + case types.ErrMsg: + b.error = msg + b.state = errorState + return b, nil + + case fileMsg: + content := b.renderFile(msg) + b.fileViewport.Viewport.SetContent(content) + b.fileViewport.Viewport.GotoTop() + b.state = fileState + } + + switch b.state { + case fileState: + rv, cmd := b.fileViewport.Update(msg) + b.fileViewport = rv.(*vp.ViewportBubble) + cmds = append(cmds, cmd) + case treeState: + l, cmd := b.list.Update(msg) + b.list = l + cmds = append(cmds, cmd) + } + + return b, tea.Batch(cmds...) +} + +func (b *Bubble) View() string { + switch b.state { + case treeState: + return b.list.View() + case errorState: + return b.error.ViewWithPrefix(b.style, "Error") + case fileState: + return b.fileViewport.View() + default: + return "" + } +} + +func (b *Bubble) loadFile() tea.Cmd { + return func() tea.Msg { + i := b.list.SelectedItem() + if i == nil { + return nil + } + f, ok := i.(item) + if !ok { + return nil + } + if !f.Mode().IsFile() || f.File == nil { + return types.ErrMsg{types.ErrInvalidFile} + } + bin, err := f.File.IsBinary() + if err != nil { + return types.ErrMsg{err} + } + if bin { + return types.ErrMsg{types.ErrBinaryFile} + } + c, err := f.File.Contents() + if err != nil { + return types.ErrMsg{err} + } + return fileMsg{ + content: c, + } + } +} + +func (b *Bubble) renderFile(m fileMsg) string { + s := strings.Builder{} + c := m.content + if len(strings.Split(c, "\n")) > types.MaxDiffLines { + s.WriteString(types.ErrFileTooLarge.Error()) + } else { + lexer := lexers.Match(b.path) + lang := "" + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + formatter := &gansi.CodeBlockElement{ + Code: c, + Language: lang, + } + r := strings.Builder{} + err := formatter.Render(&r, types.RenderCtx) + if err != nil { + s.WriteString(err.Error()) + } else { + s.WriteString(r.String()) + } + } + return b.style.TreeFileContent.Copy().Width(b.width - b.widthMargin).Render(s.String()) +} diff --git a/internal/tui/bubbles/git/types/consts.go b/internal/tui/bubbles/git/types/consts.go new file mode 100644 index 0000000000000000000000000000000000000000..940938638aa3082404ce339b8cdfbad01c52f7a2 --- /dev/null +++ b/internal/tui/bubbles/git/types/consts.go @@ -0,0 +1,28 @@ +package types + +import ( + "time" + + "github.com/charmbracelet/bubbles/key" +) + +// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git + +const ( + GlamourMaxWidth = 120 + RepoNameMaxWidth = 32 + MaxDiffLines = 1000 + MaxDiffFiles = 100 + MaxPatchWait = time.Second * 3 +) + +var ( + PrevPage = key.NewBinding( + key.WithKeys("pgup", "b", "u"), + key.WithHelp("pgup", "prev page"), + ) + NextPage = key.NewBinding( + key.WithKeys("pgdown", "f", "d"), + key.WithHelp("pgdn", "next page"), + ) +) diff --git a/internal/tui/bubbles/git/types/error.go b/internal/tui/bubbles/git/types/error.go new file mode 100644 index 0000000000000000000000000000000000000000..0e2cf1e4621a00626bba67d2eda23e5ab231c158 --- /dev/null +++ b/internal/tui/bubbles/git/types/error.go @@ -0,0 +1,36 @@ +package types + +import ( + "errors" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/internal/tui/style" +) + +var ( + ErrDiffTooLong = errors.New("diff is too long") + ErrDiffFilesTooLong = errors.New("diff files are too long") + ErrBinaryFile = errors.New("binary file") + ErrFileTooLarge = errors.New("file is too large") + ErrInvalidFile = errors.New("invalid file") +) + +type ErrMsg struct { + Err error +} + +func (e ErrMsg) Error() string { + return e.Err.Error() +} + +func (e ErrMsg) View(s *style.Styles) string { + return e.ViewWithPrefix(s, "") +} + +func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string { + return lipgloss.JoinHorizontal( + lipgloss.Top, + s.ErrorTitle.Render(prefix), + s.ErrorBody.Render(e.Error()), + ) +} diff --git a/internal/tui/bubbles/git/types/formatter.go b/internal/tui/bubbles/git/types/formatter.go new file mode 100644 index 0000000000000000000000000000000000000000..7356581d19a61ada2c184b25d364e8c7c8b85ccb --- /dev/null +++ b/internal/tui/bubbles/git/types/formatter.go @@ -0,0 +1,36 @@ +package types + +import ( + "github.com/charmbracelet/glamour" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/muesli/termenv" +) + +var ( + RenderCtx = DefaultRenderCtx() + Styles = DefaultStyles() +) + +func DefaultStyles() 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 DefaultRenderCtx() gansi.RenderContext { + return gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: DefaultStyles(), + }) +} + +func NewRenderCtx(worldwrap int) gansi.RenderContext { + return gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: DefaultStyles(), + WordWrap: worldwrap, + }) +} diff --git a/internal/tui/bubbles/git/types/git.go b/internal/tui/bubbles/git/types/git.go new file mode 100644 index 0000000000000000000000000000000000000000..8e0213871b9bc7e868e15dfa9fd87b5a44fb88e7 --- /dev/null +++ b/internal/tui/bubbles/git/types/git.go @@ -0,0 +1,29 @@ +package types + +import ( + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type Repo interface { + Name() string + GetReference() *plumbing.Reference + SetReference(*plumbing.Reference) error + GetReadme() string + GetCommits(limit int) (Commits, error) + Repository() *git.Repository + Tree(path string) (*object.Tree, error) +} + +type Commit struct { + *object.Commit +} + +type Commits []*Commit + +func (cl Commits) Len() int { return len(cl) } +func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } +func (cl Commits) Less(i, j int) bool { + return cl[i].Author.When.After(cl[j].Author.When) +} diff --git a/internal/tui/bubbles/git/types/help.go b/internal/tui/bubbles/git/types/help.go new file mode 100644 index 0000000000000000000000000000000000000000..ae04818c76b15bd1914478fb5f2a45e7f23f0b04 --- /dev/null +++ b/internal/tui/bubbles/git/types/help.go @@ -0,0 +1,10 @@ +package types + +type HelpableBubble interface { + Help() []HelpEntry +} + +type HelpEntry struct { + Key string + Value string +} diff --git a/internal/tui/bubbles/git/types/utils.go b/internal/tui/bubbles/git/types/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..c1758097d235186a24d120676b42b667604f8fff --- /dev/null +++ b/internal/tui/bubbles/git/types/utils.go @@ -0,0 +1,17 @@ +package types + +import "github.com/muesli/reflow/truncate" + +func TruncateString(s string, max int, tail string) string { + if max < 0 { + max = 0 + } + return truncate.StringWithTail(s, uint(max), tail) +} + +func Max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/bubbles/repo/viewport_patch.go b/internal/tui/bubbles/git/viewport/viewport_patch.go similarity index 96% rename from internal/tui/bubbles/repo/viewport_patch.go rename to internal/tui/bubbles/git/viewport/viewport_patch.go index df176ebebe429decac912802b7e98c21476a8d64..4163b5c40ee90025690f1f4d97d01ac12d310584 100644 --- a/internal/tui/bubbles/repo/viewport_patch.go +++ b/internal/tui/bubbles/git/viewport/viewport_patch.go @@ -1,4 +1,4 @@ -package repo +package viewport import ( "github.com/charmbracelet/bubbles/viewport" diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go index 12f0ebbc237961c2510c3e6a0f72b07429e5bf82..d96d3b10ca718fa5689fe5307fffd32c1bf7c8d7 100644 --- a/internal/tui/bubbles/repo/bubble.go +++ b/internal/tui/bubbles/repo/bubble.go @@ -1,111 +1,71 @@ package repo import ( - "bytes" "fmt" "strconv" - "text/template" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/git" + gitui "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git" + gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/go-git/go-git/v5/plumbing" "github.com/muesli/reflow/truncate" "github.com/muesli/reflow/wrap" ) const ( - glamourMaxWidth = 120 repoNameMaxWidth = 32 ) -var glamourStyle = func() ansi.StyleConfig { - noColor := "" - s := glamour.DarkStyleConfig - s.Document.StylePrimitive.Color = &noColor - s.CodeBlock.Chroma.Text.Color = &noColor - s.CodeBlock.Chroma.Name.Color = &noColor - return s -}() - -type ErrMsg struct { - Error error -} - type Bubble struct { - templateObject interface{} - repoSource *git.RepoSource - name string - repo *git.Repo - styles *style.Styles - readmeViewport *ViewportBubble - readme string - height int - heightMargin int - width int - widthMargin int - Active bool - - // XXX: ideally, we get these from the parent as a pointer. Currently, we - // can't add a *tui.Config because it's an illegal import cycle. One - // solution would be to (rename and) move this Bubble into the parent - // package. - Host string - Port int + name string + host string + port int + repo gittypes.Repo + styles *style.Styles + width int + widthMargin int + height int + heightMargin int + box *gitui.Bubble + + Active bool } -func NewBubble(rs *git.RepoSource, name string, styles *style.Styles, width, wm, height, hm int, tmp interface{}) *Bubble { +func NewBubble(name, host string, port int, repo gittypes.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble { b := &Bubble{ - templateObject: tmp, - repoSource: rs, - name: name, - styles: styles, - heightMargin: hm, - widthMargin: wm, - readmeViewport: &ViewportBubble{ - Viewport: &viewport.Model{}, - }, - } - b.SetSize(width, height) + name: name, + host: host, + port: port, + repo: repo, + width: width, + widthMargin: wm, + height: height, + heightMargin: hm, + styles: styles, + } + b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize()) return b } func (b *Bubble) Init() tea.Cmd { - return b.setupCmd + return b.box.Init() } func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - b.SetSize(msg.Width, msg.Height) - // XXX: if we find that longer readmes take more than a few - // milliseconds to render we may need to move Glamour rendering into a - // command. - md, err := b.glamourize(b.readme) - if err != nil { - return b, nil - } - b.readmeViewport.Viewport.SetContent(md) + b.width = msg.Width + b.height = msg.Height } - rv, cmd := b.readmeViewport.Update(msg) - b.readmeViewport = rv.(*ViewportBubble) - cmds = append(cmds, cmd) - return b, tea.Batch(cmds...) + box, cmd := b.box.Update(msg) + b.box = box.(*gitui.Bubble) + return b, cmd } -func (b *Bubble) SetSize(w, h int) { - b.width = w - b.height = h - b.readmeViewport.Viewport.Width = w - b.widthMargin - b.readmeViewport.Viewport.Height = h - lipgloss.Height(b.headerView()) - b.heightMargin -} - -func (b *Bubble) GotoTop() { - b.readmeViewport.Viewport.GotoTop() +func (b *Bubble) Help() []gittypes.HelpEntry { + return b.box.Help() } func (b Bubble) headerView() string { @@ -135,7 +95,7 @@ func (b Bubble) headerView() string { note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note) // Render borders on name and command - height := max(lipgloss.Height(title), lipgloss.Height(note)) + height := gittypes.Max(lipgloss.Height(title), lipgloss.Height(note)) titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height) noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height) if b.Active { @@ -157,86 +117,18 @@ func (b *Bubble) View() string { } body := bs.Width(b.width - b.widthMargin - b.styles.RepoBody.GetVerticalFrameSize()). Height(b.height - b.heightMargin - lipgloss.Height(header)). - Render(b.readmeViewport.View()) + Render(b.box.View()) return header + body } +func (b *Bubble) Reference() plumbing.ReferenceName { + return b.box.Reference() +} + func (b Bubble) sshAddress() string { - p := ":" + strconv.Itoa(int(b.Port)) + p := ":" + strconv.Itoa(int(b.port)) if p == ":22" { p = "" } - return fmt.Sprintf("ssh://%s%s/%s", b.Host, p, b.name) -} - -func (b *Bubble) setupCmd() tea.Msg { - r, err := b.repoSource.GetRepo(b.name) - if err == git.ErrMissingRepo { - return nil - } - if err != nil { - return ErrMsg{err} - } - md := r.Readme - if b.templateObject != nil { - md, err = b.templatize(md) - if err != nil { - return ErrMsg{err} - } - } - b.readme = md - md, err = b.glamourize(md) - if err != nil { - return ErrMsg{err} - } - b.readmeViewport.Viewport.SetContent(md) - b.GotoTop() - return nil -} - -func (b *Bubble) templatize(mdt string) (string, error) { - t, err := template.New("readme").Parse(mdt) - if err != nil { - return "", err - } - buf := &bytes.Buffer{} - err = t.Execute(buf, b.templateObject) - if err != nil { - return "", err - } - return buf.String(), nil -} - -func (b *Bubble) glamourize(md string) (string, error) { - w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize() - if w > glamourMaxWidth { - w = glamourMaxWidth - } - tr, err := glamour.NewTermRenderer( - glamour.WithStyles(glamourStyle), - glamour.WithWordWrap(w), - ) - - if err != nil { - return "", err - } - mdt, err := tr.Render(md) - if err != nil { - return "", err - } - // For now, hard-wrap long lines in Glamour that would otherwise break the - // layout when wrapping. This may be due to #43 in Reflow, which has to do - // with a bug in the way lines longer than the given width are wrapped. - // - // https://github.com/muesli/reflow/issues/43 - // - // TODO: solve this upstream in Glamour/Reflow. - return wrap.String(mdt, w), nil -} - -func max(a, b int) int { - if a > b { - return a - } - return b + return fmt.Sprintf("ssh://%s%s/%s", b.host, p, b.name) } diff --git a/internal/tui/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go index 6a8a59342651ac3ebfc0056a9328b17a0b75beb0..26730dd214f19f720e65b15cada8d88a3becb44f 100644 --- a/internal/tui/bubbles/selection/bubble.go +++ b/internal/tui/bubbles/selection/bubble.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/style" "github.com/muesli/reflow/truncate" ) @@ -79,6 +80,12 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return b, tea.Batch(cmds...) } +func (b *Bubble) Help() []gittypes.HelpEntry { + return []gittypes.HelpEntry{ + {"↑/↓", "navigate"}, + } +} + func (b *Bubble) sendActiveMessage() tea.Msg { if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) { return ActiveMsg{ diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 6b98ca7d6d32279c8c27ab7e0826c1d3dde70e2a..d8879e0873903f25884d9d6a1f33a66b191deeeb 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -1,12 +1,14 @@ package tui import ( + "bytes" "fmt" + "text/template" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/config" - br "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo" + gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection" gm "github.com/charmbracelet/wish/git" ) @@ -93,37 +95,54 @@ func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) { return mes, nil } -func (b *Bubble) newMenuEntry(name string, repo string) (MenuEntry, error) { - var tmplConfig *config.Config - if repo == "config" { - tmplConfig = b.config +func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) { + gr := &Repo{ + name: rn, + } + me := MenuEntry{Name: name, Repo: rn} + r, err := b.config.Source.GetRepo(rn) + if err != nil { + return me, err + } + if rn == "config" { + md, err := templatize(r.Readme, b.config) + if err != nil { + return me, err + } + r.Readme = md + } + gr.repo = r.Repository + gr.readme = r.Readme + gr.ref, err = r.Repository.Head() + if err != nil { + return me, err } - me := MenuEntry{Name: name, Repo: repo} - width := b.width boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize() // TODO: also send this along with a tea.WindowSizeMsg var heightMargin = lipgloss.Height(b.headerView()) + lipgloss.Height(b.footerView()) + b.styles.RepoBody.GetVerticalFrameSize() + b.styles.App.GetVerticalMargins() - rb := br.NewBubble( - b.config.Source, - me.Repo, - b.styles, - width, - boxLeftWidth, - b.height, - heightMargin, - tmplConfig, - ) - rb.Host = b.config.Host - rb.Port = b.config.Port + rb := repo.NewBubble(rn, b.config.Host, b.config.Port, gr, b.styles, b.width, boxLeftWidth, b.height, heightMargin) initCmd := rb.Init() msg := initCmd() switch msg := msg.(type) { - case br.ErrMsg: - return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Error) + case gitypes.ErrMsg: + return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error()) } me.bubble = rb return me, nil } + +func templatize(mdt string, tmpl interface{}) (string, error) { + t, err := template.New("readme").Parse(mdt) + if err != nil { + return "", err + } + buf := &bytes.Buffer{} + err = t.Execute(buf, tmpl) + if err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/internal/tui/git.go b/internal/tui/git.go new file mode 100644 index 0000000000000000000000000000000000000000..f8105a695317d734ddb4502ee04cb34dd3253968 --- /dev/null +++ b/internal/tui/git.go @@ -0,0 +1,105 @@ +package tui + +import ( + "path/filepath" + + gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type Repo struct { + name string + repo *git.Repository + readme string + ref *plumbing.Reference +} + +func (r *Repo) Name() string { + return r.name +} + +func (r *Repo) GetReference() *plumbing.Reference { + return r.ref +} + +func (r *Repo) SetReference(ref *plumbing.Reference) error { + r.ref = ref + return nil +} + +func (r *Repo) Repository() *git.Repository { + return r.repo +} + +func (r *Repo) Tree(path string) (*object.Tree, error) { + path = filepath.Clean(path) + c, err := r.repo.CommitObject(r.ref.Hash()) + if err != nil { + return nil, err + } + t, err := c.Tree() + if err != nil { + return nil, err + } + if path == "." { + return t, nil + } + return t.Tree(path) +} + +func (r *Repo) GetCommits(limit int) (gitypes.Commits, error) { + commits := gitypes.Commits{} + l, err := r.repo.Log(&git.LogOptions{ + Order: git.LogOrderCommitterTime, + From: r.ref.Hash(), + }) + if err != nil { + return nil, err + } + err = l.ForEach(func(c *object.Commit) error { + commits = append(commits, &gitypes.Commit{c}) + return nil + }) + if err != nil { + return nil, err + } + if limit <= 0 || limit > len(commits) { + limit = len(commits) + } + return commits[:limit], nil +} + +func (r *Repo) GetReadme() string { + if r.readme != "" { + return r.readme + } + md, err := r.readFile("README.md") + if err != nil { + return "" + } + return md +} + +func (r *Repo) readFile(path string) (string, error) { + lg, err := r.repo.Log(&git.LogOptions{ + From: r.ref.Hash(), + }) + if err != nil { + return "", err + } + c, err := lg.Next() + if err != nil { + return "", err + } + f, err := c.File(path) + if err != nil { + return "", err + } + content, err := f.Contents() + if err != nil { + return "", err + } + return content, nil +} diff --git a/internal/tui/session.go b/internal/tui/session.go index c322d01c19d1ef53d6fba06442ffbd692caa6e35..2b6db15b1336a2742aac58444f4ff54c146a3b1e 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "net/url" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/soft-serve/internal/config" @@ -16,6 +18,10 @@ func SessionHandler(cfg *config.Config) func(ssh.Session) (tea.Model, []tea.Prog case 0: scfg.InitialRepo = "" case 1: + p, err := url.Parse(cmd[0]) + if err != nil || strings.Contains(p.Path, "/") { + return nil, nil + } scfg.InitialRepo = cmd[0] default: return nil, nil diff --git a/internal/tui/style/style.go b/internal/tui/style/style.go index ec09bd832dbd1cba88d8abdb3f69666e0c57ba75..1f57cb5b763cd83ea39c5add97521545ab2ac6eb 100644 --- a/internal/tui/style/style.go +++ b/internal/tui/style/style.go @@ -31,6 +31,7 @@ type Styles struct { RepoBody lipgloss.Style Footer lipgloss.Style + Branch lipgloss.Style HelpKey lipgloss.Style HelpValue lipgloss.Style HelpDivider lipgloss.Style @@ -38,6 +39,32 @@ type Styles struct { Error lipgloss.Style ErrorTitle lipgloss.Style ErrorBody lipgloss.Style + + LogItemSelector lipgloss.Style + LogItemActive lipgloss.Style + LogItemInactive lipgloss.Style + LogItemHash lipgloss.Style + LogCommit lipgloss.Style + LogCommitHash lipgloss.Style + LogCommitAuthor lipgloss.Style + LogCommitDate lipgloss.Style + LogCommitBody lipgloss.Style + LogCommitStatsAdd lipgloss.Style + LogCommitStatsDel lipgloss.Style + + RefItemSelector lipgloss.Style + RefItemActive lipgloss.Style + RefItemInactive lipgloss.Style + RefItemBranch lipgloss.Style + RefItemTag lipgloss.Style + + TreeItemSelector lipgloss.Style + TreeItemActive lipgloss.Style + TreeItemInactive lipgloss.Style + TreeFileDir lipgloss.Style + TreeFileMode lipgloss.Style + TreeFileSize lipgloss.Style + TreeFileContent lipgloss.Style } // DefaultStyles returns default styles for the TUI. @@ -133,6 +160,11 @@ func DefaultStyles() *Styles { s.Footer = lipgloss.NewStyle(). MarginTop(1) + s.Branch = lipgloss.NewStyle(). + Foreground(lipgloss.Color("203")). + Background(lipgloss.Color("236")). + Padding(0, 1) + s.HelpKey = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) @@ -157,5 +189,68 @@ func DefaultStyles() *Styles { MarginLeft(2). Width(52) // for now + s.LogItemInactive = lipgloss.NewStyle(). + MarginLeft(1) + + s.LogItemSelector = s.LogItemInactive.Copy(). + Width(1). + Foreground(lipgloss.Color("#B083EA")) + + s.LogItemActive = s.LogItemInactive.Copy(). + Bold(true) + + s.LogItemHash = s.LogItemInactive.Copy(). + Width(7). + Foreground(lipgloss.Color("#A3A322")) + + s.LogCommit = lipgloss.NewStyle(). + Margin(0, 2) + + s.LogCommitHash = s.LogItemHash.Copy(). + UnsetMarginLeft(). + UnsetWidth(). + Bold(true) + + s.LogCommitBody = lipgloss.NewStyle(). + MarginTop(1). + MarginLeft(2) + + s.LogCommitStatsAdd = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00D787")). + Bold(true) + + s.LogCommitStatsDel = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FD5B5B")). + Bold(true) + + s.RefItemSelector = s.LogItemSelector.Copy() + + s.RefItemActive = s.LogItemActive.Copy() + + s.RefItemInactive = s.LogItemInactive.Copy() + + s.RefItemBranch = lipgloss.NewStyle() + + s.RefItemTag = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A3A322")) + + s.TreeItemSelector = s.LogItemSelector.Copy() + + s.TreeItemActive = s.LogItemActive.Copy() + + s.TreeItemInactive = s.LogItemInactive.Copy() + + s.TreeFileDir = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00AAFF")) + + s.TreeFileMode = s.LogItemInactive.Copy(). + Width(10). + Foreground(lipgloss.Color("#777777")) + + s.TreeFileSize = s.LogItemInactive.Copy(). + Foreground(lipgloss.Color("252")) + + s.TreeFileContent = lipgloss.NewStyle() + return s }