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:
2026-07-22 23:18:23 +10:00
commit 8af44dfcd2
31 changed files with 4219 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
package picker
import (
tea "github.com/charmbracelet/bubbletea"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// Layout budget (lines) reserved around the scrolling list.
const (
headerLines = 2 // title + blank spacer
footerLines = 1 // key hints
previewLines = 8 // bordered preview box (2 border + 6 fields)
linesPerRow = 3 // alias line + detail line + blank spacer
)
// visibleListLines returns how many rendered lines the scrolling list may
// occupy, reserving space for the header, preview box, and footer.
func (m *model) visibleListLines() int {
reserved := headerLines + previewLines + footerLines + 1 // +1 slack for separators
if m.filtering {
reserved++ // filter input line
}
n := m.height - reserved
if n < linesPerRow {
return linesPerRow // always show at least one host block
}
return n
}
// visibleCount estimates how many host rows fit on screen, used only as the
// page size for PgUp/PgDn.
func (m *model) visibleCount() int {
n := m.visibleListLines() / linesPerRow
if n < 1 {
return 1
}
return n
}
// ensureVisible clamps the cursor to a valid host position. The actual line
// scrolling (m.top) is computed during render, where header/host line heights
// are known.
func (m *model) ensureVisible() {
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ensureVisible()
return m, nil
case tea.KeyMsg:
if m.filtering {
return m.updateFiltering(msg)
}
return m.updateNormal(msg)
}
return m, nil
}
// updateNormal handles keys in the resting (static list) state.
func (m *model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = ""
switch msg.String() {
case "ctrl+c", "q", "esc":
m.quitting = true
return m, tea.Quit
case "up", "k", "ctrl+p":
m.move(-1)
case "down", "j", "ctrl+n":
m.move(1)
case "home":
m.cursor = 0
m.ensureVisible()
case "end", "G":
m.cursor = len(m.view) - 1
m.ensureVisible()
case "pgup":
m.move(-m.visibleCount())
case "pgdown":
m.move(m.visibleCount())
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
// Jump the cursor to the row bearing that number (1-based, absolute).
n := int(msg.String()[0] - '0')
if n >= 1 && n <= len(m.view) {
m.cursor = n - 1
m.ensureVisible()
}
case "/":
m.filtering = true
m.query = ""
return m, nil
case "s":
// Cycle sort mode (recent → frequent → alpha) and persist the preference.
m.sortM = m.sortM.Next()
m.persistSort()
m.reindex()
case "g":
// Toggle tag grouping (orthogonal to sort) and persist.
m.grouped = !m.grouped
m.persistGroup()
if m.grouped {
m.status = "grouped by tag"
} else {
m.status = "grouping off"
}
m.reindex()
case "f":
m.toggleFavorite()
case "enter":
return m.selectWith(ActionConnect)
}
return m, nil
}
// updateFiltering handles keys while the on-demand search box is open.
func (m *model) updateFiltering(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "ctrl+c":
// Leave filter mode and return to the clean, static resting list.
m.filtering = false
m.query = ""
m.applyFilter()
return m, nil
case "enter":
return m.selectWith(ActionConnect)
case "up", "ctrl+p":
m.move(-1)
return m, nil
case "down", "ctrl+n":
m.move(1)
return m, nil
case "backspace":
if len(m.query) > 0 {
// Trim one rune from the query.
r := []rune(m.query)
m.query = string(r[:len(r)-1])
m.applyFilter()
}
return m, nil
default:
if msg.Type == tea.KeyRunes {
m.query += string(msg.Runes)
m.applyFilter()
}
return m, nil
}
}
func (m *model) move(delta int) {
if len(m.view) == 0 {
return
}
m.cursor += delta
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
m.ensureVisible()
}
// reindex re-sorts and re-filters while keeping the highlighted host under the
// cursor where possible.
func (m *model) reindex() {
var keepAlias string
if m.cursor >= 0 && m.cursor < len(m.view) {
keepAlias = m.rows[m.view[m.cursor]].host.Alias
}
m.sortAll()
m.applyFilter()
if keepAlias != "" {
for i, vi := range m.view {
if m.rows[vi].host.Alias == keepAlias {
m.cursor = i
break
}
}
}
m.ensureVisible()
}
func (m *model) selectWith(action Action) (tea.Model, tea.Cmd) {
if m.cursor < 0 || m.cursor >= len(m.view) {
return m, nil
}
m.result = Result{Alias: m.rows[m.view[m.cursor]].host.Alias, Action: action}
m.quitting = true
return m, tea.Quit
}
func (m *model) toggleFavorite() {
if m.cursor < 0 || m.cursor >= len(m.view) {
return
}
ri := m.view[m.cursor]
alias := m.rows[ri].host.Alias
on, err := store.ToggleFavorite(alias)
if err != nil {
m.status = "could not save favourite: " + err.Error()
return
}
m.rows[ri].fav = on
if on {
m.status = "★ favourited " + alias
} else {
m.status = "removed favourite " + alias
}
m.reindex()
}
func (m *model) persistSort() {
s := store.LoadSettings()
s.Sort = m.sortM
_ = store.SaveSettings(s) // non-fatal: preference only
}
func (m *model) persistGroup() {
s := store.LoadSettings()
s.GroupByTag = m.grouped
_ = store.SaveSettings(s) // non-fatal: preference only
}