From 460b4496a07674835d62e815ba2875975fb61e57 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 14 Aug 2026 03:42:43 +0800 Subject: [PATCH] feat: add responsive tui layouts --- go.mod | 2 +- internal/tui/app.go | 109 +----------- internal/tui/dashboard.go | 307 ++++++++++++++++++++++++++++++++++ internal/tui/form.go | 216 +++++++++++------------- internal/tui/forward.go | 234 +++++++++++++------------- internal/tui/help_screen.go | 28 +++- internal/tui/layout.go | 68 ++++++++ internal/tui/layout_test.go | 189 +++++++++++++++++++++ internal/tui/template_form.go | 8 +- 9 files changed, 820 insertions(+), 341 deletions(-) create mode 100644 internal/tui/dashboard.go create mode 100644 internal/tui/layout.go create mode 100644 internal/tui/layout_test.go diff --git a/go.mod b/go.mod index cf00dc6..fea61be 100644 --- a/go.mod +++ b/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 diff --git a/internal/tui/app.go b/internal/tui/app.go index 2e9d443..c559aef 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 { diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 0000000..b5b7804 --- /dev/null +++ b/internal/tui/dashboard.go @@ -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") +} diff --git a/internal/tui/form.go b/internal/tui/form.go index 41cd52f..13b08c5 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -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 { diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 548b2d4..49ea7e2 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -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 diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index ae082dc..3e78c67 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -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") } diff --git a/internal/tui/layout.go b/internal/tui/layout.go new file mode 100644 index 0000000..6c73b3a --- /dev/null +++ b/internal/tui/layout.go @@ -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) +} diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go new file mode 100644 index 0000000..ad63072 --- /dev/null +++ b/internal/tui/layout_test.go @@ -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:]) +} diff --git a/internal/tui/template_form.go b/internal/tui/template_form.go index a96d666..3110206 100644 --- a/internal/tui/template_form.go +++ b/internal/tui/template_form.go @@ -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 {