// Package picker is dial's interactive host list. Per the brief it is a static, // numbered, high-contrast list — NOT a fuzzy-filter-as-you-type UI. The resting // state is always a clean, well-spaced, readable list; a search/filter mode is // available on demand via "/" but is never the default view. // // The picker only *selects* a host. Connecting is the caller's job (see package // connect), keeping selection decoupled from what's done with the chosen host. package picker import ( "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "gitea.apointless.space/bsncubed/dial/internal/sshconf" "gitea.apointless.space/bsncubed/dial/internal/store" "gitea.apointless.space/bsncubed/dial/internal/theme" ) // Action is what the user chose to do with the selected host. The type leaves // room for additional actions to slot in without reworking the result plumbing. type Action int const ( ActionNone Action = iota ActionConnect ) // Result reports the outcome of a picker session. type Result struct { Alias string // selected host alias; empty if the user quit Action Action } // row is a host enriched with its usage stats and favourite flag. type row struct { host sshconf.Host count int last time.Time fav bool } type model struct { th theme.Theme rows []row // immutable set of all hosts sortM store.SortMode sorted []int // indices into rows, in sorted order (favourites first) view []int // indices into rows currently shown (sorted, filtered, grouped) grouped bool // section the list by tag headers map[int]sectionHead // view position -> section header shown before it cursor int // position within view (a host row) top int // scroll offset, in rendered lines // winFirst/winLast are the first/last host positions visible after the last // render, used for the "showing X–Y of N" header hint. winFirst, winLast int filtering bool query string width, height int status string // transient one-line message result Result quitting bool } // sectionHead labels a group section in grouped mode. tag is the colour key // (empty for the Favourites/untagged sections, which render neutral). type sectionHead struct { title string tag string } // Run displays the picker and blocks until the user connects or quits. It // persists sort-mode and favourite changes via the store as they happen. The // returned Result carries the chosen alias (empty if quit). func Run(hosts []sshconf.Host, data store.Data, settings store.Settings) (Result, error) { m, _ := buildModel(hosts, data, settings) p := tea.NewProgram(m, tea.WithAltScreen()) fm, err := p.Run() if err != nil { return Result{}, err } return fm.(*model).result, nil } // buildModel constructs an initialised model (rows enriched, sorted, filtered) // without starting a tea program, so tests can render it directly. func buildModel(hosts []sshconf.Host, data store.Data, settings store.Settings) (*model, error) { fav := map[string]bool{} for _, f := range data.Favorites { fav[f] = true } rows := make([]row, len(hosts)) for i, h := range hosts { r := row{host: h, fav: fav[h.Alias]} if st := data.Stats[h.Alias]; st != nil { r.count = st.Count r.last = st.LastUsed } rows[i] = r } m := &model{ th: theme.New(settings.Theme), rows: rows, sortM: settings.Sort, grouped: settings.GroupByTag, } m.sortAll() m.applyFilter() return m, nil } func (m *model) Init() tea.Cmd { return nil } // sortAll rebuilds m.sorted: favourites first, then by the active sort mode, // ties broken by alias for stable, predictable ordering. func (m *model) sortAll() { idx := make([]int, len(m.rows)) for i := range idx { idx[i] = i } less := func(a, b int) bool { ra, rb := m.rows[a], m.rows[b] if ra.fav != rb.fav { return ra.fav // favourites float to the top } switch m.sortM { case store.SortFrequent: if ra.count != rb.count { return ra.count > rb.count } case store.SortAlpha: // Alias-only ordering; handled by the shared tiebreaker below. default: // SortRecent if !ra.last.Equal(rb.last) { return ra.last.After(rb.last) } } return strings.ToLower(ra.host.Alias) < strings.ToLower(rb.host.Alias) } sort.SliceStable(idx, func(i, j int) bool { return less(idx[i], idx[j]) }) m.sorted = idx } // applyFilter recomputes m.view from m.sorted using the current query. An empty // query shows everything (the resting state). Matching is a case-insensitive // substring over alias, hostname, user, and tags — predictable, not fuzzy. func (m *model) applyFilter() { q := strings.ToLower(strings.TrimSpace(m.query)) filtered := make([]int, 0, len(m.sorted)) for _, i := range m.sorted { if q == "" || rowMatches(m.rows[i], q) { filtered = append(filtered, i) } } if m.grouped { m.view, m.headers = m.regroup(filtered) } else { m.view, m.headers = filtered, nil } if m.cursor >= len(m.view) { m.cursor = len(m.view) - 1 } if m.cursor < 0 { m.cursor = 0 } m.ensureVisible() } // regroup reorders the filtered hosts into sections: a Favourites section // first (if any), then one section per tag (tags sorted alphabetically), then // an "untagged" section. A host is placed under its first tag only, so the view // stays a clean permutation with no duplicated rows. Within each section, the // active sort order is preserved (favourites are pulled out, so their tag // sections don't repeat them). func (m *model) regroup(filtered []int) ([]int, map[int]sectionHead) { var favs, untagged []int buckets := map[string][]int{} var tagOrder []string seenTag := map[string]bool{} for _, vi := range filtered { r := m.rows[vi] if r.fav { favs = append(favs, vi) continue } if len(r.host.Tags) == 0 { untagged = append(untagged, vi) continue } t := r.host.Tags[0] if !seenTag[t] { seenTag[t] = true tagOrder = append(tagOrder, t) } buckets[t] = append(buckets[t], vi) } sort.Strings(tagOrder) view := make([]int, 0, len(filtered)) headers := map[int]sectionHead{} addSection := func(h sectionHead, hosts []int) { if len(hosts) == 0 { return } headers[len(view)] = h view = append(view, hosts...) } addSection(sectionHead{title: "★ favourites"}, favs) for _, t := range tagOrder { addSection(sectionHead{title: t, tag: t}, buckets[t]) } addSection(sectionHead{title: "untagged"}, untagged) return view, headers } func rowMatches(r row, q string) bool { h := r.host if strings.Contains(strings.ToLower(h.Alias), q) || strings.Contains(strings.ToLower(h.HostName), q) || strings.Contains(strings.ToLower(h.User), q) { return true } for _, t := range h.Tags { if strings.Contains(strings.ToLower(t), q) { return true } } return false }