This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/internal/tui/app.go
T
Simon FrankenandClaude Opus 5.5 2212b24fe1 Add submarine TUI prototype with mock data
Bubble Tea prototype for browsing repositories with many nested git
submodules: collapsible tree, pins shown as ref + distance, summarized
issue words with severity levels, detail pane, async loading via a
Provider interface backed by mock data. Includes docs/ with
requirements, state model, UX guide, architecture and decision log,
plus AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:59:53 +02:00

212 lines
4.9 KiB
Go

// Package tui is the Bubble Tea application. It only talks to repository
// data through submodule.Provider.
package tui
import (
"context"
"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
)
type Model struct {
ctx context.Context
provider submodule.Provider
theme theme.Theme
keys keyMap
repo *submodule.Repo
byPath map[string]*submodule.Submodule
loading int
err error
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) Model {
t := theme.Default()
k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
return Model{
ctx: ctx,
provider: p,
theme: t,
keys: k,
tree: tree.New(t),
detail: detail.New(t),
footer: footer.New(t, k),
spinner: sp,
}
}
func (m Model) Init() tea.Cmd {
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}
}
}
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:
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.repo = msg.repo
m.byPath = map[string]*submodule.Submodule{}
var cmds []tea.Cmd
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
m.byPath[s.Path] = s
cmds = append(cmds, m.loadStatus(s.Path))
})
m.loading = len(cmds)
m.tree.SetRoots(m.repo.Submodules)
return m, tea.Batch(cmds...)
case statusLoadedMsg:
if s, ok := m.byPath[msg.path]; ok {
s.Status, s.Err = msg.status, msg.err
m.loading--
}
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.repo != nil && m.loading == 0 {
return m, nil // stop ticking once everything is loaded
}
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
}
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.Update, k.Bump, k.Fetch, k.Filter, k.Issues):
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
m.layout()
return m, cmd
}
return m, nil
}
// 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.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.width),
body,
m.footer.View(),
))
}