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,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
|
||||
}
|
||||
Reference in New Issue
Block a user