Files

845 lines
19 KiB
Go

// Package ui contains limen's Bubble Tea models.
package ui
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/KofTwentyTwo/limen/internal/config"
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
"github.com/KofTwentyTwo/limen/internal/format"
"github.com/KofTwentyTwo/limen/internal/probe"
"github.com/KofTwentyTwo/limen/internal/state"
"github.com/KofTwentyTwo/limen/internal/ui/theme"
)
const (
defaultWidth = 100
defaultHeight = 30
tinyTerminalMessage = "terminal too small for limen UI; falling back to bare tmux new"
)
type stage int
const (
stageHosts stage = iota
stageSessions
stageNewSession
)
// App is the data needed to start the TUI.
type App struct {
Config config.Config
State state.State
ProbeResults <-chan probe.ProbeResult
Now func() time.Time
Hostname string
}
// Result is the selected command plan or quit outcome from the TUI.
type Result struct {
Plan limenexec.Plan
HasPlan bool
ExitCode int
Message string
}
// Run starts the Bubble Tea program and returns the final user decision.
func Run(app App) (Result, error) {
program := tea.NewProgram(NewModel(app), tea.WithAltScreen())
finalModel, err := program.Run()
if err != nil {
return Result{ExitCode: 1}, err
}
model, ok := finalModel.(Model)
if !ok {
return Result{ExitCode: 1}, fmt.Errorf("unexpected final model %T", finalModel)
}
return model.Result(), nil
}
// Model is the root Bubble Tea model for both picker stages.
type Model struct {
stage stage
width int
height int
hosts []hostEntry
hostCursor int
sessionCursor int
filter string
filtering bool
help bool
newName string
inputError string
probeResults <-chan probe.ProbeResult
now func() time.Time
result Result
}
type hostEntry struct {
Name string
DisplayName string
Connection string
Description string
Remote *config.Host
Probing bool
Reachable bool
Sessions []probe.SessionInfo
LastAttached time.Time
}
type sessionRow struct {
New bool
Session probe.SessionInfo
}
type probeMsg probe.ProbeResult
type probesDoneMsg struct{}
// NewModel constructs the initial root model.
func NewModel(app App) Model {
now := app.Now
if now == nil {
now = time.Now
}
hostname := app.Hostname
if hostname == "" {
if local, err := os.Hostname(); err == nil {
hostname = local
}
}
hosts := make([]hostEntry, 0, len(app.Config.Hosts)+1)
hosts = append(hosts, hostEntry{
Name: probe.LocalhostName,
DisplayName: "localhost (" + shortHostname(hostname) + ")",
Connection: "localhost",
Probing: true,
LastAttached: app.State.LastAttached[probe.LocalhostName],
})
for _, host := range app.Config.Hosts {
hostCopy := host
hosts = append(hosts, hostEntry{
Name: host.Name,
DisplayName: host.Name,
Connection: connectionLine(host),
Description: host.Description,
Remote: &hostCopy,
Probing: true,
LastAttached: app.State.LastAttached[host.Name],
})
}
sort.SliceStable(hosts, func(i int, j int) bool {
return labelLess(hosts[i].DisplayName, hosts[j].DisplayName)
})
probeResults := app.ProbeResults
if probeResults == nil {
probeResults = probe.Start(contextBackground(), app.Config.Hosts, probe.Options{})
}
return Model{
width: defaultWidth,
height: defaultHeight,
hosts: hosts,
probeResults: probeResults,
now: now,
}
}
func contextBackground() context.Context {
return context.Background()
}
// Init starts listening for probe results.
func (m Model) Init() tea.Cmd {
return waitForProbe(m.probeResults)
}
// Update applies keyboard, resize, and probe messages.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if msg.Width < 50 || msg.Height < 15 {
m.acceptPlan(mustPlan(limenexec.LocalNew("")))
m.result.Message = tinyTerminalMessage
return m, tea.Quit
}
return m, nil
case probeMsg:
m.applyProbeResult(probe.ProbeResult(msg))
m.clampCursor()
return m, waitForProbe(m.probeResults)
case probesDoneMsg:
return m, nil
case tea.KeyMsg:
return m.updateKey(msg)
default:
return m, nil
}
}
// View renders the active picker stage.
func (m Model) View() string {
if m.width < 50 || m.height < 15 {
return tinyTerminalMessage
}
var body string
if m.width < 80 || m.height < 24 {
body = m.singleColumnView()
} else {
body = m.splitView()
}
if m.help {
body = body + "\n\n" + m.helpView()
}
return body + "\n\n" + m.footer()
}
// Result returns the final user decision.
func (m Model) Result() Result {
return m.result
}
func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.help {
switch msg.String() {
case "?", "esc":
m.help = false
return m, nil
}
}
if m.filtering {
return m.updateFilterKey(msg)
}
if msg.Type == tea.KeyRunes && m.stage != stageNewSession && startsTypeahead(msg.String()) {
m.filtering = true
m.filter = string(msg.Runes)
m.clampCursor()
return m, nil
}
switch msg.String() {
case "ctrl+c":
m.result.ExitCode = 130
return m, tea.Quit
case "q":
m.result.ExitCode = 0
return m, tea.Quit
case "?":
m.help = !m.help
return m, nil
case "/":
if m.stage != stageNewSession {
m.filtering = true
m.filter = ""
}
return m, nil
case "tab":
if m.stage != stageNewSession {
m.filtering = true
m.completeFilter()
}
return m, nil
case "up", "k":
m.moveCursor(-1)
return m, nil
case "down", "j":
m.moveCursor(1)
return m, nil
case "home", "g":
m.setCursor(0)
return m, nil
case "end", "G":
m.setCursor(m.entryCount() - 1)
return m, nil
case "esc":
return m.escape()
case "enter":
return m.enter()
case "backspace":
if m.stage == stageNewSession && len(m.newName) > 0 {
m.newName = m.newName[:len(m.newName)-1]
m.inputError = ""
}
return m, nil
}
if msg.Type == tea.KeyRunes && m.stage == stageNewSession {
m.newName += string(msg.Runes)
m.inputError = ""
}
return m, nil
}
func (m Model) updateFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.filtering = false
m.filter = ""
m.clampCursor()
return m, nil
case "enter":
m.filtering = false
return m.enter()
case "tab":
m.completeFilter()
return m, nil
case "up":
m.moveCursor(-1)
return m, nil
case "down":
m.moveCursor(1)
return m, nil
case "home":
m.setCursor(0)
return m, nil
case "end":
m.setCursor(m.entryCount() - 1)
return m, nil
case "backspace":
if len(m.filter) > 0 {
m.filter = m.filter[:len(m.filter)-1]
m.clampCursor()
}
return m, nil
}
if msg.Type == tea.KeyRunes {
m.filter += string(msg.Runes)
m.clampCursor()
}
return m, nil
}
func (m Model) enter() (tea.Model, tea.Cmd) {
switch m.stage {
case stageHosts:
indexes := m.filteredHostIndexes()
if len(indexes) == 0 {
return m, nil
}
m.hostCursor = indexes[m.hostCursor]
m.stage = stageSessions
m.sessionCursor = 0
m.filter = ""
m.filtering = false
return m, nil
case stageSessions:
rows := m.filteredSessionRows()
if len(rows) == 0 {
return m, nil
}
row := rows[m.sessionCursor]
if row.New {
m.stage = stageNewSession
m.newName = ""
m.inputError = ""
m.filter = ""
m.filtering = false
return m, nil
}
host := m.selectedHost()
session := row.Session
if host.Remote == nil {
m.acceptPlan(limenexec.LocalAttach(session.Name))
} else {
m.acceptPlan(limenexec.RemoteAttach(*host.Remote, session.Name))
}
return m, tea.Quit
case stageNewSession:
host := m.selectedHost()
var plan limenexec.Plan
var err error
if host.Remote == nil {
plan, err = limenexec.LocalNew(m.newName)
} else {
plan, err = limenexec.RemoteNew(*host.Remote, m.newName)
}
if err != nil {
m.inputError = err.Error()
return m, nil
}
m.acceptPlan(plan)
return m, tea.Quit
default:
return m, nil
}
}
func (m Model) escape() (tea.Model, tea.Cmd) {
if m.stage == stageHosts {
m.acceptPlan(mustPlan(limenexec.LocalNew("")))
return m, tea.Quit
}
host := m.selectedHost()
var plan limenexec.Plan
if host.Remote == nil {
plan = mustPlan(limenexec.LocalNew(""))
} else {
plan = mustPlan(limenexec.RemoteNew(*host.Remote, ""))
}
m.acceptPlan(plan)
return m, tea.Quit
}
func (m *Model) acceptPlan(plan limenexec.Plan) {
m.result = Result{Plan: plan, HasPlan: true, ExitCode: 0}
}
func (m *Model) applyProbeResult(result probe.ProbeResult) {
for i := range m.hosts {
if m.hosts[i].Name != result.HostName {
continue
}
m.hosts[i].Probing = false
m.hosts[i].Reachable = result.Reachable
m.hosts[i].Sessions = result.Sessions
return
}
}
func waitForProbe(results <-chan probe.ProbeResult) tea.Cmd {
return func() tea.Msg {
result, ok := <-results
if !ok {
return probesDoneMsg{}
}
return probeMsg(result)
}
}
func (m Model) splitView() string {
leftWidth := maxInt(30, int(float64(m.width)*0.40))
rightWidth := maxInt(30, m.width-leftWidth-4)
height := maxInt(10, m.height-6)
left := theme.Pane.Width(leftWidth).Height(height).Render(m.listView())
right := theme.Pane.Width(rightWidth).Height(height).Render(m.detailView())
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
}
func (m Model) singleColumnView() string {
return theme.Pane.Width(maxInt(30, m.width-4)).Render(m.listView())
}
func (m Model) listView() string {
var builder strings.Builder
builder.WriteString(theme.Title.Render(m.leftTitle()))
builder.WriteString("\n\n")
switch m.stage {
case stageHosts:
m.writeHostRows(&builder)
case stageSessions, stageNewSession:
m.writeSessionRows(&builder)
}
return builder.String()
}
func (m Model) leftTitle() string {
if m.stage == stageHosts {
return "limen"
}
return m.selectedHost().DisplayName
}
func (m Model) writeHostRows(builder *strings.Builder) {
indexes := m.filteredHostIndexes()
if len(indexes) == 0 {
builder.WriteString(theme.Dimmed.Render("no matching hosts"))
return
}
for row, index := range indexes {
host := m.hosts[index]
cursor := " "
if row == m.hostCursor {
cursor = ">"
}
line := fmt.Sprintf("%s %s %-24s %s", cursor, statusDot(host), host.DisplayName, sessionSummary(host))
if row == m.hostCursor {
line = theme.Selected.Render(line)
}
builder.WriteString(line)
builder.WriteString("\n")
}
}
func (m Model) writeSessionRows(builder *strings.Builder) {
rows := m.filteredSessionRows()
if len(rows) == 0 {
builder.WriteString(theme.Dimmed.Render("no matching sessions"))
return
}
for row, entry := range rows {
cursor := " "
if row == m.sessionCursor {
cursor = ">"
}
line := cursor + " " + sessionRowText(entry)
if row == m.sessionCursor {
line = theme.Selected.Render(line)
}
builder.WriteString(line)
builder.WriteString("\n")
if entry.New && len(rows) > 1 {
builder.WriteString(theme.Dimmed.Render("──────────────────────────────"))
builder.WriteString("\n")
}
}
}
func (m Model) detailView() string {
if m.stage == stageNewSession {
return m.newSessionView()
}
if m.stage == stageSessions {
return m.sessionDetailView()
}
host := m.selectedHost()
var builder strings.Builder
builder.WriteString(theme.Title.Render("DETAILS"))
builder.WriteString("\n\n")
builder.WriteString(host.DisplayName)
builder.WriteString("\n")
builder.WriteString(theme.Dimmed.Render("──────────────────────────────────"))
builder.WriteString("\n")
builder.WriteString(host.Connection)
builder.WriteString("\n\n")
builder.WriteString("Status: ")
builder.WriteString(statusText(host))
builder.WriteString("\n")
builder.WriteString("Sessions: ")
builder.WriteString(sessionDetail(host))
builder.WriteString("\n")
builder.WriteString("Last attached: ")
builder.WriteString(format.RelativeTime(host.LastAttached, m.now()))
if host.Description != "" {
builder.WriteString("\n\n")
builder.WriteString(host.Description)
}
return builder.String()
}
func (m Model) sessionDetailView() string {
rows := m.filteredSessionRows()
if len(rows) == 0 {
return theme.Title.Render("DETAILS") + "\n\n" + theme.Dimmed.Render("no matching sessions")
}
if rows[m.sessionCursor].New {
return m.newSessionView()
}
session := rows[m.sessionCursor].Session
var builder strings.Builder
builder.WriteString(theme.Title.Render("DETAILS"))
builder.WriteString("\n\n")
builder.WriteString(session.Name)
builder.WriteString("\n")
builder.WriteString(theme.Dimmed.Render("──────────────────────────────────"))
builder.WriteString("\n\n")
builder.WriteString("Windows: ")
builder.WriteString(fmt.Sprintf("%d", session.Windows))
builder.WriteString("\n")
if !session.Created.IsZero() {
builder.WriteString("Created: ")
builder.WriteString(session.Created.Format("Mon Jan 2 15:04"))
builder.WriteString("\n")
}
builder.WriteString("Attached: ")
if session.Attached {
builder.WriteString("★ currently attached")
} else {
builder.WriteString("no")
}
return builder.String()
}
func (m Model) newSessionView() string {
var builder strings.Builder
builder.WriteString(theme.Title.Render("+ New session"))
builder.WriteString("\n\n")
builder.WriteString("Name for the new session:\n\n")
builder.WriteString("> ")
builder.WriteString(m.newName)
builder.WriteString("\n\n")
if m.inputError != "" {
builder.WriteString(m.inputError)
builder.WriteString("\n")
}
builder.WriteString(theme.Dimmed.Render("Enter to create. Esc creates an unnamed session."))
return builder.String()
}
func (m Model) helpView() string {
escapeHelp := "esc skip"
if m.stage != stageHosts {
escapeHelp = "esc new unnamed"
}
return theme.Pane.Render("↑/k up ↓/j down enter connect / search " + escapeHelp + " q quit ctrl+c quit")
}
func (m Model) footer() string {
if m.filtering {
return theme.Dimmed.Render("/ " + m.filter + " · enter select · esc cancel filter")
}
if m.stage == stageNewSession {
return theme.Dimmed.Render("enter create · esc new unnamed · q quit")
}
if m.stage == stageSessions {
return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc new unnamed")
}
return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc skip")
}
func (m Model) selectedHost() hostEntry {
if len(m.hosts) == 0 {
return hostEntry{Name: probe.LocalhostName, DisplayName: "localhost", Connection: "localhost"}
}
if m.hostCursor < 0 || m.hostCursor >= len(m.hosts) {
return m.hosts[0]
}
return m.hosts[m.hostCursor]
}
func (m Model) sessionEntries() []probe.SessionInfo {
sessions := append([]probe.SessionInfo(nil), m.selectedHost().Sessions...)
sort.SliceStable(sessions, func(i int, j int) bool {
return labelLess(sessions[i].Name, sessions[j].Name)
})
return sessions
}
func (m *Model) moveCursor(delta int) {
m.setCursor(m.currentCursor() + delta)
}
func (m *Model) setCursor(value int) {
count := m.entryCount()
if count == 0 {
m.hostCursor = 0
m.sessionCursor = 0
return
}
if value < 0 {
value = 0
}
if value >= count {
value = count - 1
}
switch m.stage {
case stageHosts:
m.hostCursor = value
case stageSessions, stageNewSession:
m.sessionCursor = value
}
}
func (m *Model) clampCursor() {
m.setCursor(m.currentCursor())
}
func (m *Model) completeFilter() {
completion, ok := m.currentCompletion()
if !ok {
return
}
m.filter = completion
m.clampCursor()
}
func (m Model) currentCompletion() (string, bool) {
if m.stage == stageHosts {
indexes := m.filteredHostIndexes()
if len(indexes) == 0 {
return "", false
}
return m.hosts[indexes[m.hostCursor]].Name, true
}
rows := m.filteredSessionRows()
if len(rows) == 0 {
return "", false
}
row := rows[m.sessionCursor]
if row.New {
return "new session", true
}
return row.Session.Name, true
}
func (m Model) currentCursor() int {
if m.stage == stageHosts {
return m.hostCursor
}
return m.sessionCursor
}
func (m Model) entryCount() int {
if m.stage == stageHosts {
return len(m.filteredHostIndexes())
}
return len(m.filteredSessionRows())
}
func (m Model) filteredHostIndexes() []int {
indexes := make([]int, 0, len(m.hosts))
filter := strings.ToLower(m.filter)
for i, host := range m.hosts {
if filter == "" || startsWithFold(host.DisplayName, filter) {
indexes = append(indexes, i)
}
}
return indexes
}
func (m Model) filteredSessionRows() []sessionRow {
filter := strings.ToLower(m.filter)
rows := make([]sessionRow, 0, len(m.sessionEntries())+1)
if filter == "" || startsWithFold("new session", filter) {
rows = append(rows, sessionRow{New: true})
}
for _, session := range m.sessionEntries() {
if filter == "" || startsWithFold(session.Name, filter) {
rows = append(rows, sessionRow{Session: session})
}
}
return rows
}
func sessionRowText(row sessionRow) string {
if row.New {
return "+ New session"
}
marker := " "
if row.Session.Attached {
marker = "●"
}
star := ""
if row.Session.Attached {
star = " ★"
}
return fmt.Sprintf("%s %-18s %d windows%s", marker, row.Session.Name, row.Session.Windows, star)
}
func statusDot(host hostEntry) string {
switch {
case host.Probing:
return theme.ProbingStyle.Render("…")
case host.Reachable:
return theme.OnlineStyle.Render("●")
default:
return theme.UnreachableStyle.Render("○")
}
}
func statusText(host hostEntry) string {
switch {
case host.Probing:
return theme.ProbingStyle.Render("… probing")
case host.Reachable:
return theme.OnlineStyle.Render("● online")
default:
return theme.UnreachableStyle.Render("○ unreachable")
}
}
func sessionSummary(host hostEntry) string {
if host.Probing {
return "probing"
}
if !host.Reachable {
return "unreach."
}
if len(host.Sessions) == 0 {
return "—"
}
return fmt.Sprintf("%d sess.", len(host.Sessions))
}
func sessionDetail(host hostEntry) string {
if host.Probing {
return "probing"
}
if !host.Reachable {
return "n/a"
}
if len(host.Sessions) == 0 {
return "—"
}
names := make([]string, 0, len(host.Sessions))
for _, session := range host.Sessions {
names = append(names, session.Name)
}
return fmt.Sprintf("%d (%s)", len(host.Sessions), strings.Join(names, ", "))
}
func connectionLine(host config.Host) string {
target := host.Hostname
if host.User != "" {
target = host.User + "@" + target
}
if host.Port != 0 && host.Port != 22 {
target = fmt.Sprintf("%s:%d", target, host.Port)
}
return target
}
func shortHostname(hostname string) string {
if hostname == "" {
return "unknown"
}
return strings.SplitN(hostname, ".", 2)[0]
}
func mustPlan(plan limenexec.Plan, err error) limenexec.Plan {
if err != nil {
return limenexec.Plan{}
}
return plan
}
func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}
func labelLess(left string, right string) bool {
leftFolded := strings.ToLower(left)
rightFolded := strings.ToLower(right)
if leftFolded == rightFolded {
return left < right
}
return leftFolded < rightFolded
}
func startsTypeahead(key string) bool {
return key != "/" && key != "?" && key != "q"
}
func startsWithFold(value string, foldedPrefix string) bool {
return strings.HasPrefix(strings.ToLower(value), foldedPrefix)
}