190 lines
4.8 KiB
Go
190 lines
4.8 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)
|
|
position := search.Position()
|
|
search.SetValue(profile.ObscureURLForEditing(search.Value()))
|
|
search.SetCursor(position)
|
|
|
|
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(protectRenderedText(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()
|
|
}
|