845 lines
19 KiB
Go
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)
|
|
}
|