Files
proxy/internal/tui/profile_picker.go
T

187 lines
4.7 KiB
Go

package tui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
// profilePickerState is the dashboard's routine Active Profile switching
// component. Bubbles owns text editing and viewport clipping; this type only
// supplies the product ordering and identity-based selection rules.
type profilePickerState struct {
open bool
search textinput.Model
selectedName string
}
func newProfilePickerState() profilePickerState {
search := textinput.New()
search.Prompt = "Search: "
search.Placeholder = "type to filter by name"
search.CharLimit = 256
return profilePickerState{search: search}
}
func (p *profilePickerState) show(snapshot app.Snapshot) tea.Cmd {
p.open = true
p.search.SetValue("")
p.search.CursorEnd()
p.selectedName = ""
p.retainSelection(snapshot)
return p.search.Focus()
}
func (p *profilePickerState) close() {
p.open = false
p.search.Blur()
}
func (p *profilePickerState) update(key tea.KeyMsg, snapshot app.Snapshot) tea.Cmd {
switch key.String() {
case "up", "ctrl+p":
p.move(snapshot, -1)
return nil
case "down", "ctrl+n":
p.move(snapshot, 1)
return nil
case "pgup":
p.move(snapshot, -8)
return nil
case "pgdown":
p.move(snapshot, 8)
return nil
}
previous := p.search.Value()
var command tea.Cmd
p.search, command = p.search.Update(key)
if p.search.Value() != previous {
p.retainSelection(snapshot)
}
return command
}
func (p *profilePickerState) move(snapshot app.Snapshot, delta int) {
items := p.filtered(snapshot)
if len(items) == 0 {
p.selectedName = ""
return
}
index := profileIndex(items, p.selectedName)
if index < 0 {
index = 0
}
index = max(0, min(len(items)-1, index+delta))
p.selectedName = items[index].Name
}
func (p *profilePickerState) retainSelection(snapshot app.Snapshot) {
items := p.filtered(snapshot)
if profileIndex(items, p.selectedName) >= 0 {
return
}
p.selectedName = ""
if len(items) > 0 {
p.selectedName = items[0].Name
}
}
func (p profilePickerState) selected(snapshot app.Snapshot) (profile.Profile, bool) {
items := p.filtered(snapshot)
index := profileIndex(items, p.selectedName)
if index < 0 {
return profile.Profile{}, false
}
return items[index], true
}
func (p profilePickerState) filtered(snapshot app.Snapshot) []profile.Profile {
ordered := orderedProfiles(snapshot)
query := strings.ToLower(strings.TrimSpace(p.search.Value()))
if query == "" {
return ordered
}
filtered := make([]profile.Profile, 0, len(ordered))
for _, item := range ordered {
if strings.Contains(strings.ToLower(item.Name), query) {
filtered = append(filtered, item)
}
}
return filtered
}
func orderedProfiles(snapshot app.Snapshot) []profile.Profile {
items := append([]profile.Profile(nil), snapshot.Profiles...)
sort.SliceStable(items, func(i, j int) bool {
left, right := strings.ToLower(items[i].Name), strings.ToLower(items[j].Name)
if left == right {
return items[i].Name < items[j].Name
}
return left < right
})
activeIndex := profileIndex(items, snapshot.ActiveProfile)
if activeIndex <= 0 {
return items
}
active := items[activeIndex]
copy(items[1:activeIndex+1], items[:activeIndex])
items[0] = active
return items
}
func profileIndex(items []profile.Profile, name string) int {
for i := range items {
if items[i].Name == name {
return i
}
}
return -1
}
func (p profilePickerState) view(snapshot app.Snapshot, width, height int) string {
search := p.search
search.Width = max(12, width-len(search.Prompt)-4)
items := p.filtered(snapshot)
lines := make([]string, 0, len(items))
selectedIndex := profileIndex(items, p.selectedName)
for _, item := range items {
cursor := " "
if item.Name == p.selectedName {
cursor = "> "
}
active := ""
if item.Name == snapshot.ActiveProfile {
active = "★ "
}
line := cursor + active + item.Name
if item.Name == snapshot.ActiveProfile {
line += " — Active Profile"
}
lines = append(lines, line)
}
if len(lines) == 0 {
selectedIndex = 0
if len(snapshot.Profiles) == 0 {
lines = append(lines, "No Proxy Profiles are available.", "Press Escape, then open Profile management to create one.")
} else {
lines = append(lines, fmt.Sprintf("No Proxy Profiles match %q.", p.search.Value()), "Edit the search or press Escape to cancel.")
}
}
listHeight := max(2, height-5)
list := viewport.New(max(1, width), listHeight)
list.SetContent(strings.Join(lines, "\n"))
if selectedIndex >= listHeight {
list.SetYOffset(selectedIndex - listHeight + 1)
}
return "Switch Active Profile\n" + search.View() + "\n" + paneRule(width) + "\n" + list.View()
}