mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor: enhance TUI configuration and user management with improved UI elements and concurrency
This commit is contained in:
@@ -95,6 +95,9 @@ func Load(path string) (*TUIConfig, error) {
|
||||
|
||||
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||
func Save(path string, cfg *TUIConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := toml.NewEncoder(&buf)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
@@ -13,26 +16,119 @@ import (
|
||||
|
||||
// App is the root TUI application.
|
||||
type App struct {
|
||||
tapp *tview.Application
|
||||
pages *tview.Pages
|
||||
pageStack []string
|
||||
cfg *tuicfg.TUIConfig
|
||||
configPath string
|
||||
homeRefreshFn func()
|
||||
tapp *tview.Application
|
||||
pages *tview.Pages
|
||||
pageStack []string
|
||||
cfg *tuicfg.TUIConfig
|
||||
configPath string
|
||||
pageRefreshFns map[string]func()
|
||||
headerModelTV *tview.TextView
|
||||
modalOpen map[string]bool
|
||||
|
||||
modelCache map[string][]modelEntry
|
||||
modelCacheMu sync.RWMutex
|
||||
refreshMu sync.Mutex
|
||||
}
|
||||
|
||||
// cacheKey returns the map key for a (scheme, user) pair.
|
||||
func cacheKey(schemeName, userName string) string {
|
||||
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||
}
|
||||
|
||||
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||
a.modelCacheMu.RLock()
|
||||
defer a.modelCacheMu.RUnlock()
|
||||
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||
return append([]modelEntry(nil), entries...)
|
||||
}
|
||||
|
||||
// refreshModelCache fetches models for every user in the config concurrently.
|
||||
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||
func (a *App) refreshModelCache(onDone func()) {
|
||||
go func() {
|
||||
a.refreshMu.Lock()
|
||||
defer a.refreshMu.Unlock()
|
||||
|
||||
users := a.cfg.Provider.Users
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
|
||||
schemeURL := make(map[string]string, len(schemes))
|
||||
for _, s := range schemes {
|
||||
schemeURL[s.Name] = s.BaseURL
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range users {
|
||||
baseURL, ok := schemeURL[u.Scheme]
|
||||
if !ok || baseURL == "" {
|
||||
continue
|
||||
}
|
||||
if u.Key == "" {
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
a.modelCacheMu.Unlock()
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
u := u
|
||||
bURL := baseURL
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
entries, err := fetchModels(bURL, u.Key)
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err != nil || len(entries) == 0 {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
} else {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if onDone != nil {
|
||||
a.tapp.QueueUpdateDraw(onDone)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// New creates and wires up the TUI application.
|
||||
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
|
||||
tview.Styles.ContrastBackgroundColor = tcell.ColorTeal
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.ColorLime
|
||||
tview.Styles.BorderColor = tcell.ColorDarkCyan
|
||||
tview.Styles.TitleColor = tcell.ColorAqua
|
||||
tview.Styles.GraphicsColor = tcell.ColorDarkCyan
|
||||
tview.Styles.PrimaryTextColor = tcell.ColorWhite
|
||||
tview.Styles.SecondaryTextColor = tcell.ColorSilver
|
||||
tview.Styles.TertiaryTextColor = tcell.ColorAqua
|
||||
tview.Styles.InverseTextColor = tcell.ColorBlack
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.ColorNavy
|
||||
|
||||
a := &App{
|
||||
tapp: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
pageStack: []string{},
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
tapp: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
pageStack: []string{},
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
pageRefreshFns: make(map[string]func()),
|
||||
modalOpen: make(map[string]bool),
|
||||
}
|
||||
|
||||
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
if len(a.modalOpen) > 0 {
|
||||
return nil
|
||||
}
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
@@ -53,6 +149,7 @@ func (a *App) buildPages() {
|
||||
}
|
||||
|
||||
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||
a.pages.RemovePage(name)
|
||||
a.pages.AddPage(name, page, true, false)
|
||||
a.pageStack = append(a.pageStack, name)
|
||||
a.pages.SwitchToPage(name)
|
||||
@@ -62,26 +159,35 @@ func (a *App) goBack() *tcell.EventKey {
|
||||
if len(a.pageStack) <= 1 {
|
||||
return nil
|
||||
}
|
||||
popped := a.pageStack[len(a.pageStack)-1]
|
||||
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||
a.pages.RemovePage(popped)
|
||||
prev := a.pageStack[len(a.pageStack)-1]
|
||||
if prev == "home" && a.homeRefreshFn != nil {
|
||||
a.homeRefreshFn()
|
||||
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||
fn()
|
||||
}
|
||||
if prev == "home" && a.headerModelTV != nil {
|
||||
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||
}
|
||||
a.pages.SwitchToPage(prev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||
a.modalOpen[name] = true
|
||||
a.pages.AddPage(name, primitive, true, true)
|
||||
}
|
||||
|
||||
func (a *App) hideModal(name string) {
|
||||
delete(a.modalOpen, name)
|
||||
a.pages.HidePage(name)
|
||||
a.pages.RemovePage(name)
|
||||
}
|
||||
|
||||
func (a *App) save() {
|
||||
_ = tuicfg.Save(a.configPath, a.cfg)
|
||||
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||
a.showError("save failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) showError(msg string) {
|
||||
@@ -91,6 +197,10 @@ func (a *App) showError(msg string) {
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
a.hideModal("error")
|
||||
})
|
||||
modal.SetBackgroundColor(tcell.ColorNavy)
|
||||
modal.SetTextColor(tcell.ColorWhite)
|
||||
modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
|
||||
modal.SetButtonTextColor(tcell.ColorWhite)
|
||||
a.showModal("error", modal)
|
||||
}
|
||||
|
||||
@@ -104,20 +214,99 @@ func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||
onConfirm()
|
||||
}
|
||||
})
|
||||
modal.SetBackgroundColor(tcell.ColorNavy)
|
||||
modal.SetTextColor(tcell.ColorWhite)
|
||||
modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
|
||||
modal.SetButtonTextColor(tcell.ColorWhite)
|
||||
a.showModal("confirm-delete", modal)
|
||||
}
|
||||
|
||||
func centeredForm(form *tview.Form, width, height int) tview.Primitive {
|
||||
return tview.NewGrid().
|
||||
SetColumns(0, width, 0).
|
||||
SetRows(0, height, 0).
|
||||
AddItem(form, 1, 1, 1, 1, 0, 0, true)
|
||||
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(form, height, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
}
|
||||
|
||||
func hintBar(text string) *tview.TextView {
|
||||
tv := tview.NewTextView().
|
||||
SetText(text).
|
||||
SetTextAlign(tview.AlignCenter)
|
||||
tv.SetBackgroundColor(tcell.ColorDarkBlue)
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetTextColor(tcell.ColorAqua)
|
||||
tv.SetBackgroundColor(tcell.ColorMidnightBlue)
|
||||
return tv
|
||||
}
|
||||
|
||||
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||
var modelTV *tview.TextView
|
||||
if pageID == "home" {
|
||||
if a.headerModelTV == nil {
|
||||
a.headerModelTV = tview.NewTextView()
|
||||
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.ColorBlack)
|
||||
}
|
||||
modelTV = a.headerModelTV
|
||||
modelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||
} else {
|
||||
modelTV = tview.NewTextView()
|
||||
modelTV.SetBackgroundColor(tcell.ColorBlack)
|
||||
}
|
||||
|
||||
headerLeft := tview.NewTextView().
|
||||
SetText(" ▓▓ PICOCLAW LAUNCHER ▓▓").
|
||||
SetTextColor(tcell.ColorAqua).
|
||||
SetBackgroundColor(tcell.ColorBlack)
|
||||
|
||||
header := tview.NewFlex().
|
||||
AddItem(headerLeft, 0, 1, false).
|
||||
AddItem(modelTV, 0, 1, false)
|
||||
|
||||
sidebar := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetWrap(false)
|
||||
sidebar.SetBackgroundColor(tcell.ColorNavy)
|
||||
|
||||
activeColor := "[lime]▶ "
|
||||
inactiveColor := "[gray] "
|
||||
|
||||
sbText := "\n"
|
||||
if pageID == "home" {
|
||||
sbText += activeColor + "HOME[-]\n"
|
||||
} else {
|
||||
sbText += inactiveColor + "HOME[-]\n"
|
||||
}
|
||||
if pageID == "schemes" {
|
||||
sbText += activeColor + "SCHEMES[-]\n"
|
||||
} else {
|
||||
sbText += inactiveColor + "SCHEMES[-]\n"
|
||||
}
|
||||
if pageID == "users" {
|
||||
sbText += activeColor + "USERS[-]\n"
|
||||
} else {
|
||||
sbText += inactiveColor + "USERS[-]\n"
|
||||
}
|
||||
if pageID == "models" {
|
||||
sbText += activeColor + "MODELS[-]\n"
|
||||
} else {
|
||||
sbText += inactiveColor + "MODELS[-]\n"
|
||||
}
|
||||
|
||||
sidebar.SetText(sbText)
|
||||
|
||||
footer := hintBar(hint)
|
||||
|
||||
grid := tview.NewGrid().
|
||||
SetRows(1, 0, 1).
|
||||
SetColumns(16, 0).
|
||||
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
@@ -12,32 +12,27 @@ import (
|
||||
|
||||
func (a *App) newHomePage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ")
|
||||
list.SetBorder(true).SetTitle(" Active Configuration ").SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
|
||||
list.SetMainTextColor(tcell.ColorWhite)
|
||||
list.SetSecondaryTextColor(tcell.ColorDarkGray)
|
||||
list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
|
||||
list.SetSelectedBackgroundColor(tcell.ColorTeal)
|
||||
list.SetSelectedTextColor(tcell.ColorWhite)
|
||||
|
||||
rebuildList := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() {
|
||||
a.pages.RemovePage("schemes")
|
||||
a.navigateTo("schemes", a.newSchemesPage())
|
||||
})
|
||||
list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() })
|
||||
if sel > 0 && sel < list.GetItemCount() {
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuildList()
|
||||
|
||||
a.homeRefreshFn = rebuildList
|
||||
a.pageRefreshFns["home"] = rebuildList
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
return event
|
||||
})
|
||||
|
||||
footer := hintBar(" Enter: select q: quit ")
|
||||
|
||||
return tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(list, 0, 1, true).
|
||||
AddItem(footer, 1, 0, false)
|
||||
return a.buildShell("home", list, " m: configure model q: quit ")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false).
|
||||
SetFixed(0, 0)
|
||||
table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName))
|
||||
table.SetBorder(true).SetTitle(fmt.Sprintf(" Models · %s / %s ", schemeName, userName))
|
||||
table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
|
||||
table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
|
||||
|
||||
var modelIDs []string
|
||||
|
||||
@@ -41,19 +43,35 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true).
|
||||
SetText("[yellow]Fetching models…[-]")
|
||||
|
||||
footer := hintBar(" Enter: select ESC: back ")
|
||||
status.SetBackgroundColor(tcell.ColorBlack)
|
||||
|
||||
flex := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(status, 1, 0, false).
|
||||
AddItem(table, 0, 1, false).
|
||||
AddItem(footer, 1, 0, false)
|
||||
AddItem(table, 0, 1, false)
|
||||
|
||||
apiKey := a.resolveKey(schemeName, userName)
|
||||
|
||||
go func() {
|
||||
entries, err := fetchModels(baseURL, apiKey)
|
||||
var entries []modelEntry
|
||||
var err error
|
||||
if apiKey == "" {
|
||||
err = fmt.Errorf("key is required")
|
||||
} else {
|
||||
entries, err = fetchModels(baseURL, apiKey)
|
||||
}
|
||||
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err == nil && len(entries) > 0 {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||
} else {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
|
||||
a.tapp.QueueUpdateDraw(func() {
|
||||
if err != nil {
|
||||
status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error()))
|
||||
@@ -68,7 +86,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
|
||||
return
|
||||
}
|
||||
|
||||
status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries)))
|
||||
status.SetText(fmt.Sprintf("[lime]%d model(s) loaded[-]", len(entries)))
|
||||
for i, m := range entries {
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
table.SetCell(i, 0,
|
||||
@@ -80,7 +98,8 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
|
||||
table.SetCell(i, 1,
|
||||
tview.NewTableCell(" "+m.ID).
|
||||
SetAlign(tview.AlignLeft).
|
||||
SetExpansion(1),
|
||||
SetExpansion(1).
|
||||
SetTextColor(tcell.ColorWhite),
|
||||
)
|
||||
}
|
||||
a.tapp.SetFocus(table)
|
||||
@@ -100,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
return flex
|
||||
return a.buildShell("models", flex, " Enter: select ESC: back ")
|
||||
}
|
||||
|
||||
func (a *App) resolveKey(schemeName, userName string) string {
|
||||
@@ -135,9 +154,20 @@ func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var result modelsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
return result.Data, nil
|
||||
|
||||
var result modelsAPIResponse
|
||||
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
var arr []modelEntry
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("decode response: unrecognised shape: %s", strings.TrimSpace(string(body[:min(len(body), 256)])))
|
||||
}
|
||||
|
||||
@@ -14,74 +14,159 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) newSchemesPage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ")
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).SetTitle(" Provider Schemes ")
|
||||
table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
|
||||
table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
|
||||
|
||||
rowToIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedSchemeName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx >= 0 && idx < len(schemes) {
|
||||
return schemes[idx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
for _, s := range a.cfg.Provider.Schemes {
|
||||
name := s.Name
|
||||
list.AddItem(
|
||||
fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type),
|
||||
"",
|
||||
0,
|
||||
func() {
|
||||
a.pages.RemovePage("users")
|
||||
a.navigateTo("users", a.newUsersPage(name))
|
||||
},
|
||||
selName := selectedSchemeName()
|
||||
table.Clear()
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
for i, s := range schemes {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+s.Name).
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
|
||||
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||
n := len(users)
|
||||
m := 0
|
||||
for _, u := range users {
|
||||
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||
m++
|
||||
}
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(fmt.Sprintf(" (%d/%d)%s", m, n, s.BaseURL)).
|
||||
SetTextColor(tcell.ColorDarkGray).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell(s.Type+" ").
|
||||
SetTextColor(tcell.ColorDarkGray).
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
if selName != "" {
|
||||
for i, s := range schemes {
|
||||
if s.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.navigateTo("users", a.newUsersPage(name))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||
a.save()
|
||||
rebuild()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
idx := list.GetCurrentItem()
|
||||
if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
orig := a.cfg.Provider.Schemes[idx]
|
||||
origName := schemes[idx].Name
|
||||
orig := schemes[idx]
|
||||
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||
a.cfg.Provider.Schemes[idx] = s
|
||||
current := a.cfg.Provider.Schemes
|
||||
for i, sc := range current {
|
||||
if sc.Name == origName {
|
||||
a.cfg.Provider.Schemes[i] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
a.save()
|
||||
rebuild()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, sc := range a.cfg.Provider.Schemes {
|
||||
if sc.Name == s.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
idx := list.GetCurrentItem()
|
||||
if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
name := a.cfg.Provider.Schemes[idx].Name
|
||||
name := schemes[idx].Name
|
||||
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...)
|
||||
current := a.cfg.Provider.Schemes
|
||||
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||
for _, sc := range current {
|
||||
if sc.Name != name {
|
||||
newSchemes = append(newSchemes, sc)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Schemes = newSchemes
|
||||
|
||||
existing := a.cfg.Provider.Users
|
||||
filtered := make([]tuicfg.User, 0, len(existing))
|
||||
for _, u := range existing {
|
||||
if u.Scheme != name {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Users = filtered
|
||||
|
||||
a.save()
|
||||
rebuild()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ")
|
||||
|
||||
return tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(list, 0, 1, true).
|
||||
AddItem(footer, 1, 0, false)
|
||||
return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open ESC: back ")
|
||||
}
|
||||
|
||||
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||
@@ -108,14 +193,11 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
|
||||
|
||||
form := tview.NewForm()
|
||||
|
||||
var nameField *tview.InputField
|
||||
|
||||
form.
|
||||
AddInputField("Name", name, 40, nil, func(text string) { name = text }).
|
||||
AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }).
|
||||
AddInputField("Name", name, 32, nil, func(text string) { name = text }).
|
||||
AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||
AddButton("Save", func() {
|
||||
_ = nameField
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
@@ -139,9 +221,20 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
|
||||
a.hideModal("scheme-form")
|
||||
})
|
||||
|
||||
nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField)
|
||||
form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
|
||||
form.SetBorderColor(tcell.ColorDarkCyan)
|
||||
form.SetFieldBackgroundColor(tcell.ColorBlack)
|
||||
form.SetFieldTextColor(tcell.ColorWhite)
|
||||
form.SetLabelColor(tcell.ColorAqua)
|
||||
form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
|
||||
form.SetButtonTextColor(tcell.ColorWhite)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("scheme-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(title)
|
||||
|
||||
a.showModal("scheme-form", centeredForm(form, 68, 12))
|
||||
a.showModal("scheme-form", centeredForm(form, 6, 12))
|
||||
}
|
||||
|
||||
@@ -14,98 +14,173 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName))
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).SetTitle(fmt.Sprintf(" Users · %s ", schemeName))
|
||||
table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
|
||||
table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
|
||||
|
||||
indexInCfg := func(visibleIdx int) int {
|
||||
count := 0
|
||||
for i, u := range a.cfg.Provider.Users {
|
||||
visibleUsers := func() []tuicfg.User {
|
||||
var out []tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName {
|
||||
if count == visibleIdx {
|
||||
return i
|
||||
}
|
||||
count++
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
findUserGlobalIdx := func(userName string) int {
|
||||
for i, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
rowToVisIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedUserName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
users := visibleUsers()
|
||||
visIdx := rowToVisIdx(row)
|
||||
if visIdx >= 0 && visIdx < len(users) {
|
||||
return users[visIdx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme != schemeName {
|
||||
continue
|
||||
selName := selectedUserName()
|
||||
table.Clear()
|
||||
users := visibleUsers()
|
||||
for i, u := range users {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+u.Name).
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
table.SetCell(nameRow, 1,
|
||||
tview.NewTableCell("").
|
||||
SetSelectable(false),
|
||||
)
|
||||
|
||||
models := a.cachedModels(schemeName, u.Name)
|
||||
var detailText string
|
||||
if len(models) > 0 {
|
||||
detailText = fmt.Sprintf(" %d models", len(models))
|
||||
} else {
|
||||
detailText = " [red]Inactive[-]"
|
||||
}
|
||||
uName := u.Name
|
||||
uType := u.Type
|
||||
list.AddItem(
|
||||
fmt.Sprintf("%s · %s", u.Name, uType),
|
||||
"",
|
||||
0,
|
||||
func() {
|
||||
a.pages.RemovePage("models")
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||
return
|
||||
}
|
||||
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||
},
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(detailText).
|
||||
SetTextColor(tcell.ColorDarkGray).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell(u.Type+" ").
|
||||
SetTextColor(tcell.ColorDarkGray).
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
if selName != "" {
|
||||
for i, u := range users {
|
||||
if u.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||
return
|
||||
}
|
||||
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||
a.save()
|
||||
rebuild()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
visIdx := list.GetCurrentItem()
|
||||
cfgIdx := indexInCfg(visIdx)
|
||||
if cfgIdx < 0 {
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
orig := a.cfg.Provider.Users[cfgIdx]
|
||||
origName := users[visIdx].Name
|
||||
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||
cfgIdx := findUserGlobalIdx(origName)
|
||||
if cfgIdx < 0 {
|
||||
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Users[cfgIdx] = u
|
||||
a.save()
|
||||
rebuild()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, usr := range visibleUsers() {
|
||||
if usr.Name == u.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
visIdx := list.GetCurrentItem()
|
||||
cfgIdx := indexInCfg(visIdx)
|
||||
if cfgIdx < 0 {
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
uName := a.cfg.Provider.Users[cfgIdx].Name
|
||||
uName := users[visIdx].Name
|
||||
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||
users := a.cfg.Provider.Users
|
||||
a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...)
|
||||
cfgIdx := findUserGlobalIdx(uName)
|
||||
if cfgIdx < 0 {
|
||||
return
|
||||
}
|
||||
all := a.cfg.Provider.Users
|
||||
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||
a.save()
|
||||
rebuild()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ")
|
||||
|
||||
return tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(list, 0, 1, true).
|
||||
AddItem(footer, 1, 0, false)
|
||||
return a.buildShell("users", table, " a: add e: edit d: delete Enter: models ESC: back ")
|
||||
}
|
||||
|
||||
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||
@@ -132,9 +207,9 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
|
||||
|
||||
form := tview.NewForm()
|
||||
form.
|
||||
AddInputField("Name", name, 40, nil, func(text string) { name = text }).
|
||||
AddInputField("Name", name, 32, nil, func(text string) { name = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||
AddPasswordField("Key", key, 60, '*', func(text string) { key = text }).
|
||||
AddPasswordField("Key", key, 32, '*', func(text string) { key = text }).
|
||||
AddButton("Save", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
@@ -155,7 +230,20 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
|
||||
a.hideModal("user-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).SetTitle(title)
|
||||
form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
|
||||
form.SetBorderColor(tcell.ColorDarkCyan)
|
||||
form.SetFieldBackgroundColor(tcell.ColorBlack)
|
||||
form.SetFieldTextColor(tcell.ColorWhite)
|
||||
form.SetLabelColor(tcell.ColorAqua)
|
||||
form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
|
||||
form.SetButtonTextColor(tcell.ColorWhite)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("user-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("user-form", centeredForm(form, 68, 13))
|
||||
a.showModal("user-form", centeredForm(form, 6, 13))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user