[#1223] lens/tui: Add error message on invalid search prompt

Signed-off-by: Aleksey Savchuk <a.savchuk@yadro.com>
This commit is contained in:
Aleksey Savchuk 2024-08-07 13:50:15 +03:00
parent 235cc15037
commit a3e0380e43
No known key found for this signature in database
3 changed files with 251 additions and 140 deletions

View file

@ -2,6 +2,7 @@ package tuiutil
import (
"context"
"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"
@ -35,6 +36,10 @@ func NewBucketsView(ui *UI, filter *Filter) *BucketsView {
}
func (v *BucketsView) Mount(ctx context.Context) error {
if v.onUnmount != nil {
return errors.New("try to mount already mounted component")
}
ctx, v.onUnmount = context.WithCancel(ctx)
handler := metabase.MetabaseParser

View file

@ -2,6 +2,7 @@ package tuiutil
import (
"context"
"errors"
"fmt"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
@ -50,6 +51,10 @@ func NewRecordsView(ui *UI, bucket *Bucket, filter *Filter) *RecordsView {
}
func (v *RecordsView) Mount(ctx context.Context) error {
if v.onUnmount != nil {
return errors.New("try to mount already mounted component")
}
ctx, v.onUnmount = context.WithCancel(ctx)
tempBuffer, err := LoadRecords(ctx, v.ui.db, v.bucket.Path)

View file

@ -27,9 +27,9 @@ type Primitive interface {
type UI struct {
*tview.Box
// Need to use context while drawing some pages those read data from
// a database. Context should be shared among multiple draw events.
// Library tview doesn't use contexts at all, so do that feature by myself.
// Need to use context while updating pages those read data from a database.
// Context should be shared among all mount and updates. Current TUI library
// doesn't use contexts at all, so I do that feature by myself.
//nolint:containedctx
ctx context.Context
@ -38,16 +38,27 @@ type UI struct {
pageHistory []Primitive
mountedPage Primitive
pageToMount Primitive
pageStub tview.Primitive
saveMounted bool
pageStub tview.Primitive
infoBar *tview.TextView
searchBar *tview.InputField
loadingBar *LoadingBar
isFirstMount bool
isSearching bool
isLoading atomic.Bool
helpPage *tview.TextView
searchErrorBar *tview.TextView
isFirstMount bool
isSearching bool
isLoading atomic.Bool
isShowingError bool
isShowingHelp bool
loadingBarDelay time.Duration
cancelLoading func()
@ -66,21 +77,35 @@ func NewUI(ctx context.Context, app *tview.Application, db *bbolt.DB) *UI {
isFirstMount: true,
infoBar: tview.NewTextView(),
pageStub: tview.NewBox(),
helpPage: tview.NewTextView(),
searchBar: tview.NewInputField(),
searchErrorBar: tview.NewTextView(),
filters: make(map[string]func(string) (any, error)),
compositeFilters: make(map[string]func(string) (map[string]any, error)),
saveMounted: true,
loadingBarDelay: 100 * time.Millisecond,
}
ui.loadingBar = NewLoadingBar(ui.triggerDraw)
barBackgroundColor := tview.Styles.PrimaryTextColor
barTextColor := ui.GetBackgroundColor()
barAlertTextColor := tcell.ColorRed
ui.searchBar.SetFieldBackgroundColor(barTextColor)
ui.searchBar.SetFieldTextColor(barBackgroundColor)
ui.searchBar.SetLabelColor(barBackgroundColor)
ui.infoBar.SetBackgroundColor(barBackgroundColor)
ui.infoBar.SetTextColor(barTextColor)
ui.searchErrorBar.SetBackgroundColor(barTextColor)
ui.searchErrorBar.SetTextColor(barAlertTextColor)
ui.loadingBar = NewLoadingBar(ui.triggerDraw)
ui.pageToMount = NewBucketsView(ui, NewFilter(nil))
ui.searchBar.SetFieldBackgroundColor(ui.GetBackgroundColor())
ui.searchBar.SetFieldTextColor(tview.Styles.PrimaryTextColor)
ui.infoBar.SetBackgroundColor(tview.Styles.PrimaryTextColor)
ui.infoBar.SetTextColor(ui.GetBackgroundColor())
ui.helpPage.SetText("Work In Progress【ツ】")
ui.searchBar.SetLabel("/")
ui.infoBar.SetText(fmt.Sprintf(" %s (press h for help, / to search or q to quit) ", db.Path()))
return ui
@ -118,18 +143,20 @@ func (ui *UI) AddCompositeFilter(
return nil
}
// TODO: enhance error handling
// func (ui *UI) stopOnError(err error) {
// if err != nil {
// ui.app.QueueEvent(tcell.NewEventError(err))
// }
// }
// TODO: enhance error handling.
func (ui *UI) stopOnError(err error) {
if err != nil {
ui.app.QueueEvent(tcell.NewEventError(err))
}
}
func (ui *UI) movePrevPage() {
ui.mountedPage.Unmount()
ui.mountedPage = ui.pageHistory[len(ui.pageHistory)-1]
ui.pageHistory = ui.pageHistory[:len(ui.pageHistory)-1]
ui.triggerDraw()
if len(ui.pageHistory) != 0 {
ui.mountedPage.Unmount()
ui.mountedPage = ui.pageHistory[len(ui.pageHistory)-1]
ui.pageHistory = ui.pageHistory[:len(ui.pageHistory)-1]
ui.triggerDraw()
}
}
func (ui *UI) moveNextPage(page Primitive) {
@ -153,16 +180,16 @@ func (ui *UI) Draw(screen tcell.Screen) {
ready := make(chan struct{})
go func() {
defer cancel()
defer close(ready)
ui.load(ctx)
cancel()
close(ready)
ui.isLoading.Store(false)
}()
select {
case <-ready:
case <-time.After(100 * time.Millisecond):
case <-time.After(ui.loadingBarDelay):
ui.loadingBar.Start(ctx)
ui.cancelLoading = cancel
@ -180,54 +207,11 @@ func (ui *UI) load(ctx context.Context) {
if ui.mountedPage == nil && ui.pageToMount == nil {
return
}
// Pending mount either fails w/o retry or succeeds.
// Either way the page to mount need to be reset.
defer func() {
ui.pageToMount = nil
ui.isFirstMount = false
}()
pageToUpdate := ui.mountedPage
if ui.pageToMount != nil {
ready := make(chan struct{})
go func() {
defer close(ready)
// TODO: add error handling
_ = ui.pageToMount.Mount(ctx)
}()
select {
case <-ctx.Done():
return
case <-ready:
}
pageToUpdate = ui.pageToMount
}
ready := make(chan struct{})
go func() {
defer close(ready)
x, y, w, h := ui.GetInnerRect()
pageToUpdate.SetRect(x, y, w, h-1)
// TODO: add error handling
_ = pageToUpdate.Update(ctx)
}()
select {
case <-ctx.Done():
return
case <-ready:
}
if ui.pageToMount != nil {
if ui.mountedPage != nil {
ui.pageHistory = append(ui.pageHistory, ui.mountedPage)
}
ui.mountedPage = ui.pageToMount
ui.mountAndUpdate(ctx)
} else {
ui.update(ctx)
}
}
@ -235,22 +219,39 @@ func (ui *UI) draw(screen tcell.Screen) {
ui.DrawForSubclass(screen, ui)
x, y, width, height := ui.GetInnerRect()
var pageToDraw tview.Primitive
if ui.mountedPage != nil {
var (
pageToDraw tview.Primitive
barToDraw tview.Primitive
needDrawBar = true
)
switch {
case ui.isShowingHelp:
pageToDraw = ui.helpPage
needDrawBar = false
case ui.mountedPage != nil:
pageToDraw = ui.mountedPage
} else {
default:
pageToDraw = ui.pageStub
}
if !needDrawBar {
pageToDraw.SetRect(x, y, width, height)
pageToDraw.Draw(screen)
return
}
pageToDraw.SetRect(x, y, width, height-1)
pageToDraw.Draw(screen)
var barToDraw tview.Primitive
switch {
case ui.isLoading.Load():
barToDraw = ui.loadingBar
case ui.isSearching:
barToDraw = ui.searchBar
case ui.isShowingError:
barToDraw = ui.searchErrorBar
default:
barToDraw = ui.infoBar
}
@ -259,76 +260,179 @@ func (ui *UI) draw(screen tcell.Screen) {
barToDraw.Draw(screen)
}
func (ui *UI) mountAndUpdate(ctx context.Context) {
defer func() {
// Operation succeeded or was canceled, either way reset page to mount.
ui.pageToMount = nil
// Not saving previously mounted page in history on new mount is
// exceptional, we always want to have previously mounted page saved.
ui.saveMounted = true
}()
s := loadOp(ctx, ui.pageToMount.Mount)
if s.err != nil {
ui.stopOnError(s.err)
return
}
// Mount canceled.
if !s.done {
return
}
s = loadOp(ctx, ui.pageToMount.Update)
if s.err != nil {
ui.stopOnError(s.err)
return
}
// Update canceled.
if !s.done {
ui.pageToMount.Unmount()
return
}
if ui.saveMounted {
ui.saveOnMount()
}
ui.swapOnMount()
}
func (ui *UI) update(ctx context.Context) {
s := loadOp(ctx, ui.mountedPage.Update)
if s.err != nil {
ui.stopOnError(s.err)
return
}
}
type status struct {
done bool
err error
}
func loadOp(ctx context.Context, op func(ctx context.Context) error) status {
errCh := make(chan error)
go func() {
errCh <- op(ctx)
}()
select {
case <-ctx.Done():
return status{done: false, err: nil}
case err := <-errCh:
return status{done: true, err: err}
}
}
func (ui *UI) saveOnMount() {
if ui.mountedPage != nil {
ui.pageHistory = append(ui.pageHistory, ui.mountedPage)
}
}
func (ui *UI) swapOnMount() {
ui.mountedPage = ui.pageToMount
ui.pageToMount = nil
}
func (ui *UI) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return ui.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
m, k, r := event.Modifiers(), event.Key(), event.Rune()
if ui.isLoading.Load() {
if k != tcell.KeyEsc && r != 'q' {
return
}
ui.cancelLoading()
if r == 'q' || ui.isFirstMount {
ui.app.Stop()
return
}
switch {
case ui.isLoading.Load():
ui.handleInputOnLoading(event)
case ui.isShowingHelp:
ui.handleInputOnShowingHelp(event)
case ui.isShowingError:
ui.handleInputOnShowingError()
case ui.isSearching:
ui.handleInputOnSearching(event)
default:
ui.handleInput(event)
}
})
}
if ui.isSearching {
switch event.Key() {
case tcell.KeyEnter:
prompt := ui.searchBar.GetText()[1:]
func (ui *UI) handleInput(event *tcell.EventKey) {
m, k, r := event.Modifiers(), event.Key(), event.Rune()
res, err := ui.processPrompt(prompt)
if err != nil {
return
}
switch {
case k == tcell.KeyEsc:
ui.movePrevPage()
case m == 0 && k == tcell.KeyRune && r == 'h':
ui.isShowingHelp = true
case m == 0 && k == tcell.KeyRune && r == '/':
ui.isSearching = true
case m == 0 && k == tcell.KeyRune && r == 'q':
ui.app.Stop()
default:
if ui.mountedPage != nil {
ui.mountedPage.InputHandler()(event, func(tview.Primitive) {})
}
}
}
// TODO: make two mount strategies.
switch ui.mountedPage.(type) {
case *BucketsView:
// ui.mountedPage.Unmount()
// ui.mountedPage = nil
ui.moveNextPage(NewBucketsView(ui, res))
case *RecordsView:
bucket := ui.mountedPage.(*RecordsView).bucket
func (ui *UI) handleInputOnLoading(event *tcell.EventKey) {
k, r := event.Key(), event.Rune()
if k != tcell.KeyEsc && r != 'q' {
return
}
ui.cancelLoading()
if r == 'q' || ui.isFirstMount {
ui.app.Stop()
return
}
}
// ui.mountedPage.Unmount()
// ui.mountedPage = nil
func (ui *UI) handleInputOnShowingError() {
ui.isShowingError = false
ui.isSearching = true
}
ui.moveNextPage(NewRecordsView(ui, bucket, res))
}
func (ui *UI) handleInputOnShowingHelp(event *tcell.EventKey) {
switch event.Key() {
case tcell.KeyEsc:
ui.isShowingHelp = false
default:
ui.helpPage.InputHandler()(event, func(tview.Primitive) {})
}
}
ui.isSearching = false
ui.searchBar.SetText("")
case tcell.KeyEsc:
ui.isSearching = false
ui.searchBar.SetText("")
default:
ui.searchBar.InputHandler()(event, func(tview.Primitive) {})
if len(ui.searchBar.GetText()) == 0 {
ui.isSearching = false
}
}
func (ui *UI) handleInputOnSearching(event *tcell.EventKey) {
m, k := event.Modifiers(), event.Key()
switch {
case k == tcell.KeyEnter:
prompt := ui.searchBar.GetText()
res, err := ui.processPrompt(prompt)
if err != nil {
ui.isShowingError = true
ui.isSearching = false
ui.searchErrorBar.SetText(err.Error() + " (press any key to continue)")
return
}
switch {
case m == 0 && k == tcell.KeyRune && r == '/':
ui.isSearching = true
ui.searchBar.SetText("/")
case m == 0 && k == tcell.KeyRune && r == 'q':
ui.app.Stop()
case k == tcell.KeyEsc || m&tcell.ModCtrl != 0 && k == tcell.KeyLeft:
if len(ui.pageHistory) != 0 {
ui.movePrevPage()
}
default:
if ui.mountedPage != nil {
ui.mountedPage.InputHandler()(event, func(tview.Primitive) {})
}
// TODO: make two mount strategies.
switch ui.mountedPage.(type) {
case *BucketsView:
ui.saveMounted = false
ui.moveNextPage(NewBucketsView(ui, res))
case *RecordsView:
bucket := ui.mountedPage.(*RecordsView).bucket
ui.saveMounted = false
ui.moveNextPage(NewRecordsView(ui, bucket, res))
}
})
ui.isSearching = false
ui.searchBar.SetText("")
case k == tcell.KeyEsc:
ui.isSearching = false
ui.searchBar.SetText("")
case (k == tcell.KeyBackspace2 || m&tcell.ModCtrl != 0 && k == tcell.KeyETB) && len(ui.searchBar.GetText()) == 0:
ui.isSearching = false
default:
ui.searchBar.InputHandler()(event, func(tview.Primitive) {})
}
}
func (ui *UI) processPrompt(prompt string) (filter *Filter, err error) {
@ -338,7 +442,7 @@ func (ui *UI) processPrompt(prompt string) (filter *Filter, err error) {
parts := strings.Split(prompt, ":")
if len(parts) != 2 {
return nil, errors.New("invalid filter syntax")
return nil, errors.New("invalid syntax, expected <filter id>:<filter value>")
}
filterID := strings.TrimSpace(parts[0])
@ -348,10 +452,7 @@ func (ui *UI) processPrompt(prompt string) (filter *Filter, err error) {
if ok {
filterValue, err := parser(filterString)
if err != nil {
return nil, fmt.Errorf(
"can't parse '%s' filter value '%s': %w",
filterID, filterString, err,
)
return nil, fmt.Errorf("can't parse '%s' filter value: %w", filterID, err)
}
return NewFilter(map[string]any{filterID: filterValue}), nil
}
@ -368,5 +469,5 @@ func (ui *UI) processPrompt(prompt string) (filter *Filter, err error) {
return NewFilter(filterValue), nil
}
return nil, fmt.Errorf("unknown filter '%s'", filterID)
return nil, fmt.Errorf("unknown filter id '%s'", filterID)
}