clean

Ayman Bagabas created

Change summary

server/cmd/cat.go                  |   4 
tui/about/bubble.go                | 122 ----------
tui/bubble.go                      | 155 ------------
tui/common/consts.go               |  28 --
tui/common/error.go                |  36 ---
tui/common/formatter.go            |  88 -------
tui/common/git.go                  |  16 -
tui/common/help.go                 |  10 
tui/common/reset.go                |   7 
tui/common/utils.go                |  17 -
tui/log/bubble.go                  | 383 --------------------------------
tui/refs/bubble.go                 | 185 ---------------
tui/tree/bubble.go                 | 341 ----------------------------
tui/viewport/viewport_patch.go     |  24 --
ui/common/style.go                 |  18 +
ui/components/code/code.go         |  13 -
ui/components/copy/copy.go         |  64 -----
ui/components/footer/footer.go     |   4 
ui/components/header/header.go     |   3 
ui/components/yankable/yankable.go |  61 -----
ui/ui.go                           |   2 
21 files changed, 27 insertions(+), 1,554 deletions(-)

Detailed changes

server/cmd/cat.go 🔗

@@ -8,7 +8,7 @@ import (
 	gansi "github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/tui/common"
+	"github.com/charmbracelet/soft-serve/ui/common"
 	gitwish "github.com/charmbracelet/wish/git"
 	"github.com/muesli/termenv"
 	"github.com/spf13/cobra"
@@ -109,7 +109,7 @@ func withFormatting(p, c string) (string, error) {
 		Language: lang,
 	}
 	r := strings.Builder{}
-	styles := common.DefaultStyles()
+	styles := common.StyleConfig()
 	styles.CodeBlock.Margin = &zero
 	rctx := gansi.NewRenderContext(gansi.Options{
 		Styles:       styles,

tui/about/bubble.go 🔗

@@ -1,122 +0,0 @@
-package about
-
-import (
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
-	"github.com/muesli/reflow/wrap"
-)
-
-type Bubble struct {
-	readmeViewport *vp.ViewportBubble
-	repo           common.GitRepo
-	styles         *style.Styles
-	height         int
-	heightMargin   int
-	width          int
-	widthMargin    int
-	ref            *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
-	b := &Bubble{
-		readmeViewport: &vp.ViewportBubble{
-			Viewport: &viewport.Model{},
-		},
-		repo:         repo,
-		styles:       styles,
-		widthMargin:  wm,
-		heightMargin: hm,
-	}
-	b.SetSize(width, height)
-	return b
-}
-func (b *Bubble) Init() tea.Cmd {
-	return b.reset
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-		// XXX: if we find that longer readmes take more than a few
-		// milliseconds to render we may need to move Glamour rendering into a
-		// command.
-		md, err := b.glamourize()
-		if err != nil {
-			return b, nil
-		}
-		b.readmeViewport.Viewport.SetContent(md)
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "R":
-			return b, b.reset
-		}
-	case refs.RefMsg:
-		b.ref = msg
-		return b, b.reset
-	}
-	rv, cmd := b.readmeViewport.Update(msg)
-	b.readmeViewport = rv.(*vp.ViewportBubble)
-	cmds = append(cmds, cmd)
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) SetSize(w, h int) {
-	b.width = w
-	b.height = h
-	b.readmeViewport.Viewport.Width = w - b.widthMargin
-	b.readmeViewport.Viewport.Height = h - b.heightMargin
-}
-
-func (b *Bubble) GotoTop() {
-	b.readmeViewport.Viewport.GotoTop()
-}
-
-func (b *Bubble) View() string {
-	return b.readmeViewport.View()
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) glamourize() (string, error) {
-	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
-	rm, rp := b.repo.Readme()
-	if rm == "" {
-		return b.styles.AboutNoReadme.Render("No readme found."), nil
-	}
-	f, err := common.RenderFile(rp, rm, w)
-	if err != nil {
-		return "", err
-	}
-	// For now, hard-wrap long lines in Glamour that would otherwise break the
-	// layout when wrapping. This may be due to #43 in Reflow, which has to do
-	// with a bug in the way lines longer than the given width are wrapped.
-	//
-	//     https://github.com/muesli/reflow/issues/43
-	//
-	// TODO: solve this upstream in Glamour/Reflow.
-	return wrap.String(f, w), nil
-}
-
-func (b *Bubble) reset() tea.Msg {
-	md, err := b.glamourize()
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	head, err := b.repo.HEAD()
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	b.ref = head
-	b.readmeViewport.Viewport.SetContent(md)
-	b.GotoTop()
-	return nil
-}

tui/bubble.go 🔗

@@ -1,155 +0,0 @@
-package tui
-
-import (
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/about"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/log"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	"github.com/charmbracelet/soft-serve/tui/tree"
-)
-
-const (
-	repoNameMaxWidth = 32
-)
-
-type state int
-
-const (
-	aboutState state = iota
-	refsState
-	logState
-	treeState
-)
-
-type Bubble struct {
-	state        state
-	repo         common.GitRepo
-	height       int
-	heightMargin int
-	width        int
-	widthMargin  int
-	style        *style.Styles
-	boxes        []tea.Model
-	ref          *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
-	b := &Bubble{
-		repo:         repo,
-		state:        aboutState,
-		width:        width,
-		widthMargin:  wm,
-		height:       height,
-		heightMargin: hm,
-		style:        styles,
-		boxes:        make([]tea.Model, 4),
-	}
-	heightMargin := hm + lipgloss.Height(b.headerView())
-	b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
-	b.boxes[refsState] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
-	b.boxes[logState] = log.NewBubble(repo, b.style, width, wm, height, heightMargin)
-	b.boxes[treeState] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin)
-	return b
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return b.setupCmd
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		if b.repo.Name() != "config" {
-			switch msg.String() {
-			case "R":
-				b.state = aboutState
-			case "B":
-				b.state = refsState
-			case "C":
-				b.state = logState
-			case "F":
-				b.state = treeState
-			}
-		}
-	case tea.WindowSizeMsg:
-		b.width = msg.Width
-		b.height = msg.Height
-		for i, bx := range b.boxes {
-			m, cmd := bx.Update(msg)
-			b.boxes[i] = m
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	case refs.RefMsg:
-		b.state = treeState
-		b.ref = msg
-		for i, bx := range b.boxes {
-			m, cmd := bx.Update(msg)
-			b.boxes[i] = m
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	}
-	m, cmd := b.boxes[b.state].Update(msg)
-	b.boxes[b.state] = m
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	h := []common.HelpEntry{}
-	h = append(h, b.boxes[b.state].(common.BubbleHelper).Help()...)
-	if b.repo.Name() != "config" {
-		h = append(h, common.HelpEntry{Key: "R", Value: "readme"})
-		h = append(h, common.HelpEntry{Key: "F", Value: "files"})
-		h = append(h, common.HelpEntry{Key: "C", Value: "commits"})
-		h = append(h, common.HelpEntry{Key: "B", Value: "branches"})
-	}
-	return h
-}
-
-func (b *Bubble) Reference() *git.Reference {
-	return b.ref
-}
-
-func (b *Bubble) headerView() string {
-	// TODO better header, tabs?
-	return ""
-}
-
-func (b *Bubble) View() string {
-	header := b.headerView()
-	return header + b.boxes[b.state].View()
-}
-
-func (b *Bubble) setupCmd() tea.Msg {
-	head, err := b.repo.HEAD()
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	b.ref = head
-	cmds := make([]tea.Cmd, 0)
-	for _, bx := range b.boxes {
-		if bx != nil {
-			initCmd := bx.Init()
-			if initCmd != nil {
-				msg := initCmd()
-				switch msg := msg.(type) {
-				case common.ErrMsg:
-					return msg
-				}
-			}
-			cmds = append(cmds, initCmd)
-		}
-	}
-	return tea.Batch(cmds...)
-}

tui/common/consts.go 🔗

@@ -1,28 +0,0 @@
-package common
-
-import (
-	"time"
-
-	"github.com/charmbracelet/bubbles/key"
-)
-
-// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git
-
-const (
-	GlamourMaxWidth  = 120
-	RepoNameMaxWidth = 32
-	MaxDiffLines     = 1000
-	MaxDiffFiles     = 100
-	MaxPatchWait     = time.Second * 3
-)
-
-var (
-	PrevPage = key.NewBinding(
-		key.WithKeys("pgup", "b", "u"),
-		key.WithHelp("pgup", "prev page"),
-	)
-	NextPage = key.NewBinding(
-		key.WithKeys("pgdown", "f", "d"),
-		key.WithHelp("pgdn", "next page"),
-	)
-)

tui/common/error.go 🔗

@@ -1,36 +0,0 @@
-package common
-
-import (
-	"errors"
-
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-)
-
-var (
-	ErrDiffTooLong      = errors.New("diff is too long")
-	ErrDiffFilesTooLong = errors.New("diff files are too long")
-	ErrBinaryFile       = errors.New("binary file")
-	ErrFileTooLarge     = errors.New("file is too large")
-	ErrInvalidFile      = errors.New("invalid file")
-)
-
-type ErrMsg struct {
-	Err error
-}
-
-func (e ErrMsg) Error() string {
-	return e.Err.Error()
-}
-
-func (e ErrMsg) View(s *style.Styles) string {
-	return e.ViewWithPrefix(s, "")
-}
-
-func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string {
-	return lipgloss.JoinHorizontal(
-		lipgloss.Top,
-		s.ErrorTitle.Render(prefix),
-		s.ErrorBody.Render(e.Error()),
-	)
-}

tui/common/formatter.go 🔗

@@ -1,88 +0,0 @@
-package common
-
-import (
-	"strings"
-
-	"github.com/alecthomas/chroma/lexers"
-	"github.com/charmbracelet/glamour"
-	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/muesli/termenv"
-)
-
-var (
-	RenderCtx = DefaultRenderCtx()
-	Styles    = DefaultStyles()
-)
-
-func DefaultStyles() 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 DefaultRenderCtx() gansi.RenderContext {
-	return gansi.NewRenderContext(gansi.Options{
-		ColorProfile: termenv.TrueColor,
-		Styles:       DefaultStyles(),
-	})
-}
-
-func NewRenderCtx(worldwrap int) gansi.RenderContext {
-	return gansi.NewRenderContext(gansi.Options{
-		ColorProfile: termenv.TrueColor,
-		Styles:       DefaultStyles(),
-		WordWrap:     worldwrap,
-	})
-}
-
-func Glamourize(w int, md string) (string, error) {
-	if w > GlamourMaxWidth {
-		w = GlamourMaxWidth
-	}
-	tr, err := glamour.NewTermRenderer(
-		glamour.WithStyles(DefaultStyles()),
-		glamour.WithWordWrap(w),
-	)
-
-	if err != nil {
-		return "", err
-	}
-	mdt, err := tr.Render(md)
-	if err != nil {
-		return "", err
-	}
-	return mdt, nil
-}
-
-func RenderFile(path, content string, width int) (string, error) {
-	lexer := lexers.Fallback
-	if path == "" {
-		lexer = lexers.Analyse(content)
-	} else {
-		lexer = lexers.Match(path)
-	}
-	lang := ""
-	if lexer != nil && lexer.Config() != nil {
-		lang = lexer.Config().Name
-	}
-	formatter := &gansi.CodeBlockElement{
-		Code:     content,
-		Language: lang,
-	}
-	if lang == "markdown" {
-		md, err := Glamourize(width, content)
-		if err != nil {
-			return "", err
-		}
-		return md, nil
-	}
-	r := strings.Builder{}
-	err := formatter.Render(&r, RenderCtx)
-	if err != nil {
-		return "", err
-	}
-	return r.String(), nil
-}

tui/common/git.go 🔗

@@ -1,16 +0,0 @@
-package common
-
-import (
-	"github.com/charmbracelet/soft-serve/git"
-)
-
-type GitRepo interface {
-	Name() string
-	Readme() (string, string)
-	HEAD() (*git.Reference, 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)
-}

tui/common/help.go 🔗

@@ -1,10 +0,0 @@
-package common
-
-type BubbleHelper interface {
-	Help() []HelpEntry
-}
-
-type HelpEntry struct {
-	Key   string
-	Value string
-}

tui/common/reset.go 🔗

@@ -1,7 +0,0 @@
-package common
-
-import tea "github.com/charmbracelet/bubbletea"
-
-type BubbleReset interface {
-	Reset() tea.Msg
-}

tui/common/utils.go 🔗

@@ -1,17 +0,0 @@
-package common
-
-import "github.com/muesli/reflow/truncate"
-
-func TruncateString(s string, max int, tail string) string {
-	if max < 0 {
-		max = 0
-	}
-	return truncate.StringWithTail(s, uint(max), tail)
-}
-
-func Max(a, b int) int {
-	if a > b {
-		return a
-	}
-	return b
-}

tui/log/bubble.go 🔗

@@ -1,383 +0,0 @@
-package log
-
-import (
-	"fmt"
-	"io"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/bubbles/list"
-	"github.com/charmbracelet/bubbles/spinner"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
-)
-
-var (
-	diffChroma = &gansi.CodeBlockElement{
-		Code:     "",
-		Language: "diff",
-	}
-	waitBeforeLoading = time.Millisecond * 300
-)
-
-type itemsMsg struct{}
-
-type commitMsg *git.Commit
-
-type countMsg int64
-
-type sessionState int
-
-const (
-	logState sessionState = iota
-	commitState
-	errorState
-)
-
-type item struct {
-	*git.Commit
-}
-
-func (i item) Title() string {
-	if i.Commit != nil {
-		return strings.Split(i.Commit.Message, "\n")[0]
-	}
-	return ""
-}
-
-func (i item) FilterValue() string { return i.Title() }
-
-type itemDelegate struct {
-	style *style.Styles
-}
-
-func (d itemDelegate) Height() int                               { return 1 }
-func (d itemDelegate) Spacing() int                              { return 0 }
-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	i, ok := listItem.(item)
-	if !ok {
-		return
-	}
-	if i.Commit == nil {
-		return
-	}
-
-	hash := i.ID.String()
-	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
-		d.style.LogItemSelector.GetWidth() +
-		d.style.LogItemHash.GetMarginLeft() +
-		d.style.LogItemHash.GetWidth() +
-		d.style.LogItemInactive.GetMarginLeft()
-	title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
-	if index == m.Index() {
-		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
-			d.style.LogItemHash.Bold(true).Render(hash[:7])+
-			d.style.LogItemActive.Render(title))
-	} else {
-		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
-			d.style.LogItemHash.Render(hash[:7])+
-			d.style.LogItemInactive.Render(title))
-	}
-}
-
-type Bubble struct {
-	repo           common.GitRepo
-	count          int64
-	list           list.Model
-	state          sessionState
-	commitViewport *vp.ViewportBubble
-	ref            *git.Reference
-	style          *style.Styles
-	width          int
-	widthMargin    int
-	height         int
-	heightMargin   int
-	error          common.ErrMsg
-	spinner        spinner.Model
-	loading        bool
-	loadingStart   time.Time
-	selectedCommit *git.Commit
-	nextPage       int
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
-	l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
-	l.SetShowFilter(false)
-	l.SetShowHelp(false)
-	l.SetShowPagination(true)
-	l.SetShowStatusBar(false)
-	l.SetShowTitle(false)
-	l.SetFilteringEnabled(false)
-	l.DisableQuitKeybindings()
-	l.KeyMap.NextPage = common.NextPage
-	l.KeyMap.PrevPage = common.PrevPage
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = styles.Spinner
-	b := &Bubble{
-		commitViewport: &vp.ViewportBubble{
-			Viewport: &viewport.Model{},
-		},
-		repo:         repo,
-		style:        styles,
-		state:        logState,
-		width:        width,
-		widthMargin:  widthMargin,
-		height:       height,
-		heightMargin: heightMargin,
-		list:         l,
-		spinner:      s,
-	}
-	b.SetSize(width, height)
-	return b
-}
-
-func (b *Bubble) countCommits() tea.Msg {
-	if b.ref == nil {
-		ref, err := b.repo.HEAD()
-		if err != nil {
-			return common.ErrMsg{Err: err}
-		}
-		b.ref = ref
-	}
-	count, err := b.repo.CountCommits(b.ref)
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	return countMsg(count)
-}
-
-func (b *Bubble) updateItems() tea.Msg {
-	if b.count == 0 {
-		b.count = int64(b.countCommits().(countMsg))
-	}
-	count := b.count
-	items := make([]list.Item, count)
-	page := b.nextPage
-	limit := b.list.Paginator.PerPage
-	skip := page * limit
-	// CommitsByPage pages start at 1
-	cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	for i, c := range cc {
-		idx := i + skip
-		if int64(idx) >= count {
-			break
-		}
-		items[idx] = item{c}
-	}
-	b.list.SetItems(items)
-	b.SetSize(b.width, b.height)
-	return itemsMsg{}
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) GotoTop() {
-	b.commitViewport.Viewport.GotoTop()
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return nil
-}
-
-func (b *Bubble) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.commitViewport.Viewport.Width = width - b.widthMargin
-	b.commitViewport.Viewport.Height = height - b.heightMargin
-	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
-	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-		cmds = append(cmds, b.updateItems)
-
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "C":
-			b.count = 0
-			b.loading = true
-			b.loadingStart = time.Now().Add(-waitBeforeLoading) // always show spinner
-			b.list.Select(0)
-			b.nextPage = 0
-			return b, tea.Batch(b.updateItems, b.spinner.Tick)
-		case "enter", "right", "l":
-			if b.state == logState {
-				i := b.list.SelectedItem()
-				if i != nil {
-					c, ok := i.(item)
-					if ok {
-						b.selectedCommit = c.Commit
-					}
-				}
-				cmds = append(cmds, b.loadCommit, b.spinner.Tick)
-			}
-		case "esc", "left", "h":
-			if b.state != logState {
-				b.state = logState
-				b.selectedCommit = nil
-			}
-		}
-		switch b.state {
-		case logState:
-			curPage := b.list.Paginator.Page
-			m, cmd := b.list.Update(msg)
-			b.list = m
-			if m.Paginator.Page != curPage {
-				b.loading = true
-				b.loadingStart = time.Now()
-				b.list.Paginator.Page = curPage
-				b.nextPage = m.Paginator.Page
-				cmds = append(cmds, b.updateItems, b.spinner.Tick)
-			}
-			cmds = append(cmds, cmd)
-		case commitState:
-			rv, cmd := b.commitViewport.Update(msg)
-			b.commitViewport = rv.(*vp.ViewportBubble)
-			cmds = append(cmds, cmd)
-		}
-		return b, tea.Batch(cmds...)
-	case itemsMsg:
-		b.loading = false
-		b.list.Paginator.Page = b.nextPage
-		if b.state != commitState {
-			b.state = logState
-		}
-	case countMsg:
-		b.count = int64(msg)
-	case common.ErrMsg:
-		b.error = msg
-		b.state = errorState
-		b.loading = false
-		return b, nil
-	case commitMsg:
-		b.loading = false
-		b.state = commitState
-	case refs.RefMsg:
-		b.ref = msg
-		b.count = 0
-		cmds = append(cmds, b.countCommits)
-	case spinner.TickMsg:
-		if b.loading {
-			s, cmd := b.spinner.Update(msg)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-			b.spinner = s
-		}
-	}
-
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) loadPatch(c *git.Commit) error {
-	var patch strings.Builder
-	style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
-	p, err := b.repo.Diff(c)
-	if err != nil {
-		return err
-	}
-	stats := strings.Split(p.Stats().String(), "\n")
-	for i, l := range stats {
-		ch := strings.Split(l, "|")
-		if len(ch) > 1 {
-			adddel := ch[len(ch)-1]
-			adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
-			adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
-			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
-		}
-	}
-	patch.WriteString(b.renderCommit(c))
-	fpl := len(p.Files)
-	if fpl > common.MaxDiffFiles {
-		patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
-	} else {
-		patch.WriteString("\n" + strings.Join(stats, "\n"))
-	}
-	if fpl <= common.MaxDiffFiles {
-		ps := ""
-		if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
-			patch.WriteString("\n" + common.ErrDiffTooLong.Error())
-		} else {
-			patch.WriteString("\n" + b.renderDiff(p))
-		}
-	}
-	content := style.Render(patch.String())
-	b.commitViewport.Viewport.SetContent(content)
-	b.GotoTop()
-	return nil
-}
-
-func (b *Bubble) loadCommit() tea.Msg {
-	b.loading = true
-	b.loadingStart = time.Now()
-	c := b.selectedCommit
-	if err := b.loadPatch(c); err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	return commitMsg(c)
-}
-
-func (b *Bubble) renderCommit(c *git.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",
-		b.style.LogCommitHash.Render("commit "+c.ID.String()),
-		b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
-		b.style.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
-		b.style.LogCommitBody.Render(msg),
-	))
-	return s.String()
-}
-
-func (b *Bubble) renderDiff(diff *git.Diff) string {
-	var s strings.Builder
-	var pr strings.Builder
-	diffChroma.Code = diff.Patch()
-	err := diffChroma.Render(&pr, common.RenderCtx)
-	if err != nil {
-		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
-	} else {
-		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
-	}
-	return s.String()
-}
-
-func (b *Bubble) View() string {
-	if b.loading && b.loadingStart.Add(waitBeforeLoading).Before(time.Now()) {
-		msg := fmt.Sprintf("%s loading commit", b.spinner.View())
-		if b.selectedCommit == nil {
-			msg += "s"
-		}
-		msg += "…"
-		return msg
-	}
-	switch b.state {
-	case logState:
-		return b.list.View()
-	case errorState:
-		return b.error.ViewWithPrefix(b.style, "Error")
-	case commitState:
-		return b.commitViewport.View()
-	default:
-		return ""
-	}
-}

tui/refs/bubble.go 🔗

@@ -1,185 +0,0 @@
-package refs
-
-import (
-	"fmt"
-	"io"
-	"sort"
-
-	"github.com/charmbracelet/bubbles/list"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-)
-
-type RefMsg = *git.Reference
-
-type item struct {
-	*git.Reference
-}
-
-func (i item) Short() string {
-	return i.Reference.Name().Short()
-}
-
-func (i item) FilterValue() string { return i.Short() }
-
-type items []item
-
-func (cl items) Len() int      { return len(cl) }
-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl items) Less(i, j int) bool {
-	return cl[i].Short() < cl[j].Short()
-}
-
-type itemDelegate struct {
-	style *style.Styles
-}
-
-func (d itemDelegate) Height() int                               { return 1 }
-func (d itemDelegate) Spacing() int                              { return 0 }
-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.style
-	i, ok := listItem.(item)
-	if !ok {
-		return
-	}
-
-	ref := i.Short()
-	if i.Reference.IsTag() {
-		ref = s.RefItemTag.Render(ref)
-	}
-	ref = s.RefItemBranch.Render(ref)
-	refMaxWidth := m.Width() -
-		s.RefItemSelector.GetMarginLeft() -
-		s.RefItemSelector.GetWidth() -
-		s.RefItemInactive.GetMarginLeft()
-	ref = common.TruncateString(ref, refMaxWidth, "…")
-	if index == m.Index() {
-		fmt.Fprint(w, s.RefItemSelector.Render(">")+
-			s.RefItemActive.Render(ref))
-	} else {
-		fmt.Fprint(w, s.LogItemSelector.Render(" ")+
-			s.RefItemInactive.Render(ref))
-	}
-}
-
-type Bubble struct {
-	repo         common.GitRepo
-	list         list.Model
-	style        *style.Styles
-	width        int
-	widthMargin  int
-	height       int
-	heightMargin int
-	ref          *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
-	head, err := repo.HEAD()
-	if err != nil {
-		return nil
-	}
-	l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
-	l.SetShowFilter(false)
-	l.SetShowHelp(false)
-	l.SetShowPagination(true)
-	l.SetShowStatusBar(false)
-	l.SetShowTitle(false)
-	l.SetFilteringEnabled(false)
-	l.DisableQuitKeybindings()
-	b := &Bubble{
-		repo:         repo,
-		style:        styles,
-		width:        width,
-		height:       height,
-		widthMargin:  widthMargin,
-		heightMargin: heightMargin,
-		list:         l,
-		ref:          head,
-	}
-	b.SetSize(width, height)
-	return b
-}
-
-func (b *Bubble) SetBranch(ref *git.Reference) (tea.Model, tea.Cmd) {
-	b.ref = ref
-	return b, func() tea.Msg {
-		return RefMsg(ref)
-	}
-}
-
-func (b *Bubble) reset() tea.Cmd {
-	cmd := b.updateItems()
-	b.SetSize(b.width, b.height)
-	return cmd
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return nil
-}
-
-func (b *Bubble) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
-	b.list.Styles.PaginationStyle = b.style.RefPaginator.Copy().Width(width - b.widthMargin)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) updateItems() tea.Cmd {
-	its := make(items, 0)
-	tags := make(items, 0)
-	refs, err := b.repo.References()
-	if err != nil {
-		return func() tea.Msg { return common.ErrMsg{Err: err} }
-	}
-	for _, r := range refs {
-		if r.IsTag() {
-			tags = append(tags, item{r})
-		} else if r.IsBranch() {
-			its = append(its, item{r})
-		}
-	}
-	sort.Sort(its)
-	sort.Sort(tags)
-	its = append(its, tags...)
-	itt := make([]list.Item, len(its))
-	for i, it := range its {
-		itt[i] = it
-	}
-	return b.list.SetItems(itt)
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "B":
-			return b, b.reset()
-		case "enter", "right", "l":
-			if b.list.Index() >= 0 {
-				ref := b.list.SelectedItem().(item).Reference
-				return b.SetBranch(ref)
-			}
-		}
-	}
-
-	l, cmd := b.list.Update(msg)
-	b.list = l
-	cmds = append(cmds, cmd)
-
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) View() string {
-	return b.list.View()
-}

tui/tree/bubble.go 🔗

@@ -1,341 +0,0 @@
-package tree
-
-import (
-	"fmt"
-	"io"
-	"io/fs"
-	"path/filepath"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/list"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
-	"github.com/dustin/go-humanize"
-)
-
-type fileMsg struct {
-	content string
-}
-
-type sessionState int
-
-const (
-	treeState sessionState = iota
-	fileState
-	errorState
-)
-
-type item struct {
-	entry *git.TreeEntry
-}
-
-func (i item) Name() string {
-	return i.entry.Name()
-}
-
-func (i item) Mode() fs.FileMode {
-	return i.entry.Mode()
-}
-
-func (i item) FilterValue() string { return i.Name() }
-
-type items []item
-
-func (cl items) Len() int      { return len(cl) }
-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl items) Less(i, j int) bool {
-	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
-		return cl[i].Name() < cl[j].Name()
-	} else if cl[i].entry.IsTree() {
-		return true
-	} else if cl[j].entry.IsTree() {
-		return false
-	} else {
-		return cl[i].Name() < cl[j].Name()
-	}
-}
-
-type itemDelegate struct {
-	style *style.Styles
-}
-
-func (d itemDelegate) Height() int                               { return 1 }
-func (d itemDelegate) Spacing() int                              { return 0 }
-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.style
-	i, ok := listItem.(item)
-	if !ok {
-		return
-	}
-
-	name := i.Name()
-	size := humanize.Bytes(uint64(i.entry.Size()))
-	if i.entry.IsTree() {
-		size = ""
-		name = s.TreeFileDir.Render(name)
-	}
-	var cs lipgloss.Style
-	mode := i.Mode()
-	if index == m.Index() {
-		cs = s.TreeItemActive
-		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
-	} else {
-		cs = s.TreeItemInactive
-		fmt.Fprint(w, s.TreeItemSelector.Render(" "))
-	}
-	leftMargin := s.TreeItemSelector.GetMarginLeft() +
-		s.TreeItemSelector.GetWidth() +
-		s.TreeFileMode.GetMarginLeft() +
-		s.TreeFileMode.GetWidth() +
-		cs.GetMarginLeft()
-	rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
-	name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
-	sizeStyle := s.TreeFileSize.Copy().
-		Width(m.Width() -
-			leftMargin -
-			s.TreeFileSize.GetMarginLeft() -
-			lipgloss.Width(name)).
-		Align(lipgloss.Right)
-	fmt.Fprint(w, s.TreeFileMode.Render(mode.String())+
-		cs.Render(name)+
-		sizeStyle.Render(size))
-}
-
-type Bubble struct {
-	repo         common.GitRepo
-	list         list.Model
-	style        *style.Styles
-	width        int
-	widthMargin  int
-	height       int
-	heightMargin int
-	path         string
-	state        sessionState
-	error        common.ErrMsg
-	fileViewport *vp.ViewportBubble
-	lastSelected []int
-	ref          *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
-	l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
-	l.SetShowFilter(false)
-	l.SetShowHelp(false)
-	l.SetShowPagination(true)
-	l.SetShowStatusBar(false)
-	l.SetShowTitle(false)
-	l.SetFilteringEnabled(false)
-	l.DisableQuitKeybindings()
-	l.KeyMap.NextPage = common.NextPage
-	l.KeyMap.PrevPage = common.PrevPage
-	l.Styles.NoItems = styles.TreeNoItems
-	b := &Bubble{
-		fileViewport: &vp.ViewportBubble{
-			Viewport: &viewport.Model{},
-		},
-		repo:         repo,
-		style:        styles,
-		width:        width,
-		height:       height,
-		widthMargin:  widthMargin,
-		heightMargin: heightMargin,
-		list:         l,
-		state:        treeState,
-	}
-	b.SetSize(width, height)
-	return b
-}
-
-func (b *Bubble) reset() tea.Cmd {
-	b.path = ""
-	b.state = treeState
-	b.lastSelected = make([]int, 0)
-	cmd := b.updateItems()
-	return cmd
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	head, err := b.repo.HEAD()
-	if err != nil {
-		return func() tea.Msg {
-			return common.ErrMsg{Err: err}
-		}
-	}
-	b.ref = head
-	return nil
-}
-
-func (b *Bubble) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.fileViewport.Viewport.Width = width - b.widthMargin
-	b.fileViewport.Viewport.Height = height - b.heightMargin
-	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
-	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) updateItems() tea.Cmd {
-	files := make([]list.Item, 0)
-	dirs := make([]list.Item, 0)
-	t, err := b.repo.Tree(b.ref, b.path)
-	if err != nil {
-		return func() tea.Msg { return common.ErrMsg{Err: err} }
-	}
-	ents, err := t.Entries()
-	if err != nil {
-		return func() tea.Msg { return common.ErrMsg{Err: err} }
-	}
-	ents.Sort()
-	for _, e := range ents {
-		if e.IsTree() {
-			dirs = append(dirs, item{e})
-		} else {
-			files = append(files, item{e})
-		}
-	}
-	cmd := b.list.SetItems(append(dirs, files...))
-	b.list.Select(0)
-	b.SetSize(b.width, b.height)
-	return cmd
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-
-	case tea.KeyMsg:
-		if b.state == errorState {
-			ref, _ := b.repo.HEAD()
-			b.ref = ref
-			return b, tea.Batch(b.reset(), func() tea.Msg {
-				return ref
-			})
-		}
-
-		switch msg.String() {
-		case "F":
-			return b, b.reset()
-		case "enter", "right", "l":
-			if len(b.list.Items()) > 0 && b.state == treeState {
-				index := b.list.Index()
-				item := b.list.SelectedItem().(item)
-				mode := item.Mode()
-				b.path = filepath.Join(b.path, item.Name())
-				if mode.IsDir() {
-					b.lastSelected = append(b.lastSelected, index)
-					cmds = append(cmds, b.updateItems())
-				} else {
-					b.lastSelected = append(b.lastSelected, index)
-					cmds = append(cmds, b.loadFile(item))
-				}
-			}
-		case "esc", "left", "h":
-			if b.state != treeState {
-				b.state = treeState
-			}
-			p := filepath.Dir(b.path)
-			b.path = p
-			cmds = append(cmds, b.updateItems())
-			index := 0
-			if len(b.lastSelected) > 0 {
-				index = b.lastSelected[len(b.lastSelected)-1]
-				b.lastSelected = b.lastSelected[:len(b.lastSelected)-1]
-			}
-			b.list.Select(index)
-		}
-
-	case refs.RefMsg:
-		b.ref = msg
-		return b, b.reset()
-
-	case common.ErrMsg:
-		b.error = msg
-		b.state = errorState
-		return b, nil
-
-	case fileMsg:
-		content := b.renderFile(msg)
-		b.fileViewport.Viewport.SetContent(content)
-		b.fileViewport.Viewport.GotoTop()
-		b.state = fileState
-	}
-
-	switch b.state {
-	case fileState:
-		rv, cmd := b.fileViewport.Update(msg)
-		b.fileViewport = rv.(*vp.ViewportBubble)
-		cmds = append(cmds, cmd)
-	case treeState:
-		l, cmd := b.list.Update(msg)
-		b.list = l
-		cmds = append(cmds, cmd)
-	}
-
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) View() string {
-	switch b.state {
-	case treeState:
-		return b.list.View()
-	case errorState:
-		return b.error.ViewWithPrefix(b.style, "Error")
-	case fileState:
-		return b.fileViewport.View()
-	default:
-		return ""
-	}
-}
-
-func (b *Bubble) loadFile(i item) tea.Cmd {
-	return func() tea.Msg {
-		f := i.entry.File()
-		if i.Mode().IsDir() || f == nil {
-			return common.ErrMsg{Err: common.ErrInvalidFile}
-		}
-		bin, err := f.IsBinary()
-		if err != nil {
-			return common.ErrMsg{Err: err}
-		}
-		if bin {
-			return common.ErrMsg{Err: common.ErrBinaryFile}
-		}
-		c, err := f.Bytes()
-		if err != nil {
-			return common.ErrMsg{Err: err}
-		}
-		return fileMsg{
-			content: string(c),
-		}
-	}
-}
-
-func (b *Bubble) renderFile(m fileMsg) string {
-	s := strings.Builder{}
-	c := m.content
-	if len(strings.Split(c, "\n")) > common.MaxDiffLines {
-		s.WriteString(b.style.TreeNoItems.Render(common.ErrFileTooLarge.Error()))
-	} else {
-		w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize()
-		f, err := common.RenderFile(b.path, m.content, w)
-		if err != nil {
-			s.WriteString(err.Error())
-		} else {
-			s.WriteString(f)
-		}
-	}
-	return b.style.TreeFileContent.Copy().Width(b.width - b.widthMargin).Render(s.String())
-}

tui/viewport/viewport_patch.go 🔗

@@ -1,24 +0,0 @@
-package viewport
-
-import (
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-)
-
-type ViewportBubble struct {
-	Viewport *viewport.Model
-}
-
-func (v *ViewportBubble) Init() tea.Cmd {
-	return nil
-}
-
-func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	vp, cmd := v.Viewport.Update(msg)
-	v.Viewport = &vp
-	return v, cmd
-}
-
-func (v *ViewportBubble) View() string {
-	return v.Viewport.View()
-}

ui/common/style.go 🔗

@@ -0,0 +1,18 @@
+package common
+
+import (
+	"github.com/charmbracelet/glamour"
+	gansi "github.com/charmbracelet/glamour/ansi"
+)
+
+// StyleConfig returns the default Glamour style configuration.
+func StyleConfig() gansi.StyleConfig {
+	noColor := ""
+	s := glamour.DarkStyleConfig
+	// This fixes an issue with the default style config. For example
+	// highlighting empty spaces with red in Dockerfile type.
+	s.Document.StylePrimitive.Color = &noColor
+	s.CodeBlock.Chroma.Text.Color = &noColor
+	s.CodeBlock.Chroma.Name.Color = &noColor
+	return s
+}

ui/components/code/code.go 🔗

@@ -36,7 +36,7 @@ func New(c common.Common, content, extension string) *Code {
 		Viewport:       vp.New(c),
 		NoContentStyle: c.Styles.CodeNoContent.Copy(),
 	}
-	st := styleConfig()
+	st := common.StyleConfig()
 	r.styleConfig = st
 	r.renderContext = gansi.NewRenderContext(gansi.Options{
 		ColorProfile: termenv.TrueColor,
@@ -196,14 +196,3 @@ func (r *Code) renderFile(path, content string, width int) (string, error) {
 	}
 	return s.String(), nil
 }
-
-func styleConfig() gansi.StyleConfig {
-	noColor := ""
-	s := glamour.DarkStyleConfig
-	// This fixes an issue with the default style config. For example
-	// highlighting empty spaces with red in Dockerfile type.
-	s.Document.StylePrimitive.Color = &noColor
-	s.CodeBlock.Chroma.Text.Color = &noColor
-	s.CodeBlock.Chroma.Name.Color = &noColor
-	return s
-}

ui/components/copy/copy.go 🔗

@@ -1,64 +0,0 @@
-package copy
-
-import (
-	"github.com/aymanbagabas/go-osc52"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-)
-
-// CopyMsg is a message that is sent when the user copies text.
-type CopyMsg string
-
-// CopyCmd is a command that copies text to the clipboard using OSC52.
-func CopyCmd(output *osc52.Output, str string) tea.Cmd {
-	return func() tea.Msg {
-		output.Copy(str)
-		return CopyMsg(str)
-	}
-}
-
-type Copy struct {
-	output      *osc52.Output
-	text        string
-	copied      bool
-	CopiedStyle lipgloss.Style
-	TextStyle   lipgloss.Style
-}
-
-func New(output *osc52.Output, text string) *Copy {
-	copy := &Copy{
-		output: output,
-		text:   text,
-	}
-	return copy
-}
-
-func (c *Copy) SetText(text string) {
-	c.text = text
-}
-
-func (c *Copy) Init() tea.Cmd {
-	c.copied = false
-	return nil
-}
-
-func (c *Copy) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg.(type) {
-	case CopyMsg:
-		c.copied = true
-	default:
-		c.copied = false
-	}
-	return c, nil
-}
-
-func (c *Copy) View() string {
-	if c.copied {
-		return c.CopiedStyle.String()
-	}
-	return c.TextStyle.Render(c.text)
-}
-
-func (c *Copy) CopyCmd() tea.Cmd {
-	return CopyCmd(c.output, c.text)
-}

ui/components/footer/footer.go 🔗

@@ -1,6 +1,8 @@
 package footer
 
 import (
+	"strings"
+
 	"github.com/charmbracelet/bubbles/help"
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
@@ -54,7 +56,7 @@ func (f *Footer) View() string {
 	}
 	s := f.common.Styles.Footer.Copy().Width(f.common.Width)
 	helpView := f.help.View(f.keymap)
-	return s.Render(helpView)
+	return s.Render(strings.TrimSpace(helpView))
 }
 
 // ShortHelp returns the short help key bindings.

ui/components/header/header.go 🔗

@@ -24,8 +24,7 @@ func New(c common.Common, text string) *Header {
 
 // SetSize implements common.Component.
 func (h *Header) SetSize(width, height int) {
-	h.common.Width = width
-	h.common.Height = height
+	h.common.SetSize(width, height)
 }
 
 // Init implements tea.Model.

ui/components/yankable/yankable.go 🔗

@@ -1,61 +0,0 @@
-package yankable
-
-import (
-	"io"
-
-	"github.com/aymanbagabas/go-osc52"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-)
-
-type Yankable struct {
-	yankStyle lipgloss.Style
-	style     lipgloss.Style
-	text      string
-	clicked   bool
-	osc52     *osc52.Output
-}
-
-func New(w io.Writer, environ []string, style, yankStyle lipgloss.Style, text string) *Yankable {
-	return &Yankable{
-		yankStyle: yankStyle,
-		style:     style,
-		text:      text,
-		clicked:   false,
-		osc52:     osc52.NewOutput(w, environ),
-	}
-}
-
-func (y *Yankable) SetText(text string) {
-	y.text = text
-}
-
-func (y *Yankable) Init() tea.Cmd {
-	return nil
-}
-
-func (y *Yankable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.MouseMsg:
-		switch msg.Type {
-		case tea.MouseRight:
-			y.clicked = true
-			return y, y.copy()
-		}
-	default:
-		y.clicked = false
-	}
-	return y, nil
-}
-
-func (y *Yankable) View() string {
-	if y.clicked {
-		return y.yankStyle.String()
-	}
-	return y.style.Render(y.text)
-}
-
-func (y *Yankable) copy() tea.Cmd {
-	y.osc52.Copy(y.text)
-	return nil
-}

ui/ui.go 🔗

@@ -65,6 +65,8 @@ func (ui *UI) getMargins() (wm, hm int) {
 	wm = ui.common.Styles.App.GetHorizontalFrameSize()
 	hm = ui.common.Styles.App.GetVerticalFrameSize() +
 		ui.common.Styles.Header.GetHeight() +
+		ui.common.Styles.Header.GetVerticalFrameSize() +
+		ui.common.Styles.Footer.GetVerticalFrameSize() +
 		ui.footer.Height()
 	return
 }