Detailed changes
@@ -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
@@ -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=
@@ -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
}
@@ -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))
}
@@ -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()
-}
@@ -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)
@@ -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
+}
@@ -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...)
+}
@@ -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:
+ // <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
+ // example: " main.go | 10 +++++++--- "
+
+ // <pad><filename><pad>
+ leftTextLength := padLength + longestLength + padLength
+
+ // <pad><number><pad><+++++/-----><newline>
+ // 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 ""
+ }
+}
@@ -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()
+}
@@ -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())
+}
@@ -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"),
+ )
+)
@@ -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()),
+ )
+}
@@ -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,
+ })
+}
@@ -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)
+}
@@ -0,0 +1,10 @@
+package types
+
+type HelpableBubble interface {
+ Help() []HelpEntry
+}
+
+type HelpEntry struct {
+ Key string
+ Value string
+}
@@ -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
+}
@@ -1,4 +1,4 @@
-package repo
+package viewport
import (
"github.com/charmbracelet/bubbles/viewport"
@@ -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)
}
@@ -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{
@@ -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
+}
@@ -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
+}
@@ -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
@@ -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
}