Detailed changes
@@ -0,0 +1,301 @@
+package main
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/proto"
+ "github.com/charmbracelet/soft-serve/server/ui/common"
+ "github.com/charmbracelet/soft-serve/server/ui/components/footer"
+ "github.com/charmbracelet/soft-serve/server/ui/pages/repo"
+ "github.com/muesli/termenv"
+ "github.com/spf13/cobra"
+)
+
+var browseCmd = &cobra.Command{
+ Use: "browse PATH",
+ Short: "Browse a repository",
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rp := "."
+ if len(args) > 0 {
+ rp = args[0]
+ }
+
+ abs, err := filepath.Abs(rp)
+ if err != nil {
+ return err
+ }
+
+ r, err := git.Open(abs)
+ if err != nil {
+ return fmt.Errorf("failed to open repository: %w", err)
+ }
+
+ // Bubble Tea uses Termenv default output so we have to use the same
+ // thing here.
+ output := termenv.DefaultOutput()
+ ctx := cmd.Context()
+ c := common.NewCommon(ctx, output, 0, 0)
+ comps := []common.TabComponent{
+ repo.NewReadme(c),
+ repo.NewFiles(c),
+ repo.NewLog(c),
+ }
+ if !r.IsBare {
+ comps = append(comps, repo.NewStash(c))
+ }
+ comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags))
+ m := &model{
+ model: repo.New(c, comps...),
+ repo: repository{r},
+ common: c,
+ }
+
+ m.footer = footer.New(c, m)
+ p := tea.NewProgram(m,
+ tea.WithAltScreen(),
+ tea.WithMouseCellMotion(),
+ )
+
+ _, err = p.Run()
+ return err
+ },
+}
+
+func init() {
+ // HACK: This is a hack to hide the clone url
+ // TODO: Make this configurable
+ common.CloneCmd = func(publicURL, name string) string { return "" }
+ rootCmd.AddCommand(browseCmd)
+}
+
+type state int
+
+const (
+ startState state = iota
+ errorState
+)
+
+type model struct {
+ model *repo.Repo
+ footer *footer.Footer
+ repo proto.Repository
+ common common.Common
+ state state
+ showFooter bool
+ error error
+}
+
+var _ tea.Model = &model{}
+
+func (m *model) SetSize(w, h int) {
+ m.common.SetSize(w, h)
+ style := m.common.Styles.App.Copy()
+ wm := style.GetHorizontalFrameSize()
+ hm := style.GetVerticalFrameSize()
+ if m.showFooter {
+ hm += m.footer.Height()
+ }
+
+ m.footer.SetSize(w-wm, h-hm)
+ m.model.SetSize(w-wm, h-hm)
+}
+
+// ShortHelp implements help.KeyMap.
+func (m model) ShortHelp() []key.Binding {
+ switch m.state {
+ case errorState:
+ return []key.Binding{
+ m.common.KeyMap.Back,
+ m.common.KeyMap.Quit,
+ m.common.KeyMap.Help,
+ }
+ default:
+ return m.model.ShortHelp()
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (m model) FullHelp() [][]key.Binding {
+ switch m.state {
+ case errorState:
+ return [][]key.Binding{
+ {
+ m.common.KeyMap.Back,
+ },
+ {
+ m.common.KeyMap.Quit,
+ m.common.KeyMap.Help,
+ },
+ }
+ default:
+ return m.model.FullHelp()
+ }
+}
+
+// Init implements tea.Model.
+func (m *model) Init() tea.Cmd {
+ return tea.Batch(
+ m.model.Init(),
+ m.footer.Init(),
+ func() tea.Msg {
+ return repo.RepoMsg(m.repo)
+ },
+ repo.UpdateRefCmd(m.repo),
+ )
+}
+
+// Update implements tea.Model.
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ m.common.Logger.Debugf("msg received: %T", msg)
+ cmds := make([]tea.Cmd, 0)
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.SetSize(msg.Width, msg.Height)
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, m.common.KeyMap.Back) && m.error != nil:
+ m.error = nil
+ m.state = startState
+ // Always show the footer on error.
+ m.showFooter = m.footer.ShowAll()
+ case key.Matches(msg, m.common.KeyMap.Help):
+ cmds = append(cmds, footer.ToggleFooterCmd)
+ case key.Matches(msg, m.common.KeyMap.Quit):
+ // Stop bubblezone background workers.
+ m.common.Zone.Close()
+ return m, tea.Quit
+ }
+ case tea.MouseMsg:
+ switch msg.Type {
+ case tea.MouseLeft:
+ switch {
+ case m.common.Zone.Get("footer").InBounds(msg):
+ cmds = append(cmds, footer.ToggleFooterCmd)
+ }
+ }
+ case footer.ToggleFooterMsg:
+ m.footer.SetShowAll(!m.footer.ShowAll())
+ m.showFooter = !m.showFooter
+ case common.ErrorMsg:
+ m.error = msg
+ m.state = errorState
+ m.showFooter = true
+ }
+
+ f, cmd := m.footer.Update(msg)
+ m.footer = f.(*footer.Footer)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ r, cmd := m.model.Update(msg)
+ m.model = r.(*repo.Repo)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ // This fixes determining the height margin of the footer.
+ m.SetSize(m.common.Width, m.common.Height)
+
+ return m, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (m *model) View() string {
+ style := m.common.Styles.App.Copy()
+ wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize()
+ if m.showFooter {
+ hm += m.footer.Height()
+ }
+
+ var view string
+ switch m.state {
+ case startState:
+ view = m.model.View()
+ case errorState:
+ err := m.common.Styles.ErrorTitle.Render("Bummer")
+ err += m.common.Styles.ErrorBody.Render(m.error.Error())
+ view = m.common.Styles.Error.Copy().
+ Width(m.common.Width -
+ wm -
+ m.common.Styles.ErrorBody.GetHorizontalFrameSize()).
+ Height(m.common.Height -
+ hm -
+ m.common.Styles.Error.GetVerticalFrameSize()).
+ Render(err)
+ }
+
+ if m.showFooter {
+ view = lipgloss.JoinVertical(lipgloss.Top, view, m.footer.View())
+ }
+
+ return m.common.Zone.Scan(style.Render(view))
+}
+
+type repository struct {
+ r *git.Repository
+}
+
+var _ proto.Repository = repository{}
+
+// Description implements proto.Repository.
+func (r repository) Description() string {
+ return ""
+}
+
+// ID implements proto.Repository.
+func (r repository) ID() int64 {
+ return 0
+}
+
+// IsHidden implements proto.Repository.
+func (repository) IsHidden() bool {
+ return false
+}
+
+// IsMirror implements proto.Repository.
+func (repository) IsMirror() bool {
+ return false
+}
+
+// IsPrivate implements proto.Repository.
+func (repository) IsPrivate() bool {
+ return false
+}
+
+// Name implements proto.Repository.
+func (r repository) Name() string {
+ return filepath.Base(r.r.Path)
+}
+
+// Open implements proto.Repository.
+func (r repository) Open() (*git.Repository, error) {
+ return r.r, nil
+}
+
+// ProjectName implements proto.Repository.
+func (r repository) ProjectName() string {
+ return r.Name()
+}
+
+// UpdatedAt implements proto.Repository.
+func (r repository) UpdatedAt() time.Time {
+ t, err := r.r.LatestCommitTime()
+ if err != nil {
+ return time.Time{}
+ }
+
+ return t
+}
+
+// UserID implements proto.Repository.
+func (r repository) UserID() int64 {
+ return 0
+}
@@ -124,7 +124,7 @@ var migrateConfig = &cobra.Command{
}
}
- readme, readmePath, err := git.LatestFile(r, "README*")
+ readme, readmePath, err := git.LatestFile(r, nil, "README*")
hasReadme := err == nil
// Set server name
@@ -33,6 +33,9 @@ var (
Short: "A self-hostable Git server for the command line",
Long: "Soft Serve is a self-hostable Git server for the command line.",
SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return browseCmd.RunE(cmd, args)
+ },
}
)
@@ -4,27 +4,11 @@ import (
"github.com/gogs/git-module"
)
-// ZeroHash is the zero hash.
-var ZeroHash Hash = git.EmptyID
-
-// Hash represents a git hash.
-type Hash string
-
-// String returns the string representation of a hash as a string.
-func (h Hash) String() string {
- return string(h)
-}
-
-// SHA1 represents the hash as a SHA1.
-func (h Hash) SHA1() *git.SHA1 {
- return git.MustIDFromString(h.String())
-}
+// ZeroID is the zero hash.
+const ZeroID = git.EmptyID
// Commit is a wrapper around git.Commit with helper methods.
-type Commit struct {
- *git.Commit
- Hash Hash
-}
+type Commit = git.Commit
// Commits is a list of commits.
type Commits []*Commit
@@ -115,14 +115,14 @@ func (f *DiffFileChange) Mode() git.EntryMode {
// Files returns the diff files.
func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
- if f.OldIndex != ZeroHash.String() {
+ if f.OldIndex != ZeroID {
from = &DiffFileChange{
hash: f.OldIndex,
name: f.OldName(),
mode: f.OldMode(),
}
}
- if f.Index != ZeroHash.String() {
+ if f.Index != ZeroID {
to = &DiffFileChange{
hash: f.Index,
name: f.Name,
@@ -298,14 +298,14 @@ func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
lines = append(lines,
fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
fmt.Sprintf("new file mode %o", to.Mode()),
- fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()),
+ fmt.Sprintf("index %s..%s", ZeroID, to.Hash()),
)
lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
case to == nil:
lines = append(lines,
fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
fmt.Sprintf("deleted file mode %o", from.Mode()),
- fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash),
+ fmt.Sprintf("index %s..%s", from.Hash(), ZeroID),
)
lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
}
@@ -332,3 +332,24 @@ func (d *Diff) Patch() string {
}
return p.String()
}
+
+func toDiff(ddiff *git.Diff) *Diff {
+ files := make([]*DiffFile, 0, len(ddiff.Files))
+ for _, df := range ddiff.Files {
+ sections := make([]*DiffSection, 0, len(df.Sections))
+ for _, ds := range df.Sections {
+ sections = append(sections, &DiffSection{
+ DiffSection: ds,
+ })
+ }
+ files = append(files, &DiffFile{
+ DiffFile: df,
+ Sections: sections,
+ })
+ }
+ diff := &Diff{
+ Diff: ddiff,
+ Files: files,
+ }
+ return diff
+}
@@ -18,23 +18,12 @@ const (
// Reference is a wrapper around git.Reference with helper methods.
type Reference struct {
*git.Reference
- Hash Hash
path string // repo path
}
// ReferenceName is a Refspec wrapper.
type ReferenceName string
-// NewReference creates a new reference.
-func NewReference(rp, refspec string) *Reference {
- return &Reference{
- Reference: &git.Reference{
- Refspec: refspec,
- },
- path: rp,
- }
-}
-
// String returns the reference name i.e. refs/heads/master.
func (r ReferenceName) String() string {
return string(r)
@@ -42,11 +31,7 @@ func (r ReferenceName) String() string {
// Short returns the short name of the reference i.e. master.
func (r ReferenceName) Short() string {
- s := strings.Split(r.String(), "/")
- if len(s) > 0 {
- return s[len(s)-1]
- }
- return r.String()
+ return git.RefShortName(string(r))
}
// Name returns the reference name i.e. refs/heads/master.
@@ -63,14 +48,3 @@ func (r *Reference) IsBranch() bool {
func (r *Reference) IsTag() bool {
return strings.HasPrefix(r.Refspec, git.RefsTags)
}
-
-// TargetHash returns the hash of the reference target.
-func (r *Reference) TargetHash() Hash {
- if r.IsTag() {
- id, err := git.ShowRefVerify(r.path, r.Refspec)
- if err == nil {
- return Hash(id)
- }
- }
- return r.Hash
-}
@@ -77,7 +77,6 @@ func (r *Repository) HEAD() (*Reference, error) {
ID: hash,
Refspec: rn,
},
- Hash: Hash(hash),
path: r.Path,
}, nil
}
@@ -92,7 +91,6 @@ func (r *Repository) References() ([]*Reference, error) {
for _, ref := range refs {
rrefs = append(rrefs, &Reference{
Reference: ref,
- Hash: Hash(ref.ID),
path: r.Path,
})
}
@@ -121,7 +119,7 @@ func (r *Repository) Tree(ref *Reference) (*Tree, error) {
}
ref = rref
}
- return r.LsTree(ref.Hash.String())
+ return r.LsTree(ref.ID)
}
// TreePath returns the tree for the given path.
@@ -142,7 +140,7 @@ func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
// Diff returns the diff for the given commit.
func (r *Repository) Diff(commit *Commit) (*Diff, error) {
- ddiff, err := r.Repository.Diff(commit.Hash.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
+ diff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
CommandOptions: git.CommandOptions{
Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
},
@@ -150,24 +148,7 @@ func (r *Repository) Diff(commit *Commit) (*Diff, error) {
if err != nil {
return nil, err
}
- files := make([]*DiffFile, 0, len(ddiff.Files))
- for _, df := range ddiff.Files {
- sections := make([]*DiffSection, 0, len(df.Sections))
- for _, ds := range df.Sections {
- sections = append(sections, &DiffSection{
- DiffSection: ds,
- })
- }
- files = append(files, &DiffFile{
- DiffFile: df,
- Sections: sections,
- })
- }
- diff := &Diff{
- Diff: ddiff,
- Files: files,
- }
- return diff, nil
+ return toDiff(diff), nil
}
// Patch returns the patch for the given reference.
@@ -191,12 +172,7 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
return nil, err
}
commits := make(Commits, len(cs))
- for i, c := range cs {
- commits[i] = &Commit{
- Commit: c,
- Hash: Hash(c.ID.String()),
- }
- }
+ copy(commits, cs)
return commits, nil
}
@@ -0,0 +1,16 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// StashDiff returns the diff of the given stash index.
+func (r *Repository) StashDiff(index int) (*Diff, error) {
+ diff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
+ CommandOptions: git.CommandOptions{
+ Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toDiff(diff), nil
+}
@@ -0,0 +1,6 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// Tag is a git tag.
+type Tag = git.Tag
@@ -8,14 +8,17 @@ import (
)
// LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path.
-func LatestFile(repo *Repository, pattern string) (string, string, error) {
+func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) {
g := glob.MustCompile(pattern)
dir := filepath.Dir(pattern)
- head, err := repo.HEAD()
- if err != nil {
- return "", "", err
+ if ref == nil {
+ head, err := repo.HEAD()
+ if err != nil {
+ return "", "", err
+ }
+ ref = head
}
- t, err := repo.TreePath(head, dir)
+ t, err := repo.TreePath(ref, dir)
if err != nil {
return "", "", err
}
@@ -2,6 +2,8 @@ module github.com/charmbracelet/soft-serve
go 1.20
+replace github.com/gogs/git-module => github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213
+
require (
github.com/alecthomas/chroma v0.10.0
github.com/charmbracelet/bubbles v0.16.1
@@ -4,6 +4,8 @@ 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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213 h1:/tUfPeV5T/tn2UjvQedq1incFa9B9WkFHTv0fdt5Ah0=
+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213/go.mod h1:3OBxY2gWeblk83u6BlGMO1TYDEbV4bspATMP/S2Kfsk=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
@@ -63,8 +65,6 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/gogs/git-module v1.8.3 h1:4N9HOLzkmSfb5y4Go4f/gdt1/Z60/aQaAKr8lbsfFps=
-github.com/gogs/git-module v1.8.3/go.mod h1:yAn6ZMwh8x0u3fMotXqMP7Ct1XNNOZWNdBSBx6IFGCY=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -209,7 +209,6 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -7,17 +7,17 @@ import (
// LatestFile returns the contents of the latest file at the specified path in
// the repository and its file path.
-func LatestFile(r proto.Repository, pattern string) (string, string, error) {
+func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) {
repo, err := r.Open()
if err != nil {
return "", "", err
}
- return git.LatestFile(repo, pattern)
+ return git.LatestFile(repo, ref, pattern)
}
// Readme returns the repository's README.
-func Readme(r proto.Repository) (readme string, path string, err error) {
+func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) {
pattern := "[rR][eE][aA][dD][mM][eE]*"
- readme, path, err = LatestFile(r, pattern)
+ readme, path, err = LatestFile(r, ref, pattern)
return
}
@@ -2,29 +2,21 @@ package cmd
import (
"fmt"
- "strings"
- "github.com/alecthomas/chroma/lexers"
- gansi "github.com/charmbracelet/glamour/ansi"
- "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/ui/common"
- "github.com/muesli/termenv"
+ "github.com/charmbracelet/soft-serve/server/ui/styles"
"github.com/spf13/cobra"
)
-var (
- lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
- lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
-)
-
// blobCommand returns a command that prints the contents of a file.
func blobCommand() *cobra.Command {
var linenumber bool
var color bool
var raw bool
+ styles := styles.DefaultStyles()
cmd := &cobra.Command{
Use: "blob REPOSITORY [REFERENCE] [PATH]",
Aliases: []string{"cat", "show"},
@@ -60,7 +52,7 @@ func blobCommand() *cobra.Command {
if err != nil {
return err
}
- ref = head.Hash.String()
+ ref = head.ID
}
tree, err := r.LsTree(ref)
@@ -92,14 +84,14 @@ func blobCommand() *cobra.Command {
}
} else {
if color {
- c, err = withFormatting(fp, c)
+ c, err = common.FormatHighlight(fp, c)
if err != nil {
return err
}
}
if linenumber {
- c = withLineNumber(c, color)
+ c, _ = common.FormatLineNumber(styles, c, color)
}
cmd.Println(c)
@@ -114,50 +106,3 @@ func blobCommand() *cobra.Command {
return cmd
}
-
-func withLineNumber(s string, color bool) string {
- lines := strings.Split(s, "\n")
- // NB: len() is not a particularly safe way to count string width (because
- // it's counting bytes instead of runes) but in this case it's okay
- // because we're only dealing with digits, which are one byte each.
- mll := len(fmt.Sprintf("%d", len(lines)))
- for i, l := range lines {
- digit := fmt.Sprintf("%*d", mll, i+1)
- bar := "β"
- if color {
- digit = lineDigitStyle.Render(digit)
- bar = lineBarStyle.Render(bar)
- }
- if i < len(lines)-1 || len(l) != 0 {
- // If the final line was a newline we'll get an empty string for
- // the final line, so drop the newline altogether.
- lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
- }
- }
- return strings.Join(lines, "\n")
-}
-
-func withFormatting(p, c string) (string, error) {
- zero := uint(0)
- lang := ""
- lexer := lexers.Match(p)
- if lexer != nil && lexer.Config() != nil {
- lang = lexer.Config().Name
- }
- formatter := &gansi.CodeBlockElement{
- Code: c,
- Language: lang,
- }
- r := strings.Builder{}
- styles := common.StyleConfig()
- styles.CodeBlock.Margin = &zero
- rctx := gansi.NewRenderContext(gansi.Options{
- Styles: styles,
- ColorProfile: termenv.TrueColor,
- })
- err := formatter.Render(&r, rctx)
- if err != nil {
- return "", err
- }
- return r.String(), nil
-}
@@ -40,16 +40,11 @@ func commitCommand() *cobra.Command {
return err
}
- rawCommit, err := r.CommitByRevision(commitSHA)
+ commit, err := r.CommitByRevision(commitSHA)
if err != nil {
return err
}
- commit := &git.Commit{
- Commit: rawCommit,
- Hash: git.Hash(commitSHA),
- }
-
patch, err := r.Patch(commit)
if err != nil {
return err
@@ -49,7 +49,7 @@ func treeCommand() *cobra.Command {
return err
}
- ref = head.Hash.String()
+ ref = head.ID
}
tree, err := r.LsTree(ref)
@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/server/proto"
- "github.com/charmbracelet/soft-serve/server/ui"
"github.com/charmbracelet/soft-serve/server/ui/common"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
@@ -58,7 +57,7 @@ func SessionHandler(s ssh.Session) *tea.Program {
output := termenv.NewOutput(s, termenv.WithColorCache(true), termenv.WithEnvironment(envs))
c := common.NewCommon(ctx, output, pty.Window.Width, pty.Window.Height)
c.SetValue(common.ConfigKey, cfg)
- m := ui.New(c, initialRepo)
+ m := NewUI(c, initialRepo)
p := tea.NewProgram(m,
tea.WithInput(s),
tea.WithOutput(s),
@@ -1,4 +1,4 @@
-package ui
+package ssh
import (
"errors"
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/proto"
"github.com/charmbracelet/soft-serve/server/ui/common"
"github.com/charmbracelet/soft-serve/server/ui/components/footer"
@@ -45,8 +46,8 @@ type UI struct {
error error
}
-// New returns a new UI model.
-func New(c common.Common, initialRepo string) *UI {
+// NewUI returns a new UI model.
+func NewUI(c common.Common, initialRepo string) *UI {
serverName := c.Config().Name
h := header.New(c, serverName)
ui := &UI{
@@ -133,7 +134,13 @@ func (ui *UI) SetSize(width, height int) {
// Init implements tea.Model.
func (ui *UI) Init() tea.Cmd {
ui.pages[selectionPage] = selection.New(ui.common)
- ui.pages[repoPage] = repo.New(ui.common)
+ ui.pages[repoPage] = repo.New(ui.common,
+ repo.NewReadme(ui.common),
+ repo.NewFiles(ui.common),
+ repo.NewLog(ui.common),
+ repo.NewRefs(ui.common, git.RefsHeads),
+ repo.NewRefs(ui.common, git.RefsTags),
+ )
ui.SetSize(ui.common.Width, ui.common.Height)
cmds := make([]tea.Cmd, 0)
cmds = append(cmds,
@@ -273,10 +280,10 @@ func (ui *UI) View() string {
view = "Unknown state :/ this is a bug!"
}
if ui.activePage == selectionPage {
- view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
+ view = lipgloss.JoinVertical(lipgloss.Top, ui.header.View(), view)
}
if ui.showFooter {
- view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
+ view = lipgloss.JoinVertical(lipgloss.Top, view, ui.footer.View())
}
return ui.common.Zone.Scan(
ui.common.Styles.App.Render(view),
@@ -11,3 +11,21 @@ type Component interface {
help.KeyMap
SetSize(width, height int)
}
+
+// TabComponenet represents a model that is mounted to a tab.
+// TODO: find a better name
+type TabComponent interface {
+ Component
+
+ // StatusBarValue returns the status bar value component.
+ StatusBarValue() string
+
+ // StatusBarInfo returns the status bar info component.
+ StatusBarInfo() string
+
+ // SpinnerID returns the ID of the spinner.
+ SpinnerID() int
+
+ // TabName returns the name of the tab.
+ TabName() string
+}
@@ -0,0 +1,60 @@
+package common
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/alecthomas/chroma/lexers"
+ gansi "github.com/charmbracelet/glamour/ansi"
+ "github.com/charmbracelet/soft-serve/server/ui/styles"
+ "github.com/muesli/termenv"
+)
+
+// FormatLineNumber adds line numbers to a string.
+func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) {
+ lines := strings.Split(s, "\n")
+ // NB: len() is not a particularly safe way to count string width (because
+ // it's counting bytes instead of runes) but in this case it's okay
+ // because we're only dealing with digits, which are one byte each.
+ mll := len(fmt.Sprintf("%d", len(lines)))
+ for i, l := range lines {
+ digit := fmt.Sprintf("%*d", mll, i+1)
+ bar := "β"
+ if color {
+ digit = styles.Code.LineDigit.Render(digit)
+ bar = styles.Code.LineBar.Render(bar)
+ }
+ if i < len(lines)-1 || len(l) != 0 {
+ // If the final line was a newline we'll get an empty string for
+ // the final line, so drop the newline altogether.
+ lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
+ }
+ }
+ return strings.Join(lines, "\n"), mll
+}
+
+// FormatHighlight adds syntax highlighting to a string.
+func FormatHighlight(p, c string) (string, error) {
+ zero := uint(0)
+ lang := ""
+ lexer := lexers.Match(p)
+ if lexer != nil && lexer.Config() != nil {
+ lang = lexer.Config().Name
+ }
+ formatter := &gansi.CodeBlockElement{
+ Code: c,
+ Language: lang,
+ }
+ r := strings.Builder{}
+ styles := StyleConfig()
+ styles.CodeBlock.Margin = &zero
+ rctx := gansi.NewRenderContext(gansi.Options{
+ Styles: styles,
+ ColorProfile: termenv.TrueColor,
+ })
+ err := formatter.Render(&r, rctx)
+ if err != nil {
+ return "", err
+ }
+ return r.String(), nil
+}
@@ -35,6 +35,6 @@ func RepoURL(publicURL, name string) string {
}
// CloneCmd returns the URL of the repository.
-func CloneCmd(publicURL, name string) string {
+var CloneCmd = func(publicURL, name string) string {
return fmt.Sprintf("git clone %s", RepoURL(publicURL, name))
}
@@ -1,7 +1,7 @@
package code
import (
- "fmt"
+ "math"
"strings"
"sync"
@@ -16,40 +16,38 @@ import (
)
const (
- tabWidth = 4
-)
-
-var (
- lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
- lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+ defaultTabWidth = 4
+ defaultSideNotePercent = 0.3
)
// Code is a code snippet.
type Code struct {
*vp.Viewport
- common common.Common
- content string
- extension string
- renderContext gansi.RenderContext
- renderMutex sync.Mutex
- styleConfig gansi.StyleConfig
- showLineNumber bool
-
- NoContentStyle lipgloss.Style
- LineDigitStyle lipgloss.Style
- LineBarStyle lipgloss.Style
+ common common.Common
+ sidenote string
+ content string
+ extension string
+ renderContext gansi.RenderContext
+ renderMutex sync.Mutex
+ styleConfig gansi.StyleConfig
+
+ SideNotePercent float64
+ TabWidth int
+ ShowLineNumber bool
+ NoContentStyle lipgloss.Style
+ UseGlamour bool
}
// New returns a new Code.
func New(c common.Common, content, extension string) *Code {
r := &Code{
- common: c,
- content: content,
- extension: extension,
- Viewport: vp.New(c),
- NoContentStyle: c.Styles.NoContent.Copy(),
- LineDigitStyle: lineDigitStyle,
- LineBarStyle: lineBarStyle,
+ common: c,
+ content: content,
+ extension: extension,
+ TabWidth: defaultTabWidth,
+ SideNotePercent: defaultSideNotePercent,
+ Viewport: vp.New(c),
+ NoContentStyle: c.Styles.NoContent.Copy().SetString("No Content."),
}
st := common.StyleConfig()
r.styleConfig = st
@@ -61,11 +59,6 @@ func New(c common.Common, content, extension string) *Code {
return r
}
-// SetShowLineNumber sets whether to show line numbers.
-func (r *Code) SetShowLineNumber(show bool) {
- r.showLineNumber = show
-}
-
// SetSize implements common.Component.
func (r *Code) SetSize(width, height int) {
r.common.SetSize(width, height)
@@ -79,19 +72,62 @@ func (r *Code) SetContent(c, ext string) tea.Cmd {
return r.Init()
}
+// SetSideNote sets the sidenote of the Code.
+func (r *Code) SetSideNote(s string) tea.Cmd {
+ r.sidenote = s
+ return r.Init()
+}
+
// Init implements tea.Model.
func (r *Code) Init() tea.Cmd {
w := r.common.Width
- c := r.content
- if c == "" {
+ content := r.content
+ if content == "" {
r.Viewport.Model.SetContent(r.NoContentStyle.String())
return nil
}
- f, err := r.renderFile(r.extension, c, w)
- if err != nil {
- return common.ErrorCmd(err)
+
+ // FIXME chroma & glamour might break wrapping when using tabs since tab
+ // width depends on the terminal. This is a workaround to replace tabs with
+ // 4-spaces.
+ content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth))
+
+ if r.UseGlamour {
+ md, err := r.glamourize(w, content)
+ if err != nil {
+ return common.ErrorCmd(err)
+ }
+ content = md
+ } else {
+ f, err := r.renderFile(r.extension, content)
+ if err != nil {
+ return common.ErrorCmd(err)
+ }
+ content = f
+ if r.ShowLineNumber {
+ var ml int
+ content, ml = common.FormatLineNumber(r.common.Styles, content, true)
+ w -= ml
+ }
}
- r.Viewport.Model.SetContent(f)
+
+ if r.sidenote != "" {
+ lines := strings.Split(r.sidenote, "\n")
+ sideNoteWidth := int(math.Ceil(float64(r.Model.Width) * r.SideNotePercent))
+ for i, l := range lines {
+ lines[i] = common.TruncateString(l, sideNoteWidth)
+ }
+ content = lipgloss.JoinHorizontal(lipgloss.Left, strings.Join(lines, "\n"), content)
+ }
+
+ // Fix styles after hard wrapping
+ // https://github.com/muesli/reflow/issues/43
+ //
+ // TODO: solve this upstream in Glamour/Reflow.
+ content = lipgloss.NewStyle().Width(w).Render(content)
+
+ r.Viewport.Model.SetContent(content)
+
return nil
}
@@ -161,6 +197,15 @@ func (r *Code) ScrollPercent() float64 {
return r.Viewport.ScrollPercent()
}
+// ScrollPosition returns the viewport's scroll position.
+func (r *Code) ScrollPosition() int {
+ scroll := r.ScrollPercent() * 100
+ if scroll < 0 || math.IsNaN(scroll) {
+ scroll = 0
+ }
+ return int(scroll)
+}
+
func (r *Code) glamourize(w int, md string) (string, error) {
r.renderMutex.Lock()
defer r.renderMutex.Unlock()
@@ -182,11 +227,7 @@ func (r *Code) glamourize(w int, md string) (string, error) {
return mdt, nil
}
-func (r *Code) renderFile(path, content string, width int) (string, error) {
- // FIXME chroma & glamour might break wrapping when using tabs since tab
- // width depends on the terminal. This is a workaround to replace tabs with
- // 4-spaces.
- content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth))
+func (r *Code) renderFile(path, content string) (string, error) {
lexer := lexers.Match(path)
if path == "" {
lexer = lexers.Analyse(content)
@@ -195,63 +236,26 @@ func (r *Code) renderFile(path, content string, width int) (string, error) {
if lexer != nil && lexer.Config() != nil {
lang = lexer.Config().Name
}
- var c string
- if lang == "markdown" {
- md, err := r.glamourize(width, content)
- if err != nil {
- return "", err
- }
- c = md
- } else {
- formatter := &gansi.CodeBlockElement{
- Code: content,
- Language: lang,
- }
- s := strings.Builder{}
- rc := r.renderContext
- if r.showLineNumber {
- st := common.StyleConfig()
- var m uint
- st.CodeBlock.Margin = &m
- rc = gansi.NewRenderContext(gansi.Options{
- ColorProfile: termenv.TrueColor,
- Styles: st,
- })
- }
- err := formatter.Render(&s, rc)
- if err != nil {
- return "", err
- }
- c = s.String()
- if r.showLineNumber {
- var ml int
- c, ml = withLineNumber(c)
- width -= ml
- }
- }
- // Fix styling when after line breaks.
- // https://github.com/muesli/reflow/issues/43
- //
- // TODO: solve this upstream in Glamour/Reflow.
- return lipgloss.NewStyle().Width(width).Render(c), nil
-}
-func withLineNumber(s string) (string, int) {
- lines := strings.Split(s, "\n")
- // NB: len() is not a particularly safe way to count string width (because
- // it's counting bytes instead of runes) but in this case it's okay
- // because we're only dealing with digits, which are one byte each.
- mll := len(fmt.Sprintf("%d", len(lines)))
- for i, l := range lines {
- digit := fmt.Sprintf("%*d", mll, i+1)
- bar := "β"
- digit = lineDigitStyle.Render(digit)
- bar = lineBarStyle.Render(bar)
- if i < len(lines)-1 || len(l) != 0 {
- // If the final line was a newline we'll get an empty string for
- // the final line, so drop the newline altogether.
- lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
- }
+ formatter := &gansi.CodeBlockElement{
+ Code: content,
+ Language: lang,
}
- return strings.Join(lines, "\n"), mll
+ s := strings.Builder{}
+ rc := r.renderContext
+ if r.ShowLineNumber {
+ st := common.StyleConfig()
+ var m uint
+ st.CodeBlock.Margin = &m
+ rc = gansi.NewRenderContext(gansi.Options{
+ ColorProfile: termenv.TrueColor,
+ Styles: st,
+ })
+ }
+ err := formatter.Render(&s, rc)
+ if err != nil {
+ return "", err
+ }
+
+ return s.String(), nil
}
@@ -1,6 +1,8 @@
package selector
import (
+ "sync"
+
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
@@ -9,10 +11,16 @@ import (
// Selector is a list of items that can be selected.
type Selector struct {
- list.Model
+ *list.Model
common common.Common
active int
filterState list.FilterState
+
+ // XXX: we use a mutex to support concurrent access to the model. This is
+ // needed to implement pagination for the Log component. list.Model does
+ // not support item pagination so we hack it ourselves on top of
+ // list.Model.
+ mtx sync.RWMutex
}
// IdentifiableItem is an item that can be identified by a string. Implements
@@ -40,9 +48,9 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
itms[i] = item
}
l := list.New(itms, delegate, common.Width, common.Height)
- l.Styles.NoItems = common.Styles.NoItems
+ l.Styles.NoItems = common.Styles.NoContent
s := &Selector{
- Model: l,
+ Model: &l,
common: common,
}
s.SetSize(common.Width, common.Height)
@@ -51,66 +59,111 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
// PerPage returns the number of items per page.
func (s *Selector) PerPage() int {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
return s.Model.Paginator.PerPage
}
// SetPage sets the current page.
func (s *Selector) SetPage(page int) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.Paginator.Page = page
}
// Page returns the current page.
func (s *Selector) Page() int {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
return s.Model.Paginator.Page
}
// TotalPages returns the total number of pages.
func (s *Selector) TotalPages() int {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
return s.Model.Paginator.TotalPages
}
+// SetTotalPages sets the total number of pages given the number of items.
+func (s *Selector) SetTotalPages(items int) int {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
+ return s.Model.Paginator.SetTotalPages(items)
+}
+
+// SelectedItem returns the currently selected item.
+func (s *Selector) SelectedItem() IdentifiableItem {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
+ item := s.Model.SelectedItem()
+ i, ok := item.(IdentifiableItem)
+ if !ok {
+ return nil
+ }
+ return i
+}
+
// Select selects the item at the given index.
func (s *Selector) Select(index int) {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
s.Model.Select(index)
}
// SetShowTitle sets the show title flag.
func (s *Selector) SetShowTitle(show bool) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.SetShowTitle(show)
}
// SetShowHelp sets the show help flag.
func (s *Selector) SetShowHelp(show bool) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.SetShowHelp(show)
}
// SetShowStatusBar sets the show status bar flag.
func (s *Selector) SetShowStatusBar(show bool) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.SetShowStatusBar(show)
}
// DisableQuitKeybindings disables the quit keybindings.
func (s *Selector) DisableQuitKeybindings() {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.DisableQuitKeybindings()
}
// SetShowFilter sets the show filter flag.
func (s *Selector) SetShowFilter(show bool) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.SetShowFilter(show)
}
// SetShowPagination sets the show pagination flag.
func (s *Selector) SetShowPagination(show bool) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.SetShowPagination(show)
}
// SetFilteringEnabled sets the filtering enabled flag.
func (s *Selector) SetFilteringEnabled(enabled bool) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.Model.SetFilteringEnabled(enabled)
}
// SetSize implements common.Component.
func (s *Selector) SetSize(width, height int) {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
s.common.SetSize(width, height)
s.Model.SetSize(width, height)
}
@@ -121,14 +174,53 @@ func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd {
for i, item := range items {
its[i] = item
}
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
return s.Model.SetItems(its)
}
// Index returns the index of the selected item.
func (s *Selector) Index() int {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
return s.Model.Index()
}
+// Items returns the items in the selector.
+func (s *Selector) Items() []list.Item {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
+ return s.Model.Items()
+}
+
+// VisibleItems returns all the visible items in the selector.
+func (s *Selector) VisibleItems() []list.Item {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
+ return s.Model.VisibleItems()
+}
+
+// FilterState returns the filter state.
+func (s *Selector) FilterState() list.FilterState {
+ s.mtx.RLock()
+ defer s.mtx.RUnlock()
+ return s.Model.FilterState()
+}
+
+// CursorUp moves the cursor up.
+func (s *Selector) CursorUp() {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
+ s.Model.CursorUp()
+}
+
+// CursorDown moves the cursor down.
+func (s *Selector) CursorDown() {
+ s.mtx.Lock()
+ defer s.mtx.Unlock()
+ s.Model.CursorDown()
+}
+
// Init implements tea.Model.
func (s *Selector) Init() tea.Cmd {
return s.activeCmd
@@ -141,26 +233,26 @@ func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseMsg:
switch msg.Type {
case tea.MouseWheelUp:
- s.Model.CursorUp()
+ s.CursorUp()
case tea.MouseWheelDown:
- s.Model.CursorDown()
+ s.CursorDown()
case tea.MouseLeft:
- curIdx := s.Model.Index()
- for i, item := range s.Model.Items() {
+ curIdx := s.Index()
+ for i, item := range s.Items() {
item, _ := item.(IdentifiableItem)
// Check each item to see if it's in bounds.
if item != nil && s.common.Zone.Get(item.ID()).InBounds(msg) {
if i == curIdx {
- cmds = append(cmds, s.selectCmd)
+ cmds = append(cmds, s.SelectItemCmd)
} else {
- s.Model.Select(i)
+ s.Select(i)
}
break
}
}
}
case tea.KeyMsg:
- filterState := s.Model.FilterState()
+ filterState := s.FilterState()
switch {
case key.Matches(msg, s.common.KeyMap.Help):
if filterState == list.Filtering {
@@ -168,28 +260,30 @@ func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case key.Matches(msg, s.common.KeyMap.Select):
if filterState != list.Filtering {
- cmds = append(cmds, s.selectCmd)
+ cmds = append(cmds, s.SelectItemCmd)
}
}
case list.FilterMatchesMsg:
cmds = append(cmds, s.activeFilterCmd)
}
m, cmd := s.Model.Update(msg)
- s.Model = m
+ s.mtx.Lock()
+ s.Model = &m
+ s.mtx.Unlock()
if cmd != nil {
cmds = append(cmds, cmd)
}
// Track filter state and update active item when filter state changes.
- filterState := s.Model.FilterState()
+ filterState := s.FilterState()
if s.filterState != filterState {
cmds = append(cmds, s.activeFilterCmd)
}
s.filterState = filterState
// Send ActiveMsg when index change.
- if s.active != s.Model.Index() {
+ if s.active != s.Index() {
cmds = append(cmds, s.activeCmd)
}
- s.active = s.Model.Index()
+ s.active = s.Index()
return s, tea.Batch(cmds...)
}
@@ -198,34 +292,21 @@ func (s *Selector) View() string {
return s.Model.View()
}
-// SelectItem is a command that selects the currently active item.
-func (s *Selector) SelectItem() tea.Msg {
- return s.selectCmd()
-}
-
-func (s *Selector) selectCmd() tea.Msg {
- item := s.Model.SelectedItem()
- i, ok := item.(IdentifiableItem)
- if !ok {
- return SelectMsg{}
- }
- return SelectMsg{i}
+// SelectItemCmd is a command that selects the currently active item.
+func (s *Selector) SelectItemCmd() tea.Msg {
+ return SelectMsg{s.SelectedItem()}
}
func (s *Selector) activeCmd() tea.Msg {
- item := s.Model.SelectedItem()
- i, ok := item.(IdentifiableItem)
- if !ok {
- return ActiveMsg{}
- }
- return ActiveMsg{i}
+ item := s.SelectedItem()
+ return ActiveMsg{item}
}
func (s *Selector) activeFilterCmd() tea.Msg {
// Here we use VisibleItems because when list.FilterMatchesMsg is sent,
// VisibleItems is the only way to get the list of filtered items. The list
// bubble should export something like list.FilterMatchesMsg.Items().
- items := s.Model.VisibleItems()
+ items := s.VisibleItems()
if len(items) == 0 {
return nil
}
@@ -7,16 +7,8 @@ import (
"github.com/muesli/reflow/truncate"
)
-// StatusBarMsg is a message sent to the status bar.
-type StatusBarMsg struct { //nolint:revive
- Key string
- Value string
- Info string
- Extra string
-}
-
-// StatusBar is a status bar model.
-type StatusBar struct {
+// Model is a status bar model.
+type Model struct {
common common.Common
key string
value string
@@ -24,53 +16,52 @@ type StatusBar struct {
extra string
}
-// Model is an interface that supports setting the status bar information.
-type Model interface {
- StatusBarValue() string
- StatusBarInfo() string
-}
-
// New creates a new status bar component.
-func New(c common.Common) *StatusBar {
- s := &StatusBar{
+func New(c common.Common) *Model {
+ s := &Model{
common: c,
}
return s
}
// SetSize implements common.Component.
-func (s *StatusBar) SetSize(width, height int) {
+func (s *Model) SetSize(width, height int) {
s.common.Width = width
s.common.Height = height
}
+// SetStatus sets the status bar status.
+func (s *Model) SetStatus(key, value, info, extra string) {
+ if key != "" {
+ s.key = key
+ }
+ if value != "" {
+ s.value = value
+ }
+ if info != "" {
+ s.info = info
+ }
+ if extra != "" {
+ s.extra = extra
+ }
+}
+
// Init implements tea.Model.
-func (s *StatusBar) Init() tea.Cmd {
+func (s *Model) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
-func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case StatusBarMsg:
- if msg.Key != "" {
- s.key = msg.Key
- }
- if msg.Value != "" {
- s.value = msg.Value
- }
- if msg.Info != "" {
- s.info = msg.Info
- }
- if msg.Extra != "" {
- s.extra = msg.Extra
- }
+ case tea.WindowSizeMsg:
+ s.SetSize(msg.Width, msg.Height)
}
return s, nil
}
// View implements tea.Model.
-func (s *StatusBar) View() string {
+func (s *Model) View() string {
st := s.common.Styles
w := lipgloss.Width
help := s.common.Zone.Mark(
@@ -3,23 +3,26 @@ package repo
import (
"errors"
"fmt"
- "log"
"path/filepath"
+ "strings"
"github.com/alecthomas/chroma/lexers"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/proto"
"github.com/charmbracelet/soft-serve/server/ui/common"
"github.com/charmbracelet/soft-serve/server/ui/components/code"
"github.com/charmbracelet/soft-serve/server/ui/components/selector"
+ gitm "github.com/gogs/git-module"
)
type filesView int
const (
- filesViewFiles filesView = iota
+ filesViewLoading filesView = iota
+ filesViewFiles
filesViewContent
)
@@ -34,6 +37,14 @@ var (
key.WithKeys("l"),
key.WithHelp("l", "toggle line numbers"),
)
+ blameView = key.NewBinding(
+ key.WithKeys("b"),
+ key.WithHelp("b", "toggle blame view"),
+ )
+ preview = key.NewBinding(
+ key.WithKeys("p"),
+ key.WithHelp("p", "toggle preview"),
+ )
)
// FileItemsMsg is a message that contains a list of files.
@@ -45,6 +56,9 @@ type FileContentMsg struct {
ext string
}
+// FileBlameMsg is a message that contains the blame of a file.
+type FileBlameMsg *gitm.Blame
+
// Files is the model for the files view.
type Files struct {
common common.Common
@@ -56,8 +70,12 @@ type Files struct {
path string
currentItem *FileItem
currentContent FileContentMsg
+ currentBlame FileBlameMsg
lastSelected []int
lineNumber bool
+ spinner spinner.Model
+ cursor int
+ blameView bool
}
// NewFiles creates a new files model.
@@ -65,7 +83,7 @@ func NewFiles(common common.Common) *Files {
f := &Files{
common: common,
code: code.New(common, "", ""),
- activeView: filesViewFiles,
+ activeView: filesViewLoading,
lastSelected: make([]int, 0),
lineNumber: true,
}
@@ -80,10 +98,18 @@ func NewFiles(common common.Common) *Files {
selector.KeyMap.NextPage = common.KeyMap.NextPage
selector.KeyMap.PrevPage = common.KeyMap.PrevPage
f.selector = selector
- f.code.SetShowLineNumber(f.lineNumber)
+ f.code.ShowLineNumber = f.lineNumber
+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(common.Styles.Spinner))
+ f.spinner = s
return f
}
+// TabName returns the tab name.
+func (f *Files) TabName() string {
+ return "Files"
+}
+
// SetSize implements common.Component.
func (f *Files) SetSize(width, height int) {
f.common.SetSize(width, height)
@@ -96,30 +122,16 @@ func (f *Files) ShortHelp() []key.Binding {
k := f.selector.KeyMap
switch f.activeView {
case filesViewFiles:
- copyKey := f.common.KeyMap.Copy
- copyKey.SetHelp("c", "copy name")
return []key.Binding{
f.common.KeyMap.SelectItem,
f.common.KeyMap.BackItem,
k.CursorUp,
k.CursorDown,
- copyKey,
}
case filesViewContent:
- copyKey := f.common.KeyMap.Copy
- copyKey.SetHelp("c", "copy content")
b := []key.Binding{
f.common.KeyMap.UpDown,
f.common.KeyMap.BackItem,
- copyKey,
- }
- lexer := lexers.Match(f.currentContent.ext)
- lang := ""
- if lexer != nil && lexer.Config() != nil {
- lang = lexer.Config().Name
- }
- if lang != "markdown" {
- b = append(b, lineNo)
}
return b
default:
@@ -131,15 +143,25 @@ func (f *Files) ShortHelp() []key.Binding {
func (f *Files) FullHelp() [][]key.Binding {
b := make([][]key.Binding, 0)
copyKey := f.common.KeyMap.Copy
+ actionKeys := []key.Binding{
+ copyKey,
+ }
+ if !f.code.UseGlamour {
+ actionKeys = append(actionKeys, lineNo)
+ }
+ actionKeys = append(actionKeys, blameView)
+ if f.isSelectedMarkdown() && !f.blameView {
+ actionKeys = append(actionKeys, preview)
+ }
switch f.activeView {
case filesViewFiles:
copyKey.SetHelp("c", "copy name")
k := f.selector.KeyMap
- b = append(b, []key.Binding{
- f.common.KeyMap.SelectItem,
- f.common.KeyMap.BackItem,
- })
b = append(b, [][]key.Binding{
+ {
+ f.common.KeyMap.SelectItem,
+ f.common.KeyMap.BackItem,
+ },
{
k.CursorUp,
k.CursorDown,
@@ -149,7 +171,6 @@ func (f *Files) FullHelp() [][]key.Binding {
{
k.GoToStart,
k.GoToEnd,
- copyKey,
},
}...)
case filesViewContent:
@@ -165,35 +186,27 @@ func (f *Files) FullHelp() [][]key.Binding {
k.HalfPageDown,
k.HalfPageUp,
},
+ {
+ k.Down,
+ k.Up,
+ f.common.KeyMap.GotoTop,
+ f.common.KeyMap.GotoBottom,
+ },
}...)
- lc := []key.Binding{
- k.Down,
- k.Up,
- f.common.KeyMap.GotoTop,
- f.common.KeyMap.GotoBottom,
- copyKey,
- }
- lexer := lexers.Match(f.currentContent.ext)
- lang := ""
- if lexer != nil && lexer.Config() != nil {
- lang = lexer.Config().Name
- }
- if lang != "markdown" {
- lc = append(lc, lineNo)
- }
- b = append(b, lc)
}
- return b
+ return append(b, actionKeys)
}
// Init implements tea.Model.
func (f *Files) Init() tea.Cmd {
f.path = ""
f.currentItem = nil
- f.activeView = filesViewFiles
+ f.activeView = filesViewLoading
f.lastSelected = make([]int, 0)
- f.selector.Select(0)
- return f.updateFilesCmd
+ f.blameView = false
+ f.currentBlame = nil
+ f.code.UseGlamour = false
+ return tea.Batch(f.spinner.Tick, f.updateFilesCmd)
}
// Update implements tea.Model.
@@ -204,18 +217,28 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.repo = msg
case RefMsg:
f.ref = msg
+ f.selector.Select(0)
cmds = append(cmds, f.Init())
case FileItemsMsg:
cmds = append(cmds,
f.selector.SetItems(msg),
- updateStatusBarCmd,
)
+ f.activeView = filesViewFiles
+ if f.cursor >= 0 {
+ f.selector.Select(f.cursor)
+ f.cursor = -1
+ }
case FileContentMsg:
f.activeView = filesViewContent
f.currentContent = msg
- f.code.SetContent(msg.content, msg.ext)
+ f.code.UseGlamour = f.isSelectedMarkdown()
+ cmds = append(cmds, f.code.SetContent(msg.content, msg.ext))
f.code.GotoTop()
- cmds = append(cmds, updateStatusBarCmd)
+ case FileBlameMsg:
+ f.currentBlame = msg
+ f.activeView = filesViewContent
+ f.code.UseGlamour = false
+ f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg))
case selector.SelectMsg:
switch sel := msg.IdentifiableItem.(type) {
case FileItem:
@@ -227,30 +250,47 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, f.selectFileCmd)
}
}
- case BackMsg:
- cmds = append(cmds, f.deselectItemCmd)
+ case GoBackMsg:
+ switch f.activeView {
+ case filesViewFiles, filesViewContent:
+ cmds = append(cmds, f.deselectItemCmd())
+ }
case tea.KeyMsg:
switch f.activeView {
case filesViewFiles:
switch {
case key.Matches(msg, f.common.KeyMap.SelectItem):
- cmds = append(cmds, f.selector.SelectItem)
+ cmds = append(cmds, f.selector.SelectItemCmd)
case key.Matches(msg, f.common.KeyMap.BackItem):
- cmds = append(cmds, backCmd)
+ cmds = append(cmds, f.deselectItemCmd())
}
case filesViewContent:
switch {
case key.Matches(msg, f.common.KeyMap.BackItem):
- cmds = append(cmds, backCmd)
+ cmds = append(cmds, f.deselectItemCmd())
case key.Matches(msg, f.common.KeyMap.Copy):
cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
- case key.Matches(msg, lineNo):
+ case key.Matches(msg, lineNo) && !f.code.UseGlamour:
f.lineNumber = !f.lineNumber
- f.code.SetShowLineNumber(f.lineNumber)
+ f.code.ShowLineNumber = f.lineNumber
+ cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
+ case key.Matches(msg, blameView):
+ f.activeView = filesViewLoading
+ f.blameView = !f.blameView
+ if f.blameView {
+ cmds = append(cmds, f.fetchBlame)
+ } else {
+ f.activeView = filesViewContent
+ cmds = append(cmds, f.code.SetSideNote(""))
+ }
+ cmds = append(cmds, f.spinner.Tick)
+ case key.Matches(msg, preview) && f.isSelectedMarkdown() && !f.blameView:
+ f.code.UseGlamour = !f.code.UseGlamour
cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
}
}
case tea.WindowSizeMsg:
+ f.SetSize(msg.Width, msg.Height)
switch f.activeView {
case filesViewFiles:
if f.repo != nil {
@@ -265,8 +305,6 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
- case selector.ActiveMsg:
- cmds = append(cmds, updateStatusBarCmd)
case EmptyRepoMsg:
f.ref = nil
f.path = ""
@@ -275,6 +313,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.lastSelected = make([]int, 0)
f.selector.Select(0)
cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
+ case spinner.TickMsg:
+ if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {
+ s, cmd := f.spinner.Update(msg)
+ f.spinner = s
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
}
switch f.activeView {
case filesViewFiles:
@@ -296,6 +342,8 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (f *Files) View() string {
switch f.activeView {
+ case filesViewLoading:
+ return renderLoading(f.common, f.spinner)
case filesViewFiles:
return f.selector.View()
case filesViewContent:
@@ -305,11 +353,15 @@ func (f *Files) View() string {
}
}
+// SpinnerID implements common.TabComponent.
+func (f *Files) SpinnerID() int {
+ return f.spinner.ID()
+}
+
// StatusBarValue returns the status bar value.
func (f *Files) StatusBarValue() string {
p := f.path
- if p == "." {
- // FIXME: this is a hack to force clear the status bar value
+ if p == "." || p == "" {
return " "
}
return p
@@ -321,7 +373,7 @@ func (f *Files) StatusBarInfo() string {
case filesViewFiles:
return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
case filesViewContent:
- return fmt.Sprintf("β° %.f%%", f.code.ScrollPercent()*100)
+ return fmt.Sprintf("β° %d%%", f.code.ScrollPosition())
default:
return ""
}
@@ -335,17 +387,17 @@ func (f *Files) updateFilesCmd() tea.Msg {
}
r, err := f.repo.Open()
if err != nil {
- return common.ErrorMsg(err)
+ return common.ErrorCmd(err)
}
- t, err := r.TreePath(f.ref, f.path)
+ path := f.path
+ ref := f.ref
+ t, err := r.TreePath(ref, path)
if err != nil {
- log.Printf("ui: files: error getting tree %v", err)
- return common.ErrorMsg(err)
+ return common.ErrorCmd(err)
}
ents, err := t.Entries()
if err != nil {
- log.Printf("ui: files: error listing files %v", err)
- return common.ErrorMsg(err)
+ return common.ErrorCmd(err)
}
ents.Sort()
for _, e := range ents {
@@ -361,10 +413,9 @@ func (f *Files) updateFilesCmd() tea.Msg {
func (f *Files) selectTreeCmd() tea.Msg {
if f.currentItem != nil && f.currentItem.entry.IsTree() {
f.lastSelected = append(f.lastSelected, f.selector.Index())
- f.selector.Select(0)
+ f.cursor = 0
return f.updateFilesCmd()
}
- log.Printf("ui: files: current item is not a tree")
return common.ErrorMsg(errNoFileSelected)
}
@@ -373,7 +424,6 @@ func (f *Files) selectFileCmd() tea.Msg {
if i != nil && !i.entry.IsTree() {
fi := i.entry.File()
if i.Mode().IsDir() || f == nil {
- log.Printf("ui: files: current item is not a file")
return common.ErrorMsg(errInvalidFile)
}
@@ -391,32 +441,25 @@ func (f *Files) selectFileCmd() tea.Msg {
break
}
}
- } else {
- log.Printf("ui: files: error checking attributes %v", err)
}
- } else {
- log.Printf("ui: files: error opening repo %v", err)
}
if !bin {
bin, err = fi.IsBinary()
if err != nil {
f.path = filepath.Dir(f.path)
- log.Printf("ui: files: error checking if file is binary %v", err)
return common.ErrorMsg(err)
}
}
if bin {
f.path = filepath.Dir(f.path)
- log.Printf("ui: files: file is binary")
return common.ErrorMsg(errBinaryFile)
}
c, err := fi.Bytes()
if err != nil {
f.path = filepath.Dir(f.path)
- log.Printf("ui: files: error reading file %v", err)
return common.ErrorMsg(err)
}
@@ -424,21 +467,66 @@ func (f *Files) selectFileCmd() tea.Msg {
return FileContentMsg{string(c), i.entry.Name()}
}
- log.Printf("ui: files: current item is not a file")
return common.ErrorMsg(errNoFileSelected)
}
-func (f *Files) deselectItemCmd() tea.Msg {
+func (f *Files) fetchBlame() tea.Msg {
+ r, err := f.repo.Open()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+
+ b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+
+ return FileBlameMsg(b)
+}
+
+func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {
+ if f == nil || f.entry.IsTree() || b == nil {
+ return ""
+ }
+
+ lines := make([]string, 0)
+ i := 1
+ var prev string
+ for {
+ commit := b.Line(i)
+ if commit == nil {
+ break
+ }
+ line := fmt.Sprintf("%s %s",
+ c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),
+ c.Styles.Tree.Blame.Message.Render(commit.Summary()),
+ )
+ if line != prev {
+ lines = append(lines, line)
+ } else {
+ lines = append(lines, "")
+ }
+ prev = line
+ i++
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func (f *Files) deselectItemCmd() tea.Cmd {
f.path = filepath.Dir(f.path)
- f.activeView = filesViewFiles
- msg := f.updateFilesCmd()
index := 0
if len(f.lastSelected) > 0 {
index = f.lastSelected[len(f.lastSelected)-1]
f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
}
- f.selector.Select(index)
- return msg
+ f.cursor = index
+ f.activeView = filesViewFiles
+ f.code.SetSideNote("")
+ f.blameView = false
+ f.currentBlame = nil
+ f.code.UseGlamour = false
+ return f.updateFilesCmd
}
func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
@@ -446,3 +534,15 @@ func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
return FileItemsMsg(items)
}
}
+
+func (f *Files) isSelectedMarkdown() bool {
+ var lang string
+ lexer := lexers.Match(f.currentContent.ext)
+ if lexer == nil {
+ lexer = lexers.Analyse(f.currentContent.content)
+ }
+ if lexer != nil && lexer.Config() != nil {
+ lang = lexer.Config().Name
+ }
+ return lang == "markdown"
+}
@@ -60,9 +60,8 @@ func (cl FileItems) Less(i, j int) bool {
return true
} else if cl[j].entry.IsTree() {
return false
- } else {
- return cl[i].Title() < cl[j].Title()
}
+ return cl[i].Title() < cl[j].Title()
}
// FileItemDelegate is the delegate for the file item list.
@@ -16,6 +16,7 @@ import (
"github.com/charmbracelet/soft-serve/server/ui/components/footer"
"github.com/charmbracelet/soft-serve/server/ui/components/selector"
"github.com/charmbracelet/soft-serve/server/ui/components/viewport"
+ "github.com/charmbracelet/soft-serve/server/ui/styles"
"github.com/muesli/reflow/wrap"
"github.com/muesli/termenv"
)
@@ -25,7 +26,8 @@ var waitBeforeLoading = time.Millisecond * 100
type logView int
const (
- logViewCommits logView = iota
+ logViewLoading logView = iota
+ logViewCommits
logViewDiff
)
@@ -55,7 +57,6 @@ type Log struct {
selectedCommit *git.Commit
currentDiff *git.Diff
loadingTime time.Time
- loading bool
spinner spinner.Model
}
@@ -83,6 +84,11 @@ func NewLog(common common.Common) *Log {
return l
}
+// TabName returns the name of the tab.
+func (l *Log) TabName() string {
+ return "Commits"
+}
+
// SetSize implements common.Component.
func (l *Log) SetSize(width, height int) {
l.common.SetSize(width, height)
@@ -163,15 +169,10 @@ func (l *Log) FullHelp() [][]key.Binding {
func (l *Log) startLoading() tea.Cmd {
l.loadingTime = time.Now()
- l.loading = true
+ l.activeView = logViewLoading
return l.spinner.Tick
}
-func (l *Log) stopLoading() tea.Cmd {
- l.loading = false
- return updateStatusBarCmd
-}
-
// Init implements tea.Model.
func (l *Log) Init() tea.Cmd {
l.activeView = logViewCommits
@@ -179,9 +180,8 @@ func (l *Log) Init() tea.Cmd {
l.count = 0
l.activeCommit = nil
l.selectedCommit = nil
- l.selector.Select(0)
return tea.Batch(
- l.updateCommitsCmd,
+ l.countCommitsCmd,
// start loading on init
l.startLoading(),
)
@@ -195,15 +195,17 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
l.repo = msg
case RefMsg:
l.ref = msg
+ l.selector.Select(0)
cmds = append(cmds, l.Init())
case LogCountMsg:
l.count = int64(msg)
+ l.selector.SetTotalPages(int(msg))
+ l.selector.SetItems(make([]selector.IdentifiableItem, l.count))
+ cmds = append(cmds, l.updateCommitsCmd)
case LogItemsMsg:
- cmds = append(cmds,
- l.selector.SetItems(msg),
- // stop loading after receiving items
- l.stopLoading(),
- )
+ // stop loading after receiving items
+ l.activeView = logViewCommits
+ cmds = append(cmds, l.selector.SetItems(msg))
l.selector.SetPage(l.nextPage)
l.SetSize(l.common.Width, l.common.Height)
i := l.selector.SelectedItem()
@@ -217,10 +219,11 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch {
case key.Matches(kmsg, l.common.KeyMap.SelectItem):
- cmds = append(cmds, l.selector.SelectItem)
+ cmds = append(cmds, l.selector.SelectItemCmd)
}
}
- // This is a hack for loading commits on demand based on list.Pagination.
+ // XXX: This is a hack for loading commits on demand based on
+ // list.Pagination.
curPage := l.selector.Page()
s, cmd := l.selector.Update(msg)
m := s.(*selector.Selector)
@@ -239,22 +242,17 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch {
case key.Matches(kmsg, l.common.KeyMap.BackItem):
- cmds = append(cmds, backCmd)
+ l.goBack()
}
}
}
- case BackMsg:
- if l.activeView == logViewDiff {
- l.activeView = logViewCommits
- l.selectedCommit = nil
- cmds = append(cmds, updateStatusBarCmd)
- }
+ case GoBackMsg:
+ l.goBack()
case selector.ActiveMsg:
switch sel := msg.IdentifiableItem.(type) {
case LogItem:
l.activeCommit = sel.Commit
}
- cmds = append(cmds, updateStatusBarCmd)
case selector.SelectMsg:
switch sel := msg.IdentifiableItem.(type) {
case LogItem:
@@ -271,26 +269,22 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
l.vp.SetContent(
lipgloss.JoinVertical(lipgloss.Top,
l.renderCommit(l.selectedCommit),
- l.renderSummary(msg),
- l.renderDiff(msg),
+ renderSummary(msg, l.common.Styles, l.common.Width),
+ renderDiff(msg, l.common.Width),
),
)
l.vp.GotoTop()
l.activeView = logViewDiff
- cmds = append(cmds,
- updateStatusBarCmd,
- // stop loading after setting the viewport content
- l.stopLoading(),
- )
case footer.ToggleFooterMsg:
cmds = append(cmds, l.updateCommitsCmd)
case tea.WindowSizeMsg:
+ l.SetSize(msg.Width, msg.Height)
if l.selectedCommit != nil && l.currentDiff != nil {
l.vp.SetContent(
lipgloss.JoinVertical(lipgloss.Top,
l.renderCommit(l.selectedCommit),
- l.renderSummary(l.currentDiff),
- l.renderDiff(l.currentDiff),
+ renderSummary(l.currentDiff, l.common.Styles, l.common.Width),
+ renderDiff(l.currentDiff, l.common.Width),
),
)
}
@@ -304,21 +298,23 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case EmptyRepoMsg:
l.ref = nil
- l.loading = false
l.activeView = logViewCommits
l.nextPage = 0
l.count = 0
l.activeCommit = nil
l.selectedCommit = nil
l.selector.Select(0)
- cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
- }
- if l.loading {
- s, cmd := l.spinner.Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
+ cmds = append(cmds,
+ l.setItems([]selector.IdentifiableItem{}),
+ )
+ case spinner.TickMsg:
+ if l.activeView == logViewLoading && l.spinner.ID() == msg.ID {
+ s, cmd := l.spinner.Update(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ l.spinner = s
}
- l.spinner = s
}
switch l.activeView {
case logViewDiff:
@@ -333,17 +329,19 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (l *Log) View() string {
- if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
- msg := fmt.Sprintf("%s loading commit", l.spinner.View())
- if l.selectedCommit == nil {
- msg += "s"
- }
- msg += "β¦"
- return l.common.Styles.SpinnerContainer.Copy().
- Height(l.common.Height).
- Render(msg)
- }
switch l.activeView {
+ case logViewLoading:
+ if l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
+ msg := fmt.Sprintf("%s loading commit", l.spinner.View())
+ if l.selectedCommit == nil {
+ msg += "s"
+ }
+ msg += "β¦"
+ return l.common.Styles.SpinnerContainer.Copy().
+ Height(l.common.Height).
+ Render(msg)
+ }
+ fallthrough
case logViewCommits:
return l.selector.View()
case logViewDiff:
@@ -353,9 +351,14 @@ func (l *Log) View() string {
}
}
+// SpinnerID implements common.TabComponent.
+func (l *Log) SpinnerID() int {
+ return l.spinner.ID()
+}
+
// StatusBarValue returns the status bar value.
func (l *Log) StatusBarValue() string {
- if l.loading {
+ if l.activeView == logViewLoading {
return ""
}
c := l.activeCommit
@@ -376,6 +379,11 @@ func (l *Log) StatusBarValue() string {
// StatusBarInfo returns the status bar info.
func (l *Log) StatusBarInfo() string {
switch l.activeView {
+ case logViewLoading:
+ if l.count == 0 {
+ return ""
+ }
+ fallthrough
case logViewCommits:
// We're using l.nextPage instead of l.selector.Paginator.Page because
// of the paginator hack above.
@@ -387,6 +395,13 @@ func (l *Log) StatusBarInfo() string {
}
}
+func (l *Log) goBack() {
+ if l.activeView == logViewDiff {
+ l.activeView = logViewCommits
+ l.selectedCommit = nil
+ }
+}
+
func (l *Log) countCommitsCmd() tea.Msg {
if l.ref == nil {
return nil
@@ -404,28 +419,26 @@ func (l *Log) countCommitsCmd() tea.Msg {
}
func (l *Log) updateCommitsCmd() tea.Msg {
- count := l.count
- if l.count == 0 {
- switch msg := l.countCommitsCmd().(type) {
- case common.ErrorMsg:
- return msg
- case LogCountMsg:
- count = int64(msg)
- }
- }
if l.ref == nil {
return nil
}
- items := make([]selector.IdentifiableItem, count)
- page := l.nextPage
- limit := l.selector.PerPage()
- skip := page * limit
r, err := l.repo.Open()
if err != nil {
return common.ErrorMsg(err)
}
+
+ count := l.count
+ if count == 0 {
+ return LogItemsMsg([]selector.IdentifiableItem{})
+ }
+
+ page := l.nextPage
+ limit := l.selector.PerPage()
+ skip := page * limit
+ ref := l.ref
+ items := make([]selector.IdentifiableItem, count)
// CommitsByPage pages start at 1
- cc, err := r.CommitsByPage(l.ref, page+1, limit)
+ cc, err := r.CommitsByPage(ref, page+1, limit)
if err != nil {
l.common.Logger.Debugf("ui: error loading commits: %v", err)
return common.ErrorMsg(err)
@@ -447,6 +460,9 @@ func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
}
func (l *Log) loadDiffCmd() tea.Msg {
+ if l.selectedCommit == nil {
+ return nil
+ }
r, err := l.repo.Open()
if err != nil {
l.common.Logger.Debugf("ui: error loading diff repository: %v", err)
@@ -481,21 +497,21 @@ func (l *Log) renderCommit(c *git.Commit) string {
return wrap.String(s.String(), l.common.Width-2)
}
-func (l *Log) renderSummary(diff *git.Diff) string {
+func renderSummary(diff *git.Diff, styles *styles.Styles, width int) string {
stats := strings.Split(diff.Stats().String(), "\n")
for i, line := range stats {
ch := strings.Split(line, "|")
if len(ch) > 1 {
adddel := ch[len(ch)-1]
- adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
- adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
+ adddel = strings.ReplaceAll(adddel, "+", styles.Log.CommitStatsAdd.Render("+"))
+ adddel = strings.ReplaceAll(adddel, "-", styles.Log.CommitStatsDel.Render("-"))
stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
}
}
- return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
+ return wrap.String(strings.Join(stats, "\n"), width-2)
}
-func (l *Log) renderDiff(diff *git.Diff) string {
+func renderDiff(diff *git.Diff, width int) string {
var s strings.Builder
var pr strings.Builder
diffChroma := &gansi.CodeBlockElement{
@@ -508,7 +524,7 @@ func (l *Log) renderDiff(diff *git.Diff) string {
} else {
s.WriteString(fmt.Sprintf("\n%s", pr.String()))
}
- return wrap.String(s.String(), l.common.Width)
+ return wrap.String(s.String(), width)
}
func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
@@ -5,6 +5,7 @@ import (
"path/filepath"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/proto"
@@ -14,7 +15,8 @@ import (
// ReadmeMsg is a message sent when the readme is loaded.
type ReadmeMsg struct {
- Msg tea.Msg
+ Content string
+ Path string
}
// Readme is the readme component page.
@@ -24,18 +26,30 @@ type Readme struct {
ref RefMsg
repo proto.Repository
readmePath string
+ spinner spinner.Model
+ isLoading bool
}
// NewReadme creates a new readme model.
func NewReadme(common common.Common) *Readme {
readme := code.New(common, "", "")
readme.NoContentStyle = readme.NoContentStyle.Copy().SetString("No readme found.")
+ readme.UseGlamour = true
+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(common.Styles.Spinner))
return &Readme{
- code: readme,
- common: common,
+ code: readme,
+ common: common,
+ spinner: s,
+ isLoading: true,
}
}
+// TabName returns the name of the tab.
+func (r *Readme) TabName() string {
+ return "Readme"
+}
+
// SetSize implements common.Component.
func (r *Readme) SetSize(width, height int) {
r.common.SetSize(width, height)
@@ -72,7 +86,8 @@ func (r *Readme) FullHelp() [][]key.Binding {
// Init implements tea.Model.
func (r *Readme) Init() tea.Cmd {
- return r.updateReadmeCmd
+ r.isLoading = true
+ return tea.Batch(r.spinner.Tick, r.updateReadmeCmd)
}
// Update implements tea.Model.
@@ -84,9 +99,26 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case RefMsg:
r.ref = msg
cmds = append(cmds, r.Init())
+ case tea.WindowSizeMsg:
+ r.SetSize(msg.Width, msg.Height)
case EmptyRepoMsg:
- r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
- r.repo.Name()), ".md")
+ cmds = append(cmds,
+ r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
+ r.repo.Name()), ".md"),
+ )
+ case ReadmeMsg:
+ r.isLoading = false
+ r.readmePath = msg.Path
+ r.code.GotoTop()
+ cmds = append(cmds, r.code.SetContent(msg.Content, msg.Path))
+ case spinner.TickMsg:
+ if r.isLoading && r.spinner.ID() == msg.ID {
+ s, cmd := r.spinner.Update(msg)
+ r.spinner = s
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
}
c, cmd := r.code.Update(msg)
r.code = c.(*code.Code)
@@ -98,34 +130,38 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (r *Readme) View() string {
+ if r.isLoading {
+ return renderLoading(r.common, r.spinner)
+ }
return r.code.View()
}
+// SpinnerID implements common.TabComponent.
+func (r *Readme) SpinnerID() int {
+ return r.spinner.ID()
+}
+
// StatusBarValue implements statusbar.StatusBar.
func (r *Readme) StatusBarValue() string {
dir := filepath.Dir(r.readmePath)
- if dir == "." {
- return ""
+ if dir == "." || dir == "" {
+ return " "
}
return dir
}
// StatusBarInfo implements statusbar.StatusBar.
func (r *Readme) StatusBarInfo() string {
- return fmt.Sprintf("β° %.f%%", r.code.ScrollPercent()*100)
+ return fmt.Sprintf("β° %d%%", r.code.ScrollPosition())
}
func (r *Readme) updateReadmeCmd() tea.Msg {
m := ReadmeMsg{}
if r.repo == nil {
- return common.ErrorCmd(common.ErrMissingRepo)
- }
- rm, rp, _ := backend.Readme(r.repo)
- r.readmePath = rp
- r.code.GotoTop()
- cmd := r.code.SetContent(rm, rp)
- if cmd != nil {
- m.Msg = cmd()
+ return common.ErrorMsg(common.ErrMissingRepo)
}
+ rm, rp, _ := backend.Readme(r.repo, r.ref)
+ m.Content = rm
+ m.Path = rp
return m
}
@@ -6,17 +6,16 @@ import (
"strings"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/soft-serve/git"
- ggit "github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/proto"
"github.com/charmbracelet/soft-serve/server/ui/common"
"github.com/charmbracelet/soft-serve/server/ui/components/selector"
- "github.com/charmbracelet/soft-serve/server/ui/components/tabs"
)
// RefMsg is a message that contains a git.Reference.
-type RefMsg *ggit.Reference
+type RefMsg *git.Reference
// RefItemsMsg is a message that contains a list of RefItem.
type RefItemsMsg struct {
@@ -32,6 +31,8 @@ type Refs struct {
ref *git.Reference
activeRef *git.Reference
refPrefix string
+ spinner spinner.Model
+ isLoading bool
}
// NewRefs creates a new Refs component.
@@ -39,6 +40,7 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
r := &Refs{
common: common,
refPrefix: refPrefix,
+ isLoading: true,
}
s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})
s.SetShowFilter(false)
@@ -49,9 +51,22 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
s.SetFilteringEnabled(false)
s.DisableQuitKeybindings()
r.selector = s
+ sp := spinner.New(spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(common.Styles.Spinner))
+ r.spinner = sp
return r
}
+// TabName returns the name of the tab.
+func (r *Refs) TabName() string {
+ if r.refPrefix == git.RefsHeads {
+ return "Branches"
+ } else if r.refPrefix == git.RefsTags {
+ return "Tags"
+ }
+ return "Refs"
+}
+
// SetSize implements common.Component.
func (r *Refs) SetSize(width, height int) {
r.common.SetSize(width, height)
@@ -94,7 +109,8 @@ func (r *Refs) FullHelp() [][]key.Binding {
// Init implements tea.Model.
func (r *Refs) Init() tea.Cmd {
- return r.updateItemsCmd
+ r.isLoading = true
+ return tea.Batch(r.spinner.Tick, r.updateItemsCmd)
}
// Update implements tea.Model.
@@ -107,6 +123,8 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case RefMsg:
r.ref = msg
cmds = append(cmds, r.Init())
+ case tea.WindowSizeMsg:
+ r.SetSize(msg.Width, msg.Height)
case RefItemsMsg:
if r.refPrefix == msg.prefix {
cmds = append(cmds, r.selector.SetItems(msg.items))
@@ -114,29 +132,37 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if i != nil {
r.activeRef = i.(RefItem).Reference
}
+ r.isLoading = false
}
case selector.ActiveMsg:
switch sel := msg.IdentifiableItem.(type) {
case RefItem:
r.activeRef = sel.Reference
}
- cmds = append(cmds, updateStatusBarCmd)
case selector.SelectMsg:
switch i := msg.IdentifiableItem.(type) {
case RefItem:
cmds = append(cmds,
switchRefCmd(i.Reference),
- tabs.SelectTabCmd(int(filesTab)),
+ switchTabCmd(&Files{}),
)
}
case tea.KeyMsg:
switch {
case key.Matches(msg, r.common.KeyMap.SelectItem):
- cmds = append(cmds, r.selector.SelectItem)
+ cmds = append(cmds, r.selector.SelectItemCmd)
}
case EmptyRepoMsg:
r.ref = nil
cmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))
+ case spinner.TickMsg:
+ if r.isLoading && r.spinner.ID() == msg.ID {
+ s, cmd := r.spinner.Update(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ r.spinner = s
+ }
}
m, cmd := r.selector.Update(msg)
r.selector = m.(*selector.Selector)
@@ -148,9 +174,17 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (r *Refs) View() string {
+ if r.isLoading {
+ return renderLoading(r.common, r.spinner)
+ }
return r.selector.View()
}
+// SpinnerID implements common.TabComponent.
+func (r *Refs) SpinnerID() int {
+ return r.spinner.ID()
+}
+
// StatusBarValue implements statusbar.StatusBar.
func (r *Refs) StatusBarValue() string {
if r.activeRef == nil {
@@ -162,10 +196,10 @@ func (r *Refs) StatusBarValue() string {
// StatusBarInfo implements statusbar.StatusBar.
func (r *Refs) StatusBarInfo() string {
totalPages := r.selector.TotalPages()
- if totalPages > 1 {
- return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages)
+ if totalPages <= 1 {
+ return "p. 1/1"
}
- return ""
+ return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages)
}
func (r *Refs) updateItemsCmd() tea.Msg {
@@ -181,7 +215,19 @@ func (r *Refs) updateItemsCmd() tea.Msg {
}
for _, ref := range refs {
if strings.HasPrefix(ref.Name().String(), r.refPrefix) {
- its = append(its, RefItem{Reference: ref})
+ refItem := RefItem{
+ Reference: ref,
+ }
+
+ if ref.IsTag() {
+ refItem.Tag, _ = rr.Tag(ref.Name().Short())
+ if refItem.Tag != nil {
+ refItem.Commit, _ = refItem.Tag.Commit()
+ }
+ } else {
+ refItem.Commit, _ = rr.CatFileCommit(ref.ID)
+ }
+ its = append(its, refItem)
}
}
sort.Sort(its)
@@ -204,7 +250,7 @@ func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {
}
}
-func switchRefCmd(ref *ggit.Reference) tea.Cmd {
+func switchRefCmd(ref *git.Reference) tea.Cmd {
return func() tea.Msg {
return RefMsg(ref)
}
@@ -3,6 +3,8 @@ package repo
import (
"fmt"
"io"
+ "strings"
+ "time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
@@ -10,11 +12,15 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/ui/common"
+ "github.com/dustin/go-humanize"
+ "github.com/muesli/reflow/truncate"
)
// RefItem is a git reference item.
type RefItem struct {
*git.Reference
+ *git.Tag
+ *git.Commit
}
// ID implements selector.IdentifiableItem.
@@ -51,7 +57,12 @@ func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
// Less implements sort.Interface.
func (cl RefItems) Less(i, j int) bool {
- return cl[i].Short() < cl[j].Short()
+ if cl[i].Commit != nil && cl[j].Commit != nil {
+ return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)
+ } else if cl[i].Commit != nil && cl[j].Commit == nil {
+ return true
+ }
+ return false
}
// RefItemDelegate is the delegate for the ref item.
@@ -83,46 +94,112 @@ func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
// Render implements list.ItemDelegate.
func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
- s := d.common.Styles.Ref
i, ok := listItem.(RefItem)
if !ok {
return
}
- var st lipgloss.Style
- var selector string
-
isTag := i.Reference.IsTag()
isActive := index == m.Index()
+ s := d.common.Styles.Ref
+ st := s.Normal
+ selector := " "
+ if isActive {
+ st = s.Active
+ selector = s.ItemSelector.String()
+ }
+ horizontalFrameSize := st.Base.GetHorizontalFrameSize()
+ var itemSt lipgloss.Style
if isTag && isActive {
- st = s.Active.ItemTag
+ itemSt = st.ItemTag
} else if isTag {
- st = s.Normal.ItemTag
+ itemSt = st.ItemTag
} else if isActive {
- st = s.Active.Item
+ itemSt = st.Item
} else {
- st = s.Normal.Item
+ itemSt = st.Item
}
- if isActive {
- selector = s.ItemSelector.String()
- } else {
- selector = " "
+ var sha string
+ c := i.Commit
+ if c != nil {
+ sha = c.ID.String()[:7]
}
ref := i.Short()
- ref = s.ItemBranch.Render(ref)
- refMaxWidth := m.Width() -
- s.ItemSelector.GetMarginLeft() -
- s.ItemSelector.GetWidth() -
- s.Normal.Item.GetMarginLeft()
- ref = common.TruncateString(ref, refMaxWidth)
- ref = st.Render(ref)
+
+ var desc string
+ if isTag {
+ if c != nil {
+ date := c.Committer.When.Format("Jan 02")
+ if c.Committer.When.Year() != time.Now().Year() {
+ date += fmt.Sprintf(" %d", c.Committer.When.Year())
+ }
+ desc += " " + st.ItemDesc.Render(date)
+ }
+
+ t := i.Tag
+ if t != nil {
+ msgSt := st.ItemDesc.Copy().Faint(false)
+ msg := t.Message()
+ nl := strings.Index(msg, "\n")
+ if nl > 0 {
+ msg = msg[:nl]
+ }
+ msg = strings.TrimSpace(msg)
+ if msg != "" {
+ msgMargin := m.Width() -
+ horizontalFrameSize -
+ lipgloss.Width(selector) -
+ lipgloss.Width(ref) -
+ lipgloss.Width(desc) -
+ lipgloss.Width(sha) -
+ 3 // 3 is for the paddings and truncation symbol
+ if msgMargin >= 0 {
+ msg = common.TruncateString(msg, msgMargin)
+ desc = " " + msgSt.Render(msg) + desc
+ }
+ }
+ }
+ } else if c != nil {
+ onMargin := m.Width() -
+ horizontalFrameSize -
+ lipgloss.Width(selector) -
+ lipgloss.Width(ref) -
+ lipgloss.Width(desc) -
+ lipgloss.Width(sha) -
+ 2 // 2 is for the padding and truncation symbol
+ if onMargin >= 0 {
+ on := common.TruncateString("updated "+humanize.Time(c.Committer.When), onMargin)
+ desc += " " + st.ItemDesc.Render(on)
+ }
+ }
+
+ var hash string
+ ref = itemSt.Render(ref)
+ hashMargin := m.Width() -
+ horizontalFrameSize -
+ lipgloss.Width(selector) -
+ lipgloss.Width(ref) -
+ lipgloss.Width(desc) -
+ lipgloss.Width(sha) -
+ 1 // 1 is for the left padding
+ if hashMargin >= 0 {
+ hash = strings.Repeat(" ", hashMargin) + st.ItemHash.Copy().
+ Align(lipgloss.Right).
+ PaddingLeft(1).
+ Render(sha)
+ }
fmt.Fprint(w,
d.common.Zone.Mark(
i.ID(),
- fmt.Sprint(selector, ref),
+ st.Base.Render(
+ lipgloss.JoinHorizontal(lipgloss.Left,
+ truncate.String(selector+ref+desc+hash,
+ uint(m.Width()-horizontalFrameSize)),
+ ),
+ ),
),
)
}
@@ -2,6 +2,7 @@ package repo
import (
"fmt"
+ "strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
@@ -12,6 +13,7 @@ import (
"github.com/charmbracelet/soft-serve/server/proto"
"github.com/charmbracelet/soft-serve/server/ui/common"
"github.com/charmbracelet/soft-serve/server/ui/components/footer"
+ "github.com/charmbracelet/soft-serve/server/ui/components/selector"
"github.com/charmbracelet/soft-serve/server/ui/components/statusbar"
"github.com/charmbracelet/soft-serve/server/ui/components/tabs"
)
@@ -23,41 +25,17 @@ const (
readyState
)
-type tab int
-
-const (
- readmeTab tab = iota
- filesTab
- commitsTab
- branchesTab
- tagsTab
- lastTab
-)
-
-func (t tab) String() string {
- return []string{
- "Readme",
- "Files",
- "Commits",
- "Branches",
- "Tags",
- }[t]
-}
-
// EmptyRepoMsg is a message to indicate that the repository is empty.
type EmptyRepoMsg struct{}
// CopyURLMsg is a message to copy the URL of the current repository.
type CopyURLMsg struct{}
-// UpdateStatusBarMsg updates the status bar.
-type UpdateStatusBarMsg struct{}
-
// RepoMsg is a message that contains a git.Repository.
type RepoMsg proto.Repository // nolint:revive
-// BackMsg is a message to go back to the previous view.
-type BackMsg struct{}
+// GoBackMsg is a message to go back to the previous view.
+type GoBackMsg struct{}
// CopyMsg is a message to indicate copied text.
type CopyMsg struct {
@@ -65,63 +43,60 @@ type CopyMsg struct {
Message string
}
+// SwitchTabMsg is a message to switch tabs.
+type SwitchTabMsg common.TabComponent
+
// Repo is a view for a git repository.
type Repo struct {
common common.Common
selectedRepo proto.Repository
- activeTab tab
+ activeTab int
tabs *tabs.Tabs
- statusbar *statusbar.StatusBar
- panes []common.Component
+ statusbar *statusbar.Model
+ panes []common.TabComponent
ref *git.Reference
state state
spinner spinner.Model
- panesReady [lastTab]bool
+ panesReady []bool
}
// New returns a new Repo.
-func New(c common.Common) *Repo {
+func New(c common.Common, comps ...common.TabComponent) *Repo {
sb := statusbar.New(c)
- ts := make([]string, lastTab)
- // Tabs must match the order of tab constants above.
- for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
- ts[i] = t.String()
+ ts := make([]string, 0)
+ for _, c := range comps {
+ ts = append(ts, c.TabName())
}
c.Logger = c.Logger.WithPrefix("ui.repo")
tb := tabs.New(c, ts)
- readme := NewReadme(c)
- log := NewLog(c)
- files := NewFiles(c)
- branches := NewRefs(c, git.RefsHeads)
- tags := NewRefs(c, git.RefsTags)
// Make sure the order matches the order of tab constants above.
- panes := []common.Component{
- readme,
- files,
- log,
- branches,
- tags,
- }
s := spinner.New(spinner.WithSpinner(spinner.Dot),
spinner.WithStyle(c.Styles.Spinner))
r := &Repo{
- common: c,
- tabs: tb,
- statusbar: sb,
- panes: panes,
- state: loadingState,
- spinner: s,
+ common: c,
+ tabs: tb,
+ statusbar: sb,
+ panes: comps,
+ state: loadingState,
+ spinner: s,
+ panesReady: make([]bool, len(comps)),
}
return r
}
-// SetSize implements common.Component.
-func (r *Repo) SetSize(width, height int) {
- r.common.SetSize(width, height)
+func (r *Repo) getMargins() (int, int) {
+ hh := lipgloss.Height(r.headerView())
hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
- r.common.Styles.Repo.Header.GetHeight() +
+ hh +
r.common.Styles.Repo.Header.GetVerticalFrameSize() +
r.common.Styles.StatusBar.GetHeight()
+ return 0, hm
+}
+
+// SetSize implements common.Component.
+func (r *Repo) SetSize(width, height int) {
+ r.common.SetSize(width, height)
+ _, hm := r.getMargins()
r.tabs.SetSize(width, height-hm)
r.statusbar.SetSize(width, height-hm)
for _, p := range r.panes {
@@ -157,9 +132,12 @@ func (r *Repo) FullHelp() [][]key.Binding {
// Init implements tea.View.
func (r *Repo) Init() tea.Cmd {
+ r.state = loadingState
+ r.activeTab = 0
return tea.Batch(
r.tabs.Init(),
r.statusbar.Init(),
+ r.spinner.Tick,
)
}
@@ -169,40 +147,25 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case RepoMsg:
// Set the state to loading when we get a new repository.
- r.state = loadingState
- r.panesReady = [lastTab]bool{}
- r.activeTab = 0
r.selectedRepo = msg
cmds = append(cmds,
- r.tabs.Init(),
+ r.Init(),
// This will set the selected repo in each pane's model.
r.updateModels(msg),
- r.spinner.Tick,
)
case RefMsg:
r.ref = msg
- for _, p := range r.panes {
- // Init will initiate each pane's model with its contents.
- cmds = append(cmds, p.Init())
- }
- cmds = append(cmds,
- r.updateStatusBarCmd,
- r.updateModels(msg),
- )
+ cmds = append(cmds, r.updateModels(msg))
+ r.state = readyState
case tabs.SelectTabMsg:
- r.activeTab = tab(msg)
+ r.activeTab = int(msg)
t, cmd := r.tabs.Update(msg)
r.tabs = t.(*tabs.Tabs)
if cmd != nil {
cmds = append(cmds, cmd)
}
case tabs.ActiveTabMsg:
- r.activeTab = tab(msg)
- if r.selectedRepo != nil {
- cmds = append(cmds,
- r.updateStatusBarCmd,
- )
- }
+ r.activeTab = int(msg)
case tea.KeyMsg, tea.MouseMsg:
t, cmd := r.tabs.Update(msg)
r.tabs = t.(*tabs.Tabs)
@@ -210,7 +173,6 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
if r.selectedRepo != nil {
- cmds = append(cmds, r.updateStatusBarCmd)
urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
cmd := common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
@@ -228,7 +190,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseRight:
switch {
case r.common.Zone.Get("repo-main").InBounds(msg):
- cmds = append(cmds, backCmd)
+ cmds = append(cmds, goBackCmd)
}
}
}
@@ -237,69 +199,91 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cfg := r.common.Config(); cfg != nil {
r.common.Output.Copy(txt)
}
- cmds = append(cmds, func() tea.Msg {
- return statusbar.StatusBarMsg{
- Value: msg.Message,
- }
- })
- case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
- cmds = append(cmds, r.updateRepo(msg))
+ r.statusbar.SetStatus("", msg.Message, "", "")
+ case ReadmeMsg:
+ cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
+ case FileItemsMsg, FileContentMsg:
+ cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
+ case LogItemsMsg, LogDiffMsg, LogCountMsg:
+ cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
+ case RefItemsMsg:
+ cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
+ case StashListMsg, StashPatchMsg:
+ cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
// We have two spinners, one is used to when loading the repository and the
// other is used when loading the log.
// Check if the spinner ID matches the spinner model.
case spinner.TickMsg:
- switch msg.ID {
- case r.spinner.ID():
- if r.state == loadingState {
- s, cmd := r.spinner.Update(msg)
- r.spinner = s
- if cmd != nil {
- cmds = append(cmds, cmd)
+ if r.state == loadingState && r.spinner.ID() == msg.ID {
+ s, cmd := r.spinner.Update(msg)
+ r.spinner = s
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ } else {
+ for i, c := range r.panes {
+ if c.SpinnerID() == msg.ID {
+ m, cmd := c.Update(msg)
+ r.panes[i] = m.(common.TabComponent)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ break
}
}
- default:
- cmds = append(cmds, r.updateRepo(msg))
}
- case UpdateStatusBarMsg:
- cmds = append(cmds, r.updateStatusBarCmd)
case tea.WindowSizeMsg:
+ r.SetSize(msg.Width, msg.Height)
cmds = append(cmds, r.updateModels(msg))
case EmptyRepoMsg:
r.ref = nil
r.state = readyState
- cmds = append(cmds,
- r.updateModels(msg),
- r.updateStatusBarCmd,
- )
+ cmds = append(cmds, r.updateModels(msg))
case common.ErrorMsg:
r.state = readyState
+ case SwitchTabMsg:
+ for i, c := range r.panes {
+ if c.TabName() == msg.TabName() {
+ cmds = append(cmds, tabs.SelectTabCmd(i))
+ break
+ }
+ }
}
- s, cmd := r.statusbar.Update(msg)
- r.statusbar = s.(*statusbar.StatusBar)
+ active := r.panes[r.activeTab]
+ m, cmd := active.Update(msg)
+ r.panes[r.activeTab] = m.(common.TabComponent)
if cmd != nil {
cmds = append(cmds, cmd)
}
- m, cmd := r.panes[r.activeTab].Update(msg)
- r.panes[r.activeTab] = m.(common.Component)
+
+ // Update the status bar on these events
+ // Must come after we've updated the active tab
+ switch msg.(type) {
+ case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg,
+ FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg,
+ LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg,
+ StashListMsg, StashPatchMsg:
+ r.setStatusBarInfo()
+ }
+
+ s, cmd := r.statusbar.Update(msg)
+ r.statusbar = s.(*statusbar.Model)
if cmd != nil {
cmds = append(cmds, cmd)
}
+
return r, tea.Batch(cmds...)
}
// View implements tea.Model.
func (r *Repo) View() string {
- s := r.common.Styles.Repo.Base.Copy().
- Width(r.common.Width).
- Height(r.common.Height)
- repoBodyStyle := r.common.Styles.Repo.Body.Copy()
- hm := repoBodyStyle.GetVerticalFrameSize() +
- r.common.Styles.Repo.Header.GetHeight() +
- r.common.Styles.Repo.Header.GetVerticalFrameSize() +
- r.common.Styles.StatusBar.GetHeight() +
- r.common.Styles.Tabs.GetHeight() +
+ wm, hm := r.getMargins()
+ hm += r.common.Styles.Tabs.GetHeight() +
r.common.Styles.Tabs.GetVerticalFrameSize()
- mainStyle := repoBodyStyle.
+ s := r.common.Styles.Repo.Base.Copy().
+ Width(r.common.Width - wm).
+ Height(r.common.Height - hm)
+ mainStyle := r.common.Styles.Repo.Body.Copy().
Height(r.common.Height - hm)
var main string
var statusbar string
@@ -328,17 +312,17 @@ func (r *Repo) headerView() string {
return ""
}
truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
- name := r.selectedRepo.ProjectName()
- if name == "" {
- name = r.selectedRepo.Name()
+ header := r.selectedRepo.ProjectName()
+ if header == "" {
+ header = r.selectedRepo.Name()
}
- name = r.common.Styles.Repo.HeaderName.Render(name)
- desc := r.selectedRepo.Description()
- if desc == "" {
- desc = name
- name = ""
- } else {
- desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
+ header = r.common.Styles.Repo.HeaderName.Render(header)
+ desc := strings.TrimSpace(r.selectedRepo.Description())
+ if desc != "" {
+ header = lipgloss.JoinVertical(lipgloss.Top,
+ header,
+ r.common.Styles.Repo.HeaderDesc.Render(desc),
+ )
}
urlStyle := r.common.Styles.URLStyle.Copy().
Width(r.common.Width - lipgloss.Width(desc) - 1).
@@ -352,109 +336,59 @@ func (r *Repo) headerView() string {
fmt.Sprintf("%s-url", r.selectedRepo.Name()),
urlStyle.Render(url),
)
+
+ header = lipgloss.JoinHorizontal(lipgloss.Left, header, url)
+
style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
return style.Render(
- lipgloss.JoinVertical(lipgloss.Top,
- truncate.Render(name),
- truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
- desc,
- url,
- )),
- ),
+ truncate.Render(header),
)
}
-func (r *Repo) updateStatusBarCmd() tea.Msg {
+func (r *Repo) setStatusBarInfo() {
if r.selectedRepo == nil {
- return nil
+ return
}
- value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
- info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
- branch := "*"
+
+ active := r.panes[r.activeTab]
+ key := r.selectedRepo.Name()
+ value := active.StatusBarValue()
+ info := active.StatusBarInfo()
+ extra := "*"
if r.ref != nil {
- branch += " " + r.ref.Name().Short()
- }
- return statusbar.StatusBarMsg{
- Key: r.selectedRepo.Name(),
- Value: value,
- Info: info,
- Extra: branch,
+ extra += " " + r.ref.Name().Short()
}
+
+ r.statusbar.SetStatus(key, value, info, extra)
}
-func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
+func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, 0)
for i, b := range r.panes {
- m, cmd := b.Update(msg)
- r.panes[i] = m.(common.Component)
- if cmd != nil {
- cmds = append(cmds, cmd)
+ if b.TabName() == c.TabName() {
+ m, cmd := b.Update(msg)
+ r.panes[i] = m.(common.TabComponent)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ break
}
}
return tea.Batch(cmds...)
}
-func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
+func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, 0)
- switch msg := msg.(type) {
- case LogCountMsg, LogItemsMsg, spinner.TickMsg:
- switch msg.(type) {
- case LogItemsMsg:
- r.panesReady[commitsTab] = true
- }
- l, cmd := r.panes[commitsTab].Update(msg)
- r.panes[commitsTab] = l.(*Log)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case FileItemsMsg:
- r.panesReady[filesTab] = true
- f, cmd := r.panes[filesTab].Update(msg)
- r.panes[filesTab] = f.(*Files)
+ for i, b := range r.panes {
+ m, cmd := b.Update(msg)
+ r.panes[i] = m.(common.TabComponent)
if cmd != nil {
cmds = append(cmds, cmd)
}
- case RefItemsMsg:
- switch msg.prefix {
- case git.RefsHeads:
- r.panesReady[branchesTab] = true
- b, cmd := r.panes[branchesTab].Update(msg)
- r.panes[branchesTab] = b.(*Refs)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case git.RefsTags:
- r.panesReady[tagsTab] = true
- t, cmd := r.panes[tagsTab].Update(msg)
- r.panes[tagsTab] = t.(*Refs)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- case ReadmeMsg:
- r.panesReady[readmeTab] = true
- }
- if r.isReady() {
- r.state = readyState
}
return tea.Batch(cmds...)
}
-func (r *Repo) isReady() bool {
- ready := true
- // We purposely ignore the log pane here because it has its own spinner.
- for _, b := range []bool{
- r.panesReady[filesTab], r.panesReady[branchesTab],
- r.panesReady[tagsTab], r.panesReady[readmeTab],
- } {
- if !b {
- ready = false
- break
- }
- }
- return ready
-}
-
func copyCmd(text, msg string) tea.Cmd {
return func() tea.Msg {
return CopyMsg{
@@ -464,10 +398,19 @@ func copyCmd(text, msg string) tea.Cmd {
}
}
-func updateStatusBarCmd() tea.Msg {
- return UpdateStatusBarMsg{}
+func goBackCmd() tea.Msg {
+ return GoBackMsg{}
+}
+
+func switchTabCmd(m common.TabComponent) tea.Cmd {
+ return func() tea.Msg {
+ return SwitchTabMsg(m)
+ }
}
-func backCmd() tea.Msg {
- return BackMsg{}
+func renderLoading(c common.Common, s spinner.Model) string {
+ msg := fmt.Sprintf("%s loadingβ¦", s.View())
+ return c.Styles.SpinnerContainer.Copy().
+ Height(c.Height).
+ Render(msg)
}
@@ -0,0 +1,279 @@
+package repo
+
+import (
+ "fmt"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/proto"
+ "github.com/charmbracelet/soft-serve/server/ui/common"
+ "github.com/charmbracelet/soft-serve/server/ui/components/code"
+ "github.com/charmbracelet/soft-serve/server/ui/components/selector"
+ gitm "github.com/gogs/git-module"
+)
+
+type stashState int
+
+const (
+ stashStateLoading stashState = iota
+ stashStateList
+ stashStatePatch
+)
+
+// StashListMsg is a message sent when the stash list is loaded.
+type StashListMsg []*gitm.Stash
+
+// StashPatchMsg is a message sent when the stash patch is loaded.
+type StashPatchMsg struct{ *git.Diff }
+
+// Stash is the stash component page.
+type Stash struct {
+ common common.Common
+ code *code.Code
+ ref RefMsg
+ repo proto.Repository
+ spinner spinner.Model
+ list *selector.Selector
+ state stashState
+ currentPatch StashPatchMsg
+}
+
+// NewStash creates a new stash model.
+func NewStash(common common.Common) *Stash {
+ code := code.New(common, "", "")
+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(common.Styles.Spinner))
+ selector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common})
+ selector.SetShowFilter(false)
+ selector.SetShowHelp(false)
+ selector.SetShowPagination(false)
+ selector.SetShowStatusBar(false)
+ selector.SetShowTitle(false)
+ selector.SetFilteringEnabled(false)
+ selector.DisableQuitKeybindings()
+ selector.KeyMap.NextPage = common.KeyMap.NextPage
+ selector.KeyMap.PrevPage = common.KeyMap.PrevPage
+ return &Stash{
+ code: code,
+ common: common,
+ spinner: s,
+ list: selector,
+ }
+}
+
+// TabName returns the name of the tab.
+func (s *Stash) TabName() string {
+ return "Stash"
+}
+
+// SetSize implements common.Component.
+func (s *Stash) SetSize(width, height int) {
+ s.common.SetSize(width, height)
+ s.code.SetSize(width, height)
+ s.list.SetSize(width, height)
+}
+
+// ShortHelp implements help.KeyMap.
+func (s *Stash) ShortHelp() []key.Binding {
+ return []key.Binding{
+ s.common.KeyMap.Select,
+ s.common.KeyMap.Back,
+ s.common.KeyMap.UpDown,
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (s *Stash) FullHelp() [][]key.Binding {
+ b := [][]key.Binding{
+ {
+ s.common.KeyMap.Select,
+ s.common.KeyMap.Back,
+ s.common.KeyMap.Copy,
+ },
+ {
+ s.code.KeyMap.Down,
+ s.code.KeyMap.Up,
+ s.common.KeyMap.GotoTop,
+ s.common.KeyMap.GotoBottom,
+ },
+ }
+ return b
+}
+
+// StatusBarValue implements common.Component.
+func (s *Stash) StatusBarValue() string {
+ item, ok := s.list.SelectedItem().(StashItem)
+ if !ok {
+ return " "
+ }
+ idx := s.list.Index()
+ return fmt.Sprintf("stash@{%d}: %s", idx, item.Title())
+}
+
+// StatusBarInfo implements common.Component.
+func (s *Stash) StatusBarInfo() string {
+ switch s.state {
+ case stashStateList:
+ totalPages := s.list.TotalPages()
+ if totalPages <= 1 {
+ return "p. 1/1"
+ }
+ return fmt.Sprintf("p. %d/%d", s.list.Page()+1, totalPages)
+ case stashStatePatch:
+ return fmt.Sprintf("β° %d%%", s.code.ScrollPosition())
+ default:
+ return ""
+ }
+}
+
+// SpinnerID implements common.Component.
+func (s *Stash) SpinnerID() int {
+ return s.spinner.ID()
+}
+
+// Init initializes the model.
+func (s *Stash) Init() tea.Cmd {
+ s.state = stashStateLoading
+ return tea.Batch(s.spinner.Tick, s.fetchStash)
+}
+
+// Update updates the model.
+func (s *Stash) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ cmds := make([]tea.Cmd, 0)
+ switch msg := msg.(type) {
+ case RepoMsg:
+ s.repo = msg
+ case RefMsg:
+ s.ref = msg
+ s.list.Select(0)
+ cmds = append(cmds, s.Init())
+ case tea.WindowSizeMsg:
+ s.SetSize(msg.Width, msg.Height)
+ case spinner.TickMsg:
+ if s.state == stashStateLoading && s.spinner.ID() == msg.ID {
+ sp, cmd := s.spinner.Update(msg)
+ s.spinner = sp
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ case tea.KeyMsg:
+ switch s.state {
+ case stashStateList:
+ switch {
+ case key.Matches(msg, s.common.KeyMap.BackItem):
+ cmds = append(cmds, goBackCmd)
+ case key.Matches(msg, s.common.KeyMap.Copy):
+ cmds = append(cmds, copyCmd(s.list.SelectedItem().(StashItem).Title(), "Stash message copied to clipboard"))
+ }
+ case stashStatePatch:
+ switch {
+ case key.Matches(msg, s.common.KeyMap.BackItem):
+ cmds = append(cmds, goBackCmd)
+ case key.Matches(msg, s.common.KeyMap.Copy):
+ if s.currentPatch.Diff != nil {
+ patch := s.currentPatch.Diff
+ cmds = append(cmds, copyCmd(patch.Patch(), "Stash patch copied to clipboard"))
+ }
+ }
+ }
+ case StashListMsg:
+ s.state = stashStateList
+ items := make([]selector.IdentifiableItem, len(msg))
+ for i, stash := range msg {
+ items[i] = StashItem{stash}
+ }
+ cmds = append(cmds, s.list.SetItems(items))
+ case StashPatchMsg:
+ s.state = stashStatePatch
+ s.currentPatch = msg
+ if msg.Diff != nil {
+ title := s.common.Styles.Stash.Title.Render(s.list.SelectedItem().(StashItem).Title())
+ content := lipgloss.JoinVertical(lipgloss.Top,
+ title,
+ "",
+ renderSummary(msg.Diff, s.common.Styles, s.common.Width),
+ renderDiff(msg.Diff, s.common.Width),
+ )
+ cmds = append(cmds, s.code.SetContent(content, ".diff"))
+ s.code.GotoTop()
+ }
+ case selector.SelectMsg:
+ switch msg.IdentifiableItem.(type) {
+ case StashItem:
+ cmds = append(cmds, s.fetchStashPatch)
+ }
+ case GoBackMsg:
+ if s.state == stashStateList {
+ s.list.Select(0)
+ }
+ s.state = stashStateList
+ }
+ switch s.state {
+ case stashStateList:
+ l, cmd := s.list.Update(msg)
+ s.list = l.(*selector.Selector)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case stashStatePatch:
+ c, cmd := s.code.Update(msg)
+ s.code = c.(*code.Code)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ return s, tea.Batch(cmds...)
+}
+
+// View returns the view.
+func (s *Stash) View() string {
+ switch s.state {
+ case stashStateLoading:
+ return renderLoading(s.common, s.spinner)
+ case stashStateList:
+ return s.list.View()
+ case stashStatePatch:
+ return s.code.View()
+ }
+ return ""
+}
+
+func (s *Stash) fetchStash() tea.Msg {
+ if s.repo == nil {
+ return StashListMsg(nil)
+ }
+
+ r, err := s.repo.Open()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+
+ stash, err := r.StashList()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+
+ return StashListMsg(stash)
+}
+
+func (s *Stash) fetchStashPatch() tea.Msg {
+ if s.repo == nil {
+ return StashPatchMsg{nil}
+ }
+
+ r, err := s.repo.Open()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+
+ diff, err := r.StashDiff(s.list.Index())
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+
+ return StashPatchMsg{diff}
+}
@@ -0,0 +1,106 @@
+package repo
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/soft-serve/server/ui/common"
+ gitm "github.com/gogs/git-module"
+)
+
+// StashItem represents a stash item.
+type StashItem struct{ *gitm.Stash }
+
+// ID returns the ID of the stash item.
+func (i StashItem) ID() string {
+ return fmt.Sprintf("stash@{%d}", i.Index)
+}
+
+// Title returns the title of the stash item.
+func (i StashItem) Title() string {
+ return i.Message
+}
+
+// Description returns the description of the stash item.
+func (i StashItem) Description() string {
+ return ""
+}
+
+// FilterValue implements list.Item.
+func (i StashItem) FilterValue() string { return i.Title() }
+
+// StashItems is a list of stash items.
+type StashItems []StashItem
+
+// Len implements sort.Interface.
+func (cl StashItems) Len() int { return len(cl) }
+
+// Swap implements sort.Interface.
+func (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+
+// Less implements sort.Interface.
+func (cl StashItems) Less(i, j int) bool {
+ return cl[i].Index < cl[j].Index
+}
+
+// StashItemDelegate is a delegate for stash items.
+type StashItemDelegate struct {
+ common *common.Common
+}
+
+// Height returns the height of the stash item list. Implements list.ItemDelegate.
+func (d StashItemDelegate) Height() int { return 1 }
+
+// Spacing implements list.ItemDelegate.
+func (d StashItemDelegate) Spacing() int { return 0 }
+
+// Update implements list.ItemDelegate.
+func (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+ item, ok := m.SelectedItem().(StashItem)
+ if !ok {
+ return nil
+ }
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, d.common.KeyMap.Copy):
+ return copyCmd(item.Title(), fmt.Sprintf("Stash message %q copied to clipboard", item.Title()))
+ }
+ }
+
+ return nil
+}
+
+// Render implements list.ItemDelegate.
+func (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+ item, ok := listItem.(StashItem)
+ if !ok {
+ return
+ }
+
+ s := d.common.Styles.Stash
+
+ st := s.Normal.Message
+ selector := " "
+ if index == m.Index() {
+ selector = "> "
+ st = s.Active.Message
+ }
+
+ selector = s.Selector.Render(selector)
+ title := st.Render(item.Title())
+ fmt.Fprint(w, d.common.Zone.Mark(
+ item.ID(),
+ common.TruncateString(fmt.Sprintf("%s%s",
+ selector,
+ title,
+ ), m.Width()-
+ s.Selector.GetWidth()-
+ st.GetHorizontalFrameSize(),
+ ),
+ ))
+}
@@ -201,7 +201,7 @@ func (s *Selection) Init() tea.Cmd {
sortedItems := make(Items, 0)
for _, r := range repos {
if r.Name() == ".soft-serve" {
- readme, path, err := backend.Readme(r)
+ readme, path, err := backend.Readme(r, nil)
if err != nil {
continue
}
@@ -89,16 +89,22 @@ type Styles struct {
Ref struct {
Normal struct {
- Item lipgloss.Style
- ItemTag lipgloss.Style
+ Base lipgloss.Style
+ Item lipgloss.Style
+ ItemTag lipgloss.Style
+ ItemDesc lipgloss.Style
+ ItemHash lipgloss.Style
}
Active struct {
- Item lipgloss.Style
- ItemTag lipgloss.Style
+ Base lipgloss.Style
+ Item lipgloss.Style
+ ItemTag lipgloss.Style
+ ItemDesc lipgloss.Style
+ ItemHash lipgloss.Style
}
ItemSelector lipgloss.Style
- ItemBranch lipgloss.Style
Paginator lipgloss.Style
+ Selector lipgloss.Style
}
Tree struct {
@@ -117,6 +123,21 @@ type Styles struct {
Selector lipgloss.Style
FileContent lipgloss.Style
Paginator lipgloss.Style
+ Blame struct {
+ Hash lipgloss.Style
+ Message lipgloss.Style
+ }
+ }
+
+ Stash struct {
+ Normal struct {
+ Message lipgloss.Style
+ }
+ Active struct {
+ Message lipgloss.Style
+ }
+ Title lipgloss.Style
+ Selector lipgloss.Style
}
Spinner lipgloss.Style
@@ -124,8 +145,6 @@ type Styles struct {
NoContent lipgloss.Style
- NoItems lipgloss.Style
-
StatusBar lipgloss.Style
StatusBarKey lipgloss.Style
StatusBarValue lipgloss.Style
@@ -137,6 +156,11 @@ type Styles struct {
TabInactive lipgloss.Style
TabActive lipgloss.Style
TabSeparator lipgloss.Style
+
+ Code struct {
+ LineDigit lipgloss.Style
+ LineBar lipgloss.Style
+ }
}
// DefaultStyles returns default styles for the UI.
@@ -227,7 +251,7 @@ func DefaultStyles() *Styles {
Margin(1, 0)
s.Repo.Header = lipgloss.NewStyle().
- Height(2).
+ MaxHeight(2).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("236"))
@@ -348,7 +372,9 @@ func DefaultStyles() *Styles {
s.Ref.Active.Item = lipgloss.NewStyle().
Foreground(highlightColorDim)
- s.Ref.ItemBranch = lipgloss.NewStyle()
+ s.Ref.Normal.Base = lipgloss.NewStyle()
+
+ s.Ref.Active.Base = lipgloss.NewStyle()
s.Ref.Normal.ItemTag = lipgloss.NewStyle().
Foreground(lipgloss.Color("39"))
@@ -361,8 +387,25 @@ func DefaultStyles() *Styles {
Bold(true).
Foreground(highlightColor)
+ s.Ref.Normal.ItemDesc = lipgloss.NewStyle().
+ Faint(true)
+
+ s.Ref.Active.ItemDesc = lipgloss.NewStyle().
+ Foreground(highlightColor).
+ Faint(true)
+
+ s.Ref.Normal.ItemHash = lipgloss.NewStyle().
+ Foreground(hashColor).
+ Bold(true)
+
+ s.Ref.Active.ItemHash = lipgloss.NewStyle().
+ Foreground(highlightColor).
+ Bold(true)
+
s.Ref.Paginator = s.Log.Paginator.Copy()
+ s.Ref.Selector = lipgloss.NewStyle()
+
s.Tree.Selector = s.Tree.Normal.FileName.Copy().
Width(1).
Foreground(selectorColor)
@@ -397,6 +440,12 @@ func DefaultStyles() *Styles {
s.Tree.Paginator = s.Log.Paginator.Copy()
+ s.Tree.Blame.Hash = lipgloss.NewStyle().
+ Foreground(hashColor).
+ Bold(true)
+
+ s.Tree.Blame.Message = lipgloss.NewStyle()
+
s.Spinner = lipgloss.NewStyle().
MarginTop(1).
MarginLeft(2).
@@ -405,15 +454,10 @@ func DefaultStyles() *Styles {
s.SpinnerContainer = lipgloss.NewStyle()
s.NoContent = lipgloss.NewStyle().
- SetString("No Content.").
MarginTop(1).
MarginLeft(2).
Foreground(lipgloss.Color("242"))
- s.NoItems = lipgloss.NewStyle().
- MarginLeft(2).
- Foreground(lipgloss.Color("242"))
-
s.StatusBar = lipgloss.NewStyle().
Height(1)
@@ -457,5 +501,21 @@ func DefaultStyles() *Styles {
Padding(0, 1).
Foreground(lipgloss.Color("238"))
+ s.Code.LineDigit = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+
+ s.Code.LineBar = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+
+ s.Stash.Normal.Message = lipgloss.NewStyle().MarginLeft(1)
+
+ s.Stash.Active.Message = s.Stash.Normal.Message.Copy().Foreground(selectorColor)
+
+ s.Stash.Title = lipgloss.NewStyle().
+ Foreground(hashColor).
+ Bold(true)
+
+ s.Stash.Selector = lipgloss.NewStyle().
+ Width(1).
+ Foreground(selectorColor)
+
return s
}
@@ -683,52 +683,51 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
},
})
return
- } else {
- locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
- if err != nil {
- logger.Error("error getting locks", "err", err)
- renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
- Message: "internal server error",
- })
- return
- }
+ }
- lockList := make([]lfs.Lock, len(locks))
- users := map[int64]models.User{}
- for i, lock := range locks {
- owner, ok := users[lock.UserID]
- if !ok {
- owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
- if err != nil {
- logger.Error("error getting lock owner", "err", err)
- renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
- Message: "internal server error",
- })
- return
- }
- users[lock.UserID] = owner
- }
+ locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
+ if err != nil {
+ logger.Error("error getting locks", "err", err)
+ renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+ Message: "internal server error",
+ })
+ return
+ }
- lockList[i] = lfs.Lock{
- ID: strconv.FormatInt(lock.ID, 10),
- Path: lock.Path,
- LockedAt: lock.CreatedAt,
- Owner: lfs.Owner{
- Name: owner.Username,
- },
+ lockList := make([]lfs.Lock, len(locks))
+ users := map[int64]models.User{}
+ for i, lock := range locks {
+ owner, ok := users[lock.UserID]
+ if !ok {
+ owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
+ if err != nil {
+ logger.Error("error getting lock owner", "err", err)
+ renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+ Message: "internal server error",
+ })
+ return
}
+ users[lock.UserID] = owner
}
- resp := lfs.LockListResponse{
- Locks: lockList,
- }
- if len(locks) == limit {
- resp.NextCursor = strconv.Itoa(cursor + 1)
+ lockList[i] = lfs.Lock{
+ ID: strconv.FormatInt(lock.ID, 10),
+ Path: lock.Path,
+ LockedAt: lock.CreatedAt,
+ Owner: lfs.Owner{
+ Name: owner.Username,
+ },
}
+ }
- renderJSON(w, http.StatusOK, resp)
- return
+ resp := lfs.LockListResponse{
+ Locks: lockList,
}
+ if len(locks) == limit {
+ resp.NextCursor = strconv.Itoa(cursor + 1)
+ }
+
+ renderJSON(w, http.StatusOK, resp)
}
// POST: /<repo>.git/info/lfs/objects/locks/verify