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/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
|
|
@ -19,7 +20,6 @@ require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.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/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // 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 {
|
if m.actionMenu != nil {
|
||||||
m.actionMenu.width = msg.Width
|
m.actionMenu.width = msg.Width
|
||||||
|
m.actionMenu.height = msg.Height
|
||||||
m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height))
|
m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||||
}
|
}
|
||||||
m.templateList.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 {
|
func (m *tuiModel) View() string {
|
||||||
|
if classifyTerminal(m.width, m.height) == sizeBelowFloor {
|
||||||
|
return minimumSizeView(m.width)
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
|
|
@ -1194,10 +1198,10 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewConfirm())
|
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)))
|
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))
|
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 {
|
func (m *tuiModel) viewServerList() string {
|
||||||
var b strings.Builder
|
return m.renderServerDashboard()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewInlineBackgroundResults() string {
|
func (m *tuiModel) viewInlineBackgroundResults() string {
|
||||||
|
|
@ -2232,10 +2142,7 @@ func displayLineCount(s string) int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncate(s string, maxLen int) string {
|
func truncate(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
return truncateCells(s, maxLen)
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:maxLen-3] + "..."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitCSV(value string) []string {
|
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 {
|
func (fm *formModel) View() string {
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
title := "Add Server"
|
title := "Add Server"
|
||||||
if fm.edit {
|
if fm.edit {
|
||||||
title = "Edit Server: " + fm.server.Alias
|
title = "Edit Server: " + fm.server.Alias
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Render(title))
|
footer := renderHelp([]helpItem{
|
||||||
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{
|
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "/", Action: "pick list"},
|
{Key: "/", Action: "pick list"},
|
||||||
{Key: "Enter", Action: "select"},
|
{Key: "Enter", Action: "select"},
|
||||||
{Key: "Esc", Action: "back"},
|
{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 {
|
func renderDropdown(l list.Model) string {
|
||||||
|
|
|
||||||
|
|
@ -79,69 +79,93 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) View() string {
|
func (m *forwardScreenModel) View() string {
|
||||||
var b strings.Builder
|
footer := renderHelp([]helpItem{
|
||||||
|
|
||||||
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{
|
|
||||||
{Key: "Ctrl+A (a)", Action: "add"},
|
{Key: "Ctrl+A (a)", Action: "add"},
|
||||||
{Key: "Ctrl+E/Enter", Action: "edit"},
|
{Key: "Ctrl+E/Enter", Action: "edit"},
|
||||||
{Key: "Ctrl+D (d)", Action: "delete"},
|
{Key: "Ctrl+D (d)", Action: "delete"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, m.width))
|
}, m.width)
|
||||||
return b.String()
|
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 ---
|
// --- Forward form screen model ---
|
||||||
|
|
@ -522,64 +546,43 @@ func parseNamedPort(label, value string) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *forwardFormModel) View() string {
|
func (fm *forwardFormModel) View() string {
|
||||||
var b strings.Builder
|
|
||||||
title := "Add Port Forward"
|
title := "Add Port Forward"
|
||||||
if fm.editMode {
|
if fm.editMode {
|
||||||
title = "Edit Port Forward"
|
title = "Edit Port Forward"
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Render(title))
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||||
b.WriteString("\n\n")
|
lines = append(lines,
|
||||||
|
fitLine(fm.nameInput.View(), fm.width),
|
||||||
|
fitLine(fm.descInput.View(), fm.width),
|
||||||
|
)
|
||||||
|
|
||||||
// Name
|
typeParts := make([]string, len(forwardTypes))
|
||||||
b.WriteString(fm.nameInput.View())
|
for i, forwardType := range forwardTypes {
|
||||||
b.WriteString("\n")
|
selected := "○"
|
||||||
|
|
||||||
// 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
|
|
||||||
if i == fm.typeIdx {
|
if i == fm.typeIdx {
|
||||||
prefix = "▸ "
|
selected = "●"
|
||||||
style = selectedRowStyle
|
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
|
focus := " "
|
||||||
b.WriteString(style.Render(line))
|
if fm.focusIdx == 2+i {
|
||||||
b.WriteString("\n")
|
focus = ">"
|
||||||
|
}
|
||||||
|
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
|
||||||
}
|
}
|
||||||
// Show human-readable explanation for selected type
|
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
|
||||||
if fm.typeIdx >= 0 && fm.typeIdx < len(forwardTypes) {
|
if fm.width >= 100 {
|
||||||
explanations := map[model.ForwardType]string{
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
|
||||||
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)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Dynamic fields based on type
|
|
||||||
visible := fm.visibleFields()
|
visible := fm.visibleFields()
|
||||||
for _, idx := range visible {
|
for _, idx := range visible {
|
||||||
b.WriteString(fm.inputs[idx].View())
|
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning for 0.0.0.0
|
|
||||||
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "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.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||||
if fm.currentType != "" && fm.inputs[1].Value() != "" {
|
|
||||||
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
|
|
||||||
fwd := &model.Forward{
|
fwd := &model.Forward{
|
||||||
Type: fm.currentType,
|
Type: fm.currentType,
|
||||||
LocalAddr: fm.inputs[0].Value(),
|
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[1].Value(), "%d", &fwd.LocalPort)
|
||||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
||||||
for _, arg := range fwd.ForwardSSHArgs() {
|
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
||||||
b.WriteString(" " + arg + "\n")
|
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
|
||||||
}
|
|
||||||
b.WriteString(" -o ExitOnForwardFailure=yes\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save button
|
|
||||||
total := 2 + 3 + len(visible) + 1
|
total := 2 + 3 + len(visible) + 1
|
||||||
button := "\n[ Save ]"
|
button := " [ Save ]"
|
||||||
if fm.focusIdx == total-1 {
|
if fm.focusIdx == total-1 {
|
||||||
button = selectedStyle.Render(button)
|
button = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
b.WriteString(button)
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
if fm.err != nil {
|
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 {
|
if fm.saved {
|
||||||
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
|
lines = append(lines, successStyle.Render("✓ Saved."))
|
||||||
}
|
}
|
||||||
|
lines = append(lines, button)
|
||||||
b.WriteString(renderHelp([]helpItem{
|
footer := renderHelp([]helpItem{
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "1/2/3", Action: "select type"},
|
{Key: "1/2/3", Action: "select type"},
|
||||||
{Key: "Enter", Action: "save"},
|
{Key: "Enter", Action: "save"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, fm.width))
|
}, fm.width)
|
||||||
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
return b.String()
|
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
|
// 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 }
|
func (i actionMenuItem) FilterValue() string { return i.label }
|
||||||
|
|
||||||
type actionMenuModel struct {
|
type actionMenuModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
width int
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
|
|
@ -301,7 +302,7 @@ func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.Styles.Title = titleStyle
|
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) {
|
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 {
|
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 {
|
if tf.edit {
|
||||||
title = "Edit Template"
|
title = "Edit Template"
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Render(title))
|
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
for i := range tf.inputs {
|
for i := range tf.inputs {
|
||||||
b.WriteString(tf.inputs[i].View())
|
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
button := "[ Save ]"
|
button := " [ Save ]"
|
||||||
if tf.focusIdx == len(tf.inputs) {
|
if tf.focusIdx == len(tf.inputs) {
|
||||||
button = selectedStyle.Render(button)
|
button = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
b.WriteString("\n" + button + "\n\n")
|
b.WriteString("\n" + button + "\n\n")
|
||||||
if tf.err != nil {
|
if tf.err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue