feat: copy over ssh

Ayman Bagabas created

Change summary

go.mod                             |  2 
go.sum                             |  4 
server/session.go                  |  5 ++
ui/common/common.go                |  2 
ui/components/code/code.go         | 57 +++++++++++++-----------
ui/components/copy/copy.go         | 64 +++++++++++++++++++++++++++
ui/components/viewport/viewport.go | 27 -----------
ui/keymap/keymap.go                | 13 +++++
ui/pages/repo/files.go             | 33 ++++++++++---
ui/pages/repo/filesitem.go         | 24 ++++++++-
ui/pages/repo/log.go               | 12 ++++
ui/pages/repo/logitem.go           | 57 ++++++++++++++----------
ui/pages/repo/refs.go              | 12 ++++
ui/pages/repo/refsitem.go          | 29 +++++++++--
ui/pages/repo/repo.go              |  2 
ui/pages/selection/item.go         | 75 ++++++++++++++-----------------
ui/pages/selection/selection.go    | 40 +++-------------
ui/styles/styles.go                | 15 +++--
18 files changed, 289 insertions(+), 184 deletions(-)

Detailed changes

go.mod 🔗

@@ -5,7 +5,7 @@ go 1.17
 require (
 	github.com/alecthomas/chroma v0.10.0
 	github.com/caarlos0/env/v6 v6.9.1
-	github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97
+	github.com/charmbracelet/bubbles v0.10.4-0.20220429162018-2a8d463bd11f
 	github.com/charmbracelet/bubbletea v0.20.0
 	github.com/charmbracelet/glamour v0.4.0
 	github.com/charmbracelet/lipgloss v0.4.0

go.sum 🔗

@@ -25,8 +25,8 @@ github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k
 github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
 github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3 h1:w2ANoiT4ubmh4Nssa3/QW1M7lj3FZkma8f8V5aBDxXM=
 github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
-github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97 h1:NJqAUfS+JNHqodsbhLR0zD3sDkXI7skwjAwd77HXe/Q=
-github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
+github.com/charmbracelet/bubbles v0.10.4-0.20220429162018-2a8d463bd11f h1:5mbyuBNzjF1S1pJOGubmjMBNUVO3NmjNsaZPIsFwPUQ=
+github.com/charmbracelet/bubbles v0.10.4-0.20220429162018-2a8d463bd11f/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
 github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
 github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc=
 github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=

server/session.go 🔗

@@ -3,6 +3,7 @@ package server
 import (
 	"fmt"
 
+	"github.com/aymanbagabas/go-osc52"
 	tea "github.com/charmbracelet/bubbletea"
 	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/ui"
@@ -60,7 +61,11 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
 		if ac.Cfg.Callbacks != nil {
 			ac.Cfg.Callbacks.Tui("new session")
 		}
+		envs := s.Environ()
+		envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
+		output := osc52.NewOutput(s, envs)
 		c := common.Common{
+			Copy:   output,
 			Styles: styles.DefaultStyles(),
 			KeyMap: keymap.DefaultKeyMap(),
 			Width:  pty.Window.Width,

ui/common/common.go 🔗

@@ -1,12 +1,14 @@
 package common
 
 import (
+	"github.com/aymanbagabas/go-osc52"
 	"github.com/charmbracelet/soft-serve/ui/keymap"
 	"github.com/charmbracelet/soft-serve/ui/styles"
 )
 
 // Common is a struct all components should embed.
 type Common struct {
+	Copy   *osc52.Output
 	Styles *styles.Styles
 	KeyMap *keymap.KeyMap
 	Width  int

ui/components/code/code.go 🔗

@@ -2,6 +2,7 @@ package code
 
 import (
 	"strings"
+	"sync"
 
 	"github.com/alecthomas/chroma/lexers"
 	tea "github.com/charmbracelet/bubbletea"
@@ -20,6 +21,9 @@ type Code struct {
 	common         common.Common
 	content        string
 	extension      string
+	renderContext  gansi.RenderContext
+	renderMutex    sync.Mutex
+	styleConfig    gansi.StyleConfig
 	NoContentStyle lipgloss.Style
 }
 
@@ -32,6 +36,12 @@ func New(c common.Common, content, extension string) *Code {
 		Viewport:       vp.New(c),
 		NoContentStyle: c.Styles.CodeNoContent.Copy(),
 	}
+	st := styleConfig()
+	r.styleConfig = st
+	r.renderContext = gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       st,
+	})
 	r.SetSize(c.Width, c.Height)
 	return r
 }
@@ -56,7 +66,7 @@ func (r *Code) Init() tea.Cmd {
 	if c == "" {
 		c = r.NoContentStyle.String()
 	}
-	f, err := renderFile(r.extension, c, w)
+	f, err := r.renderFile(r.extension, c, w)
 	if err != nil {
 		return common.ErrorCmd(err)
 	}
@@ -136,30 +146,14 @@ func (r *Code) ScrollPercent() float64 {
 	return r.Viewport.ScrollPercent()
 }
 
-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
-}
-
-func renderCtx() gansi.RenderContext {
-	return gansi.NewRenderContext(gansi.Options{
-		ColorProfile: termenv.TrueColor,
-		Styles:       styleConfig(),
-	})
-}
-
-func glamourize(w int, md string) (string, error) {
+func (r *Code) glamourize(w int, md string) (string, error) {
+	r.renderMutex.Lock()
+	defer r.renderMutex.Unlock()
 	if w > 120 {
 		w = 120
 	}
 	tr, err := glamour.NewTermRenderer(
-		glamour.WithStyles(styleConfig()),
+		glamour.WithStyles(r.styleConfig),
 		glamour.WithWordWrap(w),
 	)
 
@@ -173,7 +167,7 @@ func glamourize(w int, md string) (string, error) {
 	return mdt, nil
 }
 
-func renderFile(path, content string, width int) (string, error) {
+func (r *Code) renderFile(path, content string, width int) (string, error) {
 	lexer := lexers.Fallback
 	if path == "" {
 		lexer = lexers.Analyse(content)
@@ -185,7 +179,7 @@ func renderFile(path, content string, width int) (string, error) {
 		lang = lexer.Config().Name
 	}
 	if lang == "markdown" {
-		md, err := glamourize(width, content)
+		md, err := r.glamourize(width, content)
 		if err != nil {
 			return "", err
 		}
@@ -195,10 +189,21 @@ func renderFile(path, content string, width int) (string, error) {
 		Code:     content,
 		Language: lang,
 	}
-	r := strings.Builder{}
-	err := formatter.Render(&r, renderCtx())
+	s := strings.Builder{}
+	err := formatter.Render(&s, r.renderContext)
 	if err != nil {
 		return "", err
 	}
-	return r.String(), nil
+	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 🔗

@@ -0,0 +1,64 @@
+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/viewport/viewport.go 🔗

@@ -1,7 +1,6 @@
 package viewport
 
 import (
-	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/ui/common"
@@ -17,32 +16,6 @@ type Viewport struct {
 func New(c common.Common) *Viewport {
 	vp := viewport.New(c.Width, c.Height)
 	vp.MouseWheelEnabled = true
-	vp.KeyMap = viewport.KeyMap{
-		PageDown: key.NewBinding(
-			key.WithKeys("pgdown", " ", "f"),
-			key.WithHelp("f/pgdn", "page down"),
-		),
-		PageUp: key.NewBinding(
-			key.WithKeys("pgup", "b"),
-			key.WithHelp("b/pgup", "page up"),
-		),
-		HalfPageUp: key.NewBinding(
-			key.WithKeys("u", "ctrl+u"),
-			key.WithHelp("ctrl+u/u", "half page up"),
-		),
-		HalfPageDown: key.NewBinding(
-			key.WithKeys("d", "ctrl+d"),
-			key.WithHelp("ctrl+d/d", "half page down"),
-		),
-		Up: key.NewBinding(
-			key.WithKeys("up", "k"),
-			key.WithHelp("↑/k", "up"),
-		),
-		Down: key.NewBinding(
-			key.WithKeys("down", "j"),
-			key.WithHelp("↓/j", "down"),
-		),
-	}
 	return &Viewport{
 		common: c,
 		Model:  &vp,

ui/keymap/keymap.go 🔗

@@ -19,6 +19,8 @@ type KeyMap struct {
 
 	SelectItem key.Binding
 	BackItem   key.Binding
+
+	Copy key.Binding
 }
 
 // DefaultKeyMap returns the default key map.
@@ -188,5 +190,16 @@ func DefaultKeyMap() *KeyMap {
 		),
 	)
 
+	km.Copy = key.NewBinding(
+		key.WithKeys(
+			"c",
+			"ctrl+c",
+		),
+		key.WithHelp(
+			"c",
+			"copy text",
+		),
+	)
+
 	return km
 }

ui/pages/repo/files.go 🔗

@@ -3,7 +3,6 @@ package repo
 import (
 	"errors"
 	"fmt"
-	"log"
 	"path/filepath"
 
 	"github.com/charmbracelet/bubbles/key"
@@ -60,7 +59,7 @@ func NewFiles(common common.Common) *Files {
 		activeView:   filesViewFiles,
 		lastSelected: make([]int, 0),
 	}
-	selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{common.Styles})
+	selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})
 	selector.SetShowFilter(false)
 	selector.SetShowHelp(false)
 	selector.SetShowPagination(false)
@@ -86,16 +85,22 @@ func (f *Files) ShortHelp() []key.Binding {
 	k := f.selector.KeyMap
 	switch f.activeView {
 	case filesViewFiles:
+		copyKey := f.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy name")
 		return []key.Binding{
 			f.common.KeyMap.SelectItem,
 			f.common.KeyMap.BackItem,
 			k.CursorUp,
 			k.CursorDown,
+			copyKey,
 		}
 	case filesViewContent:
+		copyKey := f.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy content")
 		return []key.Binding{
 			f.common.KeyMap.UpDown,
 			f.common.KeyMap.BackItem,
+			copyKey,
 		}
 	default:
 		return []key.Binding{}
@@ -107,13 +112,14 @@ func (f *Files) FullHelp() [][]key.Binding {
 	b := make([][]key.Binding, 0)
 	switch f.activeView {
 	case filesViewFiles:
+		copyKey := f.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy name")
 		k := f.selector.KeyMap
 		b = append(b, []key.Binding{
 			f.common.KeyMap.SelectItem,
 			f.common.KeyMap.BackItem,
 		})
 		b = append(b, [][]key.Binding{
-			{},
 			{
 				k.CursorUp,
 				k.CursorDown,
@@ -126,8 +132,13 @@ func (f *Files) FullHelp() [][]key.Binding {
 				k.GoToStart,
 				k.GoToEnd,
 			},
+			{
+				copyKey,
+			},
 		}...)
 	case filesViewContent:
+		copyKey := f.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy content")
 		k := f.code.KeyMap
 		b = append(b, []key.Binding{
 			f.common.KeyMap.BackItem,
@@ -145,6 +156,9 @@ func (f *Files) FullHelp() [][]key.Binding {
 				k.Down,
 				k.Up,
 			},
+			{
+				copyKey,
+			},
 		}...)
 	}
 	return b
@@ -186,7 +200,6 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case FileItem:
 			f.currentItem = &sel
 			f.path = filepath.Join(f.path, sel.entry.Name())
-			log.Printf("selected index %d", f.selector.Index())
 			if sel.entry.IsTree() {
 				cmds = append(cmds, f.selectTreeCmd)
 			} else {
@@ -203,9 +216,12 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, f.deselectItemCmd)
 			}
 		case filesViewContent:
-			switch msg.String() {
-			case "h", "left":
+			keyStr := msg.String()
+			switch {
+			case keyStr == "h", keyStr == "left":
 				cmds = append(cmds, f.deselectItemCmd)
+			case key.Matches(msg, f.common.KeyMap.Copy):
+				f.common.Copy.Copy(f.currentContent.content)
 			}
 		}
 	case tea.WindowSizeMsg:
@@ -288,9 +304,9 @@ func (f *Files) updateFilesCmd() tea.Msg {
 	ents.Sort()
 	for _, e := range ents {
 		if e.IsTree() {
-			dirs = append(dirs, FileItem{e})
+			dirs = append(dirs, FileItem{entry: e})
 		} else {
-			files = append(files, FileItem{e})
+			files = append(files, FileItem{entry: e})
 		}
 	}
 	return FileItemsMsg(append(dirs, files...))
@@ -341,7 +357,6 @@ func (f *Files) deselectItemCmd() tea.Msg {
 		index = f.lastSelected[len(f.lastSelected)-1]
 		f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
 	}
-	log.Printf("deselect %d", index)
 	f.selector.Select(index)
 	return msg
 }

ui/pages/repo/filesitem.go 🔗

@@ -5,11 +5,12 @@ import (
 	"io"
 	"io/fs"
 
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/dustin/go-humanize"
 )
 
@@ -65,7 +66,7 @@ func (cl FileItems) Less(i, j int) bool {
 
 // FileItemDelegate is the delegate for the file item list.
 type FileItemDelegate struct {
-	style *styles.Styles
+	common *common.Common
 }
 
 // Height returns the height of the file item list. Implements list.ItemDelegate.
@@ -75,11 +76,26 @@ func (d FileItemDelegate) Height() int { return 1 }
 func (d FileItemDelegate) Spacing() int { return 0 }
 
 // Update implements list.ItemDelegate.
-func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(FileItem)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			d.common.Copy.Copy(item.Title())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
 
 // Render implements list.ItemDelegate.
 func (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.style
+	s := d.common.Styles
 	i, ok := listItem.(FileItem)
 	if !ok {
 		return

ui/pages/repo/log.go 🔗

@@ -60,7 +60,7 @@ func NewLog(common common.Common) *Log {
 		vp:         viewport.New(common),
 		activeView: logViewCommits,
 	}
-	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{common.Styles})
+	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})
 	selector.SetShowFilter(false)
 	selector.SetShowHelp(false)
 	selector.SetShowPagination(false)
@@ -85,8 +85,11 @@ func (l *Log) SetSize(width, height int) {
 func (l *Log) ShortHelp() []key.Binding {
 	switch l.activeView {
 	case logViewCommits:
+		copyKey := l.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy hash")
 		return []key.Binding{
 			l.common.KeyMap.SelectItem,
+			copyKey,
 		}
 	case logViewDiff:
 		return []key.Binding{
@@ -104,11 +107,16 @@ func (l *Log) FullHelp() [][]key.Binding {
 	b := make([][]key.Binding, 0)
 	switch l.activeView {
 	case logViewCommits:
+		copyKey := l.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy hash")
 		b = append(b, []key.Binding{
 			l.common.KeyMap.SelectItem,
 			l.common.KeyMap.BackItem,
 		})
 		b = append(b, [][]key.Binding{
+			{
+				copyKey,
+			},
 			{
 				k.CursorUp,
 				k.CursorDown,
@@ -326,7 +334,7 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 		if int64(idx) >= count {
 			break
 		}
-		items[idx] = LogItem{c}
+		items[idx] = LogItem{Commit: c}
 	}
 	return LogItemsMsg(items)
 }

ui/pages/repo/logitem.go 🔗

@@ -6,21 +6,27 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/muesli/reflow/truncate"
 )
 
 // LogItem is a item in the log list that displays a git commit.
 type LogItem struct {
 	*git.Commit
+	copied time.Time
 }
 
 // ID implements selector.IdentifiableItem.
 func (i LogItem) ID() string {
+	return i.Hash()
+}
+
+func (i LogItem) Hash() string {
 	return i.Commit.ID.String()
 }
 
@@ -40,7 +46,7 @@ func (i LogItem) FilterValue() string { return i.Title() }
 
 // LogItemDelegate is the delegate for LogItem.
 type LogItemDelegate struct {
-	style *styles.Styles
+	common *common.Common
 }
 
 // Height returns the item height. Implements list.ItemDelegate.
@@ -50,10 +56,27 @@ func (d LogItemDelegate) Height() int { return 2 }
 func (d LogItemDelegate) Spacing() int { return 1 }
 
 // Update updates the item. Implements list.ItemDelegate.
-func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(LogItem)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			item.copied = time.Now()
+			d.common.Copy.Copy(item.Hash())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
 
 // Render renders the item. Implements list.ItemDelegate.
 func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	styles := d.common.Styles
 	i, ok := listItem.(LogItem)
 	if !ok {
 		return
@@ -63,17 +86,20 @@ func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem l
 	}
 
 	width := lipgloss.Width
-	titleStyle := d.style.LogItemTitle.Copy()
-	style := d.style.LogItemInactive
+	titleStyle := styles.LogItemTitle.Copy()
+	style := styles.LogItemInactive
 	if index == m.Index() {
 		titleStyle.Bold(true)
-		style = d.style.LogItemActive
+		style = styles.LogItemActive
 	}
 	hash := " " + i.Commit.ID.String()[:7]
+	if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
+		hash = "copied"
+	}
 	title := titleStyle.Render(
 		truncateString(i.Title(), m.Width()-style.GetHorizontalFrameSize()-width(hash)-2, "…"),
 	)
-	hash = d.style.LogItemHash.Copy().
+	hash = styles.LogItemHash.Copy().
 		Align(lipgloss.Right).
 		Width(m.Width() -
 			style.GetHorizontalFrameSize() -
@@ -108,23 +134,6 @@ func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem l
 			),
 		),
 	)
-
-	// leftMargin := d.style.LogItemSelector.GetMarginLeft() +
-	// 	d.style.LogItemSelector.GetWidth() +
-	// 	d.style.LogItemHash.GetMarginLeft() +
-	// 	d.style.LogItemHash.GetWidth() +
-	// 	d.style.LogItemInactive.GetMarginLeft()
-	// title := 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))
-	// }
-	// fmt.Fprintln(w)
 }
 
 func truncateString(s string, max int, tail string) string {

ui/pages/repo/refs.go 🔗

@@ -35,7 +35,7 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
 		common:    common,
 		refPrefix: refPrefix,
 	}
-	s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{common.Styles})
+	s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})
 	s.SetShowFilter(false)
 	s.SetShowHelp(false)
 	s.SetShowPagination(true)
@@ -55,16 +55,21 @@ func (r *Refs) SetSize(width, height int) {
 
 // ShortHelp implements help.KeyMap.
 func (r *Refs) ShortHelp() []key.Binding {
+	copyKey := r.common.KeyMap.Copy
+	copyKey.SetHelp("c", "copy ref")
 	k := r.selector.KeyMap
 	return []key.Binding{
 		r.common.KeyMap.SelectItem,
 		k.CursorUp,
 		k.CursorDown,
+		copyKey,
 	}
 }
 
 // FullHelp implements help.KeyMap.
 func (r *Refs) FullHelp() [][]key.Binding {
+	copyKey := r.common.KeyMap.Copy
+	copyKey.SetHelp("c", "copy ref")
 	k := r.selector.KeyMap
 	return [][]key.Binding{
 		{r.common.KeyMap.SelectItem},
@@ -80,6 +85,9 @@ func (r *Refs) FullHelp() [][]key.Binding {
 			k.GoToStart,
 			k.GoToEnd,
 		},
+		{
+			copyKey,
+		},
 	}
 }
 
@@ -159,7 +167,7 @@ func (r *Refs) updateItemsCmd() tea.Msg {
 	}
 	for _, ref := range refs {
 		if strings.HasPrefix(ref.Name().String(), r.refPrefix) {
-			its = append(its, RefItem{ref})
+			its = append(its, RefItem{Reference: ref})
 		}
 	}
 	sort.Sort(its)

ui/pages/repo/refsitem.go 🔗

@@ -4,11 +4,11 @@ import (
 	"fmt"
 	"io"
 
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/charmbracelet/soft-serve/ui/common"
 )
 
 // RefItem is a git reference item.
@@ -55,7 +55,7 @@ func (cl RefItems) Less(i, j int) bool {
 
 // RefItemDelegate is the delegate for the ref item.
 type RefItemDelegate struct {
-	style *styles.Styles
+	common *common.Common
 }
 
 // Height implements list.ItemDelegate.
@@ -65,11 +65,26 @@ func (d RefItemDelegate) Height() int { return 1 }
 func (d RefItemDelegate) Spacing() int { return 0 }
 
 // Update implements list.ItemDelegate.
-func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(RefItem)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			d.common.Copy.Copy(item.Title())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
 
 // Render implements list.ItemDelegate.
 func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.style
+	s := d.common.Styles
 	i, ok := listItem.(RefItem)
 	if !ok {
 		return
@@ -84,12 +99,12 @@ func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem l
 		s.RefItemSelector.GetMarginLeft() -
 		s.RefItemSelector.GetWidth() -
 		s.RefItemInactive.GetMarginLeft()
-	ref = common.TruncateString(ref, refMaxWidth, "…")
+	ref = truncateString(ref, refMaxWidth, "…")
 	if index == m.Index() {
 		fmt.Fprint(w, s.RefItemSelector.Render(">")+
 			s.RefItemActive.Render(ref))
 	} else {
-		fmt.Fprint(w, s.LogItemSelector.Render(" ")+
+		fmt.Fprint(w, s.RefItemSelector.Render(" ")+
 			s.RefItemInactive.Render(ref))
 	}
 }

ui/pages/repo/repo.go 🔗

@@ -112,7 +112,7 @@ func (r *Repo) ShortHelp() []key.Binding {
 	case readmeTab:
 		b = append(b, r.common.KeyMap.UpDown)
 	default:
-		b = append(b, r.boxes[commitsTab].(help.KeyMap).ShortHelp()...)
+		b = append(b, r.boxes[r.activeTab].(help.KeyMap).ShortHelp()...)
 	}
 	return b
 }

ui/pages/selection/item.go 🔗

@@ -6,12 +6,12 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/ui/components/yankable"
+	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/git"
-	"github.com/charmbracelet/soft-serve/ui/styles"
 	"github.com/dustin/go-humanize"
 )
 
@@ -19,7 +19,8 @@ import (
 type Item struct {
 	repo       git.GitRepo
 	lastUpdate time.Time
-	url        *yankable.Yankable
+	cmd        string
+	copied     time.Time
 }
 
 // ID implements selector.IdentifiableItem.
@@ -36,26 +37,26 @@ func (i Item) Description() string { return i.repo.Description() }
 // FilterValue implements list.Item.
 func (i Item) FilterValue() string { return i.Title() }
 
-// URL returns the item URL view.
-func (i Item) URL() string {
-	return i.url.View()
+// Command returns the item Command view.
+func (i Item) Command() string {
+	return i.cmd
 }
 
 // ItemDelegate is the delegate for the item.
 type ItemDelegate struct {
-	styles    *styles.Styles
+	common    *common.Common
 	activeBox *box
 }
 
 // Width returns the item width.
 func (d ItemDelegate) Width() int {
-	width := d.styles.MenuItem.GetHorizontalFrameSize() + d.styles.MenuItem.GetWidth()
+	width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()
 	return width
 }
 
 // Height returns the item height. Implements list.ItemDelegate.
 func (d ItemDelegate) Height() int {
-	height := d.styles.MenuItem.GetVerticalFrameSize() + d.styles.MenuItem.GetHeight()
+	height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()
 	return height
 }
 
@@ -64,40 +65,26 @@ func (d ItemDelegate) Spacing() int { return 0 }
 
 // Update implements list.ItemDelegate.
 func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
-	cmds := make([]tea.Cmd, 0)
-	// if d.activeBox == nil || *d.activeBox != selectorBox {
-	// 	return nil
-	// }
-	for i, item := range m.VisibleItems() {
-		itm, ok := item.(Item)
-		if !ok {
-			continue
-		}
-		// FIXME check if X & Y are within the item box
-		switch msg := msg.(type) {
-		case tea.MouseMsg:
-			// x := msg.X
-			y := msg.Y
-			// minX := (i * d.Width())
-			// maxX := minX + d.Width()
-			minY := (i * d.Height())
-			maxY := minY + d.Height()
-			// log.Printf("i: %d, x: %d, y: %d", i, x, y)
-			if y < minY || y > maxY {
-				continue
-			}
-		}
-		y, cmd := itm.url.Update(msg)
-		itm.url = y.(*yankable.Yankable)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
+	idx := m.Index()
+	item, ok := m.SelectedItem().(Item)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			item.copied = time.Now()
+			d.common.Copy.Copy(item.Command())
+			return m.SetItem(idx, item)
 		}
 	}
-	return tea.Batch(cmds...)
+	return nil
 }
 
 // Render implements list.ItemDelegate.
 func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	styles := d.common.Styles
 	i := listItem.(Item)
 	s := strings.Builder{}
 	var matchedRunes []int
@@ -105,13 +92,12 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	// Conditions
 	var (
 		isSelected = index == m.Index()
-		// emptyFilter = m.FilterState() == list.Filtering && m.FilterValue() == ""
 		isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied
 	)
 
-	itemStyle := d.styles.MenuItem.Copy()
+	itemStyle := styles.MenuItem.Copy()
 	if isSelected {
-		itemStyle = itemStyle.BorderForeground(d.styles.ActiveBorderColor)
+		itemStyle = itemStyle.BorderForeground(styles.ActiveBorderColor)
 		if d.activeBox != nil && *d.activeBox == readmeBox {
 			// TODO make this into its own color
 			itemStyle = itemStyle.BorderForeground(lipgloss.Color("15"))
@@ -120,7 +106,7 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 
 	title := i.Title()
 	updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.lastUpdate))
-	updated := d.styles.MenuLastUpdate.
+	updated := styles.MenuLastUpdate.
 		Copy().
 		Width(m.Width() - itemStyle.GetHorizontalFrameSize() - lipgloss.Width(title)).
 		Render(updatedStr)
@@ -144,6 +130,11 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	s.WriteString("\n")
 	s.WriteString(i.Description())
 	s.WriteString("\n\n")
-	s.WriteString(i.url.View())
+	cmdStyle := styles.RepoCommand.Copy()
+	cmd := cmdStyle.Render(i.Command())
+	if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
+		cmd = cmdStyle.Render("Copied!")
+	}
+	s.WriteString(cmd)
 	w.Write([]byte(itemStyle.Render(s.String())))
 }

ui/pages/selection/selection.go 🔗

@@ -1,16 +1,12 @@
 package selection
 
 import (
-	"fmt"
-	"strings"
-
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"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/yankable"
 	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/charmbracelet/soft-serve/ui/session"
 )
@@ -42,7 +38,7 @@ func New(s session.Session, common common.Common) *Selection {
 	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
 	selector := selector.New(common,
 		[]selector.IdentifiableItem{},
-		ItemDelegate{common.Styles, &sel.activeBox})
+		ItemDelegate{&common, &sel.activeBox})
 	selector.SetShowTitle(false)
 	selector.SetShowHelp(false)
 	selector.SetShowStatusBar(false)
@@ -77,10 +73,13 @@ func (s *Selection) ShortHelp() []key.Binding {
 		s.common.KeyMap.Section,
 	)
 	if s.activeBox == selectorBox {
+		copyKey := s.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy command")
 		kb = append(kb,
 			s.common.KeyMap.Select,
 			k.Filter,
 			k.ClearFilter,
+			copyKey,
 		)
 	}
 	return kb
@@ -106,10 +105,13 @@ func (s *Selection) FullHelp() [][]key.Binding {
 			},
 		}
 	case selectorBox:
+		copyKey := s.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy command")
 		k := s.selector.KeyMap
 		return [][]key.Binding{
 			{
 				s.common.KeyMap.Select,
+				copyKey,
 			},
 			{
 				k.CursorUp,
@@ -136,32 +138,8 @@ func (s *Selection) FullHelp() [][]key.Binding {
 
 // Init implements tea.Model.
 func (s *Selection) Init() tea.Cmd {
-	session := s.s.Session()
-	environ := session.Environ()
-	termExists := false
-	// Add TERM using pty.Term if it's not already set.
-	for _, env := range environ {
-		if strings.HasPrefix(env, "TERM=") {
-			termExists = true
-			break
-		}
-	}
-	if !termExists {
-		pty, _, _ := session.Pty()
-		environ = append(environ, fmt.Sprintf("TERM=%s", pty.Term))
-	}
 	items := make([]selector.IdentifiableItem, 0)
 	cfg := s.s.Config()
-	// TODO clean up this and move style to its own var.
-	yank := func(text string) *yankable.Yankable {
-		return yankable.New(
-			session,
-			environ,
-			lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
-			lipgloss.NewStyle().Foreground(lipgloss.Color("168")).SetString("Copied!"),
-			text,
-		)
-	}
 	// Put configured repos first
 	for _, r := range cfg.Repos {
 		repo, err := cfg.Source.GetRepo(r.Repo)
@@ -170,7 +148,7 @@ func (s *Selection) Init() tea.Cmd {
 		}
 		items = append(items, Item{
 			repo: repo,
-			url:  yank(git.RepoURL(cfg.Host, cfg.Port, r.Repo)),
+			cmd:  git.RepoURL(cfg.Host, cfg.Port, r.Repo),
 		})
 	}
 	for _, r := range cfg.Source.AllRepos() {
@@ -196,7 +174,7 @@ func (s *Selection) Init() tea.Cmd {
 			items = append(items, Item{
 				repo:       r,
 				lastUpdate: lastUpdate,
-				url:        yank(git.RepoURL(cfg.Host, cfg.Port, r.Name())),
+				cmd:        git.RepoURL(cfg.Host, cfg.Port, r.Name()),
 			})
 		}
 	}

ui/styles/styles.go 🔗

@@ -31,7 +31,7 @@ type Styles struct {
 	Repo           lipgloss.Style
 	RepoTitle      lipgloss.Style
 	RepoTitleBox   lipgloss.Style
-	RepoNote       lipgloss.Style
+	RepoCommand    lipgloss.Style
 	RepoNoteBox    lipgloss.Style
 	RepoBody       lipgloss.Style
 	RepoHeader     lipgloss.Style
@@ -183,8 +183,7 @@ func DefaultStyles() *Styles {
 		BorderStyle(s.RepoTitleBorder).
 		BorderForeground(s.InactiveBorderColor)
 
-	s.RepoNote = lipgloss.NewStyle().
-		Padding(0, 2).
+	s.RepoCommand = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("168"))
 
 	s.RepoNoteBox = lipgloss.NewStyle().
@@ -291,11 +290,15 @@ func DefaultStyles() *Styles {
 		Margin(0).
 		Align(lipgloss.Center)
 
-	s.RefItemSelector = s.TreeItemSelector.Copy()
+	s.RefItemInactive = lipgloss.NewStyle().
+		MarginLeft(1)
 
-	s.RefItemActive = s.TreeItemActive.Copy()
+	s.RefItemSelector = lipgloss.NewStyle().
+		Width(1).
+		Foreground(lipgloss.Color("#B083EA"))
 
-	s.RefItemInactive = s.TreeItemInactive.Copy()
+	s.RefItemActive = s.RefItemInactive.Copy().
+		Bold(true)
 
 	s.RefItemBranch = lipgloss.NewStyle()