From 02e1617b19312e900e2544c3b2fb5a9457ce3fa8 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 25 Oct 2023 08:20:00 -0700 Subject: [PATCH] feat: browse local repositories (#369) * feat: browse local repositories * refactor(ui): resolve race conditions and clean up code * feat(ui): update readme based on selected reference * feat(ui): add branch/tag commit date and hash Fixes: https://github.com/charmbracelet/soft-serve/issues/382 * fix(ui): clean up statusbar logic * fix(ui): cleanup statusbar and misc msgs * fix(ui): preserve header line when no description is available * fix(ui): match readme and list missing items styles * fix(ui): header height calculation Fixes: 43b4331f88ff (fix(ui): preserve header line when no description is available) * fix(ui): truncate refitem msg Fixes: fd0240995004 (feat(ui): add branch/tag commit date and hash) * fix(ui): dry code line number and highlight formatting * feat(ui): add blame file view Fixes: https://github.com/charmbracelet/soft-serve/issues/149 * fix: lint errors * fix(ui): NaN floats, analyse file content, and start spinner * feat(ui): use right mouse click to go back in the files tab * fix(ui): display "1" when there's only one page to display * feat(ui): add stash view Display repository stashed items * feat: run browse on "soft" --- cmd/soft/browse.go | 301 +++++++++++++++++ cmd/soft/migrate_config.go | 2 +- cmd/soft/root.go | 3 + git/commit.go | 22 +- git/patch.go | 29 +- git/reference.go | 28 +- git/repo.go | 32 +- git/stash.go | 16 + git/tag.go | 6 + git/utils.go | 13 +- go.mod | 2 + go.sum | 5 +- server/backend/utils.go | 8 +- server/ssh/cmd/blob.go | 65 +--- server/ssh/cmd/commit.go | 7 +- server/ssh/cmd/tree.go | 2 +- server/ssh/session.go | 3 +- server/{ui => ssh}/ui.go | 19 +- server/ui/common/component.go | 18 + server/ui/common/format.go | 60 ++++ server/ui/common/utils.go | 2 +- server/ui/components/code/code.go | 200 +++++------ server/ui/components/selector/selector.go | 149 ++++++-- server/ui/components/statusbar/statusbar.go | 61 ++-- server/ui/pages/repo/files.go | 260 +++++++++----- server/ui/pages/repo/filesitem.go | 3 +- server/ui/pages/repo/log.go | 162 +++++---- server/ui/pages/repo/readme.go | 70 +++- server/ui/pages/repo/refs.go | 70 +++- server/ui/pages/repo/refsitem.go | 119 +++++-- server/ui/pages/repo/repo.go | 355 ++++++++------------ server/ui/pages/repo/stash.go | 279 +++++++++++++++ server/ui/pages/repo/stashitem.go | 106 ++++++ server/ui/pages/selection/selection.go | 2 +- server/ui/styles/styles.go | 88 ++++- server/web/git_lfs.go | 75 ++--- 36 files changed, 1844 insertions(+), 798 deletions(-) create mode 100644 cmd/soft/browse.go create mode 100644 git/stash.go create mode 100644 git/tag.go rename server/{ui => ssh}/ui.go (94%) create mode 100644 server/ui/common/format.go create mode 100644 server/ui/pages/repo/stash.go create mode 100644 server/ui/pages/repo/stashitem.go diff --git a/cmd/soft/browse.go b/cmd/soft/browse.go new file mode 100644 index 000000000..324e7cb46 --- /dev/null +++ b/cmd/soft/browse.go @@ -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 +} diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go index 86bea23d3..5138e93c0 100644 --- a/cmd/soft/migrate_config.go +++ b/cmd/soft/migrate_config.go @@ -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 diff --git a/cmd/soft/root.go b/cmd/soft/root.go index 31a1f7803..e031a67a3 100644 --- a/cmd/soft/root.go +++ b/cmd/soft/root.go @@ -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) + }, } ) diff --git a/git/commit.go b/git/commit.go index 3dd0a8ff3..1e955ff31 100644 --- a/git/commit.go +++ b/git/commit.go @@ -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 diff --git a/git/patch.go b/git/patch.go index aaa6b9d78..d166390cb 100644 --- a/git/patch.go +++ b/git/patch.go @@ -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 +} diff --git a/git/reference.go b/git/reference.go index ec01a53fd..47aceb95b 100644 --- a/git/reference.go +++ b/git/reference.go @@ -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 -} diff --git a/git/repo.go b/git/repo.go index ef3d2759d..b71abd1cf 100644 --- a/git/repo.go +++ b/git/repo.go @@ -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 } diff --git a/git/stash.go b/git/stash.go new file mode 100644 index 000000000..0a669ddd3 --- /dev/null +++ b/git/stash.go @@ -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 +} diff --git a/git/tag.go b/git/tag.go new file mode 100644 index 000000000..f09d39a25 --- /dev/null +++ b/git/tag.go @@ -0,0 +1,6 @@ +package git + +import "github.com/gogs/git-module" + +// Tag is a git tag. +type Tag = git.Tag diff --git a/git/utils.go b/git/utils.go index 3710e172d..b4ca50fc6 100644 --- a/git/utils.go +++ b/git/utils.go @@ -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 } diff --git a/go.mod b/go.mod index dfd99002a..25682ab2c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 37e2c33b4..22b77cacd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/backend/utils.go b/server/backend/utils.go index 024ba8af2..5e90873e5 100644 --- a/server/backend/utils.go +++ b/server/backend/utils.go @@ -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 } diff --git a/server/ssh/cmd/blob.go b/server/ssh/cmd/blob.go index 5065bd1c6..06034d94a 100644 --- a/server/ssh/cmd/blob.go +++ b/server/ssh/cmd/blob.go @@ -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 -} diff --git a/server/ssh/cmd/commit.go b/server/ssh/cmd/commit.go index f8ffaa1cf..75f02efbd 100644 --- a/server/ssh/cmd/commit.go +++ b/server/ssh/cmd/commit.go @@ -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 diff --git a/server/ssh/cmd/tree.go b/server/ssh/cmd/tree.go index ccb72a70f..e380a348e 100644 --- a/server/ssh/cmd/tree.go +++ b/server/ssh/cmd/tree.go @@ -49,7 +49,7 @@ func treeCommand() *cobra.Command { return err } - ref = head.Hash.String() + ref = head.ID } tree, err := r.LsTree(ref) diff --git a/server/ssh/session.go b/server/ssh/session.go index a5bd4d168..53496e3af 100644 --- a/server/ssh/session.go +++ b/server/ssh/session.go @@ -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), diff --git a/server/ui/ui.go b/server/ssh/ui.go similarity index 94% rename from server/ui/ui.go rename to server/ssh/ui.go index 5e259004d..87c73ad71 100644 --- a/server/ui/ui.go +++ b/server/ssh/ui.go @@ -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), diff --git a/server/ui/common/component.go b/server/ui/common/component.go index ed8b9bf05..1f4d20df8 100644 --- a/server/ui/common/component.go +++ b/server/ui/common/component.go @@ -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 +} diff --git a/server/ui/common/format.go b/server/ui/common/format.go new file mode 100644 index 000000000..90d00dc54 --- /dev/null +++ b/server/ui/common/format.go @@ -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 +} diff --git a/server/ui/common/utils.go b/server/ui/common/utils.go index aac2db6da..49eaf00ce 100644 --- a/server/ui/common/utils.go +++ b/server/ui/common/utils.go @@ -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)) } diff --git a/server/ui/components/code/code.go b/server/ui/components/code/code.go index aff1a5265..5d6d4a91e 100644 --- a/server/ui/components/code/code.go +++ b/server/ui/components/code/code.go @@ -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 } diff --git a/server/ui/components/selector/selector.go b/server/ui/components/selector/selector.go index aaf3191f3..22dcfefaf 100644 --- a/server/ui/components/selector/selector.go +++ b/server/ui/components/selector/selector.go @@ -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 } diff --git a/server/ui/components/statusbar/statusbar.go b/server/ui/components/statusbar/statusbar.go index e5f0f70a5..f60e07795 100644 --- a/server/ui/components/statusbar/statusbar.go +++ b/server/ui/components/statusbar/statusbar.go @@ -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( diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go index a0a79352b..38521a7be 100644 --- a/server/ui/pages/repo/files.go +++ b/server/ui/pages/repo/files.go @@ -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" +} diff --git a/server/ui/pages/repo/filesitem.go b/server/ui/pages/repo/filesitem.go index d9d4841c2..c4f358b50 100644 --- a/server/ui/pages/repo/filesitem.go +++ b/server/ui/pages/repo/filesitem.go @@ -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. diff --git a/server/ui/pages/repo/log.go b/server/ui/pages/repo/log.go index 60659e5a6..5ea585431 100644 --- a/server/ui/pages/repo/log.go +++ b/server/ui/pages/repo/log.go @@ -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 { diff --git a/server/ui/pages/repo/readme.go b/server/ui/pages/repo/readme.go index 6c6b610f7..fe7207326 100644 --- a/server/ui/pages/repo/readme.go +++ b/server/ui/pages/repo/readme.go @@ -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 } diff --git a/server/ui/pages/repo/refs.go b/server/ui/pages/repo/refs.go index 2dac5b381..e89eee409 100644 --- a/server/ui/pages/repo/refs.go +++ b/server/ui/pages/repo/refs.go @@ -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) } diff --git a/server/ui/pages/repo/refsitem.go b/server/ui/pages/repo/refsitem.go index 0d552bf01..4ebec8669 100644 --- a/server/ui/pages/repo/refsitem.go +++ b/server/ui/pages/repo/refsitem.go @@ -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)), + ), + ), ), ) } diff --git a/server/ui/pages/repo/repo.go b/server/ui/pages/repo/repo.go index 3f1927ba8..dde1f2fee 100644 --- a/server/ui/pages/repo/repo.go +++ b/server/ui/pages/repo/repo.go @@ -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) } diff --git a/server/ui/pages/repo/stash.go b/server/ui/pages/repo/stash.go new file mode 100644 index 000000000..0693743f3 --- /dev/null +++ b/server/ui/pages/repo/stash.go @@ -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} +} diff --git a/server/ui/pages/repo/stashitem.go b/server/ui/pages/repo/stashitem.go new file mode 100644 index 000000000..482a40ad3 --- /dev/null +++ b/server/ui/pages/repo/stashitem.go @@ -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(), + ), + )) +} diff --git a/server/ui/pages/selection/selection.go b/server/ui/pages/selection/selection.go index b85573d33..b84b0a33d 100644 --- a/server/ui/pages/selection/selection.go +++ b/server/ui/pages/selection/selection.go @@ -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 } diff --git a/server/ui/styles/styles.go b/server/ui/styles/styles.go index 63805bbbe..fbaab6015 100644 --- a/server/ui/styles/styles.go +++ b/server/ui/styles/styles.go @@ -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 } diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go index d73f5a442..4d25f76e9 100644 --- a/server/web/git_lfs.go +++ b/server/web/git_lfs.go @@ -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: /.git/info/lfs/objects/locks/verify