diff --git a/docs/architecture.md b/docs/architecture.md index f97ae6f..b06ed84 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index b98e6a1..c43fdb4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `). 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. diff --git a/docs/ux-guide.md b/docs/ux-guide.md index cfd4e42..687d392 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -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 `. 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. diff --git a/go.mod b/go.mod index 5c18606..3c98c13 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/submodule/git/fetch.go b/internal/submodule/git/fetch.go index 29b31c4..fa17397 100644 --- a/internal/submodule/git/fetch.go +++ b/internal/submodule/git/fetch.go @@ -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 == "" { diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go index 988f1c9..15ea70a 100644 --- a/internal/submodule/provider.go +++ b/internal/submodule/provider.go @@ -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 diff --git a/internal/tui/app.go b/internal/tui/app.go index 77be594..a78fff4 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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) } diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go new file mode 100644 index 0000000..5335d95 --- /dev/null +++ b/internal/tui/clipboard.go @@ -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 + } +} diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index 016534c..41e5a3b 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -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 { diff --git a/internal/tui/footer/footer.go b/internal/tui/footer/footer.go index 6a1b181..ab8812c 100644 --- a/internal/tui/footer/footer.go +++ b/internal/tui/footer/footer.go @@ -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 + } + } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 4a5fef9..615e696 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -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}, } } diff --git a/internal/tui/theme/help.go b/internal/tui/theme/help.go new file mode 100644 index 0000000..e4d1483 --- /dev/null +++ b/internal/tui/theme/help.go @@ -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 +}