Files
dial/internal/picker/view.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

311 lines
7.8 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
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))
}