1package repo23import (4 "fmt"5 "strings"6 "time"78 "charm.land/bubbles/v2/key"9 "charm.land/bubbles/v2/spinner"10 tea "charm.land/bubbletea/v2"11 gansi "charm.land/glamour/v2/ansi"12 "charm.land/lipgloss/v2"13 "github.com/charmbracelet/soft-serve/git"14 "github.com/charmbracelet/soft-serve/pkg/proto"15 "github.com/charmbracelet/soft-serve/pkg/ui/common"16 "github.com/charmbracelet/soft-serve/pkg/ui/components/footer"17 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"18 "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport"19 "github.com/charmbracelet/soft-serve/pkg/ui/styles"20 "github.com/muesli/reflow/wrap"21)2223var waitBeforeLoading = time.Millisecond * 1002425type logView int2627const (28 logViewLoading logView = iota29 logViewCommits30 logViewDiff31)3233// LogCountMsg is a message that contains the number of commits in a repo.34type LogCountMsg int643536// LogItemsMsg is a message that contains a slice of LogItem.37type LogItemsMsg []selector.IdentifiableItem3839// LogCommitMsg is a message that contains a git commit.40type LogCommitMsg *git.Commit4142// LogDiffMsg is a message that contains a git diff.43type LogDiffMsg *git.Diff4445// Log is a model that displays a list of commits and their diffs.46type Log struct {47 common common.Common48 selector *selector.Selector49 vp *viewport.Viewport50 activeView logView51 repo proto.Repository52 ref *git.Reference53 count int6454 nextPage int55 activeCommit *git.Commit56 selectedCommit *git.Commit57 currentDiff *git.Diff58 loadingTime time.Time59 spinner spinner.Model60}6162// NewLog creates a new Log model.63func NewLog(common common.Common) *Log {64 l := &Log{65 common: common,66 vp: viewport.New(common),67 activeView: logViewCommits,68 }69 selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})70 selector.SetShowFilter(false)71 selector.SetShowHelp(false)72 selector.SetShowPagination(false)73 selector.SetShowStatusBar(false)74 selector.SetShowTitle(false)75 selector.SetFilteringEnabled(false)76 selector.DisableQuitKeybindings()77 selector.KeyMap.NextPage = common.KeyMap.NextPage78 selector.KeyMap.PrevPage = common.KeyMap.PrevPage79 l.selector = selector80 s := spinner.New(spinner.WithSpinner(spinner.Dot),81 spinner.WithStyle(common.Styles.Spinner))82 l.spinner = s83 return l84}8586// Path implements common.TabComponent.87func (l *Log) Path() string {88 switch l.activeView {89 case logViewCommits:90 return ""91 default:92 return "diff" // XXX: this is a place holder and doesn't mean anything93 }94}9596// TabName returns the name of the tab.97func (l *Log) TabName() string {98 return "Commits"99}100101// SetSize implements common.Component.102func (l *Log) SetSize(width, height int) {103 l.common.SetSize(width, height)104 l.selector.SetSize(width, height)105 l.vp.SetSize(width, height)106}107108// ShortHelp implements help.KeyMap.109func (l *Log) ShortHelp() []key.Binding {110 switch l.activeView {111 case logViewCommits:112 copyKey := l.common.KeyMap.Copy113 copyKey.SetHelp("c", "copy hash")114 return []key.Binding{115 l.common.KeyMap.UpDown,116 l.common.KeyMap.SelectItem,117 copyKey,118 }119 case logViewDiff:120 copyKey := l.common.KeyMap.Copy121 copyKey.SetHelp("c", "copy diff")122 return []key.Binding{123 l.common.KeyMap.UpDown,124 l.common.KeyMap.BackItem,125 copyKey,126 l.common.KeyMap.GotoTop,127 l.common.KeyMap.GotoBottom,128 }129 default:130 return []key.Binding{}131 }132}133134// FullHelp implements help.KeyMap.135func (l *Log) FullHelp() [][]key.Binding {136 k := l.selector.KeyMap137 b := make([][]key.Binding, 0)138 switch l.activeView {139 case logViewCommits:140 copyKey := l.common.KeyMap.Copy141 copyKey.SetHelp("c", "copy hash")142 b = append(b, []key.Binding{143 l.common.KeyMap.SelectItem,144 l.common.KeyMap.BackItem,145 })146 b = append(b, [][]key.Binding{147 {148 copyKey,149 k.CursorUp,150 k.CursorDown,151 },152 {153 k.NextPage,154 k.PrevPage,155 k.GoToStart,156 k.GoToEnd,157 },158 }...)159 case logViewDiff:160 copyKey := l.common.KeyMap.Copy161 copyKey.SetHelp("c", "copy diff")162 k := l.vp.KeyMap163 b = append(b, []key.Binding{164 l.common.KeyMap.BackItem,165 copyKey,166 })167 b = append(b, [][]key.Binding{168 {169 k.PageDown,170 k.PageUp,171 k.HalfPageDown,172 k.HalfPageUp,173 },174 {175 k.Down,176 k.Up,177 l.common.KeyMap.GotoTop,178 l.common.KeyMap.GotoBottom,179 },180 }...)181 }182 return b183}184185func (l *Log) startLoading() tea.Cmd {186 l.loadingTime = time.Now()187 l.activeView = logViewLoading188 return l.spinner.Tick189}190191// Init implements tea.Model.192func (l *Log) Init() tea.Cmd {193 l.activeView = logViewCommits194 l.nextPage = 0195 l.count = 0196 l.activeCommit = nil197 l.selectedCommit = nil198 return tea.Batch(199 l.countCommitsCmd,200 // start loading on init201 l.startLoading(),202 )203}204205// Update implements tea.Model.206func (l *Log) Update(msg tea.Msg) (common.Model, tea.Cmd) {207 cmds := make([]tea.Cmd, 0)208 switch msg := msg.(type) {209 case RepoMsg:210 l.repo = msg211 case RefMsg:212 l.ref = msg213 l.selector.Select(0)214 cmds = append(cmds, l.Init())215 case LogCountMsg:216 l.count = int64(msg)217 l.selector.SetTotalPages(int(msg))218 l.selector.SetItems(make([]selector.IdentifiableItem, l.count))219 cmds = append(cmds, l.updateCommitsCmd)220 case LogItemsMsg:221 // stop loading after receiving items222 l.activeView = logViewCommits223 cmds = append(cmds, l.selector.SetItems(msg))224 l.selector.SetPage(l.nextPage)225 l.SetSize(l.common.Width, l.common.Height)226 i := l.selector.SelectedItem()227 if i != nil {228 l.activeCommit = i.(LogItem).Commit229 }230 case tea.KeyPressMsg, tea.MouseClickMsg:231 switch l.activeView {232 case logViewCommits:233 switch kmsg := msg.(type) {234 case tea.KeyPressMsg:235 switch {236 case key.Matches(kmsg, l.common.KeyMap.SelectItem):237 cmds = append(cmds, l.selector.SelectItemCmd)238 }239 }240 // XXX: This is a hack for loading commits on demand based on241 // list.Pagination.242 curPage := l.selector.Page()243 s, cmd := l.selector.Update(msg)244 m := s.(*selector.Selector)245 l.selector = m246 if m.Page() != curPage {247 l.nextPage = m.Page()248 l.selector.SetPage(curPage)249 cmds = append(cmds,250 l.updateCommitsCmd,251 l.startLoading(),252 )253 }254 cmds = append(cmds, cmd)255 case logViewDiff:256 switch kmsg := msg.(type) {257 case tea.KeyPressMsg:258 switch {259 case key.Matches(kmsg, l.common.KeyMap.BackItem):260 l.goBack()261 case key.Matches(kmsg, l.common.KeyMap.Copy):262 if l.currentDiff != nil {263 cmds = append(cmds, copyCmd(l.currentDiff.Patch(), "Commit diff copied to clipboard"))264 }265 }266 }267 }268 case GoBackMsg:269 l.goBack()270 case selector.ActiveMsg:271 switch sel := msg.IdentifiableItem.(type) {272 case LogItem:273 l.activeCommit = sel.Commit274 }275 case selector.SelectMsg:276 switch sel := msg.IdentifiableItem.(type) {277 case LogItem:278 cmds = append(cmds,279 l.selectCommitCmd(sel.Commit),280 l.startLoading(),281 )282 }283 case LogCommitMsg:284 l.selectedCommit = msg285 cmds = append(cmds, l.loadDiffCmd)286 case LogDiffMsg:287 l.currentDiff = msg288 l.vp.SetContent(289 lipgloss.JoinVertical(lipgloss.Left,290 l.renderCommit(l.selectedCommit),291 renderSummary(msg, l.common.Styles, l.common.Width),292 renderDiff(msg, l.common.Width),293 ),294 )295 l.vp.GotoTop()296 l.activeView = logViewDiff297 case footer.ToggleFooterMsg:298 cmds = append(cmds, l.updateCommitsCmd)299 case tea.WindowSizeMsg:300 l.SetSize(msg.Width, msg.Height)301 if l.selectedCommit != nil && l.currentDiff != nil {302 l.vp.SetContent(303 lipgloss.JoinVertical(lipgloss.Left,304 l.renderCommit(l.selectedCommit),305 renderSummary(l.currentDiff, l.common.Styles, l.common.Width),306 renderDiff(l.currentDiff, l.common.Width),307 ),308 )309 }310 if l.repo != nil && l.ref != nil {311 cmds = append(cmds,312 l.updateCommitsCmd,313 // start loading on resize since the number of commits per page314 // might change and we'd need to load more commits.315 l.startLoading(),316 )317 }318 case EmptyRepoMsg:319 l.ref = nil320 l.activeView = logViewCommits321 l.nextPage = 0322 l.count = 0323 l.activeCommit = nil324 l.selectedCommit = nil325 l.selector.Select(0)326 cmds = append(cmds,327 l.setItems([]selector.IdentifiableItem{}),328 )329 case spinner.TickMsg:330 if l.activeView == logViewLoading && l.spinner.ID() == msg.ID {331 s, cmd := l.spinner.Update(msg)332 if cmd != nil {333 cmds = append(cmds, cmd)334 }335 l.spinner = s336 }337 }338 switch l.activeView {339 case logViewDiff:340 vp, cmd := l.vp.Update(msg)341 l.vp = vp.(*viewport.Viewport)342 if cmd != nil {343 cmds = append(cmds, cmd)344 }345 }346 return l, tea.Batch(cmds...)347}348349// View implements tea.Model.350func (l *Log) View() string {351 switch l.activeView {352 case logViewLoading:353 if l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {354 msg := fmt.Sprintf("%s loading commit", l.spinner.View())355 if l.selectedCommit == nil {356 msg += "s"357 }358 msg += "…"359 return l.common.Styles.SpinnerContainer.360 Height(l.common.Height).361 Render(msg)362 }363 fallthrough364 case logViewCommits:365 return l.selector.View()366 case logViewDiff:367 return l.vp.View()368 default:369 return ""370 }371}372373// SpinnerID implements common.TabComponent.374func (l *Log) SpinnerID() int {375 return l.spinner.ID()376}377378// StatusBarValue returns the status bar value.379func (l *Log) StatusBarValue() string {380 if l.activeView == logViewLoading {381 return ""382 }383 c := l.activeCommit384 if c == nil {385 return ""386 }387 who := c.Author.Name388 if email := c.Author.Email; email != "" {389 who += " <" + email + ">"390 }391 value := c.ID.String()[:7]392 if who != "" {393 value += " by " + who394 }395 return value396}397398// StatusBarInfo returns the status bar info.399func (l *Log) StatusBarInfo() string {400 switch l.activeView {401 case logViewLoading:402 if l.count == 0 {403 return ""404 }405 fallthrough406 case logViewCommits:407 // We're using l.nextPage instead of l.selector.Paginator.Page because408 // of the paginator hack above.409 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())410 case logViewDiff:411 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)412 default:413 return ""414 }415}416417func (l *Log) goBack() {418 if l.activeView == logViewDiff {419 l.activeView = logViewCommits420 l.selectedCommit = nil421 }422}423424func (l *Log) countCommitsCmd() tea.Msg {425 if l.ref == nil {426 return nil427 }428 r, err := l.repo.Open()429 if err != nil {430 return common.ErrorMsg(err)431 }432 count, err := r.CountCommits(l.ref)433 if err != nil {434 l.common.Logger.Debugf("ui: error counting commits: %v", err)435 return common.ErrorMsg(err)436 }437 return LogCountMsg(count)438}439440func (l *Log) updateCommitsCmd() tea.Msg {441 if l.ref == nil {442 return nil443 }444 r, err := l.repo.Open()445 if err != nil {446 return common.ErrorMsg(err)447 }448449 count := l.count450 if count == 0 {451 return LogItemsMsg([]selector.IdentifiableItem{})452 }453454 page := l.nextPage455 limit := l.selector.PerPage()456 skip := page * limit457 ref := l.ref458 items := make([]selector.IdentifiableItem, count)459 // CommitsByPage pages start at 1460 cc, err := r.CommitsByPage(ref, page+1, limit)461 if err != nil {462 l.common.Logger.Debugf("ui: error loading commits: %v", err)463 return common.ErrorMsg(err)464 }465 for i, c := range cc {466 idx := i + skip467 if int64(idx) >= count {468 break469 }470 items[idx] = LogItem{Commit: c}471 }472 return LogItemsMsg(items)473}474475func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {476 return func() tea.Msg {477 return LogCommitMsg(commit)478 }479}480481func (l *Log) loadDiffCmd() tea.Msg {482 if l.selectedCommit == nil {483 return nil484 }485 r, err := l.repo.Open()486 if err != nil {487 l.common.Logger.Debugf("ui: error loading diff repository: %v", err)488 return common.ErrorMsg(err)489 }490 diff, err := r.Diff(l.selectedCommit)491 if err != nil {492 l.common.Logger.Debugf("ui: error loading diff: %v", err)493 return common.ErrorMsg(err)494 }495 return LogDiffMsg(diff)496}497498func (l *Log) renderCommit(c *git.Commit) string {499 s := strings.Builder{}500 // FIXME: lipgloss prints empty lines when CRLF is used501 // sanitize commit message from CRLF502 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")503 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",504 l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),505 l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),506 l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),507 l.common.Styles.Log.CommitBody.Render(msg),508 ))509 return wrap.String(s.String(), l.common.Width-2)510}511512func renderSummary(diff *git.Diff, styles *styles.Styles, width int) string {513 stats := strings.Split(diff.Stats().String(), "\n")514 for i, line := range stats {515 ch := strings.Split(line, "|")516 if len(ch) > 1 {517 adddel := ch[len(ch)-1]518 adddel = strings.ReplaceAll(adddel, "+", styles.Log.CommitStatsAdd.Render("+"))519 adddel = strings.ReplaceAll(adddel, "-", styles.Log.CommitStatsDel.Render("-"))520 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel521 }522 }523 return wrap.String(strings.Join(stats, "\n"), width-2)524}525526func renderDiff(diff *git.Diff, width int) string {527 var s strings.Builder528 var pr strings.Builder529 diffChroma := &gansi.CodeBlockElement{530 Code: diff.Patch(),531 Language: "diff",532 }533 err := diffChroma.Render(&pr, common.StyleRenderer())534 if err != nil {535 s.WriteString(fmt.Sprintf("\n%s", err.Error()))536 } else {537 s.WriteString(fmt.Sprintf("\n%s", pr.String()))538 }539 return wrap.String(s.String(), width)540}541542func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {543 return func() tea.Msg {544 return LogItemsMsg(items)545 }546}