diff --git a/ui/components/selector/selector.go b/ui/components/selector/selector.go index 50a7226ed17da2f51efa33b6650355bfaa9bdad2..d6bdd564ce04618dae422a724902fcdffbbcf1aa 100644 --- a/ui/components/selector/selector.go +++ b/ui/components/selector/selector.go @@ -22,10 +22,10 @@ type IdentifiableItem interface { } // SelectMsg is a message that is sent when an item is selected. -type SelectMsg string +type SelectMsg struct{ IdentifiableItem } // ActiveMsg is a message that is sent when an item is active but not selected. -type ActiveMsg string +type ActiveMsg struct{ IdentifiableItem } // New creates a new selector. func New(common common.Common, items []IdentifiableItem, delegate list.ItemDelegate) *Selector { @@ -166,22 +166,27 @@ 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{} } - return SelectMsg(i.ID()) + return SelectMsg{i} } func (s *Selector) activeCmd() tea.Msg { item := s.Model.SelectedItem() i, ok := item.(IdentifiableItem) if !ok { - return ActiveMsg("") + return ActiveMsg{} } - return ActiveMsg(i.ID()) + return ActiveMsg{i} } func (s *Selector) activeFilterCmd() tea.Msg { @@ -197,5 +202,5 @@ func (s *Selector) activeFilterCmd() tea.Msg { if !ok { return nil } - return ActiveMsg(i.ID()) + return ActiveMsg{i} } diff --git a/ui/components/viewport/viewport.go b/ui/components/viewport/viewport.go index 0521740eec7b79ea2ead6afe04119bbca9138afa..59fc92b5a444e7bdd31203974cac23b67fe2cdfc 100644 --- a/ui/components/viewport/viewport.go +++ b/ui/components/viewport/viewport.go @@ -41,6 +41,11 @@ func (v *Viewport) View() string { return v.Viewport.View() } +// SetContent sets the viewport's content. +func (v *Viewport) SetContent(content string) { + v.Viewport.SetContent(content) +} + // GotoTop moves the viewport to the top of the log. func (v *Viewport) GotoTop() { v.Viewport.GotoTop() diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go index 77475e96d1d9b6990b5e4b1c506dbb7921b6b212..869219f575444846fada527f48781e9096529bf3 100644 --- a/ui/pages/repo/log.go +++ b/ui/pages/repo/log.go @@ -2,14 +2,22 @@ package repo import ( "fmt" + "strings" + "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/lipgloss" ggit "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/selector" "github.com/charmbracelet/soft-serve/ui/components/viewport" "github.com/charmbracelet/soft-serve/ui/git" + "github.com/muesli/reflow/wrap" + "github.com/muesli/termenv" ) type view int @@ -23,15 +31,21 @@ type LogCountMsg int64 type LogItemsMsg []list.Item +type LogCommitMsg *ggit.Commit + +type LogDiffMsg *ggit.Diff + type Log struct { - common common.Common - selector *selector.Selector - vp *viewport.Viewport - activeView view - repo git.GitRepo - ref *ggit.Reference - count int64 - nextPage int + common common.Common + selector *selector.Selector + vp *viewport.Viewport + activeView view + repo git.GitRepo + ref *ggit.Reference + count int64 + nextPage int + selectedCommit *ggit.Commit + currentDiff *ggit.Diff } func NewLog(common common.Common) *Log { @@ -60,6 +74,40 @@ func (l *Log) SetSize(width, height int) { l.vp.SetSize(width, height) } +func (l *Log) ShortHelp() []key.Binding { + switch l.activeView { + case logView: + return []key.Binding{ + key.NewBinding( + key.WithKeys( + "l", + "right", + ), + key.WithHelp( + "→", + "select", + ), + ), + } + case commitView: + return []key.Binding{ + l.common.KeyMap.UpDown, + key.NewBinding( + key.WithKeys( + "h", + "left", + ), + key.WithHelp( + "←", + "back", + ), + ), + } + default: + return []key.Binding{} + } +} + func (l *Log) Init() tea.Cmd { cmds := make([]tea.Cmd, 0) cmds = append(cmds, l.updateCommitsCmd) @@ -73,6 +121,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { l.count = 0 l.selector.Select(0) l.nextPage = 0 + l.activeView = 0 l.repo = git.GitRepo(msg) case RefMsg: l.ref = msg @@ -85,8 +134,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { l.selector.SetPage(l.nextPage) l.SetSize(l.common.Width, l.common.Height) case tea.KeyMsg, tea.MouseMsg: - // This is a hack for loading commits on demand based on list.Pagination. - if l.activeView == logView { + switch l.activeView { + case logView: + switch key := msg.(type) { + case tea.KeyMsg: + switch key.String() { + case "l", "right": + cmds = append(cmds, l.selector.SelectItem) + } + } + // 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) @@ -97,6 +154,47 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, l.updateCommitsCmd) } cmds = append(cmds, cmd) + case commitView: + switch key := msg.(type) { + case tea.KeyMsg: + switch key.String() { + case "h", "left": + l.activeView = logView + } + } + } + case selector.SelectMsg: + switch sel := msg.IdentifiableItem.(type) { + case LogItem: + cmds = append(cmds, l.selectCommitCmd(sel.Commit)) + } + case LogCommitMsg: + l.selectedCommit = msg + cmds = append(cmds, l.loadDiffCmd) + case LogDiffMsg: + l.currentDiff = msg + l.vp.SetContent( + lipgloss.JoinVertical(lipgloss.Top, + l.renderCommit(l.selectedCommit), + l.renderSummary(msg), + l.renderDiff(msg), + ), + ) + l.vp.GotoTop() + l.activeView = commitView + cmds = append(cmds, updateStatusBarCmd) + case tea.WindowSizeMsg: + 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), + ), + ) + } + if l.repo != nil { + cmds = append(cmds, l.updateCommitsCmd) } } switch l.activeView { @@ -127,6 +225,8 @@ func (l *Log) StatusBarInfo() string { // We're using l.nextPage instead of l.selector.Paginator.Page because // of the paginator hack above. return fmt.Sprintf("%d/%d", l.nextPage+1, l.selector.TotalPages()) + case commitView: + return fmt.Sprintf("%.f%%", l.vp.ScrollPercent()*100) default: return "" } @@ -168,3 +268,77 @@ func (l *Log) updateCommitsCmd() tea.Msg { } return LogItemsMsg(items) } + +func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd { + return func() tea.Msg { + return LogCommitMsg(commit) + } +} + +func (l *Log) loadDiffCmd() tea.Msg { + diff, err := l.repo.Diff(l.selectedCommit) + if err != nil { + return common.ErrorMsg(err) + } + return LogDiffMsg(diff) +} + +func styleConfig() gansi.StyleConfig { + noColor := "" + s := glamour.DarkStyleConfig + s.Document.StylePrimitive.Color = &noColor + s.CodeBlock.Chroma.Text.Color = &noColor + s.CodeBlock.Chroma.Name.Color = &noColor + return s +} + +func renderCtx() gansi.RenderContext { + return gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: styleConfig(), + }) +} + +func (l *Log) renderCommit(c *ggit.Commit) string { + s := strings.Builder{} + // FIXME: lipgloss prints empty lines when CRLF is used + // sanitize commit message from CRLF + msg := strings.ReplaceAll(c.Message, "\r\n", "\n") + s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", + l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()), + l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)), + l.common.Styles.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), + l.common.Styles.LogCommitBody.Render(msg), + )) + return wrap.String(s.String(), l.common.Width-2) +} + +func (l *Log) renderSummary(diff *ggit.Diff) 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.LogCommitStatsAdd.Render("+")) + adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-")) + stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel + } + } + return wrap.String(strings.Join(stats, "\n"), l.common.Width-2) +} + +func (l *Log) renderDiff(diff *ggit.Diff) string { + var s strings.Builder + var pr strings.Builder + diffChroma := &gansi.CodeBlockElement{ + Code: diff.Patch(), + Language: "diff", + } + err := diffChroma.Render(&pr, renderCtx()) + if err != nil { + s.WriteString(fmt.Sprintf("\n%s", err.Error())) + } else { + s.WriteString(fmt.Sprintf("\n%s", pr.String())) + } + return wrap.String(s.String(), l.common.Width-2) +} diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go index 69dbfe53c9545bd62744b1cc7313219090dc87e4..17a0f5d153fad1e97a3c76b5e6ee7eff01dbcf97 100644 --- a/ui/pages/repo/repo.go +++ b/ui/pages/repo/repo.go @@ -9,9 +9,11 @@ import ( ggit "github.com/charmbracelet/soft-serve/git" "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/statusbar" "github.com/charmbracelet/soft-serve/ui/components/tabs" "github.com/charmbracelet/soft-serve/ui/git" + "github.com/charmbracelet/soft-serve/ui/pages/selection" ) type tab int @@ -24,6 +26,8 @@ const ( tagsTab ) +type UpdateStatusBarMsg struct{} + // RepoMsg is a message that contains a git.Repository. type RepoMsg git.GitRepo @@ -35,6 +39,7 @@ type Repo struct { common common.Common rs git.GitRepoSource selectedRepo git.GitRepo + selectedItem selection.Item activeTab tab tabs *tabs.Tabs statusbar *statusbar.StatusBar @@ -68,7 +73,8 @@ func (r *Repo) SetSize(width, height int) { r.common.Styles.RepoHeader.GetHeight() + r.common.Styles.RepoHeader.GetVerticalFrameSize() + r.common.Styles.StatusBar.GetHeight() + - r.common.Styles.Tabs.GetHeight() + r.common.Styles.Tabs.GetHeight() + + r.common.Styles.Tabs.GetVerticalFrameSize() r.tabs.SetSize(width, height-hm) r.statusbar.SetSize(width, height-hm) r.readme.SetSize(width, height-hm) @@ -80,8 +86,16 @@ func (r *Repo) ShortHelp() []key.Binding { b := make([]key.Binding, 0) tab := r.common.KeyMap.Section tab.SetHelp("tab", "switch tab") - b = append(b, r.common.KeyMap.Back) + back := r.common.KeyMap.Back + back.SetHelp("esc", "repos") + b = append(b, back) b = append(b, tab) + switch r.activeTab { + case readmeTab: + b = append(b, r.common.KeyMap.UpDown) + case commitsTab: + b = append(b, r.log.ShortHelp()...) + } return b } @@ -100,6 +114,11 @@ func (r *Repo) Init() tea.Cmd { func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { + case selector.SelectMsg: + switch msg.IdentifiableItem.(type) { + case selection.Item: + r.selectedItem = msg.IdentifiableItem.(selection.Item) + } case RepoMsg: r.activeTab = 0 r.selectedRepo = git.GitRepo(msg) @@ -142,6 +161,19 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } + case UpdateStatusBarMsg: + cmds = append(cmds, r.updateStatusBarCmd) + case tea.WindowSizeMsg: + b, cmd := r.readme.Update(msg) + r.readme = b.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + l, cmd := r.log.Update(msg) + r.log = l.(*Log) + if cmd != nil { + cmds = append(cmds, cmd) + } } t, cmd := r.tabs.Update(msg) r.tabs = t.(*tabs.Tabs) @@ -183,7 +215,8 @@ func (r *Repo) View() string { r.common.Styles.RepoHeader.GetHeight() + r.common.Styles.RepoHeader.GetVerticalFrameSize() + r.common.Styles.StatusBar.GetHeight() + - r.common.Styles.Tabs.GetHeight() + r.common.Styles.Tabs.GetHeight() + + r.common.Styles.Tabs.GetVerticalFrameSize() mainStyle := repoBodyStyle. Height(r.common.Height - hm) main := mainStyle.Render("") @@ -196,6 +229,7 @@ func (r *Repo) View() string { } view := lipgloss.JoinVertical(lipgloss.Top, r.headerView(), + r.tabs.View(), main, r.statusbar.View(), ) @@ -206,12 +240,18 @@ func (r *Repo) headerView() string { if r.selectedRepo == nil { return "" } - name := r.common.Styles.RepoHeaderName.Render(r.selectedRepo.Name()) + name := r.common.Styles.RepoHeaderName.Render(r.selectedItem.Title()) + // TODO move this into a style. + url := lipgloss.NewStyle().MarginLeft(2).Render(r.selectedItem.URL()) + desc := r.common.Styles.RepoHeaderDesc.Render(r.selectedItem.Description()) style := r.common.Styles.RepoHeader.Copy().Width(r.common.Width) return style.Render( lipgloss.JoinVertical(lipgloss.Top, - name, - r.tabs.View(), + lipgloss.JoinHorizontal(lipgloss.Left, + name, + url, + ), + desc, ), ) } @@ -258,3 +298,7 @@ func (r *Repo) updateRefCmd() tea.Msg { } return RefMsg(head) } + +func updateStatusBarCmd() tea.Msg { + return UpdateStatusBarMsg{} +} diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go index 8736d0dc094085269b70e13b9b57ab33e91cd51a..ee91e75229fbc35439412ba3074b6493f4323eae 100644 --- a/ui/pages/selection/item.go +++ b/ui/pages/selection/item.go @@ -37,6 +37,10 @@ func (i Item) Description() string { return i.desc } // FilterValue implements list.Item. func (i Item) FilterValue() string { return i.name } +func (i Item) URL() string { + return i.url.View() +} + // ItemDelegate is the delegate for the item. type ItemDelegate struct { styles *styles.Styles diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go index 9879edd23e9eeae3ab0fd7a273f4ec671bd07a22..77476edc608835b5ba56c86df22a308631e4727b 100644 --- a/ui/pages/selection/selection.go +++ b/ui/pages/selection/selection.go @@ -132,7 +132,7 @@ func (s *Selection) Init() tea.Cmd { } items := make([]list.Item, 0) cfg := s.s.Config() - // TODO clean up this + // TODO clean up this and move style to its own var. yank := func(text string) *yankable.Yankable { return yankable.New( session, @@ -205,7 +205,6 @@ func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, s.changeActive(msg)) // reset readme position when active item change s.readme.GotoTop() - case selector.SelectMsg: case tea.KeyMsg: switch { case key.Matches(msg, s.common.KeyMap.Section): @@ -251,7 +250,7 @@ func (s *Selection) View() string { func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd { cfg := s.s.Config() - r, err := cfg.Source.GetRepo(string(msg)) + r, err := cfg.Source.GetRepo(msg.ID()) if err != nil { return common.ErrorCmd(err) } diff --git a/ui/styles/styles.go b/ui/styles/styles.go index 4b1a3d9b2204e9836adc7417e3fa6fc5f4bb4d1f..fbb0ba4384a11dabbed0def63aa50271c0754832 100644 --- a/ui/styles/styles.go +++ b/ui/styles/styles.go @@ -36,6 +36,7 @@ type Styles struct { RepoBody lipgloss.Style RepoHeader lipgloss.Style RepoHeaderName lipgloss.Style + RepoHeaderDesc lipgloss.Style Footer lipgloss.Style Branch lipgloss.Style @@ -196,6 +197,7 @@ func DefaultStyles() *Styles { Margin(1, 0) s.RepoHeader = lipgloss.NewStyle(). + Height(2). Border(lipgloss.NormalBorder(), false, false, true, false). BorderForeground(lipgloss.Color("241")) @@ -203,6 +205,9 @@ func DefaultStyles() *Styles { Foreground(lipgloss.Color("15")). Bold(true) + s.RepoHeaderDesc = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")) + s.Footer = lipgloss.NewStyle(). Height(1) @@ -348,8 +353,7 @@ func DefaultStyles() *Styles { Background(lipgloss.Color("#6E6ED8")). Foreground(lipgloss.Color("#F1F1F1")) - s.Tabs = lipgloss.NewStyle(). - Height(1) + s.Tabs = lipgloss.NewStyle() s.TabInactive = lipgloss.NewStyle(). Foreground(lipgloss.Color("15")) diff --git a/ui/ui.go b/ui/ui.go index b005bc3362a28892ae647ab4cebc9e0186a6cab2..1c298e18f012346b3c9f1c77bc0de968af9dfdd2 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -106,7 +106,7 @@ func (ui *UI) Init() tea.Cmd { // Update implements tea.Model. // TODO show full help func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - log.Printf("%T", msg) + log.Printf("msg: %T", msg) cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -143,9 +143,12 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ui.state = errorState return ui, nil case selector.SelectMsg: - if ui.activePage == 0 { - ui.activePage = (ui.activePage + 1) % 2 - cmds = append(cmds, ui.setRepoCmd(string(msg))) + switch msg.IdentifiableItem.(type) { + case selection.Item: + if ui.activePage == 0 { + ui.activePage = 1 + cmds = append(cmds, ui.setRepoCmd(msg.ID())) + } } } m, cmd := ui.pages[ui.activePage].Update(msg)