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
+310
View File
@@ -0,0 +1,310 @@
package picker
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
func (m *model) View() string {
if m.quitting {
return ""
}
// renderList must run first: it computes the scroll window and sets
// winFirst/winLast, which the header's "showing" hint reads.
list := m.renderList()
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n\n")
b.WriteString(list)
b.WriteString("\n")
b.WriteString(m.renderPreview())
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
func (m *model) renderHeader() string {
th := m.th
sortName := "recently used"
switch m.sortM {
case store.SortFrequent:
sortName = "most used"
case store.SortAlpha:
sortName = "name"
}
left := th.Title.Render("dial") + th.Meta.Render(
fmt.Sprintf(" %d hosts · sorted by %s", len(m.rows), sortName))
if m.grouped {
left += th.Meta.Render(" · grouped")
}
// Orientation hint when the list is scrolled/windowed.
if m.winFirst >= 0 && (m.winFirst > 0 || m.winLast < len(m.view)-1) {
left += th.Meta.Render(fmt.Sprintf(" · showing %d%d of %d",
m.winFirst+1, m.winLast+1, len(m.view)))
}
if m.status != "" {
left += " " + th.Star.Render(m.status)
}
return m.truncate(" " + left)
}
// renderList builds the whole scrollable body as lines (section headers, host
// blocks, and spacers), then windows those lines so the cursor's host — and its
// section header, if any — stays visible. Line-based windowing lets grouped and
// flat modes share one code path and keeps the preview/footer from being pushed
// off-screen regardless of how many headers are on screen.
func (m *model) renderList() string {
th := m.th
m.winFirst, m.winLast = -1, -1
if len(m.view) == 0 {
msg := "no hosts in ~/.ssh/config"
if m.query != "" {
msg = "no hosts match \"" + m.query + "\""
}
return " " + th.Meta.Render(msg)
}
var lines []string
hostLine := make([]int, len(m.view)) // index of a host's first content line
headerLine := make([]int, len(m.view)) // index of the header line before it, or -1
for i := range m.view {
headerLine[i] = -1
if m.grouped {
if h, ok := m.headers[i]; ok {
if len(lines) > 0 {
lines = append(lines, "") // gap before a new section
}
headerLine[i] = len(lines)
lines = append(lines, m.truncate(m.renderSectionHeader(h)), "")
}
}
hostLine[i] = len(lines)
l1, l2 := m.renderHostLines(i)
lines = append(lines, m.truncate(l1), m.truncate(l2), "")
}
// Scroll so the cursor block (plus its header, if any) stays visible: start
// from the previous offset and nudge up or down only as needed.
budget := m.visibleListLines()
top := m.top
blockTop := hostLine[m.cursor]
if headerLine[m.cursor] >= 0 {
blockTop = headerLine[m.cursor]
}
if blockTop < top {
top = blockTop // scroll up to reveal the cursor (and its header)
}
blockBottom := hostLine[m.cursor] + 1 // the detail line
if blockBottom >= top+budget {
top = blockBottom - budget + 1 // scroll down to reveal the cursor
}
if maxTop := len(lines) - budget; top > maxTop {
top = maxTop
}
if top < 0 {
top = 0
}
m.top = top
end := top + budget
if end > len(lines) {
end = len(lines)
}
for i := range m.view {
if hostLine[i] >= top && hostLine[i] < end {
if m.winFirst == -1 {
m.winFirst = i
}
m.winLast = i
}
}
return strings.Join(lines[top:end], "\n")
}
// renderHostLines returns the two rendered lines for the host at view position i.
func (m *model) renderHostLines(i int) (string, string) {
th := m.th
r := m.rows[m.view[i]]
selected := i == m.cursor
marker := " "
if selected {
marker = th.Cursor.Render("▸ ")
}
numStr := th.Number.Render(fmt.Sprintf("%2d", i+1))
if selected {
numStr = th.NumberSel.Render(fmt.Sprintf("%2d", i+1))
}
star := " "
if r.fav {
star = th.Star.Render("★ ")
}
alias := th.Alias.Render(r.host.Alias)
if selected {
alias = th.AliasSel.Render(r.host.Alias)
}
line1 := fmt.Sprintf("%s%s %s%s", marker, numStr, star, alias)
if len(r.host.Tags) > 0 {
line1 += " " + m.renderPills(r.host.Tags)
}
dstyle := th.Detail
if selected {
dstyle = th.DetailSel
}
return line1, " " + dstyle.Render(summary(r.host))
}
// renderPills renders each tag in its own deterministic colour.
func (m *model) renderPills(tags []string) string {
parts := make([]string, len(tags))
for i, t := range tags {
parts[i] = m.th.TagStyle(t).Render(t)
}
return strings.Join(parts, " ")
}
// renderSectionHeader renders a grouped-mode section label, coloured to match
// the tag's pill (neutral for the Favourites/untagged sections).
func (m *model) renderSectionHeader(h sectionHead) string {
return " " + m.th.TagStyle(h.tag).Bold(true).Render("▊ "+h.title)
}
// summary builds a compact "user@hostname:port (via jump)" line for the list.
func summary(h sshconf.Host) string {
target := h.HostName
if target == "" {
target = h.Alias
}
s := target
if h.User != "" {
s = h.User + "@" + target
}
if h.Port != "" && h.Port != "22" {
s += ":" + h.Port
}
if h.ProxyJump != "" {
s += " via " + h.ProxyJump
}
return s
}
func (m *model) renderPreview() string {
th := m.th
if m.cursor < 0 || m.cursor >= len(m.view) {
return th.Preview.Render(th.Meta.Render("no selection"))
}
r := m.rows[m.view[m.cursor]]
h := r.host
rowsKV := [][2]string{
{"Host", h.Alias},
{"HostName", orDash(h.HostName)},
{"User", orDash(h.User)},
{"Port", orDash(defaultPort(h.Port))},
{"ProxyJump", orDash(h.ProxyJump)},
{"Last used", humanizeSince(r.last)},
}
var lines []string
for _, kv := range rowsKV {
lines = append(lines, fmt.Sprintf("%s %s",
th.PreviewK.Render(pad(kv[0], 10)), th.PreviewV.Render(kv[1])))
}
box := th.Preview.Width(m.previewWidth()).Render(strings.Join(lines, "\n"))
return box
}
func (m *model) previewWidth() int {
// Subtract the box border so the whole box fits the terminal.
w := m.width - 6
if w > 100 {
w = 100 // don't stretch the box uncomfortably wide
}
if w < 10 {
w = 10
}
return w
}
func (m *model) renderFooter() string {
th := m.th
if m.filtering {
bar := th.FilterBar.Render("/ " + m.query + "▌")
hint := th.Help.Render(" type to filter · enter connect · esc cancel")
return m.truncate(" " + bar + hint)
}
hints := []string{
"↑↓ move", "enter connect", "/ search", "s sort", "g group", "f fav", "q quit",
}
return m.truncate(" " + th.Help.Render(strings.Join(hints, " · ")))
}
// truncate clamps a rendered line to the terminal width to avoid wrapping,
// which would break the fixed 2-lines-per-row layout.
func (m *model) truncate(s string) string {
if m.width <= 0 {
return s
}
return lipgloss.NewStyle().MaxWidth(m.width).Render(s)
}
// humanizeSince renders a connection time as a compact relative phrase for the
// preview pane. A zero time means the host has never been connected to.
func humanizeSince(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < 0:
return "just now"
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d.Minutes()), "minute")
case d < 24*time.Hour:
return plural(int(d.Hours()), "hour")
case d < 30*24*time.Hour:
return plural(int(d.Hours()/24), "day")
case d < 365*24*time.Hour:
return plural(int(d.Hours()/(24*30)), "month")
default:
return plural(int(d.Hours()/(24*365)), "year")
}
}
func plural(n int, unit string) string {
if n <= 1 {
return fmt.Sprintf("%d %s ago", n, unit)
}
return fmt.Sprintf("%d %ss ago", n, unit)
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
func defaultPort(p string) string {
if p == "" {
return "22"
}
return p
}
func pad(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}