diff --git a/ui/common/common.go b/ui/common/common.go index 8a5cb451a92824ba6289b438c0b03919bfbeba0a..3c157addf220748a79d4d035c2f3acd77bb78644 100644 --- a/ui/common/common.go +++ b/ui/common/common.go @@ -1,20 +1,56 @@ package common import ( + "context" + "github.com/aymanbagabas/go-osc52" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/ui/git" "github.com/charmbracelet/soft-serve/ui/keymap" "github.com/charmbracelet/soft-serve/ui/styles" + "github.com/gliderlabs/ssh" zone "github.com/lrstanley/bubblezone" ) +type contextKey struct { + name string +} + +// Keys to use for context.Context. +var ( + ConfigKey = &contextKey{"config"} + RepoKey = &contextKey{"repo"} +) + // Common is a struct all components should embed. type Common struct { - Copy *osc52.Output - Styles *styles.Styles - KeyMap *keymap.KeyMap - Width int - Height int - Zone *zone.Manager + ctx context.Context + Width, Height int + Styles *styles.Styles + KeyMap *keymap.KeyMap + Copy *osc52.Output + Zone *zone.Manager +} + +// NewCommon returns a new Common struct. +func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common { + if ctx == nil { + ctx = context.TODO() + } + return Common{ + ctx: ctx, + Width: width, + Height: height, + Copy: copy, + Styles: styles.DefaultStyles(), + KeyMap: keymap.DefaultKeyMap(), + Zone: zone.New(), + } +} + +// SetValue sets a value in the context. +func (c *Common) SetValue(key, value interface{}) { + c.ctx = context.WithValue(c.ctx, key, value) } // SetSize sets the width and height of the common struct. @@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) { c.Width = width c.Height = height } + +// Config returns the server config. +func (c *Common) Config() *config.Config { + v := c.ctx.Value(ConfigKey) + if cfg, ok := v.(*config.Config); ok { + return cfg + } + return nil +} + +// Repo returns the repository. +func (c *Common) Repo() *git.Repository { + v := c.ctx.Value(RepoKey) + if r, ok := v.(*git.Repository); ok { + return r + } + return nil +} + +// PublicKey returns the public key. +func (c *Common) PublicKey() ssh.PublicKey { + v := c.ctx.Value(ssh.ContextKeyPublicKey) + if p, ok := v.(ssh.PublicKey); ok { + return p + } + return nil +} diff --git a/ui/git.go b/ui/git.go deleted file mode 100644 index e4dcf80a2453d0f946ae09ad5dbd7ba71eb53571..0000000000000000000000000000000000000000 --- a/ui/git.go +++ /dev/null @@ -1,25 +0,0 @@ -package ui - -import ( - "github.com/charmbracelet/soft-serve/config" - "github.com/charmbracelet/soft-serve/ui/git" -) - -// source is a wrapper around config.RepoSource that implements git.GitRepoSource. -type source struct { - *config.RepoSource -} - -// GetRepo implements git.GitRepoSource. -func (s *source) GetRepo(name string) (git.GitRepo, error) { - return s.RepoSource.GetRepo(name) -} - -// AllRepos implements git.GitRepoSource. -func (s *source) AllRepos() []git.GitRepo { - rs := make([]git.GitRepo, 0) - for _, r := range s.RepoSource.AllRepos() { - rs = append(rs, r) - } - return rs -} diff --git a/ui/git/git.go b/ui/git/git.go index c51fee1cddf57cf602985154c117385552a1d752..4ecea43ec3e4af2e2e256999b57ce45bbc8cfa5e 100644 --- a/ui/git/git.go +++ b/ui/git/git.go @@ -4,32 +4,28 @@ import ( "errors" "fmt" - "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/proto" ) // ErrMissingRepo indicates that the requested repository could not be found. var ErrMissingRepo = errors.New("missing repo") -// GitRepo is an interface for Git repositories. -type GitRepo interface { - Repo() string - Name() string - Description() string - Readme() (string, string) - HEAD() (*git.Reference, error) - Commit(string) (*git.Commit, error) - CommitsByPage(*git.Reference, int, int) (git.Commits, error) - CountCommits(*git.Reference) (int64, error) - Diff(*git.Commit) (*git.Diff, error) - References() ([]*git.Reference, error) - Tree(*git.Reference, string) (*git.Tree, error) - IsPrivate() bool +// Repository is a Git repository with its metadata. +type Repository struct { + Repo proto.Repository + Info proto.Metadata } -// GitRepoSource is an interface for Git repository factory. -type GitRepoSource interface { - GetRepo(string) (GitRepo, error) - AllRepos() []GitRepo +// Readme returns the repository's README. +func (r *Repository) Readme() (readme string, path string) { + readme, path, _ = r.LatestFile("README*") + return +} + +// LatestFile returns the contents of the latest file at the specified path in +// the repository and its file path. +func (r *Repository) LatestFile(pattern string) (string, string, error) { + return proto.LatestFile(r.Repo, pattern) } // RepoURL returns the URL of the repository. diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go index 745016102a4c169f60381927f28f036b59167041..bed41f0d3410b90c698d5a7b7c80467ecaa11d53 100644 --- a/ui/pages/repo/files.go +++ b/ui/pages/repo/files.go @@ -3,6 +3,7 @@ package repo import ( "errors" "fmt" + "log" "path/filepath" "github.com/alecthomas/chroma/lexers" @@ -51,7 +52,7 @@ type Files struct { selector *selector.Selector ref *ggit.Reference activeView filesView - repo git.GitRepo + repo *git.Repository code *code.Code path string currentItem *FileItem @@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: - f.repo = git.GitRepo(msg) - cmds = append(cmds, f.Init()) + f.repo = msg case RefMsg: f.ref = msg cmds = append(cmds, f.Init()) @@ -320,14 +320,17 @@ func (f *Files) updateFilesCmd() tea.Msg { files := make([]selector.IdentifiableItem, 0) dirs := make([]selector.IdentifiableItem, 0) if f.ref == nil { + log.Printf("ui: files: ref is nil") return common.ErrorMsg(errNoRef) } - t, err := f.repo.Tree(f.ref, f.path) + t, err := f.repo.Repo.Repository().TreePath(f.ref, f.path) if err != nil { + log.Printf("ui: files: error getting tree %v", err) return common.ErrorMsg(err) } ents, err := t.Entries() if err != nil { + log.Printf("ui: files: error listing files %v", err) return common.ErrorMsg(err) } ents.Sort() @@ -347,6 +350,7 @@ func (f *Files) selectTreeCmd() tea.Msg { f.selector.Select(0) return f.updateFilesCmd() } + log.Printf("ui: files: current item is not a tree") return common.ErrorMsg(errNoFileSelected) } @@ -355,25 +359,30 @@ 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) } 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) } f.lastSelected = append(f.lastSelected, f.selector.Index()) return FileContentMsg{string(c), i.entry.Name()} } + log.Printf("ui: files: current item is not a file") return common.ErrorMsg(errNoFileSelected) } diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go index a1511a5ff37db765010d83757d5ac92808d4c164..92bdb53ddcafa6df849bbb460a47525d9dcf7eaf 100644 --- a/ui/pages/repo/log.go +++ b/ui/pages/repo/log.go @@ -2,6 +2,7 @@ package repo import ( "fmt" + "log" "strings" "time" @@ -47,7 +48,7 @@ type Log struct { selector *selector.Selector vp *viewport.Viewport activeView logView - repo git.GitRepo + repo *git.Repository ref *ggit.Reference count int64 nextPage int @@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log { selector.KeyMap.NextPage = common.KeyMap.NextPage selector.KeyMap.PrevPage = common.KeyMap.PrevPage l.selector = selector - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = common.Styles.Spinner + s := spinner.New(spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(common.Styles.Spinner)) l.spinner = s return l } @@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: - l.repo = git.GitRepo(msg) - cmds = append(cmds, l.Init()) + l.repo = msg case RefMsg: l.ref = msg cmds = append(cmds, l.Init()) @@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if l.activeView == logViewDiff { l.activeView = logViewCommits l.selectedCommit = nil + cmds = append(cmds, updateStatusBarCmd) } case selector.ActiveMsg: switch sel := msg.IdentifiableItem.(type) { @@ -326,7 +326,9 @@ func (l *Log) View() string { msg += "s" } msg += "…" - return msg + return l.common.Styles.SpinnerContainer.Copy(). + Height(l.common.Height). + Render(msg) } switch l.activeView { case logViewCommits: @@ -374,10 +376,12 @@ func (l *Log) StatusBarInfo() string { func (l *Log) countCommitsCmd() tea.Msg { if l.ref == nil { + log.Printf("ui: log: ref is nil") return common.ErrorMsg(errNoRef) } - count, err := l.repo.CountCommits(l.ref) + count, err := l.repo.Repo.Repository().CountCommits(l.ref) if err != nil { + log.Printf("ui: error counting commits: %v", err) return common.ErrorMsg(err) } return LogCountMsg(count) @@ -394,6 +398,7 @@ func (l *Log) updateCommitsCmd() tea.Msg { } } if l.ref == nil { + log.Printf("ui: log: ref is nil") return common.ErrorMsg(errNoRef) } items := make([]selector.IdentifiableItem, count) @@ -401,8 +406,9 @@ func (l *Log) updateCommitsCmd() tea.Msg { limit := l.selector.PerPage() skip := page * limit // CommitsByPage pages start at 1 - cc, err := l.repo.CommitsByPage(l.ref, page+1, limit) + cc, err := l.repo.Repo.Repository().CommitsByPage(l.ref, page+1, limit) if err != nil { + log.Printf("ui: error loading commits: %v", err) return common.ErrorMsg(err) } for i, c := range cc { @@ -422,8 +428,9 @@ func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd { } func (l *Log) loadDiffCmd() tea.Msg { - diff, err := l.repo.Diff(l.selectedCommit) + diff, err := l.repo.Repo.Repository().Diff(l.selectedCommit) if err != nil { + log.Printf("ui: error loading diff: %v", err) return common.ErrorMsg(err) } return LogDiffMsg(diff) diff --git a/ui/pages/repo/logitem.go b/ui/pages/repo/logitem.go index b30c21d67f4b371c121b0007c5b93073811e078d..b7defc1d9af9984c0e33a214a23b0ffbe97b40aa 100644 --- a/ui/pages/repo/logitem.go +++ b/ui/pages/repo/logitem.go @@ -26,6 +26,7 @@ func (i LogItem) ID() string { return i.Hash() } +// Hash returns the commit hash. func (i LogItem) Hash() string { return i.Commit.ID.String() } diff --git a/ui/pages/repo/readme.go b/ui/pages/repo/readme.go index 8605d320545e323fae40e2a7c0d6dd3761a03732..405b6c43917d8c0e0a5532125513f488d73b527f 100644 --- a/ui/pages/repo/readme.go +++ b/ui/pages/repo/readme.go @@ -10,14 +10,17 @@ import ( "github.com/charmbracelet/soft-serve/ui/git" ) -type ReadmeMsg struct{} +// ReadmeMsg is a message sent when the readme is loaded. +type ReadmeMsg struct { + Msg tea.Msg +} // Readme is the readme component page. type Readme struct { common common.Common code *code.Code ref RefMsg - repo git.GitRepo + repo *git.Repository } // NewReadme creates a new readme model. @@ -64,15 +67,7 @@ func (r *Readme) FullHelp() [][]key.Binding { // Init implements tea.Model. func (r *Readme) Init() tea.Cmd { - if r.repo == nil { - return common.ErrorCmd(git.ErrMissingRepo) - } - rm, rp := r.repo.Readme() - r.code.GotoTop() - return tea.Batch( - r.code.SetContent(rm, rp), - r.updateReadmeCmd, - ) + return r.updateReadmeCmd } // Update implements tea.Model. @@ -80,8 +75,7 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: - r.repo = git.GitRepo(msg) - cmds = append(cmds, r.Init()) + r.repo = msg case RefMsg: r.ref = msg cmds = append(cmds, r.Init()) @@ -110,5 +104,15 @@ func (r *Readme) StatusBarInfo() string { } func (r *Readme) updateReadmeCmd() tea.Msg { - return ReadmeMsg{} + m := ReadmeMsg{} + if r.repo == nil { + return common.ErrorCmd(git.ErrMissingRepo) + } + rm, rp := r.repo.Readme() + r.code.GotoTop() + cmd := r.code.SetContent(rm, rp) + if cmd != nil { + m.Msg = cmd() + } + return m } diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go index 308a26288372c3ecbcaa4b9b985e51075fb4136f..8dec6fc3a7e10db2d93393159c9673805f3ebf11 100644 --- a/ui/pages/repo/refs.go +++ b/ui/pages/repo/refs.go @@ -3,6 +3,7 @@ package repo import ( "errors" "fmt" + "log" "sort" "strings" @@ -19,6 +20,9 @@ var ( errNoRef = errors.New("no reference specified") ) +// RefMsg is a message that contains a git.Reference. +type RefMsg *ggit.Reference + // RefItemsMsg is a message that contains a list of RefItem. type RefItemsMsg struct { prefix string @@ -29,7 +33,7 @@ type RefItemsMsg struct { type Refs struct { common common.Common selector *selector.Selector - repo git.GitRepo + repo *git.Repository ref *ggit.Reference activeRef *ggit.Reference refPrefix string @@ -104,8 +108,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case RepoMsg: r.selector.Select(0) - r.repo = git.GitRepo(msg) - cmds = append(cmds, r.Init()) + r.repo = msg case RefMsg: r.ref = msg cmds = append(cmds, r.Init()) @@ -169,8 +172,9 @@ func (r *Refs) StatusBarInfo() string { func (r *Refs) updateItemsCmd() tea.Msg { its := make(RefItems, 0) - refs, err := r.repo.References() + refs, err := r.repo.Repo.Repository().References() if err != nil { + log.Printf("ui: error getting references: %v", err) return common.ErrorMsg(err) } for _, ref := range refs { @@ -194,3 +198,16 @@ func switchRefCmd(ref *ggit.Reference) tea.Cmd { return RefMsg(ref) } } + +// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg. +func UpdateRefCmd(repo *git.Repository) tea.Cmd { + return func() tea.Msg { + ref, err := repo.Repo.Repository().HEAD() + if err != nil { + log.Printf("ui: error getting HEAD reference: %v", err) + return common.ErrorMsg(err) + } + log.Printf("HEAD: %s", ref.Name()) + return RefMsg(ref) + } +} diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go index c6e5be91cde6b148040119b0e10f7abfca452437..e57895097f84163a6f142d00f388eb9d9acba6fb 100644 --- a/ui/pages/repo/repo.go +++ b/ui/pages/repo/repo.go @@ -9,7 +9,6 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" ggit "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/footer" @@ -56,10 +55,7 @@ type ResetURLMsg struct{} type UpdateStatusBarMsg struct{} // RepoMsg is a message that contains a git.Repository. -type RepoMsg git.GitRepo - -// RefMsg is a message that contains a git.Reference. -type RefMsg *ggit.Reference +type RepoMsg *git.Repository // BackMsg is a message to go back to the previous view. type BackMsg struct{} @@ -67,18 +63,20 @@ type BackMsg struct{} // Repo is a view for a git repository. type Repo struct { common common.Common - cfg *config.Config - selectedRepo git.GitRepo + selectedRepo *git.Repository activeTab tab tabs *tabs.Tabs statusbar *statusbar.StatusBar panes []common.Component ref *ggit.Reference copyURL time.Time + state state + spinner spinner.Model + panesReady [lastTab]bool } // New returns a new Repo. -func New(cfg *config.Config, c common.Common) *Repo { +func New(c common.Common) *Repo { sb := statusbar.New(c) ts := make([]string, lastTab) // Tabs must match the order of tab constants above. @@ -99,12 +97,15 @@ func New(cfg *config.Config, c common.Common) *Repo { branches, tags, } + s := spinner.New(spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(c.Styles.Spinner)) r := &Repo{ - cfg: cfg, common: c, tabs: tb, statusbar: sb, panes: panes, + state: loadingState, + spinner: s, } return r } @@ -162,16 +163,20 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) 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 = git.GitRepo(msg) + r.selectedRepo = msg cmds = append(cmds, r.tabs.Init(), - r.updateRefCmd, + // This will set the selected repo in each pane's model. r.updateModels(msg), ) 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, @@ -200,7 +205,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if r.selectedRepo != nil { cmds = append(cmds, r.updateStatusBarCmd) - urlID := fmt.Sprintf("%s-url", r.selectedRepo.Repo()) + urlID := fmt.Sprintf("%s-url", r.selectedRepo.Info.Name()) if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) { cmds = append(cmds, r.copyURLCmd()) } @@ -221,41 +226,32 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case CopyURLMsg: - r.common.Copy.Copy( - git.RepoURL(r.cfg.Host, r.cfg.Port, r.selectedRepo.Repo()), - ) + if cfg := r.common.Config(); cfg != nil { + host := cfg.Host + port := cfg.SSH.Port + r.common.Copy.Copy( + git.RepoURL(host, port, r.selectedRepo.Info.Name()), + ) + } case ResetURLMsg: r.copyURL = time.Time{} - case ReadmeMsg: - case FileItemsMsg: - f, cmd := r.panes[filesTab].Update(msg) - r.panes[filesTab] = f.(*Files) - if cmd != nil { - cmds = append(cmds, cmd) - } - // The Log bubble is the only bubble that uses a spinner, so this is fine - // for now. We need to pass the TickMsg to the Log bubble when the Log is - // loading but not the current selected tab so that the spinner works. - case LogCountMsg, LogItemsMsg, spinner.TickMsg: - l, cmd := r.panes[commitsTab].Update(msg) - r.panes[commitsTab] = l.(*Log) - if cmd != nil { - cmds = append(cmds, cmd) - } - case RefItemsMsg: - switch msg.prefix { - case ggit.RefsHeads: - b, cmd := r.panes[branchesTab].Update(msg) - r.panes[branchesTab] = b.(*Refs) - if cmd != nil { - cmds = append(cmds, cmd) - } - case ggit.RefsTags: - t, cmd := r.panes[tagsTab].Update(msg) - r.panes[tagsTab] = t.(*Refs) - if cmd != nil { - cmds = append(cmds, cmd) + case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg: + cmds = append(cmds, r.updateRepo(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) + } } + default: + cmds = append(cmds, r.updateRepo(msg)) } case UpdateStatusBarMsg: cmds = append(cmds, r.updateStatusBarCmd) @@ -289,15 +285,24 @@ func (r *Repo) View() string { r.common.Styles.Tabs.GetVerticalFrameSize() mainStyle := repoBodyStyle. Height(r.common.Height - hm) - main := r.common.Zone.Mark( + var main string + var statusbar string + switch r.state { + case loadingState: + main = fmt.Sprintf("%s loading…", r.spinner.View()) + case loadedState: + main = r.panes[r.activeTab].View() + statusbar = r.statusbar.View() + } + main = r.common.Zone.Mark( "repo-main", - mainStyle.Render(r.panes[r.activeTab].View()), + mainStyle.Render(main), ) view := lipgloss.JoinVertical(lipgloss.Top, r.headerView(), r.tabs.View(), main, - r.statusbar.View(), + statusbar, ) return s.Render(view) } @@ -306,10 +311,9 @@ func (r *Repo) headerView() string { if r.selectedRepo == nil { return "" } - cfg := r.cfg truncate := lipgloss.NewStyle().MaxWidth(r.common.Width) - name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name()) - desc := r.selectedRepo.Description() + name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Info.Name()) + desc := r.selectedRepo.Info.Description() if desc == "" { desc = name name = "" @@ -319,13 +323,16 @@ func (r *Repo) headerView() string { urlStyle := r.common.Styles.URLStyle.Copy(). Width(r.common.Width - lipgloss.Width(desc) - 1). Align(lipgloss.Right) - url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo()) + var url string + if cfg := r.common.Config(); cfg != nil { + url = git.RepoURL(cfg.Host, cfg.SSH.Port, r.selectedRepo.Info.Name()) + } if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) { url = "copied!" } url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1) url = r.common.Zone.Mark( - fmt.Sprintf("%s-url", r.selectedRepo.Repo()), + fmt.Sprintf("%s-url", r.selectedRepo.Info.Name()), urlStyle.Render(url), ) style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width) @@ -351,24 +358,13 @@ func (r *Repo) updateStatusBarCmd() tea.Msg { ref = r.ref.Name().Short() } return statusbar.StatusBarMsg{ - Key: r.selectedRepo.Repo(), + Key: r.selectedRepo.Info.Name(), Value: value, Info: info, Branch: fmt.Sprintf("* %s", ref), } } -func (r *Repo) updateRefCmd() tea.Msg { - if r.selectedRepo == nil { - return nil - } - head, err := r.selectedRepo.HEAD() - if err != nil { - return common.ErrorMsg(err) - } - return RefMsg(head) -} - func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, 0) for i, b := range r.panes { @@ -381,6 +377,67 @@ func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } +func (r *Repo) updateRepo(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) + if cmd != nil { + cmds = append(cmds, cmd) + } + case RefItemsMsg: + switch msg.prefix { + case ggit.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 ggit.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 = loadedState + } + 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 (r *Repo) copyURLCmd() tea.Cmd { r.copyURL = time.Now() return tea.Batch( diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go index 97abe72c1669a702c352183db1118ce84adeb58c..abb3a9472d732b7dcd42b1d288f062718ef77915 100644 --- a/ui/pages/selection/item.go +++ b/ui/pages/selection/item.go @@ -3,6 +3,8 @@ package selection import ( "fmt" "io" + "log" + "sort" "strings" "time" @@ -10,29 +12,77 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/proto" + "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/git" "github.com/dustin/go-humanize" ) +var _ sort.Interface = Items{} + +// Items is a list of Item. +type Items []Item + +// Len implements sort.Interface. +func (it Items) Len() int { + return len(it) +} + +// Less implements sort.Interface. +func (it Items) Less(i int, j int) bool { + return it[i].lastUpdate.After(it[j].lastUpdate) +} + +// Swap implements sort.Interface. +func (it Items) Swap(i int, j int) { + it[i], it[j] = it[j], it[i] +} + // Item represents a single item in the selector. type Item struct { - repo git.GitRepo + repo proto.Repository + info proto.Metadata lastUpdate time.Time cmd string copied time.Time } +// New creates a new Item. +func NewItem(info proto.Metadata, cfg *config.Config) (Item, error) { + repo, err := info.Open() + if err != nil { + log.Printf("error opening repo: %v", err) + return Item{}, err + } + lu, err := repo.Repository().LatestCommitTime() + if err != nil { + log.Printf("error getting latest commit time: %v", err) + return Item{}, err + } + return Item{ + repo: repo, + info: info, + lastUpdate: lu, + cmd: git.RepoURL(cfg.Host, cfg.SSH.Port, info.Name()), + }, nil +} + // ID implements selector.IdentifiableItem. func (i Item) ID() string { - return i.repo.Repo() + return i.info.Name() } // Title returns the item title. Implements list.DefaultItem. -func (i Item) Title() string { return i.repo.Name() } +func (i Item) Title() string { + if pn := i.info.ProjectName(); pn != "" { + return pn + } + return i.info.Name() +} // Description returns the item description. Implements list.DefaultItem. -func (i Item) Description() string { return i.repo.Description() } +func (i Item) Description() string { return i.info.Description() } // FilterValue implements list.Item. func (i Item) FilterValue() string { return i.Title() } @@ -101,7 +151,7 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list title := i.Title() title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize()) - if i.repo.IsPrivate() { + if i.info.IsPrivate() { title += " 🔒" } if isSelected { diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go index 159ee9bd3e9b4ebee85ae4363ca29ee9f1dbe853..00925da27723212b9b468d97d5452bc9637f9887 100644 --- a/ui/pages/selection/selection.go +++ b/ui/pages/selection/selection.go @@ -2,20 +2,18 @@ package selection import ( "fmt" - "strings" + "log" + "sort" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/proto" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/code" "github.com/charmbracelet/soft-serve/ui/components/selector" "github.com/charmbracelet/soft-serve/ui/components/tabs" - "github.com/charmbracelet/soft-serve/ui/git" - "github.com/gliderlabs/ssh" ) type pane int @@ -35,8 +33,6 @@ func (p pane) String() string { // Selection is the model for the selection screen/page. type Selection struct { - cfg *config.Config - pk ssh.PublicKey common common.Common readme *code.Code readmeHeight int @@ -46,7 +42,7 @@ type Selection struct { } // New creates a new selection model. -func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection { +func New(common common.Common) *Selection { ts := make([]string, lastPane) for i, b := range []pane{selectorPane, readmePane} { ts[i] = b.String() @@ -58,8 +54,6 @@ func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection t.TabDot = common.Styles.TopLevelActiveTabDot.Copy() t.UseDot = true sel := &Selection{ - cfg: cfg, - pk: pk, common: common, activePane: selectorPane, // start with the selector focused tabs: t, @@ -184,59 +178,34 @@ func (s *Selection) FullHelp() [][]key.Binding { // Init implements tea.Model. func (s *Selection) Init() tea.Cmd { var readmeCmd tea.Cmd - items := make([]selector.IdentifiableItem, 0) - cfg := s.cfg - pk := s.pk + cfg := s.common.Config() + pk := s.common.PublicKey() + if cfg == nil || pk == nil { + return nil + } + repos, err := cfg.ListRepos() + if err != nil { + return common.ErrorCmd(err) + } + sortedItems := make(Items, 0) // Put configured repos first - for _, r := range cfg.Repos { - acc := cfg.AuthRepo(r.Repo, pk) - if r.Private && acc < proto.ReadOnlyAccess { + for _, r := range repos { + log.Printf("adding configured repo %s", r.Name()) + acc := cfg.AuthRepo(r.Name(), pk) + if r.IsPrivate() && acc < proto.ReadOnlyAccess { continue } - repo, err := cfg.Source.GetRepo(r.Repo) + item, err := NewItem(r, cfg) if err != nil { + log.Printf("ui: failed to create item for %s: %v", r.Name(), err) continue } - items = append(items, Item{ - repo: repo, - cmd: git.RepoURL(cfg.Host, cfg.Port, r.Repo), - }) + sortedItems = append(sortedItems, item) } - for _, r := range cfg.Source.AllRepos() { - if r.Repo() == "config" { - rm, rp := r.Readme() - s.readmeHeight = strings.Count(rm, "\n") - readmeCmd = s.readme.SetContent(rm, rp) - } - acc := cfg.AuthRepo(r.Repo(), pk) - if r.IsPrivate() && acc < proto.ReadOnlyAccess { - continue - } - exists := false - lc, err := r.Commit("HEAD") - if err != nil { - return common.ErrorCmd(err) - } - lastUpdate := lc.Committer.When - if lastUpdate.IsZero() { - lastUpdate = lc.Author.When - } - for i, item := range items { - item := item.(Item) - if item.repo.Repo() == r.Repo() { - exists = true - item.lastUpdate = lastUpdate - items[i] = item - break - } - } - if !exists { - items = append(items, Item{ - repo: r, - lastUpdate: lastUpdate, - cmd: git.RepoURL(cfg.Host, cfg.Port, r.Name()), - }) - } + sort.Sort(sortedItems) + items := make([]selector.IdentifiableItem, len(sortedItems)) + for i, it := range sortedItems { + items[i] = it } return tea.Batch( s.selector.Init(), diff --git a/ui/styles/styles.go b/ui/styles/styles.go index f596a09a1b167688b82fd5f81b7e7cf85ed38a21..4ec2ac1d36ecf137567dad79cf701b95f596c1ff 100644 --- a/ui/styles/styles.go +++ b/ui/styles/styles.go @@ -122,7 +122,8 @@ type Styles struct { NoItems lipgloss.Style } - Spinner lipgloss.Style + Spinner lipgloss.Style + SpinnerContainer lipgloss.Style CodeNoContent lipgloss.Style @@ -409,6 +410,8 @@ func DefaultStyles() *Styles { MarginLeft(2). Foreground(lipgloss.Color("205")) + s.SpinnerContainer = lipgloss.NewStyle() + s.CodeNoContent = lipgloss.NewStyle(). SetString("No Content."). MarginTop(1). diff --git a/ui/ui.go b/ui/ui.go index dd482ac68c14bf39d343072410bed90ec1a7de67..0e529f255d6d00001699bda8a8181a2c71df7797 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,11 +1,13 @@ package ui import ( + "errors" + "log" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/footer" "github.com/charmbracelet/soft-serve/ui/components/header" @@ -13,7 +15,6 @@ import ( "github.com/charmbracelet/soft-serve/ui/git" "github.com/charmbracelet/soft-serve/ui/pages/repo" "github.com/charmbracelet/soft-serve/ui/pages/selection" - "github.com/gliderlabs/ssh" ) type page int @@ -33,9 +34,7 @@ const ( // UI is the main UI model. type UI struct { - cfg *config.Config - session ssh.Session - rs git.GitRepoSource + serverName string initialRepo string common common.Common pages []common.Component @@ -48,13 +47,14 @@ type UI struct { } // New returns a new UI model. -func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI { - src := &source{cfg.Source} - h := header.New(c, cfg.Name) +func New(c common.Common, initialRepo string) *UI { + var serverName string + if cfg := c.Config(); cfg != nil { + serverName = cfg.ServerName + } + h := header.New(c, serverName) ui := &UI{ - cfg: cfg, - session: s, - rs: src, + serverName: serverName, common: c, pages: make([]common.Component, 2), // selection & repo activePage: selectionPage, @@ -136,15 +136,8 @@ func (ui *UI) SetSize(width, height int) { // Init implements tea.Model. func (ui *UI) Init() tea.Cmd { - ui.pages[selectionPage] = selection.New( - ui.cfg, - ui.session.PublicKey(), - ui.common, - ) - ui.pages[repoPage] = repo.New( - ui.cfg, - ui.common, - ) + ui.pages[selectionPage] = selection.New(ui.common) + ui.pages[repoPage] = repo.New(ui.common) ui.SetSize(ui.common.Width, ui.common.Height) cmds := make([]tea.Cmd, 0) cmds = append(cmds, @@ -171,6 +164,7 @@ func (ui *UI) IsFiltering() bool { // Update implements tea.Model. func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + log.Printf("msg received: %T", msg) cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -220,9 +214,11 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ui.showFooter = !ui.showFooter } case repo.RepoMsg: + ui.common.SetValue(common.RepoKey, msg) ui.activePage = repoPage // Show the footer on repo page if show all is set. ui.showFooter = ui.footer.ShowAll() + cmds = append(cmds, repo.UpdateRefCmd(msg)) case common.ErrorMsg: ui.error = msg ui.state = errorState @@ -292,24 +288,48 @@ func (ui *UI) View() string { ) } +func (ui *UI) openRepo(rn string) (*git.Repository, error) { + cfg := ui.common.Config() + if cfg == nil { + return nil, errors.New("config is nil") + } + repos, err := cfg.ListRepos() + if err != nil { + log.Printf("ui: failed to list repos: %v", err) + return nil, err + } + for _, r := range repos { + if r.Name() == rn { + re, err := cfg.Open(rn) + if err != nil { + log.Printf("ui: failed to open repo: %v", err) + return nil, err + } + return &git.Repository{ + Info: r, + Repo: re, + }, nil + } + } + return nil, git.ErrMissingRepo +} + func (ui *UI) setRepoCmd(rn string) tea.Cmd { return func() tea.Msg { - for _, r := range ui.rs.AllRepos() { - if r.Repo() == rn { - return repo.RepoMsg(r) - } + r, err := ui.openRepo(rn) + if err != nil { + return common.ErrorMsg(err) } - return common.ErrorMsg(git.ErrMissingRepo) + return repo.RepoMsg(r) } } func (ui *UI) initialRepoCmd(rn string) tea.Cmd { return func() tea.Msg { - for _, r := range ui.rs.AllRepos() { - if r.Repo() == rn { - return repo.RepoMsg(r) - } + r, err := ui.openRepo(rn) + if err != nil { + return nil } - return nil + return repo.RepoMsg(r) } }