Archived
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>
424 lines
10 KiB
Go
424 lines
10 KiB
Go
// Package tui is the Bubble Tea application. It only talks to repository
|
|
// data through submodule.Provider.
|
|
package tui
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
zone "github.com/lrstanley/bubblezone"
|
|
|
|
"submarine/internal/submodule"
|
|
"submarine/internal/tui/detail"
|
|
"submarine/internal/tui/footer"
|
|
"submarine/internal/tui/header"
|
|
"submarine/internal/tui/theme"
|
|
"submarine/internal/tui/tree"
|
|
)
|
|
|
|
const (
|
|
headerHeight = 1
|
|
minDetailWidth = 36
|
|
detailHideBelow = 90 // terminal width below which the detail pane is hidden
|
|
detailWidthRatio = 0.4
|
|
)
|
|
|
|
// Options configure the application.
|
|
type Options struct {
|
|
Fetch bool // fetch all repositories in the background on startup
|
|
}
|
|
|
|
type Model struct {
|
|
ctx context.Context
|
|
provider submodule.Provider
|
|
fetcher submodule.Fetcher // nil if the provider can't fetch
|
|
watcher submodule.Watcher // nil if the provider can't watch
|
|
opts Options
|
|
theme theme.Theme
|
|
keys keyMap
|
|
|
|
repo *submodule.Repo
|
|
byPath map[string]*submodule.Submodule
|
|
err error
|
|
|
|
loads reloader
|
|
reloadStatuses bool // the running or pending tree load also reloads every status
|
|
changes <-chan submodule.Change
|
|
fetch header.Fetch
|
|
ticking bool // a spinner tick is scheduled
|
|
|
|
tree tree.Model
|
|
detail detail.Model
|
|
footer footer.Model
|
|
spinner spinner.Model
|
|
|
|
width, height int
|
|
showDetail bool
|
|
}
|
|
|
|
func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
|
t := theme.Default()
|
|
k := defaultKeys()
|
|
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
|
m := Model{
|
|
ctx: ctx,
|
|
provider: p,
|
|
opts: opts,
|
|
theme: t,
|
|
keys: k,
|
|
loads: newReloader(),
|
|
ticking: true, // Init schedules the first tick
|
|
tree: tree.New(t),
|
|
detail: detail.New(t, k.Submodule()),
|
|
footer: footer.New(t, k),
|
|
spinner: sp,
|
|
}
|
|
m.fetcher, _ = p.(submodule.Fetcher)
|
|
m.watcher, _ = p.(submodule.Watcher)
|
|
return m
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
m.loads.start(treeKey)
|
|
return tea.Batch(m.loadTree(), m.spinner.Tick)
|
|
}
|
|
|
|
func (m Model) loadTree() tea.Cmd {
|
|
return func() tea.Msg {
|
|
repo, err := m.provider.Tree(m.ctx)
|
|
return treeLoadedMsg{repo, err}
|
|
}
|
|
}
|
|
|
|
func (m Model) loadStatus(path string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
st, err := m.provider.Status(m.ctx, path)
|
|
return statusLoadedMsg{path, st, err}
|
|
}
|
|
}
|
|
|
|
// reloadTree loads the tree again, unless a load is running (then it runs
|
|
// once more afterwards). With statuses, every status is reloaded too.
|
|
func (m *Model) reloadTree(statuses bool) tea.Cmd {
|
|
m.reloadStatuses = m.reloadStatuses || statuses
|
|
if !m.loads.start(treeKey) {
|
|
return nil
|
|
}
|
|
return m.loadTree()
|
|
}
|
|
|
|
// reloadStatus loads one status again; the old one stays visible meanwhile.
|
|
func (m *Model) reloadStatus(path string) tea.Cmd {
|
|
if !m.loads.start(path) {
|
|
return nil
|
|
}
|
|
return m.loadStatus(path)
|
|
}
|
|
|
|
func (m Model) waitForChange() tea.Cmd {
|
|
ch := m.changes
|
|
return func() tea.Msg {
|
|
c, ok := <-ch
|
|
return changeMsg{change: c, closed: !ok}
|
|
}
|
|
}
|
|
|
|
// startFetch fetches the superproject and every initialized submodule in
|
|
// the background. It does nothing while a fetch is running.
|
|
func (m *Model) startFetch() tea.Cmd {
|
|
if m.fetcher == nil || m.fetch.Running || m.repo == nil {
|
|
return nil
|
|
}
|
|
paths := []string{""}
|
|
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
|
if m.fetcher.Fetchable(s.Path) {
|
|
paths = append(paths, s.Path)
|
|
}
|
|
})
|
|
m.fetch = header.Fetch{Running: true, Total: len(paths), At: m.fetch.At}
|
|
cmds := []tea.Cmd{m.tick()}
|
|
for _, p := range paths {
|
|
f, ctx := m.fetcher, m.ctx
|
|
cmds = append(cmds, func() tea.Msg { return fetchDoneMsg{p, f.Fetch(ctx, p)} })
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// busy reports whether something shows a spinner.
|
|
func (m Model) busy() bool {
|
|
if m.repo == nil || m.fetch.Running {
|
|
return true
|
|
}
|
|
loading := false
|
|
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
|
loading = loading || (s.Status == nil && s.Err == nil)
|
|
})
|
|
return loading
|
|
}
|
|
|
|
// tick schedules a spinner tick if none is scheduled.
|
|
func (m *Model) tick() tea.Cmd {
|
|
if m.ticking {
|
|
return nil
|
|
}
|
|
m.ticking = true
|
|
return m.spinner.Tick
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
m.layout()
|
|
return m, nil
|
|
|
|
case treeLoadedMsg:
|
|
return m.treeLoaded(msg)
|
|
|
|
case statusLoadedMsg:
|
|
if s, ok := m.byPath[msg.path]; ok {
|
|
if msg.err != nil {
|
|
s.Err = msg.err
|
|
} else {
|
|
s.Status, s.Err = msg.status, nil
|
|
}
|
|
}
|
|
if m.loads.done(msg.path) {
|
|
return m, m.loadStatus(msg.path)
|
|
}
|
|
return m, nil
|
|
|
|
case changeMsg:
|
|
return m.changed(msg)
|
|
|
|
case fetchDoneMsg:
|
|
m.fetch.Done++
|
|
if msg.err != nil {
|
|
m.fetch.Failed++
|
|
}
|
|
if s, ok := m.byPath[msg.path]; ok {
|
|
s.FetchErr = msg.err
|
|
}
|
|
if m.fetch.Done == m.fetch.Total {
|
|
m.fetch.Running = false
|
|
m.fetch.At = time.Now()
|
|
}
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
m.tree.SetSpinner(m.spinner.View())
|
|
m.detail.SetSpinner(m.spinner.View())
|
|
if !m.busy() {
|
|
m.ticking = false // stop ticking until there is work again
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case tea.MouseMsg:
|
|
m.tree.Update(msg)
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
}
|
|
|
|
m.footer.Update(msg)
|
|
return m, nil
|
|
}
|
|
|
|
// treeLoaded installs a (re)loaded tree. On a reload the known statuses are
|
|
// carried over by path, so rows don't fall back to a spinner.
|
|
func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
if m.loads.done(treeKey) {
|
|
cmds = append(cmds, m.loadTree())
|
|
}
|
|
initial := m.repo == nil
|
|
if msg.err != nil {
|
|
if initial {
|
|
m.err = msg.err
|
|
return m, nil
|
|
}
|
|
cmds = append(cmds, m.footer.Flash("reload failed: "+msg.err.Error()))
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
old := m.byPath
|
|
m.repo = msg.repo
|
|
m.byPath = map[string]*submodule.Submodule{}
|
|
reload := initial || m.reloadStatuses
|
|
m.reloadStatuses = false
|
|
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
|
m.byPath[s.Path] = s
|
|
if o, ok := old[s.Path]; ok {
|
|
s.Status, s.Err, s.FetchErr = o.Status, o.Err, o.FetchErr
|
|
}
|
|
if reload || s.Status == nil {
|
|
cmds = append(cmds, m.reloadStatus(s.Path))
|
|
}
|
|
})
|
|
|
|
if initial {
|
|
m.tree.SetRoots(m.repo.Submodules)
|
|
if m.watcher != nil {
|
|
m.changes = m.watcher.Watch(m.ctx)
|
|
cmds = append(cmds, m.waitForChange())
|
|
}
|
|
if m.opts.Fetch {
|
|
cmds = append(cmds, m.startFetch())
|
|
}
|
|
} else {
|
|
m.tree.ReplaceRoots(m.repo.Submodules)
|
|
}
|
|
cmds = append(cmds, m.tick())
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// changed reacts to a change the watcher saw, whoever made it.
|
|
func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
|
if msg.closed {
|
|
return m, nil
|
|
}
|
|
cmds := []tea.Cmd{m.waitForChange()}
|
|
c := msg.change
|
|
switch {
|
|
case c.Path == "" || c.Structure:
|
|
// superproject: pins of all submodules may have moved (local), or
|
|
// only its header (remote); .gitmodules: the structure changed
|
|
cmds = append(cmds, m.reloadTree(c.Local))
|
|
default:
|
|
s, ok := m.byPath[c.Path]
|
|
if !ok {
|
|
break
|
|
}
|
|
cmds = append(cmds, m.reloadStatus(s.Path))
|
|
if c.Local { // the children's pins live in this repository
|
|
for _, child := range s.Children {
|
|
cmds = append(cmds, m.reloadStatus(child.Path))
|
|
}
|
|
}
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
k := m.keys
|
|
switch {
|
|
case key.Matches(msg, k.Quit):
|
|
return m, tea.Quit
|
|
case key.Matches(msg, k.Help):
|
|
m.footer.ToggleHelp()
|
|
m.layout()
|
|
case key.Matches(msg, k.Up):
|
|
m.tree.Up()
|
|
case key.Matches(msg, k.Down):
|
|
m.tree.Down()
|
|
case key.Matches(msg, k.Left):
|
|
m.tree.Left()
|
|
case key.Matches(msg, k.Right):
|
|
m.tree.Right()
|
|
case key.Matches(msg, k.Toggle):
|
|
m.tree.Toggle()
|
|
case key.Matches(msg, k.CollapseAll):
|
|
m.tree.CollapseAll()
|
|
case key.Matches(msg, k.ExpandAll):
|
|
m.tree.ExpandAll()
|
|
case key.Matches(msg, k.Home):
|
|
m.tree.Home()
|
|
case key.Matches(msg, k.End):
|
|
m.tree.End()
|
|
case key.Matches(msg, k.PageUp):
|
|
m.tree.PageUp()
|
|
case key.Matches(msg, k.PageDown):
|
|
m.tree.PageDown()
|
|
case key.Matches(msg, k.Fetch):
|
|
var flash string
|
|
switch {
|
|
case m.fetcher == nil:
|
|
flash = "this data source can't fetch"
|
|
case m.fetch.Running:
|
|
flash = "already fetching"
|
|
}
|
|
if flash != "" {
|
|
cmd := m.footer.Flash(flash)
|
|
m.layout()
|
|
return m, 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()
|
|
return m, 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)
|
|
bodyHeight := max(m.height-headerHeight-lipgloss.Height(m.footer.View()), 0)
|
|
|
|
m.showDetail = m.width >= detailHideBelow
|
|
treeWidth := m.width
|
|
if m.showDetail {
|
|
detailWidth := max(int(float64(m.width)*detailWidthRatio), minDetailWidth)
|
|
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)
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if m.width == 0 {
|
|
return ""
|
|
}
|
|
if m.err != nil {
|
|
return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error())
|
|
}
|
|
|
|
body := m.tree.View()
|
|
if m.showDetail {
|
|
pane := lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
BorderForeground(m.theme.Border).
|
|
PaddingLeft(1).
|
|
Render(m.detail.View(m.tree.Selected()))
|
|
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
|
|
}
|
|
|
|
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
|
|
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
|
|
body,
|
|
m.footer.View(),
|
|
))
|
|
}
|