Archived
Fetch in the background and follow changes in the repositories
- On startup, after the local state is shown, fetch the superproject and every initialized submodule (all remotes, prune, at most 8 at once). Fetches never prompt; failures show git's message. --no-fetch skips it, f fetches again. - The header shows "fetching n/m", then the fetch age and failures; the detail pane shows each submodule's last fetch and fetch errors. - The git provider polls each repository's metadata (refs, HEAD, index, .gitmodules) every 2 s and reports changes. The app reloads only what changed, keeping old values, expansion and cursor; loads are single-flight with coalesced reruns. Refresh is driven by the repositories, so a fetch in another shell updates the view like ours. - r reloads everything (worktree edits are not watched). - Docs: F50-F53, N1/N8 rewritten, D18-D20 (D12 superseded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
19c6f17eb2
commit
27f0c0b5d2
+217
-26
@@ -4,6 +4,7 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
@@ -26,16 +27,29 @@ const (
|
||||
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
|
||||
loading int
|
||||
err error
|
||||
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
|
||||
@@ -46,23 +60,30 @@ type Model struct {
|
||||
showDetail bool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, p submodule.Provider) Model {
|
||||
func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
t := theme.Default()
|
||||
k := defaultKeys()
|
||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||||
return Model{
|
||||
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),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -80,6 +101,74 @@ func (m Model) loadStatus(path string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
@@ -88,25 +177,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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...)
|
||||
return m.treeLoaded(msg)
|
||||
|
||||
case statusLoadedMsg:
|
||||
if s, ok := m.byPath[msg.path]; ok {
|
||||
s.Status, s.Err = msg.status, msg.err
|
||||
m.loading--
|
||||
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
|
||||
|
||||
@@ -115,8 +214,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, 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
|
||||
if !m.busy() {
|
||||
m.ticking = false // stop ticking until there is work again
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
@@ -132,6 +232,81 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
@@ -162,7 +337,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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):
|
||||
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.Update, k.Bump, k.Filter, k.Issues):
|
||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||
m.layout()
|
||||
return m, cmd
|
||||
@@ -204,7 +395,7 @@ func (m Model) View() string {
|
||||
}
|
||||
|
||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
|
||||
header.View(m.theme, m.repo, m.width),
|
||||
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
|
||||
body,
|
||||
m.footer.View(),
|
||||
))
|
||||
|
||||
@@ -99,6 +99,12 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
||||
label("")+muted.Render("via "+st.Pin.Source.String()),
|
||||
)
|
||||
}
|
||||
if st.Presence == submodule.PresenceOK {
|
||||
out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt)))
|
||||
}
|
||||
if s.FetchErr != nil {
|
||||
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
|
||||
}
|
||||
|
||||
// commits
|
||||
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package header
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
@@ -10,8 +11,18 @@ import (
|
||||
"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 {
|
||||
// Fetch is the progress of the application's own background fetch.
|
||||
type Fetch struct {
|
||||
Running bool
|
||||
Done int // repositories fetched (or failed)
|
||||
Total int // repositories to fetch
|
||||
Failed int // failed fetches of the last run
|
||||
At time.Time // when the last run finished
|
||||
}
|
||||
|
||||
// View renders repo info, state counts and fetch state on one line. spinner
|
||||
// is the current spinner frame.
|
||||
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int) string {
|
||||
bar := lipgloss.NewStyle().Background(t.Bar)
|
||||
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
|
||||
|
||||
@@ -60,7 +71,22 @@ func View(t theme.Theme, repo *submodule.Repo, width int) string {
|
||||
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
|
||||
}
|
||||
|
||||
right := seg(t.Muted, "fetched "+theme.Ago(repo.FetchedAt)+" ")
|
||||
var right string
|
||||
switch {
|
||||
case fetch.Running:
|
||||
right = seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d ", fetch.Done, fetch.Total))
|
||||
default:
|
||||
// our own fetch may have finished before the watcher saw FETCH_HEAD
|
||||
at := repo.FetchedAt
|
||||
if fetch.At.After(at) {
|
||||
at = fetch.At
|
||||
}
|
||||
right = seg(t.Muted, "fetched "+theme.Ago(at))
|
||||
if fetch.Failed > 0 {
|
||||
right += seg(t.Muted, " · ") + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
|
||||
}
|
||||
right += seg(t.Muted, " ")
|
||||
}
|
||||
|
||||
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
|
||||
if gap < 4 {
|
||||
|
||||
@@ -3,11 +3,11 @@ 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
|
||||
Up, Down, Left, Right, Toggle key.Binding
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
||||
Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
@@ -26,6 +26,7 @@ func defaultKeys() keyMap {
|
||||
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")),
|
||||
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")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
@@ -42,7 +43,7 @@ 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.Filter, k.Issues},
|
||||
{k.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,17 @@ type statusLoadedMsg struct {
|
||||
status *submodule.Status
|
||||
err error
|
||||
}
|
||||
|
||||
// fetchDoneMsg reports one finished fetch. It only updates the fetch
|
||||
// progress; refreshing is left to the watcher, which also sees fetches made
|
||||
// outside the application.
|
||||
type fetchDoneMsg struct {
|
||||
path string // "" is the superproject
|
||||
err error
|
||||
}
|
||||
|
||||
// changeMsg is a change reported by the provider's watcher.
|
||||
type changeMsg struct {
|
||||
change submodule.Change
|
||||
closed bool // the watcher stopped
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package tui
|
||||
|
||||
// treeKey is the reloader key for loading the tree; submodules use their
|
||||
// path.
|
||||
const treeKey = "\x00tree"
|
||||
|
||||
// reloader allows at most one load per key at a time. A request while a
|
||||
// load is running marks the key dirty, and the load runs once more after
|
||||
// the current one finishes, so the last result always reflects the latest
|
||||
// change and results never arrive out of order.
|
||||
type reloader struct {
|
||||
running map[string]bool
|
||||
dirty map[string]bool
|
||||
}
|
||||
|
||||
func newReloader() reloader {
|
||||
return reloader{running: map[string]bool{}, dirty: map[string]bool{}}
|
||||
}
|
||||
|
||||
// start reports whether a load for key should start now. If one is already
|
||||
// running, the key is marked dirty instead.
|
||||
func (r reloader) start(key string) bool {
|
||||
if r.running[key] {
|
||||
r.dirty[key] = true
|
||||
return false
|
||||
}
|
||||
r.running[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// done marks the load for key as finished. It reports whether the load must
|
||||
// run again because it was requested meanwhile; the key then stays running.
|
||||
func (r reloader) done(key string) (again bool) {
|
||||
if r.dirty[key] {
|
||||
delete(r.dirty, key)
|
||||
return true
|
||||
}
|
||||
delete(r.running, key)
|
||||
return false
|
||||
}
|
||||
|
||||
// busy reports whether any load is running.
|
||||
func (r reloader) busy() bool { return len(r.running) > 0 }
|
||||
@@ -0,0 +1,31 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReloader(t *testing.T) {
|
||||
r := newReloader()
|
||||
if !r.start("a") {
|
||||
t.Fatal("first start: want true")
|
||||
}
|
||||
if r.start("a") || r.start("a") {
|
||||
t.Fatal("start while running: want false")
|
||||
}
|
||||
if !r.start("b") {
|
||||
t.Fatal("other key: want true")
|
||||
}
|
||||
if !r.done("a") {
|
||||
t.Fatal("done after requests while running: want again")
|
||||
}
|
||||
if r.done("a") {
|
||||
t.Fatal("second done: requests were coalesced into one rerun")
|
||||
}
|
||||
if r.done("b") {
|
||||
t.Fatal("done without requests: want no rerun")
|
||||
}
|
||||
if r.busy() {
|
||||
t.Fatal("busy after all done")
|
||||
}
|
||||
if !r.start("a") {
|
||||
t.Fatal("start after done: want true")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,28 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
|
||||
// expansion state and the cursor (by path); if the selected submodule is
|
||||
// gone, the cursor moves to the first one.
|
||||
func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
|
||||
var path string
|
||||
if m.cursor != nil {
|
||||
path = m.cursor.Path
|
||||
}
|
||||
m.roots = roots
|
||||
m.cursor = nil
|
||||
for _, r := range m.rows() {
|
||||
if r.kind == rowNode && r.node.Path == path {
|
||||
m.cursor = r.node
|
||||
break
|
||||
}
|
||||
}
|
||||
if m.cursor == nil && len(roots) > 0 {
|
||||
m.cursor = m.rows()[m.firstNodeRow()].node
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
m.clampOffset()
|
||||
|
||||
@@ -69,6 +69,23 @@ func visible(m *Model) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestReplaceRootsKeepsState(t *testing.T) {
|
||||
m := newTree(t)
|
||||
m.Down() // b
|
||||
m.Right() // expand b, cursor on c
|
||||
m.Right() // expand c, cursor on d
|
||||
assertCursor(t, m, "d")
|
||||
|
||||
m.ReplaceRoots(fixture()) // same paths, new nodes
|
||||
assertCursor(t, m, "d")
|
||||
if got := len(visible(m)); got != 8 {
|
||||
t.Errorf("visible rows = %v, want b and c still expanded", visible(m))
|
||||
}
|
||||
|
||||
m.ReplaceRoots(fixture()[:1]) // only "a" left
|
||||
assertCursor(t, m, "a")
|
||||
}
|
||||
|
||||
func TestInitiallyCollapsedWithHeaders(t *testing.T) {
|
||||
m := newTree(t)
|
||||
got := visible(m)
|
||||
|
||||
Reference in New Issue
Block a user