feat: add unified tui shell and ctrl-h help
This commit is contained in:
parent
4ba2bfce34
commit
762d14bb9b
|
|
@ -680,7 +680,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.err = nil
|
||||
m.success = ""
|
||||
}
|
||||
if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
||||
if msg.Type == tea.KeyCtrlH && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
||||
m.helpParent = m.screen
|
||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
||||
m.screen = screenFullHelp
|
||||
|
|
@ -1549,41 +1549,41 @@ func (m *tuiModel) viewConfirm() string {
|
|||
if m.confirm == nil {
|
||||
return ""
|
||||
}
|
||||
width := m.width
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
innerWidth := max(1, width-2)
|
||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""}
|
||||
for _, line := range wrapCells(m.confirm.target, innerWidth) {
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
if m.confirm.consequence != "" {
|
||||
lines = append(lines, "")
|
||||
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
|
||||
lines = append(lines, " "+line)
|
||||
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)...)
|
||||
if m.confirm.consequence != "" {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...)
|
||||
}
|
||||
lines = append(lines, "")
|
||||
cancel := "[ Cancel ]"
|
||||
accept := "[ " + m.confirm.verb + " ]"
|
||||
if m.confirm.focus == confirmCancel {
|
||||
cancel = selectedStyle.Render("> " + cancel)
|
||||
} else {
|
||||
accept = errorStyle.Render("> " + accept)
|
||||
}
|
||||
if m.confirm.pending {
|
||||
lines = append(lines, m.confirm.verb+" in progress…")
|
||||
} else {
|
||||
lines = append(lines, cancel+" "+accept)
|
||||
}
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
}
|
||||
lines = append(lines, "")
|
||||
cancel := "[ Cancel ]"
|
||||
accept := "[ " + m.confirm.verb + " ]"
|
||||
if m.confirm.focus == confirmCancel {
|
||||
cancel = selectedStyle.Render("> " + cancel)
|
||||
} else {
|
||||
accept = errorStyle.Render("> " + accept)
|
||||
}
|
||||
if m.confirm.pending {
|
||||
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
|
||||
} else {
|
||||
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
|
||||
}
|
||||
footer := renderHelp([]helpItem{
|
||||
{Key: "Tab", Action: "choose"},
|
||||
{Key: "Enter", Action: "activate"},
|
||||
{Key: "Esc", Action: "cancel"},
|
||||
}, width)
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
return strings.Join(lines, "\n")
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Confirm",
|
||||
status: shellStatus(m.vaultUnlocked, "Action required"),
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
body: body,
|
||||
footer: []helpItem{
|
||||
{Key: "Tab", Action: "choose"},
|
||||
{Key: "Enter", Action: "activate"},
|
||||
{Key: "Esc", Action: "cancel"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) beginConfirm(state confirmState) {
|
||||
|
|
@ -2163,7 +2163,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
|
|||
helpItem{Key: "Ctrl+F", Action: "search"},
|
||||
helpItem{Key: "Ins", Action: insAction},
|
||||
helpItem{Key: "?", Action: "hotkeys"},
|
||||
helpItem{Key: "F1", Action: "help"},
|
||||
helpItem{Key: "Ctrl+H", Action: "help"},
|
||||
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||
)
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package tui
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbletea"
|
||||
|
|
@ -12,8 +11,9 @@ import (
|
|||
// --- Help screen (?) ---
|
||||
|
||||
type helpScreenModel struct {
|
||||
list list.Model
|
||||
width int
|
||||
list list.Model
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||
|
|
@ -30,7 +30,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
|
|||
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
|
||||
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
||||
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
||||
helpScreenItem{key: "F1", action: "Full documentation", section: "Other"},
|
||||
helpScreenItem{key: "Ctrl+H", action: "Full documentation", section: "Other"},
|
||||
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
|
|||
l.SetFilteringEnabled(false)
|
||||
l.Styles.Title = titleStyle
|
||||
|
||||
return &helpScreenModel{list: l, width: w}
|
||||
return &helpScreenModel{list: l, width: w, height: h}
|
||||
}
|
||||
|
||||
type helpScreenItem struct {
|
||||
|
|
@ -86,6 +86,7 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetSize(msg.Width, msg.Height-4)
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -95,10 +96,39 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *helpScreenModel) View() string {
|
||||
return m.list.View()
|
||||
items := m.list.Items()
|
||||
body := func(width, height int) string {
|
||||
innerRows := max(1, height-2)
|
||||
start, end := visibleServerRange(len(items), m.list.Index(), innerRows)
|
||||
lines := make([]string, 0, innerRows)
|
||||
for index := start; index < end; index++ {
|
||||
item, ok := items[index].(helpScreenItem)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
if index == m.list.Index() {
|
||||
marker = "> "
|
||||
}
|
||||
lines = append(lines, marker+padCells(item.key, 12)+" "+item.action)
|
||||
}
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
}
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Quick Help",
|
||||
status: "Keyboard reference",
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
body: body,
|
||||
footer: []helpItem{
|
||||
{Key: "↑/↓", Action: "move"},
|
||||
{Key: "Ctrl+H", Action: "full help"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Full help (F1) ---
|
||||
// --- Full help (Ctrl+H) ---
|
||||
|
||||
type fullHelpModel struct {
|
||||
width int
|
||||
|
|
@ -149,11 +179,6 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *fullHelpModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
sections := []struct {
|
||||
title string
|
||||
rows [][2]string
|
||||
|
|
@ -174,7 +199,7 @@ func (m *fullHelpModel) View() string {
|
|||
{"Enter", "Select / Confirm / Open"},
|
||||
{"Esc", "Back / Cancel / Close"},
|
||||
{"?", "Quick help (hotkeys)"},
|
||||
{"F1", "Full documentation"},
|
||||
{"Ctrl+H", "Full documentation"},
|
||||
{"Ctrl+Q", "Quit"},
|
||||
}},
|
||||
{"Server list", [][2]string{
|
||||
|
|
@ -224,47 +249,45 @@ func (m *fullHelpModel) View() string {
|
|||
}},
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, sec := range sections {
|
||||
b.WriteString(sectionStyle.Render(sec.title))
|
||||
b.WriteString("\n")
|
||||
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title))
|
||||
for _, row := range sec.rows {
|
||||
if row[0] == "" {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", row[1]))
|
||||
lines = append(lines, " "+row[1])
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1]))
|
||||
lines = append(lines, fmt.Sprintf(" %-16s %s", row[0], row[1]))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close"))
|
||||
|
||||
// Simple scroll
|
||||
lines := strings.Split(b.String(), "\n")
|
||||
maxLines := m.height - 1
|
||||
if maxLines < 5 {
|
||||
maxLines = 5
|
||||
body := func(width, height int) string {
|
||||
capacity := max(1, height-2)
|
||||
start := min(m.offset, max(0, len(lines)-capacity))
|
||||
end := min(len(lines), start+capacity)
|
||||
return renderPaddedPanel(width, height, lines[start:end])
|
||||
}
|
||||
start := m.offset
|
||||
if start > len(lines)-maxLines {
|
||||
start = len(lines) - maxLines
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + maxLines
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
|
||||
return strings.Join(lines[start:end], "\n")
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Full Help",
|
||||
status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)),
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
body: body,
|
||||
footer: []helpItem{
|
||||
{Key: "↑/↓", Action: "scroll"},
|
||||
{Key: "Ctrl+H", Action: "full help"},
|
||||
{Key: "Esc/Enter", Action: "close"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Action menu ---
|
||||
|
||||
type actionMenuItem struct {
|
||||
label string
|
||||
action string
|
||||
label string
|
||||
action string
|
||||
description string
|
||||
}
|
||||
|
||||
func (i actionMenuItem) Title() string { return i.label }
|
||||
|
|
@ -279,20 +302,20 @@ type actionMenuModel struct {
|
|||
|
||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||
items := []list.Item{
|
||||
actionMenuItem{label: "Connect", action: "connect"},
|
||||
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
|
||||
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
|
||||
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
|
||||
actionMenuItem{label: "Manage port forwards", action: "forwards"},
|
||||
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
|
||||
actionMenuItem{label: "Manage route", action: "route"},
|
||||
actionMenuItem{label: "Test connection", action: "test"},
|
||||
actionMenuItem{label: "Edit", action: "edit"},
|
||||
actionMenuItem{label: "Delete", action: "delete"},
|
||||
actionMenuItem{label: "Import", action: "import"},
|
||||
actionMenuItem{label: "Export", action: "export"},
|
||||
actionMenuItem{label: "Vault: lock", action: "vault_lock"},
|
||||
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
|
||||
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."},
|
||||
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
|
||||
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
|
||||
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
|
||||
actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
|
||||
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
|
||||
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
|
||||
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
|
||||
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
|
||||
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
|
||||
actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."},
|
||||
actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."},
|
||||
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
|
||||
actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
|
||||
}
|
||||
|
||||
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
|
||||
|
|
@ -324,10 +347,44 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
|||
}
|
||||
|
||||
func (m *actionMenuModel) View() string {
|
||||
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)
|
||||
body := func(width, height int) string {
|
||||
listLines := m.actionLines(max(1, height-2))
|
||||
if classifyTerminal(width, height) == sizeWide {
|
||||
leftWidth := width * 48 / 100
|
||||
rightWidth := width - leftWidth - 1
|
||||
selected, _ := m.list.SelectedItem().(actionMenuItem)
|
||||
detail := []string{dashboardSection("Selected action"), "", selected.label, ""}
|
||||
detail = append(detail, wrapCells(selected.description, max(1, rightWidth-4))...)
|
||||
return joinPanelColumns(
|
||||
renderPaddedPanel(leftWidth, height, listLines), leftWidth,
|
||||
renderPaddedPanel(rightWidth, height, detail), rightWidth,
|
||||
)
|
||||
}
|
||||
if classifyTerminal(width, height) == sizeMedium {
|
||||
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
|
||||
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
|
||||
}
|
||||
}
|
||||
return renderPaddedPanel(width, height, listLines)
|
||||
}
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Actions",
|
||||
status: fmt.Sprintf("%d actions", len(m.list.Items())),
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
body: body,
|
||||
footer: []helpItem{
|
||||
{Key: "↑/↓", Action: "move"},
|
||||
{Key: "Enter", Action: "select"},
|
||||
{Key: "Ctrl+H", Action: "help"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *actionMenuModel) actionLines(capacity int) []string {
|
||||
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
|
||||
lines := make([]string, 0, capacity)
|
||||
for index := start; index < end; index++ {
|
||||
item, ok := m.list.Items()[index].(actionMenuItem)
|
||||
if !ok {
|
||||
|
|
@ -337,11 +394,7 @@ func (m *actionMenuModel) View() string {
|
|||
if index == m.list.Index() {
|
||||
marker = "> "
|
||||
}
|
||||
lines = append(lines, fitLine(marker+item.label, m.width))
|
||||
lines = append(lines, marker+item.label)
|
||||
}
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
if len(lines) > m.height && m.height > 0 {
|
||||
lines = lines[:m.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
return lines
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
|||
menu := newActionMenuModel(size.width, size.height)
|
||||
view := menu.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
assertUnifiedScreen(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)
|
||||
|
|
@ -154,6 +155,7 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
|||
})
|
||||
view := m.View()
|
||||
assertViewFits(t, view, size.width, size.height)
|
||||
assertUnifiedScreen(t, view, size.width, size.height)
|
||||
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||
|
|
@ -162,6 +164,22 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"quick": newHelpScreenModel(size.width, size.height).View(),
|
||||
"full": newFullHelpModel(size.width, size.height).View(),
|
||||
} {
|
||||
t.Run(name+itoa(size.width), func(t *testing.T) {
|
||||
assertUnifiedScreen(t, view, size.width, size.height)
|
||||
if strings.Contains(view, "F1") || !strings.Contains(view, "Ctrl+H") {
|
||||
t.Fatalf("help exposes the wrong global binding:\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)
|
||||
|
|
@ -190,6 +208,28 @@ func assertViewFits(t *testing.T, view string, width, height int) {
|
|||
}
|
||||
}
|
||||
|
||||
func assertUnifiedScreen(t *testing.T, view string, width, height int) {
|
||||
t.Helper()
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) != height {
|
||||
t.Fatalf("unified screen has %d lines, want %d:\n%s", len(lines), height, view)
|
||||
}
|
||||
if !strings.HasPrefix(ansi.Strip(lines[0]), "sshkeeper / ") {
|
||||
t.Fatalf("unified screen has no breadcrumb header: %q", ansi.Strip(lines[0]))
|
||||
}
|
||||
if !strings.Contains(ansi.Strip(view), "┌") || !strings.Contains(ansi.Strip(view), "┘") {
|
||||
t.Fatalf("unified screen has no framed content:\n%s", view)
|
||||
}
|
||||
if strings.TrimSpace(ansi.Strip(lines[height-1])) == "" {
|
||||
t.Fatalf("unified screen footer is not on last row:\n%s", view)
|
||||
}
|
||||
for index, line := range lines {
|
||||
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) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type screenShell struct {
|
||||
breadcrumb string
|
||||
status string
|
||||
notification string
|
||||
width int
|
||||
height int
|
||||
body func(width, height int) string
|
||||
footer []helpItem
|
||||
}
|
||||
|
||||
func renderScreenShell(shell screenShell) string {
|
||||
width, height := shell.width, shell.height
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 24
|
||||
}
|
||||
canvasWidth := max(1, width-1)
|
||||
|
||||
headerLeft := "sshkeeper"
|
||||
if shell.breadcrumb != "" {
|
||||
headerLeft += " / " + shell.breadcrumb
|
||||
}
|
||||
header := headerLeft
|
||||
if shell.status != "" {
|
||||
if gap := canvasWidth - lipgloss.Width(headerLeft) - lipgloss.Width(shell.status); gap > 0 {
|
||||
header = headerLeft + strings.Repeat(" ", gap) + shell.status
|
||||
} else {
|
||||
header = headerLeft + " " + shell.status
|
||||
}
|
||||
}
|
||||
|
||||
footer := renderHelp(shell.footer, canvasWidth)
|
||||
footerLines := splitBlock(footer)
|
||||
if len(footerLines) == 0 {
|
||||
footerLines = []string{""}
|
||||
}
|
||||
fixedRows := 2 + len(footerLines)
|
||||
notificationLines := []string(nil)
|
||||
if shell.notification != "" {
|
||||
notificationLines = []string{fitLine(shell.notification, canvasWidth)}
|
||||
fixedRows++
|
||||
}
|
||||
bodyHeight := max(1, height-fixedRows)
|
||||
body := ""
|
||||
if shell.body != nil {
|
||||
body = shell.body(canvasWidth, bodyHeight)
|
||||
}
|
||||
bodyLines := fitBlock(body, canvasWidth, bodyHeight)
|
||||
|
||||
lines := make([]string, 0, height)
|
||||
lines = append(lines,
|
||||
titleStyle.Copy().MarginLeft(0).Render(fitLine(header, canvasWidth)),
|
||||
helpStyle.Copy().MarginLeft(0).Render(strings.Repeat("─", canvasWidth)),
|
||||
)
|
||||
lines = append(lines, notificationLines...)
|
||||
lines = append(lines, bodyLines...)
|
||||
for _, line := range footerLines {
|
||||
lines = append(lines, fitLine(line, canvasWidth))
|
||||
}
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
for len(lines) < height {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderPaddedPanel(width, height int, lines []string) string {
|
||||
if width < 4 || height < 2 {
|
||||
return ""
|
||||
}
|
||||
contentWidth := width - 4
|
||||
padded := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
padded = append(padded, " "+padCells(line, contentWidth)+" ")
|
||||
}
|
||||
return renderPanel(width, height, padded)
|
||||
}
|
||||
|
||||
func fitBlock(block string, width, height int) []string {
|
||||
lines := splitBlock(block)
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
for index := range lines {
|
||||
lines[index] = fitLine(lines[index], width)
|
||||
}
|
||||
for len(lines) < height {
|
||||
lines = append(lines, strings.Repeat(" ", width))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func splitBlock(block string) []string {
|
||||
if block == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimRight(block, "\n"), "\n")
|
||||
}
|
||||
|
||||
func shellStatus(vaultUnlocked bool, detail string) string {
|
||||
vault := "Vault locked"
|
||||
if vaultUnlocked {
|
||||
vault = "Vault unlocked"
|
||||
}
|
||||
if detail == "" {
|
||||
return vault
|
||||
}
|
||||
return fmt.Sprintf("%s · %s", vault, detail)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestScreenShellFitsAndAnchorsFooter(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
t.Run(itoa(size.width)+"x"+itoa(size.height), func(t *testing.T) {
|
||||
view := renderScreenShell(screenShell{
|
||||
breadcrumb: "Actions / production-数据库",
|
||||
status: "Vault unlocked",
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
body: func(width, height int) string {
|
||||
return renderPaddedPanel(width, height, []string{"Actions", "> Connect", " Manage port forwards"})
|
||||
},
|
||||
footer: []helpItem{{Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||
})
|
||||
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) != size.height {
|
||||
t.Fatalf("shell has %d lines, want %d:\n%s", len(lines), size.height, view)
|
||||
}
|
||||
if !strings.Contains(ansi.Strip(lines[0]), "sshkeeper / Actions") {
|
||||
t.Fatalf("missing breadcrumb header: %q", ansi.Strip(lines[0]))
|
||||
}
|
||||
if !strings.HasPrefix(ansi.Strip(lines[2]), "┌") || !strings.HasSuffix(ansi.Strip(lines[size.height-2]), "┘") {
|
||||
t.Fatalf("body panel does not fill shell:\n%s", view)
|
||||
}
|
||||
if !strings.Contains(ansi.Strip(lines[size.height-1]), "Ctrl+H") {
|
||||
t.Fatalf("footer is not anchored to last row: %q", ansi.Strip(lines[size.height-1]))
|
||||
}
|
||||
for index, line := range lines {
|
||||
if got := ansi.StringWidth(line); got > size.width-1 {
|
||||
t.Fatalf("line %d uses unsafe last terminal column: width=%d, terminal=%d", index+1, got, size.width)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenShellShowsNotificationWithoutMovingFooter(t *testing.T) {
|
||||
view := renderScreenShell(screenShell{
|
||||
breadcrumb: "Port Forwards / prod",
|
||||
status: "2 rules",
|
||||
notification: "Forward saved",
|
||||
width: 60,
|
||||
height: 16,
|
||||
body: func(width, height int) string {
|
||||
return renderPaddedPanel(width, height, []string{"Local PostgreSQL"})
|
||||
},
|
||||
footer: []helpItem{{Key: "Esc", Action: "back"}},
|
||||
})
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) != 16 || !strings.Contains(view, "Forward saved") || !strings.Contains(ansi.Strip(lines[15]), "Esc") {
|
||||
t.Fatalf("notification broke shell geometry:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
||||
func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
||||
m := New(nil)
|
||||
m.screen = screenForwardList
|
||||
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
|
||||
m = updated.(*tuiModel)
|
||||
if m.screen != screenFullHelp || m.fullHelp == nil {
|
||||
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
|
||||
t.Fatalf("Ctrl+H did not open full help from forward list: screen=%v", m.screen)
|
||||
}
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
m = updated.(*tuiModel)
|
||||
|
|
@ -62,6 +62,27 @@ func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestF1NoLongerOpensFullHelp(t *testing.T) {
|
||||
m := New(nil)
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
|
||||
m = updated.(*tuiModel)
|
||||
if m.screen == screenFullHelp || m.fullHelp != nil {
|
||||
t.Fatalf("F1 still opens full help: screen=%v", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackspaceStillEditsSearchAfterCtrlHBinding(t *testing.T) {
|
||||
m := New(nil)
|
||||
m.screen = screenSearch
|
||||
m.searchInput.SetValue("prod")
|
||||
m.searchInput.Focus()
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
m = updated.(*tuiModel)
|
||||
if m.screen != screenSearch || m.searchInput.Value() != "pro" {
|
||||
t.Fatalf("Backspace did not edit search: screen=%v value=%q", m.screen, m.searchInput.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
||||
m := New(nil)
|
||||
m.screen = screenForwardList
|
||||
|
|
|
|||
Loading…
Reference in New Issue