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() }