308 lines
9.7 KiB
Go
308 lines
9.7 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
|
|
}
|
|
|
|
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 classifyTerminal(width, height) {
|
|
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")
|
|
}
|