8af44dfcd2
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>
311 lines
7.8 KiB
Go
311 lines
7.8 KiB
Go
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))
|
||
}
|