feat(ui): notify copied text

Ayman Bagabas created

Fixes: https://github.com/charmbracelet/soft-serve/issues/154

Change summary

ui/common/utils.go                   |  4 +-
ui/components/statusbar/statusbar.go | 36 ++++++++++++-----
ui/pages/repo/empty.go               |  2 
ui/pages/repo/files.go               |  2 
ui/pages/repo/filesitem.go           |  4 -
ui/pages/repo/logitem.go             |  9 ----
ui/pages/repo/refsitem.go            |  4 -
ui/pages/repo/repo.go                | 58 ++++++++++++++---------------
ui/pages/selection/item.go           | 28 +++++++++----
ui/pages/selection/selection.go      |  2 
10 files changed, 80 insertions(+), 69 deletions(-)

Detailed changes

ui/common/utils.go 🔗

@@ -16,8 +16,8 @@ func TruncateString(s string, max int) string {
 	return truncate.StringWithTail(s, uint(max), "…")
 }
 
-// RepoURL returns the URL of the repository.
-func RepoURL(publicURL, name string) string {
+// CloneCmd returns the URL of the repository.
+func CloneCmd(publicURL, name string) string {
 	name = utils.SanitizeRepo(name) + ".git"
 	url, err := url.Parse(publicURL)
 	if err == nil {

ui/components/statusbar/statusbar.go 🔗

@@ -9,16 +9,19 @@ import (
 
 // StatusBarMsg is a message sent to the status bar.
 type StatusBarMsg struct {
-	Key    string
-	Value  string
-	Info   string
-	Branch string
+	Key   string
+	Value string
+	Info  string
+	Extra string
 }
 
 // StatusBar is a status bar model.
 type StatusBar struct {
 	common common.Common
-	msg    StatusBarMsg
+	key    string
+	value  string
+	info   string
+	extra  string
 }
 
 // Model is an interface that supports setting the status bar information.
@@ -50,7 +53,18 @@ func (s *StatusBar) Init() tea.Cmd {
 func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case StatusBarMsg:
-		s.msg = msg
+		if msg.Key != "" {
+			s.key = msg.Key
+		}
+		if msg.Value != "" {
+			s.value = msg.Value
+		}
+		if msg.Info != "" {
+			s.info = msg.Info
+		}
+		if msg.Extra != "" {
+			s.extra = msg.Extra
+		}
 	}
 	return s, nil
 }
@@ -63,14 +77,14 @@ func (s *StatusBar) View() string {
 		"repo-help",
 		st.StatusBarHelp.Render("? Help"),
 	)
-	key := st.StatusBarKey.Render(s.msg.Key)
+	key := st.StatusBarKey.Render(s.key)
 	info := ""
-	if s.msg.Info != "" {
-		info = st.StatusBarInfo.Render(s.msg.Info)
+	if s.info != "" {
+		info = st.StatusBarInfo.Render(s.info)
 	}
-	branch := st.StatusBarBranch.Render(s.msg.Branch)
+	branch := st.StatusBarBranch.Render(s.extra)
 	maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help)
-	v := truncate.StringWithTail(s.msg.Value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…")
+	v := truncate.StringWithTail(s.value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…")
 	value := st.StatusBarValue.
 		Width(maxWidth).
 		Render(v)

ui/pages/repo/empty.go 🔗

@@ -36,5 +36,5 @@ git push -u origin main
 git remote add origin %[1]s
 git push -u origin main
 `+"```"+`
-`, common.RepoURL(cfg.SSH.PublicURL, repo))
+`, common.CloneCmd(cfg.SSH.PublicURL, repo))
 }

ui/pages/repo/files.go 🔗

@@ -243,7 +243,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case key.Matches(msg, f.common.KeyMap.BackItem):
 				cmds = append(cmds, backCmd)
 			case key.Matches(msg, f.common.KeyMap.Copy):
-				f.common.Copy.Copy(f.currentContent.content)
+				cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
 			case key.Matches(msg, lineNo):
 				f.lineNumber = !f.lineNumber
 				f.code.SetShowLineNumber(f.lineNumber)

ui/pages/repo/filesitem.go 🔗

@@ -78,7 +78,6 @@ func (d FileItemDelegate) Spacing() int { return 0 }
 
 // Update implements list.ItemDelegate.
 func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
-	idx := m.Index()
 	item, ok := m.SelectedItem().(FileItem)
 	if !ok {
 		return nil
@@ -87,8 +86,7 @@ func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, d.common.KeyMap.Copy):
-			d.common.Copy.Copy(item.Title())
-			return m.SetItem(idx, item)
+			return copyCmd(item.entry.Name(), fmt.Sprintf("File name %q copied to clipboard", item.entry.Name()))
 		}
 	}
 	return nil

ui/pages/repo/logitem.go 🔗

@@ -18,7 +18,6 @@ import (
 // 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.
@@ -57,7 +56,6 @@ 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 {
-	idx := m.Index()
 	item, ok := m.SelectedItem().(LogItem)
 	if !ok {
 		return nil
@@ -66,9 +64,7 @@ func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	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 copyCmd(item.Hash(), fmt.Sprintf("Commit hash %q copied to clipboard", item.Hash()))
 		}
 	}
 	return nil
@@ -92,9 +88,6 @@ func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem l
 	horizontalFrameSize := styles.Base.GetHorizontalFrameSize()
 
 	hash := i.Commit.ID.String()[:7]
-	if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
-		hash = "copied"
-	}
 	title := styles.Title.Render(
 		common.TruncateString(i.Title(),
 			m.Width()-

ui/pages/repo/refsitem.go 🔗

@@ -67,7 +67,6 @@ func (d RefItemDelegate) Spacing() int { return 0 }
 
 // Update implements list.ItemDelegate.
 func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
-	idx := m.Index()
 	item, ok := m.SelectedItem().(RefItem)
 	if !ok {
 		return nil
@@ -76,8 +75,7 @@ func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, d.common.KeyMap.Copy):
-			d.common.Copy.Copy(item.Title())
-			return m.SetItem(idx, item)
+			return copyCmd(item.ID(), fmt.Sprintf("Reference %q copied to clipboard", item.ID()))
 		}
 	}
 	return nil

ui/pages/repo/repo.go 🔗

@@ -2,7 +2,6 @@ package repo
 
 import (
 	"fmt"
-	"time"
 
 	"github.com/charmbracelet/bubbles/help"
 	"github.com/charmbracelet/bubbles/key"
@@ -56,9 +55,6 @@ type EmptyRepoMsg struct{}
 // CopyURLMsg is a message to copy the URL of the current repository.
 type CopyURLMsg struct{}
 
-// ResetURLMsg is a message to reset the URL string.
-type ResetURLMsg struct{}
-
 // UpdateStatusBarMsg updates the status bar.
 type UpdateStatusBarMsg struct{}
 
@@ -68,6 +64,12 @@ type RepoMsg backend.Repository
 // BackMsg is a message to go back to the previous view.
 type BackMsg struct{}
 
+// CopyMsg is a message to indicate copied text.
+type CopyMsg struct {
+	Text    string
+	Message string
+}
+
 // Repo is a view for a git repository.
 type Repo struct {
 	common       common.Common
@@ -77,7 +79,6 @@ type Repo struct {
 	statusbar    *statusbar.StatusBar
 	panes        []common.Component
 	ref          *git.Reference
-	copyURL      time.Time
 	state        state
 	spinner      spinner.Model
 	panesReady   [lastTab]bool
@@ -215,8 +216,9 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if r.selectedRepo != nil {
 			cmds = append(cmds, r.updateStatusBarCmd)
 			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
+			cmd := common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
 			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
-				cmds = append(cmds, r.copyURLCmd())
+				cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))
 			}
 		}
 		switch msg := msg.(type) {
@@ -234,14 +236,16 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			}
 		}
-	case CopyURLMsg:
+	case CopyMsg:
+		txt := msg.Text
 		if cfg := r.common.Config(); cfg != nil {
-			r.common.Copy.Copy(
-				common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name()),
-			)
+			r.common.Copy.Copy(txt)
 		}
-	case ResetURLMsg:
-		r.copyURL = time.Time{}
+		cmds = append(cmds, func() tea.Msg {
+			return statusbar.StatusBarMsg{
+				Value: msg.Message,
+			}
+		})
 	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
 		cmds = append(cmds, r.updateRepo(msg))
 	// We have two spinners, one is used to when loading the repository and the
@@ -345,10 +349,7 @@ func (r *Repo) headerView() string {
 		Align(lipgloss.Right)
 	var url string
 	if cfg := r.common.Config(); cfg != nil {
-		url = common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name())
-	}
-	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
-		url = "copied!"
+		url = common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
 	}
 	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
 	url = r.common.Zone.Mark(
@@ -378,10 +379,10 @@ func (r *Repo) updateStatusBarCmd() tea.Msg {
 		branch += " " + r.ref.Name().Short()
 	}
 	return statusbar.StatusBarMsg{
-		Key:    r.selectedRepo.Name(),
-		Value:  value,
-		Info:   info,
-		Branch: branch,
+		Key:   r.selectedRepo.Name(),
+		Value: value,
+		Info:  info,
+		Extra: branch,
 	}
 }
 
@@ -458,16 +459,13 @@ func (r *Repo) isReady() bool {
 	return ready
 }
 
-func (r *Repo) copyURLCmd() tea.Cmd {
-	r.copyURL = time.Now()
-	return tea.Batch(
-		func() tea.Msg {
-			return CopyURLMsg{}
-		},
-		tea.Tick(time.Second, func(time.Time) tea.Msg {
-			return ResetURLMsg{}
-		}),
-	)
+func copyCmd(text, msg string) tea.Cmd {
+	return func() tea.Msg {
+		return CopyMsg{
+			Text:    text,
+			Message: msg,
+		}
+	}
 }
 
 func updateStatusBarCmd() tea.Msg {

ui/pages/selection/item.go 🔗

@@ -51,7 +51,6 @@ type Item struct {
 	repo       backend.Repository
 	lastUpdate *time.Time
 	cmd        string
-	copied     time.Time
 }
 
 // New creates a new Item.
@@ -64,7 +63,7 @@ func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) {
 	return Item{
 		repo:       repo,
 		lastUpdate: lastUpdate,
-		cmd:        common.RepoURL(cfg.SSH.PublicURL, repo.Name()),
+		cmd:        common.CloneCmd(cfg.SSH.PublicURL, repo.Name()),
 	}, nil
 }
 
@@ -98,6 +97,16 @@ func (i Item) Command() string {
 type ItemDelegate struct {
 	common     *common.Common
 	activePane *pane
+	copiedIdx  int
+}
+
+// NewItemDelegate creates a new ItemDelegate.
+func NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate {
+	return &ItemDelegate{
+		common:     common,
+		activePane: activePane,
+		copiedIdx:  -1,
+	}
 }
 
 // Width returns the item width.
@@ -107,16 +116,16 @@ func (d ItemDelegate) Width() int {
 }
 
 // Height returns the item height. Implements list.ItemDelegate.
-func (d ItemDelegate) Height() int {
+func (d *ItemDelegate) Height() int {
 	height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()
 	return height
 }
 
 // Spacing returns the spacing between items. Implements list.ItemDelegate.
-func (d ItemDelegate) Spacing() int { return 1 }
+func (d *ItemDelegate) Spacing() int { return 1 }
 
 // Update implements list.ItemDelegate.
-func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	idx := m.Index()
 	item, ok := m.SelectedItem().(Item)
 	if !ok {
@@ -126,7 +135,7 @@ func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, d.common.KeyMap.Copy):
-			item.copied = time.Now()
+			d.copiedIdx = idx
 			d.common.Copy.Copy(item.Command())
 			return m.SetItem(idx, item)
 		}
@@ -135,7 +144,7 @@ func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 }
 
 // Render implements list.ItemDelegate.
-func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 	i := listItem.(Item)
 	s := strings.Builder{}
 	var matchedRunes []int
@@ -192,8 +201,9 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	s.WriteRune('\n')
 	cmd := common.TruncateString(i.Command(), m.Width()-styles.Base.GetHorizontalFrameSize())
 	cmd = styles.Command.Render(cmd)
-	if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
-		cmd = styles.Command.Render("Copied!")
+	if d.copiedIdx == index {
+		cmd += " " + styles.Desc.Render("(copied to clipboard)")
+		d.copiedIdx = -1
 	}
 	s.WriteString(cmd)
 	fmt.Fprint(w,

ui/pages/selection/selection.go 🔗

@@ -71,7 +71,7 @@ func New(c common.Common) *Selection {
 		SetString(defaultNoContent)
 	selector := selector.New(c,
 		[]selector.IdentifiableItem{},
-		ItemDelegate{&c, &sel.activePane})
+		NewItemDelegate(&c, &sel.activePane))
 	selector.SetShowTitle(false)
 	selector.SetShowHelp(false)
 	selector.SetShowStatusBar(false)