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>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user