Archived
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>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// 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(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package detail renders the information pane for the selected submodule.
|
||||
package detail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
const labelWidth = 10
|
||||
|
||||
type Model struct {
|
||||
theme theme.Theme
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
}
|
||||
|
||||
func New(t theme.Theme) Model { return Model{theme: t} }
|
||||
|
||||
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
|
||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||
|
||||
func (m Model) View(s *submodule.Submodule) string {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
if s != nil {
|
||||
lines = m.lines(s)
|
||||
}
|
||||
for i, l := range lines {
|
||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||
}
|
||||
if len(lines) > m.height {
|
||||
lines = lines[:m.height]
|
||||
}
|
||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (m Model) lines(s *submodule.Submodule) []string {
|
||||
t := m.theme
|
||||
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
|
||||
label := func(l string) string { return muted.Width(labelWidth).Render(l) }
|
||||
|
||||
dir := path.Dir(s.Path)
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
} else {
|
||||
dir += "/"
|
||||
}
|
||||
out := []string{
|
||||
muted.Render(dir),
|
||||
text.Bold(true).Render(s.Name),
|
||||
"",
|
||||
}
|
||||
|
||||
switch {
|
||||
case s.Err != nil:
|
||||
return append(out, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render("error")+" "+muted.Render(s.Err.Error()))
|
||||
case s.Status == nil:
|
||||
return append(out, theme.Fg(t.Primary).Render(m.spinner)+muted.Render(" loading…"))
|
||||
}
|
||||
st := s.Status
|
||||
|
||||
issues := st.Issues()
|
||||
for _, is := range issues {
|
||||
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
out = append(out, label("in sync")+muted.Render("nothing to do"))
|
||||
}
|
||||
if st.PinStaged {
|
||||
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render("staged")+text.Render("pin change staged, not committed"))
|
||||
}
|
||||
if st.Checkout == submodule.CheckoutDetached {
|
||||
out = append(out, label("")+muted.Render("detached HEAD on pin"))
|
||||
}
|
||||
out = append(out, "")
|
||||
|
||||
// ref
|
||||
if st.Pin.Ref != "" {
|
||||
delta := t.Delta(st.Pin, lipgloss.NewStyle())
|
||||
if delta != "" {
|
||||
delta = " " + delta
|
||||
}
|
||||
out = append(out,
|
||||
label("Ref")+text.Render(st.Pin.Ref)+delta,
|
||||
label("")+muted.Render("via "+st.Pin.Source.String()),
|
||||
)
|
||||
}
|
||||
|
||||
// commits
|
||||
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
|
||||
if c == nil {
|
||||
return label(name) + muted.Render("—")
|
||||
}
|
||||
shaStyle := theme.Fg(t.Primary)
|
||||
if highlight {
|
||||
shaStyle = theme.Fg(t.Warn)
|
||||
}
|
||||
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
|
||||
}
|
||||
out = append(out,
|
||||
commitLine("Pinned", st.Pinned, false),
|
||||
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
|
||||
commitLine("Tip", st.Tip, false),
|
||||
)
|
||||
|
||||
// difference between pin and tip
|
||||
if len(st.Between) > 0 {
|
||||
out = append(out, "", text.Render(m.betweenTitle(st.Pin)))
|
||||
for _, c := range st.Between {
|
||||
out = append(out,
|
||||
" "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject),
|
||||
" "+muted.Render(fmt.Sprintf("%7s %s · %s", "", c.Author, theme.Ago(c.When))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// children & meta
|
||||
out = append(out, "")
|
||||
if n := len(s.Children); n > 0 {
|
||||
children := fmt.Sprintf("%d direct", n)
|
||||
if k, worst := s.HiddenIssues(); k > 0 {
|
||||
children += t.LevelStyle(worst, lipgloss.NewStyle()).Render(fmt.Sprintf(" · %d issue(s) below", k))
|
||||
}
|
||||
out = append(out, label("Children")+children)
|
||||
}
|
||||
out = append(out, label("URL")+muted.Render(s.URL))
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) betweenTitle(p submodule.PinInfo) string {
|
||||
plural := func(n int) string {
|
||||
if n == 1 {
|
||||
return "commit"
|
||||
}
|
||||
return "commits"
|
||||
}
|
||||
switch {
|
||||
case p.Ahead > 0 && p.Behind > 0:
|
||||
return fmt.Sprintf("Pin and %s have diverged (%s%d %s%d):",
|
||||
p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
|
||||
case p.Ahead > 0:
|
||||
return fmt.Sprintf("Pin is %d %s ahead of %s:", p.Ahead, plural(p.Ahead), p.Ref)
|
||||
default:
|
||||
return fmt.Sprintf("Pin is %d %s behind %s:", p.Behind, plural(p.Behind), p.Ref)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package footer renders key hints and short-lived status messages.
|
||||
package footer
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
const flashDuration = 2 * time.Second
|
||||
|
||||
type clearFlashMsg struct{ id int }
|
||||
|
||||
type Model struct {
|
||||
theme theme.Theme
|
||||
help help.Model
|
||||
keys help.KeyMap
|
||||
width int
|
||||
flash string
|
||||
flashID int
|
||||
}
|
||||
|
||||
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 (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
m.help.Width = w - 2
|
||||
}
|
||||
|
||||
// ToggleHelp switches between short and full key help.
|
||||
func (m *Model) ToggleHelp() { m.help.ShowAll = !m.help.ShowAll }
|
||||
|
||||
// Flash shows msg for a short time instead of the key hints.
|
||||
func (m *Model) Flash(msg string) tea.Cmd {
|
||||
m.flashID++
|
||||
m.flash = msg
|
||||
id := m.flashID
|
||||
return tea.Tick(flashDuration, func(time.Time) tea.Msg { return clearFlashMsg{id} })
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) {
|
||||
if c, ok := msg.(clearFlashMsg); ok && c.id == m.flashID {
|
||||
m.flash = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
style := lipgloss.NewStyle().Width(m.width).Padding(0, 1)
|
||||
if m.flash != "" {
|
||||
return style.Foreground(m.theme.Warn).Render(m.flash)
|
||||
}
|
||||
return style.Render(m.help.View(m.keys))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package header renders the superproject summary line.
|
||||
package header
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
// View renders repo info, state counts and fetch age on one line.
|
||||
func View(t theme.Theme, repo *submodule.Repo, width int) string {
|
||||
bar := lipgloss.NewStyle().Background(t.Bar)
|
||||
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
|
||||
|
||||
if repo == nil {
|
||||
return bar.Width(width).Render(seg(t.Muted, " loading…"))
|
||||
}
|
||||
|
||||
left := seg(t.Text, " "+repo.Path) + seg(t.Muted, " ") + seg(t.Primary, repo.Branch)
|
||||
if d := t.Delta(submodule.PinInfo{Ahead: repo.Ahead, Behind: repo.Behind, Pushed: true}, bar); d != "" {
|
||||
left += seg(t.Text, " ") + d
|
||||
}
|
||||
|
||||
// count submodules per issue kind (most severe first), plus staged pins
|
||||
var total, loading, staged int
|
||||
perKind := map[submodule.IssueKind]int{}
|
||||
level := map[submodule.IssueKind]submodule.Level{}
|
||||
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) {
|
||||
total++
|
||||
if s.Status == nil {
|
||||
loading++
|
||||
return
|
||||
}
|
||||
if s.Status.PinStaged {
|
||||
staged++
|
||||
}
|
||||
for _, is := range s.Status.Issues() {
|
||||
perKind[is.Kind]++
|
||||
level[is.Kind] = is.Level
|
||||
}
|
||||
})
|
||||
|
||||
sep := seg(t.Muted, " · ")
|
||||
mid := seg(t.Muted, fmt.Sprintf("%d submodules", total))
|
||||
for _, k := range []submodule.IssueKind{
|
||||
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
|
||||
submodule.IssueOffPin, submodule.IssueChanged,
|
||||
} {
|
||||
if n := perKind[k]; n > 0 {
|
||||
mid += sep + t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k)))
|
||||
}
|
||||
}
|
||||
if staged > 0 {
|
||||
mid += sep + seg(t.Info, fmt.Sprintf("%d pin staged", staged))
|
||||
}
|
||||
if loading > 0 {
|
||||
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
|
||||
}
|
||||
|
||||
right := seg(t.Muted, "fetched "+theme.Ago(repo.FetchedAt)+" ")
|
||||
|
||||
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
|
||||
if gap < 4 {
|
||||
return bar.Width(width).MaxWidth(width).Render(left + seg(t.Text, " ") + mid)
|
||||
}
|
||||
sp := func(n int) string { return bar.Render(fmt.Sprintf("%*s", n, "")) }
|
||||
return left + sp(gap/2) + mid + sp(gap-gap/2) + right
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Up, Down, Left, Right, Toggle key.Binding
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Update, Bump, Fetch, Filter, Issues key.Binding
|
||||
Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
return keyMap{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "move")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/→", "collapse/expand")),
|
||||
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "expand, first child")),
|
||||
Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
|
||||
CollapseAll: key.NewBinding(key.WithKeys("ctrl+left"), key.WithHelp("ctrl+←", "collapse all")),
|
||||
ExpandAll: key.NewBinding(key.WithKeys("ctrl+right"), key.WithHelp("ctrl+→", "expand all")),
|
||||
Home: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "first")),
|
||||
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
|
||||
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
|
||||
PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")),
|
||||
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")),
|
||||
Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")),
|
||||
Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||
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 {
|
||||
return []key.Binding{k.Up, k.Left, k.Toggle, k.Update, k.Bump, k.Fetch, k.Help, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]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.Filter, k.Issues},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tui
|
||||
|
||||
import "submarine/internal/submodule"
|
||||
|
||||
type treeLoadedMsg struct {
|
||||
repo *submodule.Repo
|
||||
err error
|
||||
}
|
||||
|
||||
type statusLoadedMsg struct {
|
||||
path string
|
||||
status *submodule.Status
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// IssueWord is the short label for an issue kind shown in the tree.
|
||||
func IssueWord(k submodule.IssueKind) string {
|
||||
switch k {
|
||||
case submodule.IssueMissing:
|
||||
return "missing"
|
||||
case submodule.IssueUnpushed:
|
||||
return "unpushed"
|
||||
case submodule.IssueConflict:
|
||||
return "conflict"
|
||||
case submodule.IssueOffPin:
|
||||
return "off pin"
|
||||
default:
|
||||
return "changed"
|
||||
}
|
||||
}
|
||||
|
||||
// LevelStyle returns base with the text color (and weight) for a level.
|
||||
func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style {
|
||||
switch l {
|
||||
case submodule.LevelDanger:
|
||||
return base.Foreground(t.Danger).Bold(true)
|
||||
case submodule.LevelWarn:
|
||||
return base.Foreground(t.Warn)
|
||||
default:
|
||||
return base.Foreground(t.Muted)
|
||||
}
|
||||
}
|
||||
|
||||
// Delta renders ahead/behind counts like "↑1↓2", muted unless the pin is
|
||||
// unpushed. Empty if both are zero. base is applied to every segment, e.g.
|
||||
// to keep a row background.
|
||||
func (t Theme) Delta(p submodule.PinInfo, base lipgloss.Style) string {
|
||||
var s string
|
||||
if p.Ahead > 0 {
|
||||
c := t.Muted
|
||||
if !p.Pushed {
|
||||
c = t.Danger
|
||||
}
|
||||
s += base.Foreground(c).Render(fmt.Sprintf("%s%d", t.Glyphs.Ahead, p.Ahead))
|
||||
}
|
||||
if p.Behind > 0 {
|
||||
s += base.Foreground(t.Muted).Render(fmt.Sprintf("%s%d", t.Glyphs.Behind, p.Behind))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Ago formats a time relative to now, e.g. "5m ago".
|
||||
func Ago(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package theme holds the colors, glyphs and shared styles for the TUI.
|
||||
package theme
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Theme uses the 16 ANSI colors for everything that carries meaning, so
|
||||
// the TUI follows the user's terminal color scheme. Only background shades,
|
||||
// which have no ANSI equivalent, use adaptive 256-color values.
|
||||
type Theme struct {
|
||||
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
||||
Info lipgloss.TerminalColor
|
||||
Warn lipgloss.TerminalColor
|
||||
Danger lipgloss.TerminalColor
|
||||
Muted lipgloss.TerminalColor
|
||||
Text lipgloss.TerminalColor
|
||||
Border lipgloss.TerminalColor // pane separators, help separators
|
||||
Bar lipgloss.TerminalColor // background of the header bar
|
||||
Selected lipgloss.TerminalColor // background of the selected row
|
||||
Glyphs Glyphs
|
||||
}
|
||||
|
||||
func Default() Theme {
|
||||
g := Unicode()
|
||||
if os.Getenv("SUBMARINE_ASCII") != "" {
|
||||
g = ASCII()
|
||||
}
|
||||
return Theme{
|
||||
Primary: lipgloss.Color("6"), // cyan
|
||||
Info: lipgloss.Color("4"), // blue
|
||||
Warn: lipgloss.Color("3"), // yellow
|
||||
Danger: lipgloss.Color("1"), // red
|
||||
Muted: lipgloss.Color("8"), // bright black / grey
|
||||
Text: lipgloss.NoColor{}, // terminal default foreground
|
||||
Border: lipgloss.Color("8"),
|
||||
Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"},
|
||||
Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"},
|
||||
Glyphs: g,
|
||||
}
|
||||
}
|
||||
|
||||
// Fg returns a style with the given foreground color.
|
||||
func Fg(c lipgloss.TerminalColor) lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(c)
|
||||
}
|
||||
|
||||
// Glyphs are the few symbols used next to the state words.
|
||||
type Glyphs struct {
|
||||
Staged, Ahead, Behind, Collapsed, Expanded, Problems string
|
||||
}
|
||||
|
||||
func Unicode() Glyphs {
|
||||
return Glyphs{
|
||||
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·",
|
||||
}
|
||||
}
|
||||
|
||||
func ASCII() Glyphs {
|
||||
return Glyphs{
|
||||
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package tree
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
type rowKind int
|
||||
|
||||
const (
|
||||
rowDir rowKind = iota // non-selectable directory header, e.g. "modules/"
|
||||
rowNode // a submodule
|
||||
)
|
||||
|
||||
type row struct {
|
||||
kind rowKind
|
||||
depth int
|
||||
label string // directory label for rowDir
|
||||
node *submodule.Submodule
|
||||
}
|
||||
|
||||
// rows flattens the visible part of the tree.
|
||||
func (m *Model) rows() []row {
|
||||
var out []row
|
||||
m.appendRows(&out, m.roots, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// appendRows groups mods by the directory of their RelPath. Each group with a
|
||||
// directory gets a header row; its submodules are indented one level deeper.
|
||||
func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
|
||||
for _, g := range groupByDir(mods) {
|
||||
d := depth
|
||||
if g.dir != "." {
|
||||
*out = append(*out, row{kind: rowDir, depth: depth, label: g.dir + "/"})
|
||||
d++
|
||||
}
|
||||
for _, s := range g.mods {
|
||||
*out = append(*out, row{kind: rowNode, depth: d, node: s})
|
||||
if m.expanded[s.Path] && len(s.Children) > 0 {
|
||||
m.appendRows(out, s.Children, d+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type dirGroup struct {
|
||||
dir string
|
||||
mods []*submodule.Submodule
|
||||
}
|
||||
|
||||
// groupByDir keeps the order in which directories first appear.
|
||||
func groupByDir(mods []*submodule.Submodule) []dirGroup {
|
||||
var groups []dirGroup
|
||||
idx := map[string]int{}
|
||||
for _, s := range mods {
|
||||
dir := path.Dir(s.RelPath)
|
||||
i, ok := idx[dir]
|
||||
if !ok {
|
||||
i = len(groups)
|
||||
idx[dir] = i
|
||||
groups = append(groups, dirGroup{dir: dir})
|
||||
}
|
||||
groups[i].mods = append(groups[i].mods, s)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Package tree implements the collapsible submodule tree view.
|
||||
package tree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
const (
|
||||
statusWidth = 14 // "unpushed +1 ·2"
|
||||
deltaWidth = 6
|
||||
maxRefWidth = 22
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
id string // zone prefix, unique to this instance
|
||||
theme theme.Theme
|
||||
roots []*submodule.Submodule
|
||||
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
|
||||
cursor *submodule.Submodule
|
||||
offset int // index of the first visible row
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
}
|
||||
|
||||
func New(t theme.Theme) Model {
|
||||
return Model{id: zone.NewPrefix(), theme: t, expanded: map[string]bool{}}
|
||||
}
|
||||
|
||||
// SetRoots replaces the tree. All nodes start collapsed.
|
||||
func (m *Model) SetRoots(roots []*submodule.Submodule) {
|
||||
m.roots = roots
|
||||
m.expanded = map[string]bool{}
|
||||
m.cursor = nil
|
||||
m.offset = 0
|
||||
if len(roots) > 0 {
|
||||
m.cursor = m.rows()[m.firstNodeRow()].node
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
// SetSpinner sets the frame drawn for submodules that are still loading.
|
||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||
|
||||
// Selected returns the submodule under the cursor, or nil.
|
||||
func (m Model) Selected() *submodule.Submodule { return m.cursor }
|
||||
|
||||
// --- navigation --------------------------------------------------------------
|
||||
|
||||
// Up selects the previous submodule row.
|
||||
func (m *Model) Up() { m.moveBy(-1) }
|
||||
|
||||
// Down selects the next submodule row.
|
||||
func (m *Model) Down() { m.moveBy(1) }
|
||||
|
||||
// PageUp and PageDown move by one screen height.
|
||||
func (m *Model) PageUp() { m.moveBy(-max(m.height-1, 1)) }
|
||||
func (m *Model) PageDown() { m.moveBy(max(m.height-1, 1)) }
|
||||
|
||||
// Home and End select the first and last visible submodule.
|
||||
func (m *Model) Home() { m.moveBy(-len(m.rows())) }
|
||||
func (m *Model) End() { m.moveBy(len(m.rows())) }
|
||||
|
||||
// Toggle expands or collapses the selected submodule.
|
||||
func (m *Model) Toggle() {
|
||||
if m.cursor != nil && len(m.cursor.Children) > 0 {
|
||||
m.setExpanded(m.cursor, !m.expanded[m.cursor.Path])
|
||||
}
|
||||
}
|
||||
|
||||
// Right expands the selected submodule and selects its first child.
|
||||
func (m *Model) Right() {
|
||||
if m.cursor == nil || len(m.cursor.Children) == 0 {
|
||||
return
|
||||
}
|
||||
m.setExpanded(m.cursor, true)
|
||||
m.Down() // children directly follow their parent
|
||||
}
|
||||
|
||||
// Left collapses the selected submodule if it is expanded; otherwise it
|
||||
// collapses the parent and selects it.
|
||||
func (m *Model) Left() {
|
||||
switch {
|
||||
case m.cursor == nil:
|
||||
case m.expanded[m.cursor.Path] && len(m.cursor.Children) > 0:
|
||||
m.setExpanded(m.cursor, false)
|
||||
case m.cursor.Parent != nil:
|
||||
m.setExpanded(m.cursor.Parent, false)
|
||||
}
|
||||
}
|
||||
|
||||
// CollapseAll collapses every submodule and selects the top-level ancestor
|
||||
// of the current selection.
|
||||
func (m *Model) CollapseAll() {
|
||||
m.expanded = map[string]bool{}
|
||||
for m.cursor != nil && m.cursor.Parent != nil {
|
||||
m.cursor = m.cursor.Parent
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
// ExpandAll expands every submodule and follows the first child from the
|
||||
// current selection down to a submodule without children.
|
||||
func (m *Model) ExpandAll() {
|
||||
submodule.Walk(m.roots, func(s *submodule.Submodule) {
|
||||
if len(s.Children) > 0 {
|
||||
m.expanded[s.Path] = true
|
||||
}
|
||||
})
|
||||
for m.cursor != nil && len(m.cursor.Children) > 0 {
|
||||
m.Down() // children directly follow their parent
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
// Select selects the submodule with the given path if it is visible.
|
||||
func (m *Model) Select(path string) {
|
||||
for _, r := range m.rows() {
|
||||
if r.kind == rowNode && r.node.Path == path {
|
||||
m.cursor = r.node
|
||||
m.clampOffset()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setExpanded changes the expansion of s. If collapsing hides the cursor,
|
||||
// s becomes the selection.
|
||||
func (m *Model) setExpanded(s *submodule.Submodule, open bool) {
|
||||
m.expanded[s.Path] = open
|
||||
if !open && isAncestor(s, m.cursor) {
|
||||
m.cursor = s
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
func isAncestor(a, s *submodule.Submodule) bool {
|
||||
for s != nil {
|
||||
s = s.Parent
|
||||
if s == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// moveBy moves the cursor by n submodule rows, skipping directory headers
|
||||
// and stopping at the ends.
|
||||
func (m *Model) moveBy(n int) {
|
||||
rows := m.rows()
|
||||
i := m.cursorRow(rows)
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step, n = -1, -n
|
||||
}
|
||||
for j := i + step; n > 0 && j >= 0 && j < len(rows); j += step {
|
||||
if rows[j].kind == rowNode {
|
||||
i = j
|
||||
n--
|
||||
}
|
||||
}
|
||||
m.cursor = rows[i].node
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
func (m Model) cursorRow(rows []row) int {
|
||||
for i, r := range rows {
|
||||
if r.kind == rowNode && r.node == m.cursor {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m Model) firstNodeRow() int {
|
||||
for i, r := range m.rows() {
|
||||
if r.kind == rowNode {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// clampOffset scrolls so the cursor is visible. When the cursor is the first
|
||||
// submodule, headers above it are shown too.
|
||||
func (m *Model) clampOffset() {
|
||||
if m.height <= 0 {
|
||||
return
|
||||
}
|
||||
rows := m.rows()
|
||||
i := m.cursorRow(rows)
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
if i == m.firstNodeRow() {
|
||||
i = 0
|
||||
}
|
||||
if i < m.offset {
|
||||
m.offset = i
|
||||
}
|
||||
if i >= m.offset+m.height {
|
||||
m.offset = i - m.height + 1
|
||||
}
|
||||
m.offset = max(0, min(m.offset, len(rows)-m.height))
|
||||
}
|
||||
|
||||
// --- mouse -------------------------------------------------------------------
|
||||
|
||||
// Update handles mouse input. Keyboard input is mapped by the parent model.
|
||||
func (m *Model) Update(msg tea.Msg) {
|
||||
mm, ok := msg.(tea.MouseMsg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch mm.Button {
|
||||
case tea.MouseButtonWheelUp:
|
||||
m.Up()
|
||||
return
|
||||
case tea.MouseButtonWheelDown:
|
||||
m.Down()
|
||||
return
|
||||
}
|
||||
if mm.Action != tea.MouseActionPress || mm.Button != tea.MouseButtonLeft {
|
||||
return
|
||||
}
|
||||
for _, r := range m.visibleRows() {
|
||||
if r.kind != rowNode {
|
||||
continue
|
||||
}
|
||||
if zone.Get(m.id + "toggle:" + r.node.Path).InBounds(mm) {
|
||||
m.cursor = r.node
|
||||
m.Toggle()
|
||||
return
|
||||
}
|
||||
if zone.Get(m.id + "row:" + r.node.Path).InBounds(mm) {
|
||||
m.cursor = r.node
|
||||
m.clampOffset()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) visibleRows() []row {
|
||||
rows := m.rows()
|
||||
end := min(len(rows), m.offset+m.height)
|
||||
if m.offset >= end {
|
||||
return nil
|
||||
}
|
||||
return rows[m.offset:end]
|
||||
}
|
||||
|
||||
// --- view --------------------------------------------------------------------
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
rows := m.visibleRows()
|
||||
refW := m.refWidth()
|
||||
nameColW := m.nameColumnWidth(rows, refW)
|
||||
lines := make([]string, 0, m.height)
|
||||
for _, r := range rows {
|
||||
if r.kind == rowDir {
|
||||
lines = append(lines, m.dirLine(r))
|
||||
} else {
|
||||
lines = append(lines, m.nodeLine(r, nameColW, refW))
|
||||
}
|
||||
}
|
||||
for len(lines) < m.height {
|
||||
lines = append(lines, strings.Repeat(" ", m.width))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// nameColumnWidth is the width of indent + toggle + name for the widest
|
||||
// visible row, so the status column sits close to the names. It shrinks to
|
||||
// fit the available width.
|
||||
func (m Model) nameColumnWidth(rows []row, refW int) int {
|
||||
w := 0
|
||||
for _, r := range rows {
|
||||
if r.kind == rowNode {
|
||||
w = max(w, 2*r.depth+3+lipgloss.Width(r.node.Name)+len(m.childCount(r.node)))
|
||||
}
|
||||
}
|
||||
return min(w+2, m.width-statusWidth-refW-deltaWidth-3)
|
||||
}
|
||||
|
||||
func (m Model) childCount(s *submodule.Submodule) string {
|
||||
if len(s.Children) == 0 || m.expanded[s.Path] {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" [%d]", len(s.Children))
|
||||
}
|
||||
|
||||
func (m Model) refWidth() int {
|
||||
w := 0
|
||||
submodule.Walk(m.roots, func(s *submodule.Submodule) {
|
||||
w = max(w, lipgloss.Width(refLabel(s)))
|
||||
})
|
||||
return min(w, maxRefWidth)
|
||||
}
|
||||
|
||||
func refLabel(s *submodule.Submodule) string {
|
||||
if s.Status == nil {
|
||||
return ""
|
||||
}
|
||||
if s.Status.Pin.Ref != "" {
|
||||
return s.Status.Pin.Ref
|
||||
}
|
||||
if s.Status.Pinned != nil {
|
||||
return s.Status.Pinned.SHA
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m Model) dirLine(r row) string {
|
||||
text := strings.Repeat(" ", r.depth) + " " + r.label
|
||||
return theme.Fg(m.theme.Muted).Width(m.width).Render(ansi.Truncate(text, m.width, "…"))
|
||||
}
|
||||
|
||||
// nodeLine renders: indent ▸ name [n] │ status ·k │ ref │ delta
|
||||
func (m Model) nodeLine(r row, nameColW, refW int) string {
|
||||
t, s := m.theme, r.node
|
||||
base := lipgloss.NewStyle()
|
||||
if s == m.cursor {
|
||||
base = base.Background(t.Selected).Bold(true)
|
||||
}
|
||||
seg := func(c lipgloss.TerminalColor, text string) string {
|
||||
return base.Foreground(c).Render(text)
|
||||
}
|
||||
pad := func(n int) string { return base.Render(strings.Repeat(" ", max(n, 0))) }
|
||||
|
||||
collapsed := len(s.Children) > 0 && !m.expanded[s.Path]
|
||||
|
||||
// toggle marker
|
||||
marker := " "
|
||||
if len(s.Children) > 0 {
|
||||
marker = t.Glyphs.Expanded + " "
|
||||
if collapsed {
|
||||
marker = t.Glyphs.Collapsed + " "
|
||||
}
|
||||
}
|
||||
indent := pad(len(" ") * r.depth)
|
||||
toggle := zone.Mark(m.id+"toggle:"+s.Path, seg(t.Muted, " "+marker))
|
||||
|
||||
// Rows without issues are drawn muted so problems stand out.
|
||||
var issues []submodule.Issue
|
||||
if s.Status != nil {
|
||||
issues = s.Status.Issues()
|
||||
}
|
||||
quiet := s.Err == nil && s.Status != nil && len(issues) == 0
|
||||
textColor := t.Text
|
||||
if quiet && s != m.cursor {
|
||||
textColor = t.Muted
|
||||
}
|
||||
|
||||
// name + child count
|
||||
nameW := nameColW - 2*r.depth - 3
|
||||
count := m.childCount(s)
|
||||
name := ansi.Truncate(s.Name, max(nameW-len(count), 1), "…")
|
||||
nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count))
|
||||
|
||||
// status: the most severe issue as a word, "+n" for further issues
|
||||
var status string
|
||||
switch {
|
||||
case s.Err != nil:
|
||||
status = t.LevelStyle(submodule.LevelDanger, base).Render("error")
|
||||
case s.Status == nil:
|
||||
status = seg(t.Primary, m.spinner)
|
||||
case len(issues) > 0:
|
||||
status = t.LevelStyle(issues[0].Level, base).Render(theme.IssueWord(issues[0].Kind))
|
||||
if len(issues) > 1 {
|
||||
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
|
||||
}
|
||||
}
|
||||
if k, worst := s.HiddenIssues(); collapsed && k > 0 {
|
||||
if status != "" {
|
||||
status += pad(1)
|
||||
}
|
||||
status += t.LevelStyle(worst, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, k))
|
||||
}
|
||||
status = ansi.Truncate(status, statusWidth, "")
|
||||
status += pad(statusWidth - lipgloss.Width(status))
|
||||
|
||||
// ref + delta
|
||||
refColor := textColor
|
||||
if s.Status != nil && s.Status.Pin.Ref == "" {
|
||||
refColor = t.Muted
|
||||
}
|
||||
ref := ansi.Truncate(refLabel(s), refW, "…")
|
||||
refCell := seg(refColor, ref) + pad(refW-lipgloss.Width(ref))
|
||||
delta := ""
|
||||
if s.Status != nil {
|
||||
delta = t.Delta(s.Status.Pin, base)
|
||||
}
|
||||
deltaCell := delta + pad(deltaWidth-lipgloss.Width(delta))
|
||||
|
||||
content := nameCell + pad(1) + status + pad(1) + refCell + pad(1) + deltaCell
|
||||
line := indent + toggle + zone.Mark(m.id+"row:"+s.Path, content)
|
||||
if w := lipgloss.Width(line); w < m.width {
|
||||
line += pad(m.width - w)
|
||||
}
|
||||
return ansi.Truncate(line, m.width, "")
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package tree
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
zone.NewGlobal()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// fixture:
|
||||
//
|
||||
// a
|
||||
// src/
|
||||
// b
|
||||
// modules/
|
||||
// c
|
||||
// d
|
||||
// e
|
||||
// f
|
||||
func fixture() []*submodule.Submodule {
|
||||
mk := func(path, rel string, parent *submodule.Submodule) *submodule.Submodule {
|
||||
s := &submodule.Submodule{Name: path, Path: path, RelPath: rel, Parent: parent}
|
||||
if parent != nil {
|
||||
parent.Children = append(parent.Children, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
a := mk("a", "a", nil)
|
||||
b := mk("b", "src/b", nil)
|
||||
c := mk("c", "modules/c", b)
|
||||
mk("d", "d", c)
|
||||
mk("e", "modules/e", b)
|
||||
f := mk("f", "src/f", nil)
|
||||
return []*submodule.Submodule{a, b, f}
|
||||
}
|
||||
|
||||
func newTree(t *testing.T) *Model {
|
||||
t.Helper()
|
||||
m := New(theme.Default())
|
||||
m.SetRoots(fixture())
|
||||
m.SetSize(80, 20)
|
||||
return &m
|
||||
}
|
||||
|
||||
func assertCursor(t *testing.T, m *Model, want string) {
|
||||
t.Helper()
|
||||
if got := m.Selected().Path; got != want {
|
||||
t.Fatalf("cursor = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func visible(m *Model) []string {
|
||||
var out []string
|
||||
for _, r := range m.rows() {
|
||||
if r.kind == rowDir {
|
||||
out = append(out, r.label)
|
||||
} else {
|
||||
out = append(out, r.node.Path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestInitiallyCollapsedWithHeaders(t *testing.T) {
|
||||
m := newTree(t)
|
||||
got := visible(m)
|
||||
want := []string{"a", "src/", "b", "f"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
assertCursor(t, m, "a")
|
||||
}
|
||||
|
||||
func TestDownSkipsHeaders(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
assertCursor(t, m, "b")
|
||||
m.Down()
|
||||
assertCursor(t, m, "f")
|
||||
m.Down() // stays at end
|
||||
assertCursor(t, m, "f")
|
||||
m.Up()
|
||||
m.Up()
|
||||
m.Up() // stays at start
|
||||
assertCursor(t, m, "a")
|
||||
}
|
||||
|
||||
func TestRightExpandsAndSelectsFirstChild(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
m.Right()
|
||||
assertCursor(t, m, "c")
|
||||
if !m.expanded["b"] {
|
||||
t.Fatal("b should be expanded")
|
||||
}
|
||||
m.Right()
|
||||
assertCursor(t, m, "d")
|
||||
m.Right() // leaf: no-op
|
||||
assertCursor(t, m, "d")
|
||||
}
|
||||
|
||||
func TestRightOnLeafIsNoop(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Right()
|
||||
assertCursor(t, m, "a")
|
||||
}
|
||||
|
||||
func TestLeftCollapsesParentAndSelectsIt(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
m.Right() // b expanded, cursor c
|
||||
m.Down() // e
|
||||
assertCursor(t, m, "e")
|
||||
m.Left()
|
||||
assertCursor(t, m, "b")
|
||||
if m.expanded["b"] {
|
||||
t.Fatal("b should be collapsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeftOnExpandedNodeCollapsesIt(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
m.Toggle() // expand b, cursor stays
|
||||
assertCursor(t, m, "b")
|
||||
m.Left()
|
||||
assertCursor(t, m, "b")
|
||||
if m.expanded["b"] {
|
||||
t.Fatal("b should be collapsed")
|
||||
}
|
||||
m.Left() // top level and collapsed: no-op
|
||||
assertCursor(t, m, "b")
|
||||
}
|
||||
|
||||
func TestExpansionStateKept(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
m.Right() // c
|
||||
m.Right() // d, c expanded
|
||||
m.Left() // collapse c, select c
|
||||
m.Right() // d again
|
||||
m.Left() // c
|
||||
m.Toggle() // re-expand c
|
||||
m.Left() // c is expanded -> collapse c
|
||||
m.Toggle() // expand c
|
||||
m.Up() // b
|
||||
m.Toggle() // collapse b
|
||||
m.Toggle() // expand b: c should still be expanded
|
||||
got := visible(m)
|
||||
want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsingAncestorMovesCursor(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
m.Right()
|
||||
m.Right() // cursor d
|
||||
m.setExpanded(m.roots[1], false)
|
||||
assertCursor(t, m, "b")
|
||||
}
|
||||
|
||||
func TestScrollKeepsCursorVisible(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.SetSize(80, 2)
|
||||
m.Down()
|
||||
m.Right() // rows: a src/ b modules/ c e f ; cursor c (index 4)
|
||||
if i := m.cursorRow(m.rows()); i < m.offset || i >= m.offset+m.height {
|
||||
t.Fatalf("cursor row %d not in [%d,%d)", i, m.offset, m.offset+m.height)
|
||||
}
|
||||
m.Home()
|
||||
if m.offset != 0 {
|
||||
t.Fatalf("offset = %d after Home, want 0", m.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseAllSelectsTopLevelAncestor(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down()
|
||||
m.Right() // c
|
||||
m.Right() // d
|
||||
m.CollapseAll()
|
||||
assertCursor(t, m, "b")
|
||||
got := visible(m)
|
||||
want := []string{"a", "src/", "b", "f"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
m.Toggle() // re-expanding b must not restore c's expansion
|
||||
if m.expanded["c"] {
|
||||
t.Fatal("c should be collapsed after CollapseAll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandAllDescendsFirstChildren(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down() // b
|
||||
m.ExpandAll()
|
||||
assertCursor(t, m, "d")
|
||||
got := visible(m)
|
||||
want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandAllOnLeafKeepsSelection(t *testing.T) {
|
||||
m := newTree(t) // cursor a, a leaf
|
||||
m.ExpandAll()
|
||||
assertCursor(t, m, "a")
|
||||
if !m.expanded["b"] || !m.expanded["c"] {
|
||||
t.Fatal("all nodes with children should be expanded")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user