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>
238 lines
5.2 KiB
Go
238 lines
5.2 KiB
Go
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
|
|
}
|