fix: harden responsive tui layouts
This commit is contained in:
parent
5e83300ea8
commit
84070d2721
|
|
@ -1543,13 +1543,12 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
}
|
}
|
||||||
body := func(width, height int) string {
|
body := func(width, height int) string {
|
||||||
innerWidth := max(1, width-4)
|
innerWidth := max(1, width-4)
|
||||||
lines := []string{dashboardSection(m.confirm.title), ""}
|
innerHeight := max(1, height-2)
|
||||||
lines = append(lines, wrapCells(m.confirm.target, innerWidth)...)
|
message := wrapCells(m.confirm.target, innerWidth)
|
||||||
if m.confirm.consequence != "" {
|
if m.confirm.consequence != "" {
|
||||||
lines = append(lines, "")
|
message = append(message, "")
|
||||||
lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...)
|
message = append(message, wrapCells(m.confirm.consequence, innerWidth)...)
|
||||||
}
|
}
|
||||||
lines = append(lines, "")
|
|
||||||
cancel := "[ Cancel ]"
|
cancel := "[ Cancel ]"
|
||||||
accept := "[ " + m.confirm.verb + " ]"
|
accept := "[ " + m.confirm.verb + " ]"
|
||||||
if m.confirm.focus == confirmCancel {
|
if m.confirm.focus == confirmCancel {
|
||||||
|
|
@ -1557,11 +1556,23 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
} else {
|
} else {
|
||||||
accept = errorStyle.Render("> " + accept)
|
accept = errorStyle.Render("> " + accept)
|
||||||
}
|
}
|
||||||
|
action := cancel + " " + accept
|
||||||
if m.confirm.pending {
|
if m.confirm.pending {
|
||||||
lines = append(lines, m.confirm.verb+" in progress…")
|
action = m.confirm.verb + " in progress…"
|
||||||
} else {
|
|
||||||
lines = append(lines, cancel+" "+accept)
|
|
||||||
}
|
}
|
||||||
|
messageRows := max(0, innerHeight-2)
|
||||||
|
if len(message) > messageRows {
|
||||||
|
message = message[:messageRows]
|
||||||
|
if len(message) > 0 {
|
||||||
|
message[len(message)-1] = truncateCells(strings.TrimSpace(message[len(message)-1])+" …", innerWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines := []string{dashboardSection(m.confirm.title)}
|
||||||
|
lines = append(lines, message...)
|
||||||
|
for len(lines) < innerHeight-1 {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
lines = append(lines, action)
|
||||||
return renderPaddedPanel(width, height, lines)
|
return renderPaddedPanel(width, height, lines)
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
return renderScreenShell(screenShell{
|
||||||
|
|
@ -1983,10 +1994,11 @@ func (m *tuiModel) viewTemplates() string {
|
||||||
if index == m.templateList.Index() {
|
if index == m.templateList.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
line := marker + tpl.template.Name + " " + tpl.template.Command
|
||||||
if tpl.template.Description != "" && classifyTerminal(width, height) != sizeNarrow {
|
if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow {
|
||||||
lines = append(lines, " "+dashboardHelp(tpl.template.Description))
|
line += " — " + tpl.template.Description
|
||||||
}
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
return renderPaddedPanel(width, height, lines)
|
return renderPaddedPanel(width, height, lines)
|
||||||
},
|
},
|
||||||
|
|
@ -2175,7 +2187,7 @@ func (m *tuiModel) removeTag(name string) {
|
||||||
// --- Server list footer ---
|
// --- Server list footer ---
|
||||||
|
|
||||||
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
||||||
width := m.width - 2
|
width := m.width - 3
|
||||||
if width <= 0 {
|
if width <= 0 {
|
||||||
width = 80
|
width = 80
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ func (m *tuiModel) renderServerDashboard() string {
|
||||||
if height <= 0 {
|
if height <= 0 {
|
||||||
height = 40
|
height = 40
|
||||||
}
|
}
|
||||||
|
sizeClass := classifyTerminal(width, height)
|
||||||
|
width = max(1, width-1)
|
||||||
|
|
||||||
header := m.renderDashboardHeader(width)
|
header := m.renderDashboardHeader(width)
|
||||||
notification := m.renderDashboardNotification(width)
|
notification := m.renderDashboardNotification(width)
|
||||||
|
|
@ -29,7 +31,7 @@ func (m *tuiModel) renderServerDashboard() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body string
|
var body string
|
||||||
switch classifyTerminal(width, height) {
|
switch sizeClass {
|
||||||
case sizeWide:
|
case sizeWide:
|
||||||
leftWidth := width * 62 / 100
|
leftWidth := width * 62 / 100
|
||||||
rightWidth := width - leftWidth - 1
|
rightWidth := width - leftWidth - 1
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ func (m *forwardScreenModel) View() string {
|
||||||
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
||||||
}
|
}
|
||||||
body := func(width, height int) string {
|
body := func(width, height int) string {
|
||||||
switch classifyTerminal(width, height) {
|
switch classifyShellContent(width) {
|
||||||
case sizeWide:
|
case sizeWide:
|
||||||
leftWidth := width * 70 / 100
|
leftWidth := width * 70 / 100
|
||||||
rightWidth := width - leftWidth - 1
|
rightWidth := width - leftWidth - 1
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,7 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||||
func (m *actionMenuModel) View() string {
|
func (m *actionMenuModel) View() string {
|
||||||
body := func(width, height int) string {
|
body := func(width, height int) string {
|
||||||
listLines := m.actionLines(max(1, height-2))
|
listLines := m.actionLines(max(1, height-2))
|
||||||
if classifyTerminal(width, height) == sizeWide {
|
if classifyShellContent(width) == sizeWide {
|
||||||
leftWidth := width * 48 / 100
|
leftWidth := width * 48 / 100
|
||||||
rightWidth := width - leftWidth - 1
|
rightWidth := width - leftWidth - 1
|
||||||
selected, _ := m.list.SelectedItem().(actionMenuItem)
|
selected, _ := m.list.SelectedItem().(actionMenuItem)
|
||||||
|
|
@ -360,7 +360,7 @@ func (m *actionMenuModel) View() string {
|
||||||
renderPaddedPanel(rightWidth, height, detail), rightWidth,
|
renderPaddedPanel(rightWidth, height, detail), rightWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if classifyTerminal(width, height) == sizeMedium {
|
if classifyShellContent(width) == sizeMedium {
|
||||||
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
|
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
|
||||||
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
|
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
m := New(servers)
|
m := New(servers)
|
||||||
m.width, m.height = size.width, size.height
|
m.width, m.height = size.width, size.height
|
||||||
assertViewFits(t, m.View(), size.width, size.height)
|
assertViewFits(t, m.View(), size.width, size.height)
|
||||||
|
assertRightMargin(t, m.View(), size.width)
|
||||||
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
|
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
|
||||||
if !strings.Contains(m.View(), want) {
|
if !strings.Contains(m.View(), want) {
|
||||||
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
|
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
|
||||||
|
|
@ -168,6 +170,27 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfirmationKeepsActionsVisibleWithLongContent(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
m := New(nil)
|
||||||
|
m.width, m.height = size.width, size.height
|
||||||
|
m.beginConfirm(confirmState{
|
||||||
|
title: "Delete port forward?",
|
||||||
|
target: strings.Repeat("非常に長い-очень-длинный-🔐 ", 20),
|
||||||
|
consequence: strings.Repeat("Active connections can be interrupted. ", 20),
|
||||||
|
verb: "Delete",
|
||||||
|
parent: screenForwardList,
|
||||||
|
})
|
||||||
|
view := m.View()
|
||||||
|
assertUnifiedScreen(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"[ Cancel ]", "[ Delete ]"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("confirmation at %dx%d clipped %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHelpScreensUseUnifiedShell(t *testing.T) {
|
func TestHelpScreensUseUnifiedShell(t *testing.T) {
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
for name, view := range map[string]string{
|
for name, view := range map[string]string{
|
||||||
|
|
@ -218,6 +241,72 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
|
||||||
|
covered := map[screen]string{
|
||||||
|
screenList: "dashboard",
|
||||||
|
screenForm: "server form",
|
||||||
|
screenSearch: "manager matrix",
|
||||||
|
screenTags: "manager matrix",
|
||||||
|
screenTagInput: "manager matrix",
|
||||||
|
screenTemplates: "manager matrix",
|
||||||
|
screenTemplateForm: "template form",
|
||||||
|
screenTemplatePicker: "manager matrix",
|
||||||
|
screenTemplateMode: "manager matrix",
|
||||||
|
screenBackgroundResults: "manager matrix",
|
||||||
|
screenHelp: "help matrix",
|
||||||
|
screenActionMenu: "action matrix",
|
||||||
|
screenForwardList: "forward matrix",
|
||||||
|
screenForwardForm: "forward form matrix",
|
||||||
|
screenTunnelManager: "manager matrix",
|
||||||
|
screenConfirm: "confirmation matrix",
|
||||||
|
screenFullHelp: "help matrix",
|
||||||
|
}
|
||||||
|
for value := screenList; value <= screenFullHelp; value++ {
|
||||||
|
if _, ok := covered[value]; !ok {
|
||||||
|
t.Fatalf("screen %d is missing from the layout matrix", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellBreakpointsUseTerminalWidth(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
contentWidth int
|
||||||
|
want terminalSizeClass
|
||||||
|
}{{68, sizeNarrow}, {69, sizeMedium}, {98, sizeMedium}, {99, sizeWide}} {
|
||||||
|
if got := classifyShellContent(tt.contentWidth); got != tt.want {
|
||||||
|
t.Fatalf("content width %d classified as %v, want %v", tt.contentWidth, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelErrorUsesUnifiedShellRows(t *testing.T) {
|
||||||
|
tunnelModel := newTunnelScreenModel(60, 16)
|
||||||
|
tunnelModel.tunnels = []*model.TunnelState{{Name: "prod tunnel", ServerAlias: "prod", LastError: "connection lost\nretry failed"}}
|
||||||
|
tunnelModel.rebuildList()
|
||||||
|
view := tunnelModel.View()
|
||||||
|
assertUnifiedScreen(t, view, 60, 16)
|
||||||
|
if !strings.Contains(view, "connection lost") || !strings.Contains(view, "retry failed") {
|
||||||
|
t.Fatalf("tunnel error was lost:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateViewportKeepsSelectedDescribedItemVisible(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.width, m.height = 60, 16
|
||||||
|
templates := make([]*model.CommandTemplate, 20)
|
||||||
|
for index := range templates {
|
||||||
|
templates[index] = &model.CommandTemplate{Name: fmt.Sprintf("template-%02d", index), Command: "echo ok", Description: "description"}
|
||||||
|
}
|
||||||
|
m.setTemplates(templates)
|
||||||
|
m.templateList.Select(len(templates) - 1)
|
||||||
|
m.screen = screenTemplates
|
||||||
|
view := m.View()
|
||||||
|
assertUnifiedScreen(t, view, 60, 16)
|
||||||
|
if !strings.Contains(view, "> template-19") {
|
||||||
|
t.Fatalf("selected template is outside viewport:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
form := newTemplateFormModel(nil, size.width, size.height)
|
form := newTemplateFormModel(nil, size.width, size.height)
|
||||||
|
|
@ -284,6 +373,15 @@ func assertUnifiedScreen(t *testing.T, view string, width, height int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertRightMargin(t *testing.T, view string, width int) {
|
||||||
|
t.Helper()
|
||||||
|
for index, line := range strings.Split(view, "\n") {
|
||||||
|
if got := ansi.StringWidth(line); got > width-1 {
|
||||||
|
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type errText string
|
type errText string
|
||||||
|
|
||||||
func (e errText) Error() string { return string(e) }
|
func (e errText) Error() string { return string(e) }
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,17 @@ func splitBlock(block string) []string {
|
||||||
return strings.Split(strings.TrimRight(block, "\n"), "\n")
|
return strings.Split(strings.TrimRight(block, "\n"), "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classifyShellContent(contentWidth int) terminalSizeClass {
|
||||||
|
terminalWidth := contentWidth + 1
|
||||||
|
if terminalWidth >= 100 {
|
||||||
|
return sizeWide
|
||||||
|
}
|
||||||
|
if terminalWidth >= 70 {
|
||||||
|
return sizeMedium
|
||||||
|
}
|
||||||
|
return sizeNarrow
|
||||||
|
}
|
||||||
|
|
||||||
func shellStatus(vaultUnlocked bool, detail string) string {
|
func shellStatus(vaultUnlocked bool, detail string) string {
|
||||||
vault := "Vault locked"
|
vault := "Vault locked"
|
||||||
if vaultUnlocked {
|
if vaultUnlocked {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
|
@ -102,7 +103,7 @@ func (m *tunnelScreenModel) View() string {
|
||||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
|
||||||
}
|
}
|
||||||
capacity := max(1, height-2)
|
capacity := max(1, height-2)
|
||||||
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/2))
|
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
|
||||||
lines := make([]string, 0, capacity)
|
lines := make([]string, 0, capacity)
|
||||||
for index := start; index < end; index++ {
|
for index := start; index < end; index++ {
|
||||||
item := tunnelItem{state: m.tunnels[index]}
|
item := tunnelItem{state: m.tunnels[index]}
|
||||||
|
|
@ -110,7 +111,10 @@ func (m *tunnelScreenModel) View() string {
|
||||||
if index == m.list.Index() {
|
if index == m.list.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
lines = append(lines, marker+item.Title(), " "+item.Description())
|
lines = append(lines, marker+item.Title())
|
||||||
|
for _, description := range strings.Split(item.Description(), "\n") {
|
||||||
|
lines = append(lines, " "+strings.TrimSpace(description))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return renderPaddedPanel(width, height, lines)
|
return renderPaddedPanel(width, height, lines)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue