From 2e007a93688fd0ca35932ae558b72226553d775f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 24 Oct 2023 17:52:06 -0400 Subject: [PATCH] feat(ui): add stash view Display repository stashed items --- cmd/soft/browse.go | 127 +++++++------- git/patch.go | 21 +++ git/repo.go | 21 +-- git/stash.go | 16 ++ go.mod | 2 + go.sum | 5 +- server/ui/pages/repo/log.go | 21 +-- server/ui/pages/repo/repo.go | 5 +- server/ui/pages/repo/stash.go | 279 ++++++++++++++++++++++++++++++ server/ui/pages/repo/stashitem.go | 106 ++++++++++++ server/ui/styles/styles.go | 23 +++ 11 files changed, 531 insertions(+), 95 deletions(-) create mode 100644 git/stash.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 index 009c4de62..324e7cb46 100644 --- a/cmd/soft/browse.go +++ b/cmd/soft/browse.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "path/filepath" "time" @@ -31,24 +32,32 @@ var browseCmd = &cobra.Command{ 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{ - m: repo.New(c, - repo.NewReadme(c), - repo.NewFiles(c), - repo.NewLog(c), - repo.NewRefs(c, git.RefsHeads), - repo.NewRefs(c, git.RefsTags), - ), - repoPath: abs, - c: c, + model: repo.New(c, comps...), + repo: repository{r}, + common: c, } - m.f = footer.New(c, m) + m.footer = footer.New(c, m) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion(), @@ -74,10 +83,10 @@ const ( ) type model struct { - m *repo.Repo - f *footer.Footer - repoPath string - c common.Common + model *repo.Repo + footer *footer.Footer + repo proto.Repository + common common.Common state state showFooter bool error error @@ -86,16 +95,16 @@ type model struct { var _ tea.Model = &model{} func (m *model) SetSize(w, h int) { - m.c.SetSize(w, h) - style := m.c.Styles.App.Copy() + m.common.SetSize(w, h) + style := m.common.Styles.App.Copy() wm := style.GetHorizontalFrameSize() hm := style.GetVerticalFrameSize() if m.showFooter { - hm += m.f.Height() + hm += m.footer.Height() } - m.f.SetSize(w-wm, h-hm) - m.m.SetSize(w-wm, h-hm) + m.footer.SetSize(w-wm, h-hm) + m.model.SetSize(w-wm, h-hm) } // ShortHelp implements help.KeyMap. @@ -103,12 +112,12 @@ func (m model) ShortHelp() []key.Binding { switch m.state { case errorState: return []key.Binding{ - m.c.KeyMap.Back, - m.c.KeyMap.Quit, - m.c.KeyMap.Help, + m.common.KeyMap.Back, + m.common.KeyMap.Quit, + m.common.KeyMap.Help, } default: - return m.m.ShortHelp() + return m.model.ShortHelp() } } @@ -118,67 +127,61 @@ func (m model) FullHelp() [][]key.Binding { case errorState: return [][]key.Binding{ { - m.c.KeyMap.Back, + m.common.KeyMap.Back, }, { - m.c.KeyMap.Quit, - m.c.KeyMap.Help, + m.common.KeyMap.Quit, + m.common.KeyMap.Help, }, } default: - return m.m.FullHelp() + return m.model.FullHelp() } } // Init implements tea.Model. func (m *model) Init() tea.Cmd { - rr, err := git.Open(m.repoPath) - if err != nil { - return common.ErrorCmd(err) - } - - r := repository{rr} return tea.Batch( - m.m.Init(), - m.f.Init(), + m.model.Init(), + m.footer.Init(), func() tea.Msg { - return repo.RepoMsg(r) + return repo.RepoMsg(m.repo) }, - repo.UpdateRefCmd(r), + repo.UpdateRefCmd(m.repo), ) } // Update implements tea.Model. func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - m.c.Logger.Debugf("msg received: %T", msg) + 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.c.KeyMap.Back) && m.error != nil: + 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.f.ShowAll() - case key.Matches(msg, m.c.KeyMap.Help): + m.showFooter = m.footer.ShowAll() + case key.Matches(msg, m.common.KeyMap.Help): cmds = append(cmds, footer.ToggleFooterCmd) - case key.Matches(msg, m.c.KeyMap.Quit): + case key.Matches(msg, m.common.KeyMap.Quit): // Stop bubblezone background workers. - m.c.Zone.Close() + m.common.Zone.Close() return m, tea.Quit } case tea.MouseMsg: switch msg.Type { case tea.MouseLeft: switch { - case m.c.Zone.Get("footer").InBounds(msg): + case m.common.Zone.Get("footer").InBounds(msg): cmds = append(cmds, footer.ToggleFooterCmd) } } case footer.ToggleFooterMsg: - m.f.SetShowAll(!m.f.ShowAll()) + m.footer.SetShowAll(!m.footer.ShowAll()) m.showFooter = !m.showFooter case common.ErrorMsg: m.error = msg @@ -186,54 +189,54 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showFooter = true } - f, cmd := m.f.Update(msg) - m.f = f.(*footer.Footer) + f, cmd := m.footer.Update(msg) + m.footer = f.(*footer.Footer) if cmd != nil { cmds = append(cmds, cmd) } - r, cmd := m.m.Update(msg) - m.m = r.(*repo.Repo) + 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.c.Width, m.c.Height) + m.SetSize(m.common.Width, m.common.Height) return m, tea.Batch(cmds...) } // View implements tea.Model. func (m *model) View() string { - style := m.c.Styles.App.Copy() + style := m.common.Styles.App.Copy() wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize() if m.showFooter { - hm += m.f.Height() + hm += m.footer.Height() } var view string switch m.state { case startState: - view = m.m.View() + view = m.model.View() case errorState: - err := m.c.Styles.ErrorTitle.Render("Bummer") - err += m.c.Styles.ErrorBody.Render(m.error.Error()) - view = m.c.Styles.Error.Copy(). - Width(m.c.Width - + 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.c.Styles.ErrorBody.GetHorizontalFrameSize()). - Height(m.c.Height - + m.common.Styles.ErrorBody.GetHorizontalFrameSize()). + Height(m.common.Height - hm - - m.c.Styles.Error.GetVerticalFrameSize()). + m.common.Styles.Error.GetVerticalFrameSize()). Render(err) } if m.showFooter { - view = lipgloss.JoinVertical(lipgloss.Top, view, m.f.View()) + view = lipgloss.JoinVertical(lipgloss.Top, view, m.footer.View()) } - return m.c.Zone.Scan(style.Render(view)) + return m.common.Zone.Scan(style.Render(view)) } type repository struct { diff --git a/git/patch.go b/git/patch.go index f20312cc4..d166390cb 100644 --- a/git/patch.go +++ b/git/patch.go @@ -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/repo.go b/git/repo.go index 62f001910..b71abd1cf 100644 --- a/git/repo.go +++ b/git/repo.go @@ -140,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.ID.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"}, }, @@ -148,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. 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/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/ui/pages/repo/log.go b/server/ui/pages/repo/log.go index ede919630..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" ) @@ -268,8 +269,8 @@ 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() @@ -282,8 +283,8 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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), ), ) } @@ -496,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{ @@ -523,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/repo.go b/server/ui/pages/repo/repo.go index 86d4230ca..dde1f2fee 100644 --- a/server/ui/pages/repo/repo.go +++ b/server/ui/pages/repo/repo.go @@ -208,6 +208,8 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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. @@ -259,7 +261,8 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg, FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg, - LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg: + LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg, + StashListMsg, StashPatchMsg: r.setStatusBarInfo() } 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/styles/styles.go b/server/ui/styles/styles.go index 6a5078072..fbaab6015 100644 --- a/server/ui/styles/styles.go +++ b/server/ui/styles/styles.go @@ -129,6 +129,17 @@ type Styles struct { } } + Stash struct { + Normal struct { + Message lipgloss.Style + } + Active struct { + Message lipgloss.Style + } + Title lipgloss.Style + Selector lipgloss.Style + } + Spinner lipgloss.Style SpinnerContainer lipgloss.Style @@ -494,5 +505,17 @@ func DefaultStyles() *Styles { 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 }