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
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package picker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
|
||||
"gitea.apointless.space/bsncubed/dial/internal/store"
|
||||
)
|
||||
|
||||
func sampleModel() *model {
|
||||
hosts := []sshconf.Host{
|
||||
{Alias: "web1", HostName: "10.0.0.5", User: "deploy", Port: "2222", ProxyJump: "bastion", Tags: []string{"prod", "riedel"}},
|
||||
{Alias: "db-primary", HostName: "db.internal", User: "postgres", Port: "22", Tags: []string{"prod"}},
|
||||
{Alias: "bastion", HostName: "bastion.example.com", User: "admin"},
|
||||
{Alias: "staging-web", HostName: "10.1.0.9", User: "deploy", Tags: []string{"staging"}},
|
||||
}
|
||||
data := store.DefaultData()
|
||||
data.Stats["web1"] = &store.HostStat{Count: 9, LastUsed: time.Now()}
|
||||
data.Stats["bastion"] = &store.HostStat{Count: 3, LastUsed: time.Now().Add(-time.Hour)}
|
||||
data.Favorites = []string{"bastion"}
|
||||
|
||||
settings := store.DefaultSettings()
|
||||
|
||||
m := newModelForTest(hosts, data, settings)
|
||||
m.width, m.height = 90, 30
|
||||
m.ensureVisible()
|
||||
return m
|
||||
}
|
||||
|
||||
// newModelForTest mirrors the setup Run does, without starting a tea program.
|
||||
func newModelForTest(hosts []sshconf.Host, data store.Data, settings store.Settings) *model {
|
||||
res, _ := buildModel(hosts, data, settings)
|
||||
return res
|
||||
}
|
||||
|
||||
func TestRenderSnapshot(t *testing.T) {
|
||||
m := sampleModel()
|
||||
out := m.View()
|
||||
if !strings.Contains(out, "dial") {
|
||||
t.Error("header missing")
|
||||
}
|
||||
if !strings.Contains(out, "web1") || !strings.Contains(out, "bastion") {
|
||||
t.Error("hosts missing from render")
|
||||
}
|
||||
// bastion is a favourite, so it should sort to the top of a recent-sort list.
|
||||
t.Logf("\n%s", out)
|
||||
}
|
||||
|
||||
func TestFilterNarrows(t *testing.T) {
|
||||
m := sampleModel()
|
||||
m.query = "staging"
|
||||
m.applyFilter()
|
||||
if len(m.view) != 1 {
|
||||
t.Fatalf("expected 1 match for 'staging', got %d", len(m.view))
|
||||
}
|
||||
if m.rows[m.view[0]].host.Alias != "staging-web" {
|
||||
t.Errorf("wrong match: %s", m.rows[m.view[0]].host.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteSortsFirst(t *testing.T) {
|
||||
m := sampleModel()
|
||||
if len(m.view) == 0 {
|
||||
t.Fatal("no rows")
|
||||
}
|
||||
if !m.rows[m.view[0]].fav {
|
||||
t.Errorf("favourite should be first, got %s", m.rows[m.view[0]].host.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlphaSort(t *testing.T) {
|
||||
m := sampleModel()
|
||||
m.sortM = store.SortAlpha
|
||||
m.sortAll()
|
||||
m.applyFilter()
|
||||
// Favourite (bastion) still pins first; the rest are alphabetical.
|
||||
got := []string{}
|
||||
for _, vi := range m.view {
|
||||
got = append(got, m.rows[vi].host.Alias)
|
||||
}
|
||||
want := []string{"bastion", "db-primary", "staging-web", "web1"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("alpha order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortCycle(t *testing.T) {
|
||||
s := store.SortRecent
|
||||
seq := []store.SortMode{}
|
||||
for i := 0; i < 3; i++ {
|
||||
s = s.Next()
|
||||
seq = append(seq, s)
|
||||
}
|
||||
want := []store.SortMode{store.SortFrequent, store.SortAlpha, store.SortRecent}
|
||||
for i := range want {
|
||||
if seq[i] != want[i] {
|
||||
t.Errorf("cycle[%d] = %s, want %s", i, seq[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrouping(t *testing.T) {
|
||||
m := sampleModel()
|
||||
m.grouped = true
|
||||
m.applyFilter()
|
||||
|
||||
// Expect a Favourites header first, then tag sections, then no untagged
|
||||
// (bastion, the only untagged host, is a favourite).
|
||||
if h, ok := m.headers[0]; !ok || !strings.Contains(h.title, "favourites") {
|
||||
t.Fatalf("first section should be favourites, got %+v", m.headers[0])
|
||||
}
|
||||
// bastion (fav) appears once, only in the favourites section.
|
||||
count := 0
|
||||
for _, vi := range m.view {
|
||||
if m.rows[vi].host.Alias == "bastion" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("favourite host should appear exactly once, got %d", count)
|
||||
}
|
||||
// Every non-header host is reachable; view length equals host count (no dupes).
|
||||
if len(m.view) != len(m.rows) {
|
||||
t.Errorf("grouped view should contain each host once: got %d, want %d", len(m.view), len(m.rows))
|
||||
}
|
||||
// A tag section header should carry the tag as its colour key.
|
||||
foundProd := false
|
||||
for _, h := range m.headers {
|
||||
if h.title == "prod" && h.tag == "prod" {
|
||||
foundProd = true
|
||||
}
|
||||
}
|
||||
if !foundProd {
|
||||
t.Error("expected a 'prod' tag section header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOverflow(t *testing.T) {
|
||||
for _, grouped := range []bool{false, true} {
|
||||
for _, w := range []int{40, 60, 90, 140} {
|
||||
for _, h := range []int{18, 24, 40} {
|
||||
m := sampleModel()
|
||||
m.grouped = grouped
|
||||
m.applyFilter()
|
||||
m.width, m.height = w, h
|
||||
out := m.View()
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) > h {
|
||||
t.Errorf("grouped=%v %dx%d: %d lines > height", grouped, w, h, len(lines))
|
||||
}
|
||||
for _, ln := range lines {
|
||||
if lw := lipgloss.Width(ln); lw > w {
|
||||
t.Errorf("grouped=%v %dx%d: line %d cols > width", grouped, w, h, lw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user