From 84070d27219f6aea1ae93a7517be42bd8b999a80 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 14 Aug 2026 08:11:27 +0800 Subject: [PATCH] fix: harden responsive tui layouts --- internal/tui/app.go | 36 +++++++++----- internal/tui/dashboard.go | 4 +- internal/tui/forward.go | 2 +- internal/tui/help_screen.go | 4 +- internal/tui/layout_test.go | 98 +++++++++++++++++++++++++++++++++++++ internal/tui/shell.go | 11 +++++ internal/tui/tunnel.go | 8 ++- 7 files changed, 145 insertions(+), 18 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index e95914d..8133dd7 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1543,13 +1543,12 @@ func (m *tuiModel) viewConfirm() string { } body := func(width, height int) string { innerWidth := max(1, width-4) - lines := []string{dashboardSection(m.confirm.title), ""} - lines = append(lines, wrapCells(m.confirm.target, innerWidth)...) + innerHeight := max(1, height-2) + message := wrapCells(m.confirm.target, innerWidth) if m.confirm.consequence != "" { - lines = append(lines, "") - lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...) + message = append(message, "") + message = append(message, wrapCells(m.confirm.consequence, innerWidth)...) } - lines = append(lines, "") cancel := "[ Cancel ]" accept := "[ " + m.confirm.verb + " ]" if m.confirm.focus == confirmCancel { @@ -1557,11 +1556,23 @@ func (m *tuiModel) viewConfirm() string { } else { accept = errorStyle.Render("> " + accept) } + action := cancel + " " + accept if m.confirm.pending { - lines = append(lines, m.confirm.verb+" in progress…") - } else { - lines = append(lines, cancel+" "+accept) + action = m.confirm.verb + " in progress…" } + 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 renderScreenShell(screenShell{ @@ -1983,10 +1994,11 @@ func (m *tuiModel) viewTemplates() string { if index == m.templateList.Index() { marker = "> " } - lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command) - if tpl.template.Description != "" && classifyTerminal(width, height) != sizeNarrow { - lines = append(lines, " "+dashboardHelp(tpl.template.Description)) + line := marker + tpl.template.Name + " " + tpl.template.Command + if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow { + line += " — " + tpl.template.Description } + lines = append(lines, line) } return renderPaddedPanel(width, height, lines) }, @@ -2175,7 +2187,7 @@ func (m *tuiModel) removeTag(name string) { // --- Server list footer --- func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { - width := m.width - 2 + width := m.width - 3 if width <= 0 { width = 80 } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index b5b7804..dbf78e5 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -16,6 +16,8 @@ func (m *tuiModel) renderServerDashboard() string { if height <= 0 { height = 40 } + sizeClass := classifyTerminal(width, height) + width = max(1, width-1) header := m.renderDashboardHeader(width) notification := m.renderDashboardNotification(width) @@ -29,7 +31,7 @@ func (m *tuiModel) renderServerDashboard() string { } var body string - switch classifyTerminal(width, height) { + switch sizeClass { case sizeWide: leftWidth := width * 62 / 100 rightWidth := width - leftWidth - 1 diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 78e5f45..98dc70f 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -84,7 +84,7 @@ func (m *forwardScreenModel) View() string { notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) } body := func(width, height int) string { - switch classifyTerminal(width, height) { + switch classifyShellContent(width) { case sizeWide: leftWidth := width * 70 / 100 rightWidth := width - leftWidth - 1 diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index f190cc8..6e1b3c2 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -349,7 +349,7 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) { func (m *actionMenuModel) View() string { body := func(width, height int) string { listLines := m.actionLines(max(1, height-2)) - if classifyTerminal(width, height) == sizeWide { + if classifyShellContent(width) == sizeWide { leftWidth := width * 48 / 100 rightWidth := width - leftWidth - 1 selected, _ := m.list.SelectedItem().(actionMenuItem) @@ -360,7 +360,7 @@ func (m *actionMenuModel) View() string { 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 { listLines = append(listLines, "", dashboardSection("Selected"), selected.description) } diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index dd7d52e..e4e5df2 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -46,6 +47,7 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) { m := New(servers) m.width, m.height = 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"} { if !strings.Contains(m.View(), want) { 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) { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { 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) { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { 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 func (e errText) Error() string { return string(e) } diff --git a/internal/tui/shell.go b/internal/tui/shell.go index a250605..26fae40 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -110,6 +110,17 @@ func splitBlock(block string) []string { 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 { vault := "Vault locked" if vaultUnlocked { diff --git a/internal/tui/tunnel.go b/internal/tui/tunnel.go index 5f301bf..105d839 100644 --- a/internal/tui/tunnel.go +++ b/internal/tui/tunnel.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "time" "github.com/charmbracelet/bubbles/list" @@ -102,7 +103,7 @@ func (m *tunnelScreenModel) View() string { return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")}) } 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) for index := start; index < end; index++ { item := tunnelItem{state: m.tunnels[index]} @@ -110,7 +111,10 @@ func (m *tunnelScreenModel) View() string { if index == m.list.Index() { 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) }