feat: add responsive tui layouts
This commit is contained in:
parent
16f4f9937f
commit
460b4496a0
2
go.mod
2
go.mod
|
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.52.0
|
||||
|
|
@ -19,7 +20,6 @@ require (
|
|||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
if m.actionMenu != nil {
|
||||
m.actionMenu.width = msg.Width
|
||||
m.actionMenu.height = msg.Height
|
||||
m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||
}
|
||||
m.templateList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||
|
|
@ -1126,6 +1127,9 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *tuiModel) View() string {
|
||||
if classifyTerminal(m.width, m.height) == sizeBelowFloor {
|
||||
return minimumSizeView(m.width)
|
||||
}
|
||||
var b strings.Builder
|
||||
|
||||
switch m.screen {
|
||||
|
|
@ -1194,10 +1198,10 @@ func (m *tuiModel) View() string {
|
|||
b.WriteString(m.viewConfirm())
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
if m.screen != screenList && m.err != nil {
|
||||
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||
}
|
||||
if m.success != "" {
|
||||
if m.screen != screenList && m.success != "" {
|
||||
b.WriteString("\n" + successStyle.Render(m.success))
|
||||
}
|
||||
|
||||
|
|
@ -1663,101 +1667,7 @@ func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *tuiModel) viewServerList() string {
|
||||
var b strings.Builder
|
||||
selectedAlias := ""
|
||||
if item, ok := m.list.SelectedItem().(serverItem); ok && item.server != nil {
|
||||
selectedAlias = item.server.Alias
|
||||
}
|
||||
|
||||
b.WriteString(titleStyle.Render(fmt.Sprintf("sshkeeper %d servers", len(m.servers))))
|
||||
b.WriteString("\n")
|
||||
vaultStatus := "Vault locked"
|
||||
if m.vaultUnlocked {
|
||||
vaultStatus = "Vault unlocked"
|
||||
}
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("%s | %s", vaultStatus, testSummary(m.servers))))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-20s %-20s %-34s %-12s %-10s %s", "NAME", "ALIAS", "ROUTE", "AUTH", "GROUP", "STATUS")))
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(m.servers) == 0 {
|
||||
b.WriteString(helpStyle.Render(" No servers yet. Press Ctrl+A to add one."))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
selectedIndex := m.list.Index()
|
||||
start, end := visibleServerRange(len(m.servers), selectedIndex, m.visibleServerRows())
|
||||
for _, server := range m.servers[start:end] {
|
||||
marker := " "
|
||||
rowStyle := normalStyle
|
||||
if server.Alias == selectedAlias {
|
||||
marker = ">"
|
||||
rowStyle = selectedRowStyle
|
||||
}
|
||||
if m.selected[server.Alias] {
|
||||
marker = "*"
|
||||
if server.Alias == selectedAlias {
|
||||
marker = ">*"
|
||||
}
|
||||
}
|
||||
name := server.DisplayName
|
||||
if name == "" {
|
||||
name = server.Alias
|
||||
}
|
||||
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||
routeStr := server.Route.DisplaySummary(target)
|
||||
// Add visual icon prefix based on connection type
|
||||
if len(server.Route.Hops) == 0 {
|
||||
routeStr = " " + routeStr // direct
|
||||
} else {
|
||||
routeStr = "→ " + routeStr // via/chain
|
||||
}
|
||||
// If too long, collapse middle hops
|
||||
if len(routeStr) > 34 && len(server.Route.Hops) > 2 {
|
||||
first := server.Route.Hops[0]
|
||||
firstName := first.Alias
|
||||
if !first.IsProfile {
|
||||
firstName = first.Raw
|
||||
}
|
||||
routeStr = fmt.Sprintf("→ %s → … → %s", firstName, truncate(target, 34-len(firstName)-8))
|
||||
}
|
||||
group := server.GroupName
|
||||
if group == "" {
|
||||
group = "-"
|
||||
}
|
||||
row := fmt.Sprintf("%s %-20s %-20s %-34s %-12s %-10s %s",
|
||||
marker,
|
||||
truncate(name, 20),
|
||||
truncate(server.Alias, 20),
|
||||
truncate(routeStr, 34),
|
||||
authLabel(server.AuthMethod),
|
||||
truncate(group, 10),
|
||||
testStatusLabel(server),
|
||||
)
|
||||
b.WriteString(rowStyle.Render(row))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(m.servers) > end-start {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" Showing %d-%d of %d", start+1, end, len(m.servers))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
if selectedAlias != "" {
|
||||
if selected := m.selectedServer(); selected != nil {
|
||||
b.WriteString(m.viewSelectedServer(selected))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(m.bgResults) > 0 {
|
||||
b.WriteString(m.viewInlineBackgroundResults())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
selectedCount := len(m.selectedServers())
|
||||
footer := m.renderListHelp(selectedCount, len(m.bgResults) > 0)
|
||||
b.WriteString(strings.Repeat("\n", bottomPaddingLines(b.String(), footer, m.height)))
|
||||
b.WriteString(footer)
|
||||
return b.String()
|
||||
return m.renderServerDashboard()
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewInlineBackgroundResults() string {
|
||||
|
|
@ -2232,10 +2142,7 @@ func displayLineCount(s string) int {
|
|||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return truncateCells(s, maxLen)
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -566,130 +566,118 @@ func parsePort(value string) (int, error) {
|
|||
}
|
||||
|
||||
func (fm *formModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
title := "Add Server"
|
||||
if fm.edit {
|
||||
title = "Edit Server: " + fm.server.Alias
|
||||
}
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
reserved := 9
|
||||
available := fm.height - reserved
|
||||
if available < 4 {
|
||||
available = 4
|
||||
}
|
||||
|
||||
numInputs := len(fm.inputs)
|
||||
startIdx := 0
|
||||
endIdx := numInputs
|
||||
|
||||
if numInputs > available {
|
||||
focusInput := fm.focusIdx
|
||||
if focusInput >= numInputs {
|
||||
focusInput = numInputs - 1
|
||||
}
|
||||
startIdx = focusInput - available/2
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
endIdx = startIdx + available
|
||||
if endIdx > numInputs {
|
||||
endIdx = numInputs
|
||||
startIdx = endIdx - available
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if startIdx > 0 {
|
||||
b.WriteString(helpStyle.Render(" ↑ more fields above\n"))
|
||||
}
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
if section := formSectionTitle(i); section != "" {
|
||||
b.WriteString(sectionStyle.Render(section))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if i == 5 {
|
||||
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
|
||||
}
|
||||
if i == 8 && len(fm.groups) > 0 && !fm.showGroupList {
|
||||
fm.inputs[i].Placeholder = truncate(strings.Join(fm.groups, ", "), 25)
|
||||
}
|
||||
b.WriteString(fm.inputs[i].View())
|
||||
b.WriteString("\n")
|
||||
if i == 5 && fm.showAuthList {
|
||||
b.WriteString("\n" + renderDropdown(fm.authList) + "\n")
|
||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
|
||||
return b.String()
|
||||
}
|
||||
if i == 8 && fm.showGroupList {
|
||||
b.WriteString("\n" + renderDropdown(fm.groupList) + "\n")
|
||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
|
||||
if endIdx < numInputs {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" ↓ more fields below (%d-%d of %d)\n", startIdx+1, endIdx, numInputs)))
|
||||
}
|
||||
|
||||
b.WriteString(fm.password.View())
|
||||
b.WriteString("\n")
|
||||
|
||||
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
|
||||
|
||||
if fm.testing {
|
||||
b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n")
|
||||
} else if fm.saving {
|
||||
b.WriteString("\n" + fm.spinner.View() + " Saving...\n")
|
||||
} else if showResults {
|
||||
if fm.testResult != "" {
|
||||
b.WriteString("\n")
|
||||
if fm.testOK {
|
||||
b.WriteString(testOKStyle.Render("✓ " + fm.testResult))
|
||||
} else {
|
||||
b.WriteString(testFailStyle.Render("✗ " + fm.testResult))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if fm.saved {
|
||||
b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n")
|
||||
}
|
||||
}
|
||||
if fm.err != nil {
|
||||
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n")
|
||||
}
|
||||
|
||||
testBtn := "[ Test ]"
|
||||
saveBtn := "[ Save ]"
|
||||
|
||||
if fm.focusIdx == len(fm.inputs)+1 {
|
||||
testBtn = selectedStyle.Render(testBtn)
|
||||
} else {
|
||||
testBtn = normalStyle.Render(testBtn)
|
||||
}
|
||||
|
||||
if fm.focusIdx == len(fm.inputs)+2 {
|
||||
saveBtn = selectedStyle.Render(saveBtn)
|
||||
} else {
|
||||
saveBtn = normalStyle.Render(saveBtn)
|
||||
}
|
||||
|
||||
b.WriteString("\n" + sectionStyle.Render("Actions") + "\n")
|
||||
b.WriteString(testBtn + " " + saveBtn + "\n\n")
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
footer := renderHelp([]helpItem{
|
||||
{Key: "Tab/↓", Action: "next"},
|
||||
{Key: "↑", Action: "prev"},
|
||||
{Key: "/", Action: "pick list"},
|
||||
{Key: "Enter", Action: "select"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, fm.width))
|
||||
}, fm.width)
|
||||
|
||||
return b.String()
|
||||
if fm.showAuthList || fm.showGroupList {
|
||||
var dropdown list.Model
|
||||
fieldIndex := 8
|
||||
if fm.showAuthList {
|
||||
dropdown = fm.authList
|
||||
fieldIndex = 5
|
||||
} else {
|
||||
dropdown = fm.groupList
|
||||
}
|
||||
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
|
||||
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
|
||||
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
|
||||
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
|
||||
}
|
||||
|
||||
status := fm.formStatusLine()
|
||||
testBtn, saveBtn := " [ Test ]", " [ Save ]"
|
||||
if fm.focusIdx == len(fm.inputs)+1 {
|
||||
testBtn = selectedStyle.Render("> [ Test ]")
|
||||
}
|
||||
if fm.focusIdx == len(fm.inputs)+2 {
|
||||
saveBtn = selectedStyle.Render("> [ Save ]")
|
||||
}
|
||||
actions := fitLine(testBtn+" "+saveBtn, fm.width)
|
||||
|
||||
reserved := 1 + displayLineCount(footer) + 1
|
||||
if status != "" {
|
||||
reserved++
|
||||
}
|
||||
fieldRows := max(4, fm.height-reserved)
|
||||
richLayout := fm.width >= 90 && fm.height >= 24
|
||||
allFields := make([]string, 0, len(fm.inputs)+5)
|
||||
focusRows := make([]int, len(fm.inputs)+1)
|
||||
for i := range fm.inputs {
|
||||
if richLayout {
|
||||
if section := formSectionTitle(i); section != "" {
|
||||
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
|
||||
}
|
||||
}
|
||||
if i == 5 {
|
||||
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
|
||||
}
|
||||
if i == 8 && len(fm.groups) > 0 {
|
||||
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
|
||||
}
|
||||
focusRows[i] = len(allFields)
|
||||
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
|
||||
}
|
||||
focusRows[len(fm.inputs)] = len(allFields)
|
||||
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
|
||||
focusField := len(allFields) - 1
|
||||
if fm.focusIdx <= len(fm.inputs) {
|
||||
focusField = focusRows[fm.focusIdx]
|
||||
}
|
||||
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
|
||||
visible := append([]string(nil), allFields[start:end]...)
|
||||
if start > 0 && len(visible) > 0 {
|
||||
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
|
||||
}
|
||||
if end < len(allFields) && len(visible) > 0 {
|
||||
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
|
||||
}
|
||||
|
||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||
lines = append(lines, visible...)
|
||||
if status != "" {
|
||||
lines = append(lines, fitLine(status, fm.width))
|
||||
}
|
||||
if richLayout {
|
||||
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
|
||||
}
|
||||
lines = append(lines, actions)
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
if len(lines) > fm.height && fm.height > 0 {
|
||||
lines = lines[:fm.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (fm *formModel) formStatusLine() string {
|
||||
if fm.err != nil {
|
||||
return errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
|
||||
}
|
||||
if fm.testing {
|
||||
return fm.spinner.View() + " Testing connection..."
|
||||
}
|
||||
if fm.saving {
|
||||
return fm.spinner.View() + " Saving..."
|
||||
}
|
||||
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
|
||||
if showResults && fm.testResult != "" {
|
||||
if fm.testOK {
|
||||
return testOKStyle.Render("✓ " + fm.testResult)
|
||||
}
|
||||
return testFailStyle.Render("✗ " + strings.ReplaceAll(fm.testResult, "\n", " "))
|
||||
}
|
||||
if showResults && fm.saved {
|
||||
return successStyle.Render("✓ Saved.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renderDropdown(l list.Model) string {
|
||||
|
|
|
|||
|
|
@ -79,69 +79,93 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
|
|||
}
|
||||
|
||||
func (m *forwardScreenModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.list) == 0 {
|
||||
b.WriteString(helpStyle.Render(" No port forwards configured. Press Ctrl+A to add one."))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
// Column header
|
||||
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
|
||||
"NAME", "TYPE", "LISTEN", "TARGET", "ON")))
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, f := range m.list {
|
||||
name := f.Name
|
||||
if name == "" {
|
||||
name = f.ForwardListen()
|
||||
}
|
||||
enabled := "yes"
|
||||
if !f.Enabled {
|
||||
enabled = "no"
|
||||
}
|
||||
line := fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
|
||||
truncate(name, 22),
|
||||
f.Type,
|
||||
truncate(f.ForwardListen(), 20),
|
||||
truncate(f.ForwardTarget(), 20),
|
||||
enabled,
|
||||
)
|
||||
style := normalStyle
|
||||
if i == m.selected {
|
||||
style = selectedRowStyle
|
||||
}
|
||||
b.WriteString(style.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Details for selected
|
||||
if m.selected >= 0 && m.selected < len(m.list) {
|
||||
f := m.list[m.selected]
|
||||
b.WriteString("\n")
|
||||
b.WriteString(sectionStyle.Render("Selected"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" %s\n", f.ForwardHumanExplanation(m.serverAlias)))
|
||||
for _, arg := range f.ForwardSSHArgs() {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", arg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
if m.err != nil {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
footer := renderHelp([]helpItem{
|
||||
{Key: "Ctrl+A (a)", Action: "add"},
|
||||
{Key: "Ctrl+E/Enter", Action: "edit"},
|
||||
{Key: "Ctrl+D (d)", Action: "delete"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, m.width))
|
||||
return b.String()
|
||||
}, m.width)
|
||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
|
||||
if m.err != nil {
|
||||
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
|
||||
}
|
||||
|
||||
footerRows := displayLineCount(footer)
|
||||
detailRows := 0
|
||||
if len(m.list) > 0 && m.height-footerRows >= 7 {
|
||||
detailRows = 3
|
||||
}
|
||||
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
|
||||
if len(m.list) == 0 {
|
||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
|
||||
} else {
|
||||
lines = append(lines, m.renderForwardRow(nil, false))
|
||||
rowCapacity--
|
||||
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
|
||||
for index := start; index < end; index++ {
|
||||
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
|
||||
}
|
||||
if end < len(m.list) || start > 0 {
|
||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
||||
}
|
||||
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
|
||||
forward := m.list[m.selected]
|
||||
lines = append(lines,
|
||||
sectionStyle.Copy().MarginTop(0).Render("Selected"),
|
||||
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
|
||||
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
|
||||
)
|
||||
}
|
||||
}
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
if len(lines) > m.height && m.height > 0 {
|
||||
lines = lines[:m.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
|
||||
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
|
||||
if forward != nil {
|
||||
if selected {
|
||||
marker = ">"
|
||||
}
|
||||
name = forward.Name
|
||||
if name == "" {
|
||||
name = forward.ForwardListen()
|
||||
}
|
||||
kind = string(forward.Type)
|
||||
listen = forward.ForwardListen()
|
||||
target = forward.ForwardTarget()
|
||||
enabled = "yes"
|
||||
if !forward.Enabled {
|
||||
enabled = "no"
|
||||
}
|
||||
}
|
||||
wide := m.width >= 70
|
||||
typeWidth, enabledWidth := 8, 3
|
||||
if wide {
|
||||
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
|
||||
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
|
||||
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
|
||||
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
|
||||
if forward == nil {
|
||||
return listHeaderStyle.Render(fitLine(line, m.width))
|
||||
}
|
||||
if selected {
|
||||
return selectedRowStyle.Render(fitLine(line, m.width))
|
||||
}
|
||||
return fitLine(line, m.width)
|
||||
}
|
||||
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
|
||||
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
||||
if forward == nil {
|
||||
return listHeaderStyle.Render(fitLine(line, m.width))
|
||||
}
|
||||
if selected {
|
||||
return selectedRowStyle.Render(fitLine(line, m.width))
|
||||
}
|
||||
return fitLine(line, m.width)
|
||||
}
|
||||
|
||||
// --- Forward form screen model ---
|
||||
|
|
@ -522,64 +546,43 @@ func parseNamedPort(label, value string) (int, error) {
|
|||
}
|
||||
|
||||
func (fm *forwardFormModel) View() string {
|
||||
var b strings.Builder
|
||||
title := "Add Port Forward"
|
||||
if fm.editMode {
|
||||
title = "Edit Port Forward"
|
||||
}
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||
lines = append(lines,
|
||||
fitLine(fm.nameInput.View(), fm.width),
|
||||
fitLine(fm.descInput.View(), fm.width),
|
||||
)
|
||||
|
||||
// Name
|
||||
b.WriteString(fm.nameInput.View())
|
||||
b.WriteString("\n")
|
||||
|
||||
// Description
|
||||
b.WriteString(fm.descInput.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Type selector — visible radio items with descriptions
|
||||
b.WriteString(sectionStyle.Render("Type"))
|
||||
b.WriteString("\n")
|
||||
for i, t := range forwardTypes {
|
||||
prefix := " "
|
||||
style := normalStyle
|
||||
typeParts := make([]string, len(forwardTypes))
|
||||
for i, forwardType := range forwardTypes {
|
||||
selected := "○"
|
||||
if i == fm.typeIdx {
|
||||
prefix = "▸ "
|
||||
style = selectedRowStyle
|
||||
selected = "●"
|
||||
}
|
||||
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
|
||||
b.WriteString(style.Render(line))
|
||||
b.WriteString("\n")
|
||||
focus := " "
|
||||
if fm.focusIdx == 2+i {
|
||||
focus = ">"
|
||||
}
|
||||
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
|
||||
}
|
||||
// Show human-readable explanation for selected type
|
||||
if fm.typeIdx >= 0 && fm.typeIdx < len(forwardTypes) {
|
||||
explanations := map[model.ForwardType]string{
|
||||
model.ForwardLocal: "Opens a local port on this machine and forwards it through SSH to the target address.",
|
||||
model.ForwardRemote: "Opens a port on the remote SSH server and forwards it back to this machine.",
|
||||
model.ForwardDynamic: "Creates a local SOCKS proxy that routes all traffic through the SSH server.",
|
||||
}
|
||||
if exp, ok := explanations[forwardTypes[fm.typeIdx].value]; ok {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" %s\n", exp)))
|
||||
}
|
||||
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
|
||||
if fm.width >= 100 {
|
||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Dynamic fields based on type
|
||||
visible := fm.visibleFields()
|
||||
for _, idx := range visible {
|
||||
b.WriteString(fm.inputs[idx].View())
|
||||
b.WriteString("\n")
|
||||
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
|
||||
}
|
||||
|
||||
// Warning for 0.0.0.0
|
||||
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
|
||||
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n"))
|
||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
|
||||
}
|
||||
|
||||
// Preview
|
||||
if fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
|
||||
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||
fwd := &model.Forward{
|
||||
Type: fm.currentType,
|
||||
LocalAddr: fm.inputs[0].Value(),
|
||||
|
|
@ -589,37 +592,34 @@ func (fm *forwardFormModel) View() string {
|
|||
}
|
||||
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
||||
for _, arg := range fwd.ForwardSSHArgs() {
|
||||
b.WriteString(" " + arg + "\n")
|
||||
}
|
||||
b.WriteString(" -o ExitOnForwardFailure=yes\n")
|
||||
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
||||
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
|
||||
}
|
||||
|
||||
// Save button
|
||||
total := 2 + 3 + len(visible) + 1
|
||||
button := "\n[ Save ]"
|
||||
button := " [ Save ]"
|
||||
if fm.focusIdx == total-1 {
|
||||
button = selectedStyle.Render(button)
|
||||
button = selectedStyle.Render("> [ Save ]")
|
||||
}
|
||||
b.WriteString(button)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if fm.err != nil {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n")
|
||||
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
|
||||
}
|
||||
if fm.saved {
|
||||
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
|
||||
lines = append(lines, successStyle.Render("✓ Saved."))
|
||||
}
|
||||
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
lines = append(lines, button)
|
||||
footer := renderHelp([]helpItem{
|
||||
{Key: "Tab/↓", Action: "next"},
|
||||
{Key: "↑", Action: "prev"},
|
||||
{Key: "1/2/3", Action: "select type"},
|
||||
{Key: "Enter", Action: "save"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, fm.width))
|
||||
|
||||
return b.String()
|
||||
}, fm.width)
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
if len(lines) > fm.height && fm.height > 0 {
|
||||
lines = lines[:fm.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// forwardEditSignal is sent when user wants to edit a forward
|
||||
|
|
|
|||
|
|
@ -272,8 +272,9 @@ func (i actionMenuItem) Description() string { return "" }
|
|||
func (i actionMenuItem) FilterValue() string { return i.label }
|
||||
|
||||
type actionMenuModel struct {
|
||||
list list.Model
|
||||
width int
|
||||
list list.Model
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||
|
|
@ -301,7 +302,7 @@ func newActionMenuModel(w, h int) *actionMenuModel {
|
|||
l.SetShowHelp(false)
|
||||
l.Styles.Title = titleStyle
|
||||
|
||||
return &actionMenuModel{list: l, width: w}
|
||||
return &actionMenuModel{list: l, width: w, height: h}
|
||||
}
|
||||
|
||||
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||
|
|
@ -323,5 +324,24 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
|||
}
|
||||
|
||||
func (m *actionMenuModel) View() string {
|
||||
return m.list.View()
|
||||
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
|
||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
|
||||
capacity := max(1, m.height-displayLineCount(footer)-1)
|
||||
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
|
||||
for index := start; index < end; index++ {
|
||||
item, ok := m.list.Items()[index].(actionMenuItem)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
if index == m.list.Index() {
|
||||
marker = "> "
|
||||
}
|
||||
lines = append(lines, fitLine(marker+item.label, m.width))
|
||||
}
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
if len(lines) > m.height && m.height > 0 {
|
||||
lines = lines[:m.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
minimumTUIWidth = 60
|
||||
minimumTUIHeight = 16
|
||||
)
|
||||
|
||||
type terminalSizeClass int
|
||||
|
||||
const (
|
||||
sizeBelowFloor terminalSizeClass = iota
|
||||
sizeNarrow
|
||||
sizeMedium
|
||||
sizeWide
|
||||
)
|
||||
|
||||
func classifyTerminal(width, height int) terminalSizeClass {
|
||||
if width > 0 && height > 0 && (width < minimumTUIWidth || height < minimumTUIHeight) {
|
||||
return sizeBelowFloor
|
||||
}
|
||||
if width >= 100 {
|
||||
return sizeWide
|
||||
}
|
||||
if width >= 70 {
|
||||
return sizeMedium
|
||||
}
|
||||
return sizeNarrow
|
||||
}
|
||||
|
||||
func truncateCells(value string, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if ansi.StringWidth(value) <= width {
|
||||
return value
|
||||
}
|
||||
if width == 1 {
|
||||
return "…"
|
||||
}
|
||||
return ansi.Truncate(value, width, "…")
|
||||
}
|
||||
|
||||
func padCells(value string, width int) string {
|
||||
value = truncateCells(value, width)
|
||||
missing := width - ansi.StringWidth(value)
|
||||
if missing > 0 {
|
||||
value += strings.Repeat(" ", missing)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fitLine(value string, width int) string {
|
||||
return truncateCells(value, width)
|
||||
}
|
||||
|
||||
func minimumSizeView(width int) string {
|
||||
message := "sshkeeper needs at least 60x16"
|
||||
if width <= 0 {
|
||||
return message
|
||||
}
|
||||
return truncateCells(message, width)
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/mirivlad/sshkeeper/internal/model"
|
||||
)
|
||||
|
||||
func TestTruncateCellsHandlesUnicodeDisplayWidth(t *testing.T) {
|
||||
tests := []string{
|
||||
"production-сервер",
|
||||
"数据库服务器",
|
||||
"e\u0301-combining",
|
||||
"🔐 gateway",
|
||||
}
|
||||
for _, value := range tests {
|
||||
got := truncateCells(value, 8)
|
||||
if width := ansi.StringWidth(got); width > 8 {
|
||||
t.Fatalf("truncateCells(%q) width=%d result=%q", value, width, got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("truncated value has no indicator: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
|
||||
servers := []*model.Server{
|
||||
{
|
||||
Alias: "staging-数据库-bastion",
|
||||
DisplayName: "Production сервер 🔐 with a very long display name",
|
||||
Host: "bastion.staging.example.net",
|
||||
Port: 2222,
|
||||
User: "operations",
|
||||
AuthMethod: model.AuthAgent,
|
||||
GroupName: "STAGING-LONG",
|
||||
Tags: []string{"stage", "bastion", "кириллица"},
|
||||
},
|
||||
{Alias: "db", DisplayName: "Database", Host: "db.internal", Port: 22, User: "postgres", AuthMethod: model.AuthKeyPassphrase},
|
||||
}
|
||||
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
t.Run(strings.Join([]string{itoa(size.width), "x", itoa(size.height)}, ""), func(t *testing.T) {
|
||||
m := New(servers)
|
||||
m.width, m.height = size.width, size.height
|
||||
assertViewFits(t, m.View(), size.width, size.height)
|
||||
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
|
||||
if !strings.Contains(m.View(), want) {
|
||||
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardNotificationFitsSupportedTerminalSizes(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops", AuthMethod: model.AuthAgent}})
|
||||
m.width, m.height = size.width, size.height
|
||||
m.err = errText("vault reload failed and this message must remain visible")
|
||||
view := m.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
if !strings.Contains(view, "vault reload failed") {
|
||||
t.Fatalf("dashboard at %dx%d lost notification:\n%s", size.width, size.height, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreensBelowSupportedFloorShowSizeMessage(t *testing.T) {
|
||||
m := New(nil)
|
||||
m.width, m.height = 59, 15
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "60x16") {
|
||||
t.Fatalf("missing supported-size message:\n%s", view)
|
||||
}
|
||||
assertViewFits(t, view, 59, 15)
|
||||
}
|
||||
|
||||
func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
t.Run(itoa(size.width), func(t *testing.T) {
|
||||
fm := newFormModel(size.width, size.height)
|
||||
fm.inputs[0].SetValue("prod")
|
||||
fm.inputs[2].SetValue("prod.example")
|
||||
fm.inputs[3].SetValue("not-a-port")
|
||||
fm.err = errText("Port must be a number from 1 to 65535")
|
||||
fm.focusIdx = 3
|
||||
fm.updateFocus()
|
||||
view := fm.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
fm := newForwardFormModel(1, size.width, size.height)
|
||||
fm.nameInput.SetValue("Local PostgreSQL")
|
||||
fm.inputs[0].SetValue("127.0.0.1")
|
||||
fm.inputs[1].SetValue("15432")
|
||||
fm.inputs[2].SetValue("database.internal.example")
|
||||
fm.inputs[3].SetValue("5432")
|
||||
assertViewFits(t, fm.View(), size.width, size.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
fm := newForwardScreenModel(1, "production-数据库-bastion", size.width, size.height)
|
||||
fm.list = []*model.Forward{
|
||||
{Name: "Local PostgreSQL with a very long name", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "database.internal.example", RemotePort: 5432, Enabled: true},
|
||||
{Name: "SOCKS proxy", Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080, Enabled: false},
|
||||
}
|
||||
view := fm.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
menu := newActionMenuModel(size.width, size.height)
|
||||
view := menu.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
form := newTemplateFormModel(nil, size.width, size.height)
|
||||
form.inputs[0].SetValue("проверка-数据库")
|
||||
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
|
||||
view := form.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertViewFits(t *testing.T, view string, width, height int) {
|
||||
t.Helper()
|
||||
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
|
||||
if len(lines) > height {
|
||||
t.Fatalf("view has %d lines, terminal height is %d:\n%s", len(lines), height, view)
|
||||
}
|
||||
for index, line := range lines {
|
||||
if lineWidth := ansi.StringWidth(line); lineWidth > width {
|
||||
t.Fatalf("line %d has width %d, terminal width is %d: %q", index+1, lineWidth, width, ansi.Strip(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type errText string
|
||||
|
||||
func (e errText) Error() string { return string(e) }
|
||||
|
||||
func itoa(value int) string {
|
||||
const digits = "0123456789"
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var out [20]byte
|
||||
index := len(out)
|
||||
for value > 0 {
|
||||
index--
|
||||
out[index] = digits[value%10]
|
||||
value /= 10
|
||||
}
|
||||
return string(out[index:])
|
||||
}
|
||||
|
|
@ -156,15 +156,15 @@ func (tf *templateFormModel) View() string {
|
|||
if tf.edit {
|
||||
title = "Edit Template"
|
||||
}
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
|
||||
b.WriteString("\n\n")
|
||||
for i := range tf.inputs {
|
||||
b.WriteString(tf.inputs[i].View())
|
||||
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
button := "[ Save ]"
|
||||
button := " [ Save ]"
|
||||
if tf.focusIdx == len(tf.inputs) {
|
||||
button = selectedStyle.Render(button)
|
||||
button = selectedStyle.Render("> [ Save ]")
|
||||
}
|
||||
b.WriteString("\n" + button + "\n\n")
|
||||
if tf.err != nil {
|
||||
|
|
|
|||
Loading…
Reference in New Issue