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:
2026-10-21 20:48:16 +02:00
co-authored by Claude Sonnet 5.5
parent 90ea9cc241
commit 5cb809428b
12 changed files with 236 additions and 22 deletions
+7 -1
View File
@@ -31,7 +31,7 @@ internal/tui/ Bubble Tea application
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview
header/ header line
footer/ help and flash messages
footer/ key bar (navigation left, global right) and flash messages
```
### Layering rules
@@ -64,6 +64,12 @@ type Watcher interface {
}
```
```go
type Locator interface {
Dir(path string) string // absolute directory, "" if unknown
}
```
- `Tree` returns the superproject and all submodules with `Parent` and
`Children` linked. `Status` fields are nil.
- `Status` is called once per submodule, concurrently. It must be safe to
+18
View File
@@ -200,3 +200,21 @@ package so versions stay clean.
corrupt or partial downloads, not a compromised registry (no signatures).
The update check is a deliberate, outside-the-provider network use: only
on `update` and a once-a-day background check that can be disabled.
## Copy path with `c` via OSC 52
`c` copies the selected submodule's absolute directory (optional
`submodule.Locator`; the relative path as fallback). The absolute path is
what a shell needs (`cd <paste>`). OSC 52 instead of a clipboard tool
(`xclip`, `pbcopy`): no external dependency and it works over SSH. The cost:
terminals that block OSC 52 copy nothing silently; the footer still says
`copied`, since the application can't know.
## Key groups: global, navigation, submodule
Keys that act on the selected submodule (copy path, later checkout, bump) are
shown in a palette in the detail pane, next to what they act on; global keys
(`?`, `q`) sit right in the footer. Where the pane can't show the palette
(hidden below 90 columns, or under 12 lines) the footer shows these keys
instead, so small terminals need no extra UI (no overlay or action menu).
Keys never depend on what is visible.
+42
View File
@@ -36,6 +36,21 @@
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
| Footer | 1 line (more with full help) |
### Key bar and palette
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
- **Navigation** (move, fold, toggle): left in the footer.
- **Global** (`?`, `q`): right-aligned in the footer, always kept.
- **Submodule** (act on the selected one, e.g. `c`): a palette on the last
line of the detail pane. It is left out if the pane is shorter than 12
lines. Whenever the palette isn't shown (pane hidden or too short), these
keys move into the footer after the navigation keys.
If the footer is too narrow, keys are dropped from the left group's end
(submodule keys first); the global keys stay. Full help (`?`) lists all keys
in columns, whatever the size. Keys work whether or not they are shown.
### Tree row
```
@@ -191,6 +206,7 @@ colors in components.
| `q`, `ctrl+c` | Quit |
| `f` | Fetch all repositories (does nothing while a fetch runs) |
| `r` | Reload everything (e.g. after editing files) |
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied |
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
"First child" always means the first child **as displayed** (children are
@@ -200,6 +216,32 @@ Collapsing an ancestor of the selection moves the selection to that ancestor.
The view scrolls so the selection is always visible; when the first submodule
is selected, headers above it are shown too.
### Key reference
Every key in the UI, where it is shown, and whether it works. Update this
table when a key is added, moved or implemented. "Bar" is the short footer
bar, "Palette" the detail pane's palette (the footer takes its keys over
where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette)),
"Help" the full help (`?`).
| Key | Action | Group | Shown in | Implemented |
|---|---|---|---|---|
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help | yes |
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help | yes |
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help | yes |
| `space` | Toggle expand/collapse | Navigation | Bar, Help | yes |
| `home` / `g`, `end` / `G` | First / last submodule | Navigation | Help | yes |
| `pgup`, `pgdn` | Move by one screen | Navigation | Help | yes |
| `c` | Copy path | Submodule | Palette, Help | yes |
| `u` | Checkout pin | Submodule | Help | no (flashes "not implemented") |
| `b` | Bump pin to tip | Submodule | Help | no (flashes "not implemented") |
| `f` | Fetch all repositories | Global | Help | yes |
| `r` | Reload everything | Global | Help | yes |
| `/` | Filter | Global | Help | no (flashes "not implemented") |
| `!` | Problems only | Global | Help | no (flashes "not implemented") |
| `?` | Toggle full help | Global | Bar, Help | yes |
| `q`, `ctrl+c` | Quit | Global | Bar, Help | yes |
### Mouse
- Click a row: select it.
+1 -1
View File
@@ -3,6 +3,7 @@ module submarine
go 1.27.3
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
@@ -11,7 +12,6 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
+11
View File
@@ -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 == "" {
+7
View File
@@ -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
View File
@@ -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)
}
+21
View File
@@ -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
}
}
+28 -4
View File
@@ -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 {
+46 -11
View File
@@ -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
View File
@@ -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},
}
}
+15
View File
@@ -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
}