Archived
Add c to copy the selected path, split key bar into groups
c copies the absolute directory of the selected submodule via OSC 52. Keys are grouped into navigation (footer left), global (footer right) and submodule (detail pane palette, footer if the pane can't show it). Adds a key reference table to the UX guide. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,17 @@ func (p *Provider) Fetchable(path string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Dir returns the absolute directory of path ("" is the superproject),
|
||||
// whether or not it is initialized.
|
||||
func (p *Provider) Dir(path string) string {
|
||||
if path == "" {
|
||||
return p.root
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.mods[path].dir
|
||||
}
|
||||
|
||||
// repoDir returns the worktree of path if it is initialized.
|
||||
func (p *Provider) repoDir(path string) (string, bool) {
|
||||
if path == "" {
|
||||
|
||||
@@ -26,6 +26,13 @@ type Watcher interface {
|
||||
Watch(ctx context.Context) <-chan Change
|
||||
}
|
||||
|
||||
// Locator is implemented by providers whose submodules live on the local
|
||||
// disk. Dir returns the absolute directory of path ("" is the superproject),
|
||||
// or "" if it is unknown.
|
||||
type Locator interface {
|
||||
Dir(path string) string
|
||||
}
|
||||
|
||||
// Change reports that the git metadata of a repository changed.
|
||||
type Change struct {
|
||||
Path string // "" is the superproject
|
||||
|
||||
+22
-1
@@ -73,7 +73,7 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
loads: newReloader(),
|
||||
ticking: true, // Init schedules the first tick
|
||||
tree: tree.New(t),
|
||||
detail: detail.New(t),
|
||||
detail: detail.New(t, k.Submodule()),
|
||||
footer: footer.New(t, k),
|
||||
spinner: sp,
|
||||
}
|
||||
@@ -353,6 +353,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, m.startFetch()
|
||||
case key.Matches(msg, k.Reload):
|
||||
return m, m.reloadTree(true)
|
||||
case key.Matches(msg, k.Copy):
|
||||
return m.copyPath()
|
||||
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
|
||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||
m.layout()
|
||||
@@ -361,6 +363,24 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// copyPath copies the absolute directory of the selected submodule to the
|
||||
// clipboard, or its path in the superproject if the provider can't tell.
|
||||
func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
if s == nil {
|
||||
return m, nil
|
||||
}
|
||||
path := s.Path
|
||||
if l, ok := m.provider.(submodule.Locator); ok {
|
||||
if dir := l.Dir(s.Path); dir != "" {
|
||||
path = dir
|
||||
}
|
||||
}
|
||||
cmd := tea.Batch(copyToClipboard(path), m.footer.Flash("copied "+path))
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// layout distributes the terminal size among the components.
|
||||
func (m *Model) layout() {
|
||||
m.footer.SetWidth(m.width)
|
||||
@@ -373,6 +393,7 @@ func (m *Model) layout() {
|
||||
treeWidth = m.width - detailWidth
|
||||
m.detail.SetSize(detailWidth-2, bodyHeight) // border + padding
|
||||
}
|
||||
m.footer.SetActions(!m.showDetail || !m.detail.PaletteVisible())
|
||||
m.tree.SetSize(treeWidth, bodyHeight)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/aymanbagabas/go-osc52/v2"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// copyToClipboard sets the system clipboard with the OSC 52 escape sequence,
|
||||
// which works over SSH and in terminals that allow it; others ignore it.
|
||||
func copyToClipboard(text string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
seq := osc52.New(text)
|
||||
if os.Getenv("TMUX") != "" {
|
||||
seq = seq.Tmux()
|
||||
}
|
||||
_, _ = seq.WriteTo(os.Stderr)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
@@ -20,9 +22,20 @@ type Model struct {
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
help help.Model
|
||||
actions []key.Binding
|
||||
}
|
||||
|
||||
func New(t theme.Theme) Model { return Model{theme: t} }
|
||||
// paletteMinHeight is the pane height below which the palette is left out:
|
||||
// the information is worth more than the hints.
|
||||
const paletteMinHeight = 12
|
||||
|
||||
func New(t theme.Theme, actions []key.Binding) Model {
|
||||
return Model{theme: t, help: theme.NewHelp(t), actions: actions}
|
||||
}
|
||||
|
||||
// PaletteVisible reports whether the palette of submodule actions fits.
|
||||
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
|
||||
|
||||
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
|
||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||
@@ -38,10 +51,21 @@ func (m Model) View(s *submodule.Submodule) string {
|
||||
for i, l := range lines {
|
||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||
}
|
||||
if len(lines) > m.height {
|
||||
lines = lines[:m.height]
|
||||
height := m.height
|
||||
var palette string
|
||||
if m.PaletteVisible() {
|
||||
height-- // the palette is the last line
|
||||
m.help.Width = 0
|
||||
palette = ansi.Truncate(m.help.ShortHelpView(m.actions), m.width, "…")
|
||||
}
|
||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(lines, "\n"))
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
|
||||
if palette == "" {
|
||||
return body
|
||||
}
|
||||
return body + "\n" + palette
|
||||
}
|
||||
|
||||
func (m Model) lines(s *submodule.Submodule) []string {
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
package footer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
@@ -15,26 +18,34 @@ const flashDuration = 2 * time.Second
|
||||
|
||||
type clearFlashMsg struct{ id int }
|
||||
|
||||
// Keys provides the key groups. The short bar shows Navigation (and
|
||||
// Submodule, if requested) on the left and Global on the right; full help
|
||||
// shows FullHelp.
|
||||
type Keys interface {
|
||||
help.KeyMap
|
||||
Navigation() []key.Binding
|
||||
Submodule() []key.Binding
|
||||
Global() []key.Binding
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
theme theme.Theme
|
||||
help help.Model
|
||||
keys help.KeyMap
|
||||
keys Keys
|
||||
width int
|
||||
flash string
|
||||
flashID int
|
||||
actions bool // show the submodule bindings in the short bar
|
||||
}
|
||||
|
||||
func New(t theme.Theme, keys help.KeyMap) Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = theme.Fg(t.Text).Bold(true)
|
||||
h.Styles.ShortDesc = theme.Fg(t.Muted)
|
||||
h.Styles.ShortSeparator = theme.Fg(t.Border)
|
||||
h.Styles.FullKey = h.Styles.ShortKey
|
||||
h.Styles.FullDesc = h.Styles.ShortDesc
|
||||
h.Styles.FullSeparator = h.Styles.ShortSeparator
|
||||
return Model{theme: t, help: h, keys: keys}
|
||||
func New(t theme.Theme, keys Keys) Model {
|
||||
return Model{theme: t, help: theme.NewHelp(t), keys: keys}
|
||||
}
|
||||
|
||||
// SetActions shows the submodule bindings in the short bar. Set it if the
|
||||
// detail pane doesn't show them.
|
||||
func (m *Model) SetActions(on bool) { m.actions = on }
|
||||
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
m.help.Width = w - 2
|
||||
@@ -62,5 +73,29 @@ func (m Model) View() string {
|
||||
if m.flash != "" {
|
||||
return style.Foreground(m.theme.Warn).Render(m.flash)
|
||||
}
|
||||
return style.Render(m.help.View(m.keys))
|
||||
if m.help.ShowAll {
|
||||
return style.Render(m.help.View(m.keys))
|
||||
}
|
||||
return style.Render(m.shortBar(m.width - 2))
|
||||
}
|
||||
|
||||
// shortBar puts the global keys on the right and as many of the others on the
|
||||
// left as fit; the submodule keys are dropped first.
|
||||
func (m Model) shortBar(w int) string {
|
||||
h := m.help
|
||||
h.Width = 0
|
||||
right := h.ShortHelpView(m.keys.Global())
|
||||
left := m.keys.Navigation()
|
||||
if m.actions {
|
||||
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
|
||||
}
|
||||
for ; ; left = left[:len(left)-1] {
|
||||
l := h.ShortHelpView(left)
|
||||
if gap := w - lipgloss.Width(l) - lipgloss.Width(right); gap >= 2 || len(left) == 0 {
|
||||
if gap < 0 { // not even the global keys fit
|
||||
return ansi.Truncate(right, w, "…")
|
||||
}
|
||||
return l + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-4
@@ -7,7 +7,7 @@ type keyMap struct {
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
||||
Help, Quit key.Binding
|
||||
Copy, Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
@@ -29,16 +29,29 @@ func defaultKeys() keyMap {
|
||||
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
// Navigation returns the bindings that move the selection or fold the tree.
|
||||
func (k keyMap) Navigation() []key.Binding {
|
||||
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
|
||||
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "fold"))
|
||||
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "fold all"))
|
||||
return []key.Binding{move, fold, all, k.Toggle, k.Help, k.Quit}
|
||||
return []key.Binding{move, fold, all, k.Toggle}
|
||||
}
|
||||
|
||||
// Submodule returns the bindings that act on the selected submodule. They
|
||||
// are shown in the detail pane, or in the footer if the pane can't show them.
|
||||
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy} }
|
||||
|
||||
// Global returns the bindings that don't depend on the selection.
|
||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
@@ -46,7 +59,8 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
||||
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||
{k.CollapseAll, k.ExpandAll},
|
||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
||||
{k.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Copy, k.Update, k.Bump},
|
||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/bubbles/help"
|
||||
|
||||
// NewHelp returns a help renderer in the theme's colors.
|
||||
func NewHelp(t Theme) help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = Fg(t.Text).Bold(true)
|
||||
h.Styles.ShortDesc = Fg(t.Muted)
|
||||
h.Styles.ShortSeparator = Fg(t.Border)
|
||||
h.Styles.FullKey = h.Styles.ShortKey
|
||||
h.Styles.FullDesc = h.Styles.ShortDesc
|
||||
h.Styles.FullSeparator = h.Styles.ShortSeparator
|
||||
return h
|
||||
}
|
||||
Reference in New Issue
Block a user