From 6102e30b1b943d8883b460a63bf80a476197516b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 4 May 2022 16:39:26 -0400 Subject: [PATCH] feat: copy over ssh --- 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(-) create mode 100644 ui/components/copy/copy.go diff --git a/go.mod b/go.mod index 3c3c0b38508ff55d3d2871cfb6256bd29a232199..4178ffd278982e77ffeffb5c7be54a9167ad091a 100755 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 07ae27f9d9b50521d882277f421eb3c42579f9a8..fbfa30d392163a61a55b37dec0a01e70af8fa171 100644 --- a/go.sum +++ b/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= diff --git a/server/session.go b/server/session.go index 15b08e7cb1443dc39f06b97234309c5a57a078d7..b9021b114aaccffe3a6c3788edd49432f029a3a2 100644 --- a/server/session.go +++ b/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, diff --git a/ui/common/common.go b/ui/common/common.go index ef54b33eb5b8cf0661e46e1b05e356509da15527..f18d0298b00422d343f382682c031b15301e02c0 100644 --- a/ui/common/common.go +++ b/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 diff --git a/ui/components/code/code.go b/ui/components/code/code.go index 6cda3cd1b60affdb46eea9cca5edfe060c790a4c..9ef9dbe9e398077ecb12fc2bff03915601aac523 100644 --- a/ui/components/code/code.go +++ b/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 } diff --git a/ui/components/copy/copy.go b/ui/components/copy/copy.go new file mode 100644 index 0000000000000000000000000000000000000000..3eeccdd738844a56633c665d86af4b38490f4b47 --- /dev/null +++ b/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) +} diff --git a/ui/components/viewport/viewport.go b/ui/components/viewport/viewport.go index 4223fa725ee93231c909c4faa0454929776230a8..87789a9a0f8aaa88f2703432a43d89c8678f4d37 100644 --- a/ui/components/viewport/viewport.go +++ b/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, diff --git a/ui/keymap/keymap.go b/ui/keymap/keymap.go index d3e8a79a1b224def359ca2205ea8e49e2454e473..fddfb11ca02ba92dd84e5286d88974ac236a098a 100644 --- a/ui/keymap/keymap.go +++ b/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 } diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go index 4c9e4e8ca657f2c42b89e071b538de86da3ac854..3e36fad0762afe5020f53f2699af3e8e05c3bed8 100644 --- a/ui/pages/repo/files.go +++ b/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 } diff --git a/ui/pages/repo/filesitem.go b/ui/pages/repo/filesitem.go index 813ef0dbcf25c0cf319029a4ce401a0a9067e324..ba883729eddc1f4ba8535947948b4d25fc64c56c 100644 --- a/ui/pages/repo/filesitem.go +++ b/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 diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go index e40c08927ec4d1b3d2e25ecdc51d22665d7a361e..23c757d76bb2dbee6780eb2c3b5ad60ca9e987ef 100644 --- a/ui/pages/repo/log.go +++ b/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) } diff --git a/ui/pages/repo/logitem.go b/ui/pages/repo/logitem.go index 1ce6b4c4e8ec48e84d9e35991aaed1c0de69a76d..c6d2bb2c6c81e588452be3b0bff9382490b27f98 100644 --- a/ui/pages/repo/logitem.go +++ b/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 { diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go index 28392d0f19df38ecde166b4ad714cd2c8c03d837..f0581e4878e387d199fb7fa7810eddbe8bf9e9fc 100644 --- a/ui/pages/repo/refs.go +++ b/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) diff --git a/ui/pages/repo/refsitem.go b/ui/pages/repo/refsitem.go index d60f9241539938feec2f6f42cb598ecb35af9597..54819fe32b4579f61058b53da7f0e5d4f58d8482 100644 --- a/ui/pages/repo/refsitem.go +++ b/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)) } } diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go index 0957c4023f6c1958623fc0911dca86383995894c..c7ccc83b8762c579be61d9215bf5c55de952b3c7 100644 --- a/ui/pages/repo/repo.go +++ b/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 } diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go index 343195340e73c2e22170e76f0c96125dc8842e48..41754ef4480e77f8fb68a60c2e1a8d000941182c 100644 --- a/ui/pages/selection/item.go +++ 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()))) } diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go index 1ffc2e9ae7093a5768d496dc226c46feba3b9e13..ef622fee445c7ba25d410af15eef20b42f7c20d8 100644 --- a/ui/pages/selection/selection.go +++ b/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()), }) } } diff --git a/ui/styles/styles.go b/ui/styles/styles.go index c867c65b0a279e4129005da2da4ad9b74ae72622..0ec74fc76e70554f1b224d96ec94e5e8da16816c 100644 --- a/ui/styles/styles.go +++ b/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()