Files
dial/internal/picker/picker.go
T
bsncubed 8af44dfcd2 Initial commit: dial — interactive SSH host picker
A fast, read-only ~/.ssh/config picker that exec's the system ssh client.
Includes: static high-contrast picker with recency/frequency/name sort and
tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and
an optional offline YubiKey launch gate with one-time recovery codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:18:23 +10:00

241 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 XY 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
}