[#1223] lens/tui: Refactor UI

Signed-off-by: Aleksey Savchuk <a.savchuk@yadro.com>
This commit is contained in:
Aleksey Savchuk 2024-08-14 01:03:58 +03:00
parent 73b466db3b
commit 79563454c9
No known key found for this signature in database
9 changed files with 250 additions and 287 deletions

View file

@ -5,24 +5,23 @@ import (
"errors"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/metabase"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type BucketsView struct {
*tview.Box
view *tview.TreeView
ui *UI
needExpand bool
needUpdate bool
onUnmount func()
ui *UI
filter *Filter
}
type BucketNode struct {
type bucketNode struct {
bucket *Bucket
parent *tview.TreeNode
filter *Filter
}
@ -42,19 +41,16 @@ func (v *BucketsView) Mount(ctx context.Context) error {
ctx, v.onUnmount = context.WithCancel(ctx)
handler := metabase.MetabaseParser
root := tview.NewTreeNode(".")
root.
SetSelectable(false).
SetExpanded(true).
SetReference(&BucketNode{
bucket: &Bucket{NextHandler: handler},
parent: nil,
SetReference(&bucketNode{
bucket: &Bucket{NextParser: v.ui.rootParser},
filter: v.filter,
})
if err := v.getChildrenFilter(ctx, root, v.filter); err != nil {
if err := v.loadNodeChildren(ctx, root, v.filter); err != nil {
return err
}
@ -65,88 +61,45 @@ func (v *BucketsView) Mount(ctx context.Context) error {
return nil
}
func (v *BucketsView) satisfies(
ctx context.Context,
bucket *Bucket,
filter *Filter,
) (bool, error) {
filter.Apply(bucket.Entry)
if filter.Result() == common.Yes {
return true, nil
}
if filter.Result() == common.No {
return false, nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
subbuckets, err := LoadBuckets(ctx, v.ui.db, bucket.Path)
if err != nil {
return false, err
}
for b := range subbuckets {
b.Entry, b.NextHandler, err = bucket.NextHandler(b.Name, nil)
if err != nil {
return false, err
}
flag, err := v.satisfies(ctx, b, filter.Copy())
if err != nil {
return false, err
}
if flag {
return true, nil
}
}
records, err := LoadRecords(ctx, v.ui.db, bucket.Path)
if err != nil {
return false, err
}
for r := range records {
r.Entry, _, err = bucket.NextHandler(r.Key, r.Value)
if err != nil {
return false, err
}
f := filter.Copy()
f.Apply(r.Entry)
if f.Result() == common.Yes {
return true, nil
}
}
return false, nil
}
func (v *BucketsView) Update(ctx context.Context) error {
if !v.needExpand {
if !v.needUpdate {
return nil
}
defer func() { v.needUpdate = false }()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ready := make(chan struct{})
errCh := make(chan error)
current := v.view.GetCurrentNode()
tmp := tview.NewTreeNode(current.GetText())
tmp.SetReference(current.GetReference())
tmp := tview.NewTreeNode(current.GetText()).
SetReference(current.GetReference()).
SetExpanded(current.IsExpanded())
node := current.GetReference().(*BucketNode)
node := current.GetReference().(*bucketNode)
go func() {
defer close(ready)
v.needExpand = false
if !current.IsExpanded() {
_ = v.getChildrenFilter(ctx, tmp, node.filter)
hasBuckets, err := HasBuckets(ctx, v.ui.db, node.bucket.Path)
if err != nil {
errCh <- err
}
// Show the selected bucket's records instead.
if !hasBuckets {
v.ui.moveNextPage(NewRecordsView(v.ui, node.bucket, node.filter))
}
if current.IsExpanded() {
return
}
err = v.loadNodeChildren(ctx, tmp, node.filter)
if err != nil {
errCh <- err
}
}()
@ -155,6 +108,8 @@ func (v *BucketsView) Update(ctx context.Context) error {
case <-ready:
current.SetChildren(tmp.GetChildren())
current.SetExpanded(!current.IsExpanded())
case err := <-errCh:
return err
}
return nil
@ -177,86 +132,135 @@ func (v *BucketsView) Draw(screen tcell.Screen) {
v.view.Draw(screen)
}
func (v *BucketsView) getChildrenFilter(
ctx context.Context,
parent *tview.TreeNode,
predicate *Filter,
func (v *BucketsView) loadNodeChildren(
ctx context.Context, node *tview.TreeNode, filter *Filter,
) error {
parentBucket := parent.GetReference().(*BucketNode).bucket
parentBucket := node.GetReference().(*bucketNode).bucket
path := parentBucket.Path
handler := parentBucket.NextHandler
parser := parentBucket.NextParser
buffer, err := LoadBuckets(ctx, v.ui.db, path)
if err != nil {
return err
}
for bucket := range buffer {
bucket.Entry, bucket.NextHandler, err = handler(bucket.Name, nil)
for item := range buffer {
if item.err != nil {
return item.err
}
bucket := item.val
bucket.Entry, bucket.NextParser, err = parser(bucket.Name, nil)
if err != nil {
return err
}
child := tview.NewTreeNode(bucket.Entry.String())
f := predicate.Copy()
f.Apply(bucket.Entry)
child.SetSelectable(true)
child.SetExpanded(false)
child.SetReference(&BucketNode{
bucket: bucket,
parent: parent,
filter: f,
})
flag, err := v.satisfies(ctx, bucket, predicate.Copy())
satisfies, err := v.bucketSatisfiesFilter(ctx, bucket, filter)
if err != nil {
return err
}
if flag {
parent.AddChild(child)
if !satisfies {
continue
}
child := tview.NewTreeNode(bucket.Entry.String()).
SetSelectable(true).
SetExpanded(false).
SetReference(&bucketNode{
bucket: bucket,
filter: filter.Apply(bucket.Entry),
})
node.AddChild(child)
}
return nil
}
func (v *BucketsView) bucketSatisfiesFilter(
ctx context.Context, bucket *Bucket, filter *Filter,
) (bool, error) {
// Does the current bucket satisfies the filter?
filter = filter.Apply(bucket.Entry)
if filter.Result() == common.Yes {
return true, nil
}
if filter.Result() == common.No {
return false, nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Check the current bucket's nested buckets if exist
bucketsBuffer, err := LoadBuckets(ctx, v.ui.db, bucket.Path)
if err != nil {
return false, err
}
for item := range bucketsBuffer {
if item.err != nil {
return false, item.err
}
b := item.val
b.Entry, b.NextParser, err = bucket.NextParser(b.Name, nil)
if err != nil {
return false, err
}
satisfies, err := v.bucketSatisfiesFilter(ctx, b, filter)
if err != nil {
return false, err
}
if satisfies {
return true, nil
}
}
// Check the current bucket's nested recordsBuffer if exist
recordsBuffer, err := LoadRecords(ctx, v.ui.db, bucket.Path)
if err != nil {
return false, err
}
for item := range recordsBuffer {
if item.err != nil {
return false, item.err
}
r := item.val
r.Entry, _, err = bucket.NextParser(r.Key, r.Value)
if err != nil {
return false, err
}
if filter.Apply(r.Entry).Result() == common.Yes {
return true, nil
}
}
return false, nil
}
func (v *BucketsView) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
current := v.view.GetCurrentNode()
switch m, k, r := event.Modifiers(), event.Key(), event.Rune(); {
// TODO go to records on Enter pressed
// case m == 0 && k == tcell.KeyRune && r == ' ':
// v.needExpand = true
case k == tcell.KeyEnter:
if current == nil {
return
}
node := current.GetReference().(*BucketNode)
hasBuckets, _ := HasBuckets(context.Background(), v.ui.db, node.bucket.Path)
if hasBuckets {
v.needExpand = true
return
}
v.ui.moveNextPage(NewRecordsView(v.ui, node.bucket, node.filter.Copy()))
// TODO: go to parent while iterating over its children
// case m == 0 && k == tcell.KeyLeft:
// parent := current.GetReference().(*BucketNode).parent
// fmt.Fprintln(os.Stderr, current.GetText(), parent.GetText())
// if parent != nil {
// parent.SetExpanded(false)
// v.view.SetCurrentNode(parent)
// }
case m == 0 && k == tcell.KeyRune && r == 'd':
current := v.view.GetCurrentNode().GetReference().(*BucketNode).bucket
if current != nil {
v.ui.moveNextPage(NewDetailedView(current.Entry.DetailedString()))
}
switch event.Key() {
case tcell.KeyEnter:
// Expand or collapse the selected bucket's nested buckets,
// otherwise, navigate to that bucket's records.
v.needUpdate = true
case tcell.KeyCtrlR:
// Navigate to the selected bucket's records.
node := v.view.GetCurrentNode().GetReference().(*bucketNode)
v.ui.moveNextPage(NewRecordsView(v.ui, node.bucket, node.filter))
case tcell.KeyCtrlD:
// Navigate to the selected bucket's detailed view.
node := v.view.GetCurrentNode().GetReference().(*bucketNode)
v.ui.moveNextPage(NewDetailedView(node.bucket.Entry.DetailedString()))
default:
v.view.InputHandler()(event, func(tview.Primitive) {})
}

View file

@ -8,11 +8,12 @@ import (
"go.etcd.io/bbolt"
)
const (
bufferSize = 100
)
type Item[T any] struct {
val T
err error
}
func follow(tx *bbolt.Tx, path [][]byte) (*bbolt.Bucket, error) {
func resolvePath(tx *bbolt.Tx, path [][]byte) (*bbolt.Bucket, error) {
if len(path) == 0 {
return nil, errors.New("can't find bucket without path")
}
@ -31,32 +32,27 @@ func follow(tx *bbolt.Tx, path [][]byte) (*bbolt.Bucket, error) {
return bucket, nil
}
func load(
ctx context.Context,
db *bbolt.DB,
path [][]byte,
filter func(key, value []byte) bool,
transform func(key, value []byte) any,
) (<-chan any, error) {
buffer := make(chan any, bufferSize)
errCh := make(chan error)
func load[T any](
ctx context.Context, db *bbolt.DB, path [][]byte, bufferSize int,
filter func(key, value []byte) bool, transform func(key, value []byte) T,
) (<-chan Item[T], error) {
buffer := make(chan Item[T], bufferSize)
go func() {
// TODO enhance error handling.
_ = db.View(func(tx *bbolt.Tx) error {
defer close(buffer)
defer close(buffer)
err := db.View(func(tx *bbolt.Tx) error {
var cursor *bbolt.Cursor
if len(path) == 0 {
cursor = tx.Cursor()
} else {
bucket, err := follow(tx, path)
bucket, err := resolvePath(tx, path)
if err != nil {
errCh <- fmt.Errorf("can't find bucket: %w", err)
buffer <- Item[T]{err: fmt.Errorf("can't find bucket: %w", err)}
return nil
}
cursor = bucket.Cursor()
}
close(errCh)
key, value := cursor.First()
for {
@ -71,26 +67,26 @@ func load(
select {
case <-ctx.Done():
return nil
case buffer <- transform(key, value):
case buffer <- Item[T]{val: transform(key, value)}:
key, value = cursor.Next()
}
}
})
if err != nil {
buffer <- Item[T]{err: err}
}
}()
if err := <-errCh; err != nil {
return nil, err
}
return buffer, nil
}
func LoadBuckets(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Bucket, error) {
in, err := load(
ctx, db, path,
func LoadBuckets(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan Item[*Bucket], error) {
buffer, err := load(
ctx, db, path, 100,
func(_, value []byte) bool {
return value == nil
},
func(key, _ []byte) any {
func(key, _ []byte) *Bucket {
base := make([][]byte, 0, len(path))
base = append(base, path...)
@ -104,23 +100,16 @@ func LoadBuckets(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Buck
return nil, fmt.Errorf("can't start iterating bucket: %w", err)
}
out := make(chan *Bucket, bufferSize)
go func() {
defer close(out)
for x := range in {
out <- x.(*Bucket)
}
}()
return out, nil
return buffer, nil
}
func LoadRecords(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Record, error) {
in, err := load(
ctx, db, path,
func LoadRecords(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan Item[*Record], error) {
buffer, err := load(
ctx, db, path, 100,
func(_, value []byte) bool {
return value != nil
},
func(key, value []byte) any {
func(key, value []byte) *Record {
base := make([][]byte, 0, len(path))
base = append(base, path...)
@ -135,35 +124,33 @@ func LoadRecords(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Reco
return nil, fmt.Errorf("can't start iterating bucket: %w", err)
}
out := make(chan *Record, bufferSize)
go func() {
defer close(out)
for x := range in {
out <- x.(*Record)
}
}()
return out, nil
return buffer, nil
}
// HasBuckets checks if a bucket has nested buckets. It relies on assumption
// that a bucket can have either nested buckets or records but not both.
func HasBuckets(ctx context.Context, db *bbolt.DB, path [][]byte) (bool, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
buffer, err := load(
ctx, db, path,
ctx, db, path, 1,
nil,
func(_, value []byte) any {
return value
},
func(_, value []byte) []byte { return value },
)
if err != nil {
return false, err
}
x, ok := <-buffer
if !ok || x.([]byte) != nil {
if !ok {
return false, nil
}
if x.err != nil {
return false, err
}
if x.val != nil {
return false, err
}
return true, nil
}

View file

@ -3,43 +3,22 @@ package tuiutil
import (
"context"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type DetailedView struct {
*tview.Box
view *tview.TextView
*tview.TextView
}
func NewDetailedView(detailed string) *DetailedView {
v := &DetailedView{
Box: tview.NewBox(),
view: tview.NewTextView(),
TextView: tview.NewTextView(),
}
v.view.SetText(detailed)
v.SetDynamicColors(true)
v.SetText(detailed)
return v
}
func (v *DetailedView) Mount(_ context.Context) error {
return nil
}
func (v *DetailedView) Update(_ context.Context) error {
return nil
}
func (v *DetailedView) Unmount() {}
func (v *DetailedView) Draw(screen tcell.Screen) {
x, y, width, height := v.GetInnerRect()
v.view.SetRect(x, y, width, height)
v.view.Draw(screen)
}
func (v *DetailedView) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
v.view.InputHandler()(event, func(tview.Primitive) {})
})
}
func (v *DetailedView) Mount(_ context.Context) error { return nil }
func (v *DetailedView) Update(_ context.Context) error { return nil }
func (v *DetailedView) Unmount() {}

View file

@ -13,21 +13,26 @@ type Filter struct {
func NewFilter(values map[string]any) *Filter {
f := &Filter{
values: values,
values: maps.Clone(values),
results: make(map[string]common.FilterResult),
}
for id := range values {
var zero common.FilterResult
f.results[id] = zero
for tag := range values {
f.results[tag] = common.No
}
return f
}
func (f *Filter) Apply(e common.SchemaEntry) {
// TODO: make apply return new Filter.
for id, value := range f.values {
f.results[id] = max(f.results[id], e.Filter(id, value))
func (f *Filter) Apply(e common.SchemaEntry) *Filter {
filter := &Filter{
values: f.values,
results: maps.Clone(f.results),
}
for tag, value := range filter.values {
filter.results[tag] = max(filter.results[tag], e.Filter(tag, value))
}
return filter
}
func (f *Filter) Result() common.FilterResult {
@ -37,11 +42,3 @@ func (f *Filter) Result() common.FilterResult {
}
return current
}
func (f *Filter) Copy() *Filter {
c := &Filter{
values: maps.Clone(f.values),
results: maps.Clone(f.results),
}
return c
}

View file

@ -23,21 +23,20 @@ func NewInputFieldWithHistory(historyLimit int) *InputFieldWithHistory {
}
}
func (f *InputFieldWithHistory) AddToHistory(s string) *InputFieldWithHistory {
func (f *InputFieldWithHistory) AddToHistory(s string) {
// Stop scrolling history on history change, need to start scrolling again.
defer func() { f.historyPointer = len(f.history) }()
// Used history for search prompt
// Used history data for search prompt, so just make that data recent.
if f.historyPointer != len(f.history) && s == f.history[f.historyPointer] {
f.history = append(f.history[:f.historyPointer], f.history[f.historyPointer+1:]...)
f.history = append(f.history, s)
return f
}
if len(f.history) == f.historyLimit {
f.history = f.history[1:]
}
f.history = append(f.history, s)
return f
}
func (f *InputFieldWithHistory) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
@ -49,7 +48,7 @@ func (f *InputFieldWithHistory) InputHandler() func(event *tcell.EventKey, setFo
if len(f.history) == 0 {
return
}
// Must start iterating before
// Need to start iterating before.
if f.historyPointer == len(f.history) {
return
}

View file

@ -57,17 +57,14 @@ func (b *LoadingBar) Stop() {
func (b *LoadingBar) Draw(screen tcell.Screen) {
seconds := b.secondsElapsed.Load()
var text string
if seconds < 60 {
text = fmt.Sprintf(
" Loading... %ds (press Escape to cancel) ", seconds,
)
} else {
text = fmt.Sprintf(
" Loading... %dm%ds (press Escape to cancel) ", seconds/60, seconds%60,
)
var time string
switch {
case seconds < 60:
time = fmt.Sprintf("%ds", seconds)
default:
time = fmt.Sprintf("%dm%ds", seconds/60, seconds%60)
}
b.view.SetText(text)
b.view.SetText(fmt.Sprintf(" Loading... %s (press Escape to cancel) ", time))
x, y, width, _ := b.GetInnerRect()
b.view.SetRect(x, y, width, 1)

View file

@ -52,15 +52,20 @@ func (v *RecordsView) Mount(ctx context.Context) error {
go func() {
defer close(v.buffer)
for record := range tempBuffer {
record.Entry, _, err = v.bucket.NextHandler(record.Key, record.Value)
for item := range tempBuffer {
if item.err != nil {
v.ui.stopOnError(err)
break
}
record := item.val
record.Entry, _, err = v.bucket.NextParser(record.Key, record.Value)
if err != nil {
v.ui.stopOnError(err)
break
}
f := v.filter.Copy()
f.Apply(record.Entry)
if f.Result() != common.Yes {
if v.filter.Apply(record.Entry).Result() != common.Yes {
continue
}
v.buffer <- record
@ -113,23 +118,12 @@ func (v *RecordsView) Draw(screen tcell.Screen) {
return
}
// TODO: show page number.
// pageNum := v.firstRecordIndex/height + 1
// title := v.GetTitle()
// if title != "" {
// v.SetTitle(fmt.Sprintf("%s: page %d", title, pageNum))
// } else {
// v.SetTitle(fmt.Sprintf("page %d", pageNum))
// }
v.DrawForSubclass(screen, v)
// No records in that bucket.
if v.firstRecordIndex == v.lastRecordIndex {
tview.Print(
screen, "Empty Bucket", x, y+1, width,
tview.AlignCenter, tview.Styles.PrimaryTextColor,
screen, "Empty Bucket", x, y, width, tview.AlignCenter, tview.Styles.PrimaryTextColor,
)
return
}
@ -137,6 +131,7 @@ func (v *RecordsView) Draw(screen tcell.Screen) {
for index := v.firstRecordIndex; index < v.lastRecordIndex; index++ {
result := v.records[index].Entry
text := result.String()
if index == v.selectedRecordIndex {
text = fmt.Sprintf("[:white]%s[:black]", text)
tview.Print(screen, text, x, y, width, tview.AlignLeft, tview.Styles.PrimitiveBackgroundColor)
@ -146,7 +141,6 @@ func (v *RecordsView) Draw(screen tcell.Screen) {
y++
}
// v.SetTitle(title)
}
func (v *RecordsView) moveToPrevPage() {
@ -184,12 +178,19 @@ func (v *RecordsView) selectLastRecord() {
v.selectedRecordIndex = v.lastRecordIndex - 1
}
func (v *RecordsView) getSelectedItem() *Record {
if v.selectedRecordIndex < len(v.records) {
return v.records[v.selectedRecordIndex]
}
return nil
}
func (v *RecordsView) InputHandler() func(event *tcell.EventKey, _ func(p tview.Primitive)) {
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(p tview.Primitive)) {
switch m, k := event.Modifiers(), event.Key(); {
case m == 0 && (k == tcell.KeyLeft || k == tcell.KeyPgUp):
case m == 0 && k == tcell.KeyPgUp:
v.moveToPrevPage()
case m == 0 && (k == tcell.KeyRight || k == tcell.KeyPgDn):
case m == 0 && k == tcell.KeyPgDn:
v.moveToNextPage()
case m == 0 && k == tcell.KeyDown:
// Need to move onto the next page.
@ -208,7 +209,7 @@ func (v *RecordsView) InputHandler() func(event *tcell.EventKey, _ func(p tview.
v.moveToPrevPage()
v.selectLastRecord()
}
case k == tcell.KeyEnter || m&tcell.ModCtrl != 0 && k == tcell.KeyRight:
case k == tcell.KeyEnter:
current := v.getSelectedItem()
if current != nil {
v.ui.moveNextPage(NewDetailedView(current.Entry.DetailedString()))
@ -216,10 +217,3 @@ func (v *RecordsView) InputHandler() func(event *tcell.EventKey, _ func(p tview.
}
})
}
func (v *RecordsView) getSelectedItem() *Record {
if v.selectedRecordIndex < len(v.records) {
return v.records[v.selectedRecordIndex]
}
return nil
}

View file

@ -5,19 +5,14 @@ import (
)
type Bucket struct {
Name []byte
Path [][]byte
HasBuckets,
HasRecords bool
NextHandler common.Parser
Entry common.SchemaEntry
Name []byte
Path [][]byte
Entry common.SchemaEntry
NextParser common.Parser
}
type Record struct {
Key []byte
Value []byte
Path [][]byte
Entry common.SchemaEntry
Key, Value []byte
Path [][]byte
Entry common.SchemaEntry
}

View file

@ -8,6 +8,8 @@ import (
"sync/atomic"
"time"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/metabase"
"github.com/davecgh/go-spew/spew"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
@ -61,6 +63,8 @@ type UI struct {
loadBufferSize int
rootParser common.Parser
loadingBarDelay time.Duration
cancelLoading func()
@ -78,6 +82,7 @@ func NewUI(ctx context.Context, app *tview.Application, db *bbolt.DB) *UI {
Box: tview.NewBox(),
app: app,
db: db,
rootParser: metabase.MetabaseParser,
isFirstMount: true,
loadBufferSize: 100,
infoBar: tview.NewTextView(),
@ -89,7 +94,7 @@ func NewUI(ctx context.Context, app *tview.Application, db *bbolt.DB) *UI {
filters: make(map[string]func(string) (any, error)),
compositeFilters: make(map[string]func(string) (map[string]any, error)),
saveMounted: true,
loadingBarDelay: 100 * time.Millisecond,
loadingBarDelay: 1 * time.Second,
}
barBackgroundColor := tview.Styles.PrimaryTextColor
@ -305,6 +310,9 @@ func (ui *UI) mountAndUpdate(ctx context.Context) {
return
}
x, y, width, height := ui.GetInnerRect()
ui.pageToMount.SetRect(x, y, width, height)
s = loadOp(ctx, ui.pageToMount.Update)
if s.err != nil {
ui.stopOnError(s.err)
@ -323,6 +331,9 @@ func (ui *UI) mountAndUpdate(ctx context.Context) {
}
func (ui *UI) update(ctx context.Context) {
x, y, width, height := ui.GetInnerRect()
ui.mountedPage.SetRect(x, y, width, height)
s := loadOp(ctx, ui.mountedPage.Update)
if s.err != nil {
ui.stopOnError(s.err)