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.err = nil
|
||||||
m.success = ""
|
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.helpParent = m.screen
|
||||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
m.fullHelp = newFullHelpModel(m.width, m.height)
|
||||||
m.screen = screenFullHelp
|
m.screen = screenFullHelp
|
||||||
|
|
@ -1549,20 +1549,13 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
if m.confirm == nil {
|
if m.confirm == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
width := m.width
|
body := func(width, height int) string {
|
||||||
if width <= 0 {
|
innerWidth := max(1, width-4)
|
||||||
width = 80
|
lines := []string{dashboardSection(m.confirm.title), ""}
|
||||||
}
|
lines = append(lines, wrapCells(m.confirm.target, innerWidth)...)
|
||||||
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 != "" {
|
if m.confirm.consequence != "" {
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
|
lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...)
|
||||||
lines = append(lines, " "+line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
cancel := "[ Cancel ]"
|
cancel := "[ Cancel ]"
|
||||||
|
|
@ -1573,17 +1566,24 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
accept = errorStyle.Render("> " + accept)
|
accept = errorStyle.Render("> " + accept)
|
||||||
}
|
}
|
||||||
if m.confirm.pending {
|
if m.confirm.pending {
|
||||||
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
|
lines = append(lines, m.confirm.verb+" in progress…")
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
|
lines = append(lines, cancel+" "+accept)
|
||||||
}
|
}
|
||||||
footer := renderHelp([]helpItem{
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
}
|
||||||
|
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: "Tab", Action: "choose"},
|
||||||
{Key: "Enter", Action: "activate"},
|
{Key: "Enter", Action: "activate"},
|
||||||
{Key: "Esc", Action: "cancel"},
|
{Key: "Esc", Action: "cancel"},
|
||||||
}, width)
|
},
|
||||||
lines = append(lines, strings.Split(footer, "\n")...)
|
})
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) beginConfirm(state confirmState) {
|
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: "Ctrl+F", Action: "search"},
|
||||||
helpItem{Key: "Ins", Action: insAction},
|
helpItem{Key: "Ins", Action: insAction},
|
||||||
helpItem{Key: "?", Action: "hotkeys"},
|
helpItem{Key: "?", Action: "hotkeys"},
|
||||||
helpItem{Key: "F1", Action: "help"},
|
helpItem{Key: "Ctrl+H", Action: "help"},
|
||||||
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package tui
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
"github.com/charmbracelet/bubbletea"
|
"github.com/charmbracelet/bubbletea"
|
||||||
|
|
@ -14,6 +13,7 @@ import (
|
||||||
type helpScreenModel struct {
|
type helpScreenModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
width int
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHelpScreenModel(w, h int) *helpScreenModel {
|
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: "Ins", action: "Select / deselect", section: "Server list"},
|
||||||
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
||||||
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
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"},
|
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(false)
|
||||||
l.Styles.Title = titleStyle
|
l.Styles.Title = titleStyle
|
||||||
|
|
||||||
return &helpScreenModel{list: l, width: w}
|
return &helpScreenModel{list: l, width: w, height: h}
|
||||||
}
|
}
|
||||||
|
|
||||||
type helpScreenItem struct {
|
type helpScreenItem struct {
|
||||||
|
|
@ -86,6 +86,7 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
m.list.SetSize(msg.Width, msg.Height-4)
|
m.list.SetSize(msg.Width, msg.Height-4)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -95,10 +96,39 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *helpScreenModel) View() string {
|
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 {
|
type fullHelpModel struct {
|
||||||
width int
|
width int
|
||||||
|
|
@ -149,11 +179,6 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullHelpModel) View() string {
|
func (m *fullHelpModel) View() string {
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
sections := []struct {
|
sections := []struct {
|
||||||
title string
|
title string
|
||||||
rows [][2]string
|
rows [][2]string
|
||||||
|
|
@ -174,7 +199,7 @@ func (m *fullHelpModel) View() string {
|
||||||
{"Enter", "Select / Confirm / Open"},
|
{"Enter", "Select / Confirm / Open"},
|
||||||
{"Esc", "Back / Cancel / Close"},
|
{"Esc", "Back / Cancel / Close"},
|
||||||
{"?", "Quick help (hotkeys)"},
|
{"?", "Quick help (hotkeys)"},
|
||||||
{"F1", "Full documentation"},
|
{"Ctrl+H", "Full documentation"},
|
||||||
{"Ctrl+Q", "Quit"},
|
{"Ctrl+Q", "Quit"},
|
||||||
}},
|
}},
|
||||||
{"Server list", [][2]string{
|
{"Server list", [][2]string{
|
||||||
|
|
@ -224,40 +249,37 @@ func (m *fullHelpModel) View() string {
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
b.WriteString(sectionStyle.Render(sec.title))
|
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title))
|
||||||
b.WriteString("\n")
|
|
||||||
for _, row := range sec.rows {
|
for _, row := range sec.rows {
|
||||||
if row[0] == "" {
|
if row[0] == "" {
|
||||||
b.WriteString(fmt.Sprintf(" %s\n", row[1]))
|
lines = append(lines, " "+row[1])
|
||||||
} else {
|
} 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"))
|
body := func(width, height int) string {
|
||||||
|
capacity := max(1, height-2)
|
||||||
// Simple scroll
|
start := min(m.offset, max(0, len(lines)-capacity))
|
||||||
lines := strings.Split(b.String(), "\n")
|
end := min(len(lines), start+capacity)
|
||||||
maxLines := m.height - 1
|
return renderPaddedPanel(width, height, lines[start:end])
|
||||||
if maxLines < 5 {
|
|
||||||
maxLines = 5
|
|
||||||
}
|
}
|
||||||
start := m.offset
|
return renderScreenShell(screenShell{
|
||||||
if start > len(lines)-maxLines {
|
breadcrumb: "Full Help",
|
||||||
start = len(lines) - maxLines
|
status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)),
|
||||||
}
|
width: m.width,
|
||||||
if start < 0 {
|
height: m.height,
|
||||||
start = 0
|
body: body,
|
||||||
}
|
footer: []helpItem{
|
||||||
end := start + maxLines
|
{Key: "↑/↓", Action: "scroll"},
|
||||||
if end > len(lines) {
|
{Key: "Ctrl+H", Action: "full help"},
|
||||||
end = len(lines)
|
{Key: "Esc/Enter", Action: "close"},
|
||||||
}
|
},
|
||||||
|
})
|
||||||
return strings.Join(lines[start:end], "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Action menu ---
|
// --- Action menu ---
|
||||||
|
|
@ -265,6 +287,7 @@ func (m *fullHelpModel) View() string {
|
||||||
type actionMenuItem struct {
|
type actionMenuItem struct {
|
||||||
label string
|
label string
|
||||||
action string
|
action string
|
||||||
|
description string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i actionMenuItem) Title() string { return i.label }
|
func (i actionMenuItem) Title() string { return i.label }
|
||||||
|
|
@ -279,20 +302,20 @@ type actionMenuModel struct {
|
||||||
|
|
||||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
items := []list.Item{
|
items := []list.Item{
|
||||||
actionMenuItem{label: "Connect", action: "connect"},
|
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."},
|
||||||
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
|
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
|
||||||
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
|
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
|
||||||
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
|
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
|
||||||
actionMenuItem{label: "Manage port forwards", action: "forwards"},
|
actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
|
||||||
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
|
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
|
||||||
actionMenuItem{label: "Manage route", action: "route"},
|
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
|
||||||
actionMenuItem{label: "Test connection", action: "test"},
|
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
|
||||||
actionMenuItem{label: "Edit", action: "edit"},
|
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
|
||||||
actionMenuItem{label: "Delete", action: "delete"},
|
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
|
||||||
actionMenuItem{label: "Import", action: "import"},
|
actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."},
|
||||||
actionMenuItem{label: "Export", action: "export"},
|
actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."},
|
||||||
actionMenuItem{label: "Vault: lock", action: "vault_lock"},
|
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
|
||||||
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
|
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)
|
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 {
|
func (m *actionMenuModel) View() string {
|
||||||
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
|
body := func(width, height int) string {
|
||||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
|
listLines := m.actionLines(max(1, height-2))
|
||||||
capacity := max(1, m.height-displayLineCount(footer)-1)
|
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)
|
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
|
||||||
|
lines := make([]string, 0, capacity)
|
||||||
for index := start; index < end; index++ {
|
for index := start; index < end; index++ {
|
||||||
item, ok := m.list.Items()[index].(actionMenuItem)
|
item, ok := m.list.Items()[index].(actionMenuItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -337,11 +394,7 @@ func (m *actionMenuModel) View() string {
|
||||||
if index == m.list.Index() {
|
if index == m.list.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
lines = append(lines, fitLine(marker+item.label, m.width))
|
lines = append(lines, marker+item.label)
|
||||||
}
|
}
|
||||||
lines = append(lines, strings.Split(footer, "\n")...)
|
return lines
|
||||||
if len(lines) > m.height && m.height > 0 {
|
|
||||||
lines = lines[:m.height]
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
menu := newActionMenuModel(size.width, size.height)
|
menu := newActionMenuModel(size.width, size.height)
|
||||||
view := menu.View()
|
view := menu.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
assertUnifiedScreen(t, view, size.width, size.height)
|
||||||
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
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()
|
view := m.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
assertUnifiedScreen(t, view, size.width, size.height)
|
||||||
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
|
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
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) {
|
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)
|
||||||
|
|
@ -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
|
type errText string
|
||||||
|
|
||||||
func (e errText) Error() string { return string(e) }
|
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 := New(nil)
|
||||||
m.screen = screenForwardList
|
m.screen = screenForwardList
|
||||||
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
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)
|
m = updated.(*tuiModel)
|
||||||
if m.screen != screenFullHelp || m.fullHelp == nil {
|
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})
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
m = updated.(*tuiModel)
|
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) {
|
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
||||||
m := New(nil)
|
m := New(nil)
|
||||||
m.screen = screenForwardList
|
m.screen = screenForwardList
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue