sshkeeper/internal/tui/dashboard.go

310 lines
9.8 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model"
)
func (m *tuiModel) renderServerDashboard() string {
width, height := m.width, m.height
if width <= 0 {
width = 120
}
if height <= 0 {
height = 40
}
sizeClass := classifyTerminal(width, height)
width = max(1, width-1)
header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width)
footer := m.renderListHelp(len(m.selectedServers()), len(m.bgResults) > 0)
headerHeight := displayLineCount(header)
notificationHeight := displayLineCount(notification)
footerHeight := displayLineCount(footer)
bodyHeight := height - headerHeight - notificationHeight - footerHeight
if bodyHeight < 5 {
bodyHeight = 5
}
var body string
switch sizeClass {
case sizeWide:
leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1
left := m.renderServerPanel(leftWidth, bodyHeight, true)
right := m.renderSelectedPanel(rightWidth, bodyHeight)
body = joinPanelColumns(left, leftWidth, right, rightWidth)
case sizeMedium:
detailsHeight := 6
listHeight := bodyHeight - detailsHeight
if listHeight < 5 {
listHeight = 5
}
body = m.renderServerPanel(width, listHeight, false)
if selected := m.selectedServer(); selected != nil && listHeight+detailsHeight <= bodyHeight {
body += "\n" + m.renderCompactSelected(selected, width, detailsHeight-1)
}
default:
body = m.renderServerPanel(width, bodyHeight, false)
}
view := header + notification + body
padding := height - displayLineCount(view) - footerHeight
if padding > 0 {
view += strings.Repeat("\n", padding)
}
return view + "\n" + footer
}
func (m *tuiModel) renderDashboardNotification(width int) string {
if m.err != nil {
return fitLine(errorStyle.Render("Error: "+m.err.Error()), width) + "\n"
}
if m.success != "" {
return fitLine(successStyle.Render(m.success), width) + "\n"
}
return ""
}
func (m *tuiModel) renderDashboardHeader(width int) string {
left := "sshkeeper / Servers"
vault := "Vault locked"
if m.vaultUnlocked {
vault = "Vault unlocked"
}
right := fmt.Sprintf("%s · %d profiles", vault, len(m.servers))
if selected := len(m.selectedServers()); selected > 0 {
right += fmt.Sprintf(" · %d selected", selected)
}
line := left + " " + right
if lipgloss.Width(left)+lipgloss.Width(right)+1 <= width {
line = left + strings.Repeat(" ", width-lipgloss.Width(left)-lipgloss.Width(right)) + right
}
headerStyle := titleStyle.Copy().MarginLeft(0)
separatorStyle := helpStyle.Copy().MarginLeft(0)
return headerStyle.Render(fitLine(line, width)) + "\n" + separatorStyle.Render(strings.Repeat("─", width)) + "\n"
}
func (m *tuiModel) renderServerPanel(width, height int, showTarget bool) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
lines := make([]string, 0, innerHeight)
lines = append(lines, listHeaderStyle.Render(fitLine(fmt.Sprintf("%d servers", len(m.servers)), innerWidth)))
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, nil, true))
rowCapacity := max(0, innerHeight-len(lines))
showRange := len(m.servers) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
if len(m.servers) == 0 {
lines = append(lines, helpStyle.Render(fitLine("No servers yet. Ctrl+A adds the first profile.", innerWidth)))
} else if rowCapacity > 0 {
start, end := visibleServerRange(len(m.servers), m.list.Index(), rowCapacity)
selected := m.selectedServer()
for _, server := range m.servers[start:end] {
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, server, selected != nil && server.Alias == selected.Alias))
}
if showRange {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.servers))))
}
}
for len(lines) < innerHeight {
lines = append(lines, "")
}
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
return renderPanel(width, height, lines)
}
func (m *tuiModel) renderServerColumns(width int, showTarget bool, server *model.Server, selected bool) string {
marker, name, target, auth, group, status := "", "NAME", "TARGET / ROUTE", "AUTH", "GROUP", "STATUS"
style := normalStyle
if server != nil {
marker = " "
if selected {
marker = ">"
style = selectedRowStyle
}
if m.selected[server.Alias] {
marker = "*"
if selected {
marker = ">*"
}
}
name = server.DisplayName
if name == "" {
name = server.Alias
}
target = fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
target = server.Route.DisplaySummary(target)
}
auth = authLabel(server.AuthMethod)
group = server.GroupName
if group == "" {
group = "-"
}
status = testStatusLabel(server)
}
markerWidth, authWidth, groupWidth, statusWidth := 2, 10, 10, 7
nameWidth := width - markerWidth - authWidth - groupWidth - statusWidth - 4
if showTarget {
nameWidth = min(18, max(10, nameWidth/3))
targetWidth := width - markerWidth - nameWidth - authWidth - groupWidth - statusWidth - 5
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(target, targetWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
if server == nil {
return listHeaderStyle.Render(fitLine(line, width))
}
return style.Render(fitLine(line, width))
}
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
if server == nil {
return listHeaderStyle.Render(fitLine(line, width))
}
return style.Render(fitLine(line, width))
}
func (m *tuiModel) renderSelectedPanel(width, height int) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
lines := make([]string, 0, innerHeight)
selected := m.selectedServer()
if selected == nil {
lines = append(lines, dashboardSection("Selected profile"), dashboardHelp("No profile selected."))
} else {
target := fmt.Sprintf("%s@%s:%d", selected.User, selected.Host, selected.Port)
route := "direct"
if len(selected.Route.Hops) > 0 {
route = selected.Route.DisplaySummary(target)
}
group := selected.GroupName
if group == "" {
group = "-"
}
lines = append(lines,
dashboardSection("Selected profile"),
fitLine("Alias: "+selected.Alias, innerWidth),
fitLine("Display Name: "+selected.DisplayName, innerWidth),
fitLine("Host: "+selected.Host, innerWidth),
fitLine(fmt.Sprintf("Port: %d User: %s", selected.Port, selected.User), innerWidth),
fitLine(target, innerWidth),
fitLine("Route "+route, innerWidth),
fitLine("Group "+group, innerWidth),
fitLine("Tags "+strings.Join(selected.Tags, ", "), innerWidth),
fitLine("Last test "+testStatusLabel(selected), innerWidth),
"",
dashboardSection("Primary actions"),
"Enter Connect",
"Ctrl+X More actions…",
)
lines = append(lines, m.backgroundPanelLines(selected.Alias, innerWidth)...)
}
for len(lines) < innerHeight {
lines = append(lines, "")
}
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
return renderPanel(width, height, lines)
}
func (m *tuiModel) backgroundPanelLines(alias string, width int) []string {
if len(m.bgResults) == 0 {
return nil
}
lines := []string{"", dashboardSection("Last Background Run")}
for _, result := range m.bgResults {
status := "OK"
if result.Err != "" {
status = "FAIL"
}
lines = append(lines, fitLine(result.Alias+" "+status, width))
}
result := m.backgroundResultForAlias(alias)
if result == nil && len(m.bgResults) == 1 {
result = &m.bgResults[0]
}
if result != nil {
output := strings.TrimSpace(result.Output)
if output == "" {
output = result.Err
}
if output != "" {
lines = append(lines, dashboardHelp("Output: "+result.Alias))
for _, line := range strings.Split(output, "\n") {
lines = append(lines, fitLine(strings.ReplaceAll(line, "\t", " "), width))
}
}
}
return lines
}
func (m *tuiModel) renderCompactSelected(server *model.Server, width, height int) string {
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
group := server.GroupName
if group == "" {
group = "-"
}
lines := []string{
dashboardSection("Selected profile"),
fitLine("Alias: "+server.Alias+" Target: "+target, width),
fitLine("Auth: "+authLabel(server.AuthMethod)+" Group: "+group+" Status: "+testStatusLabel(server), width),
fitLine("Enter: Connect Ctrl+X: More actions…", width),
}
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
func dashboardSection(value string) string {
return sectionStyle.Copy().MarginTop(0).Render(value)
}
func dashboardHelp(value string) string {
return helpStyle.Copy().MarginLeft(0).Render(value)
}
func renderPanel(width, height int, lines []string) string {
if width < 2 || height < 2 {
return ""
}
innerWidth := width - 2
var b strings.Builder
b.WriteString("┌" + strings.Repeat("─", innerWidth) + "┐\n")
for row := 0; row < height-2; row++ {
line := ""
if row < len(lines) {
line = lines[row]
}
b.WriteString("│" + padCells(line, innerWidth) + "│\n")
}
b.WriteString("└" + strings.Repeat("─", innerWidth) + "┘")
return b.String()
}
func joinPanelColumns(left string, leftWidth int, right string, rightWidth int) string {
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
rows := max(len(leftLines), len(rightLines))
joined := make([]string, rows)
for row := 0; row < rows; row++ {
leftLine, rightLine := "", ""
if row < len(leftLines) {
leftLine = leftLines[row]
}
if row < len(rightLines) {
rightLine = rightLines[row]
}
joined[row] = padCells(leftLine, leftWidth) + " " + padCells(rightLine, rightWidth)
}
return strings.Join(joined, "\n")
}