// 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(), )) }