feat: unify tui manager screens

This commit is contained in:
mirivlad 2026-08-14 07:58:47 +08:00
parent d8ae2d4236
commit 18d7a7c07a
3 changed files with 226 additions and 130 deletions

View File

@ -1213,8 +1213,7 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewServerList()) b.WriteString(m.viewServerList())
case screenSearch: case screenSearch:
b.WriteString("Search: " + m.searchInput.View() + "\n") b.WriteString(m.viewSearch())
b.WriteString(renderHelp([]helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Esc", Action: "cancel"}}, m.width))
case screenForm: case screenForm:
b.WriteString(m.form.View()) b.WriteString(m.form.View())
@ -1274,13 +1273,6 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewConfirm()) b.WriteString(m.viewConfirm())
} }
if m.screen != screenList && m.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
}
if m.screen != screenList && m.success != "" {
b.WriteString("\n" + successStyle.Render(m.success))
}
return b.String() return b.String()
} }
@ -1771,6 +1763,36 @@ func (m *tuiModel) viewServerList() string {
return m.renderServerDashboard() return m.renderServerDashboard()
} }
func (m *tuiModel) rootNotification() string {
if m.err != nil {
return errorStyle.Render("Error: " + m.err.Error())
}
if m.success != "" {
return successStyle.Render(m.success)
}
return ""
}
func (m *tuiModel) viewSearch() string {
return renderScreenShell(screenShell{
breadcrumb: "Search",
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d profiles", len(m.servers))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{
dashboardSection("Find server"),
"",
m.searchInput.View(),
"",
dashboardHelp("Search alias, host, display name, group, tags, notes, and route."),
})
},
footer: []helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
}
func (m *tuiModel) viewInlineBackgroundResults() string { func (m *tuiModel) viewInlineBackgroundResults() string {
var b strings.Builder var b strings.Builder
b.WriteString(sectionStyle.Render("Last Background Run")) b.WriteString(sectionStyle.Render("Last Background Run"))
@ -1891,37 +1913,34 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
} }
func (m *tuiModel) viewTags() string { func (m *tuiModel) viewTags() string {
var b strings.Builder return renderScreenShell(screenShell{
b.WriteString(titleStyle.Render("Tags")) breadcrumb: "Tags",
b.WriteString("\n\n") status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
if len(m.tags) == 0 { if len(m.tags) == 0 {
b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers.")) return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")})
b.WriteString("\n") }
} else { capacity := max(1, height-2)
for i, item := range m.tagList.Items() { start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity)
tag, ok := item.(groupItem) lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
tag, ok := m.tagList.Items()[index].(groupItem)
if !ok { if !ok {
continue continue
} }
marker := " " marker := " "
style := normalStyle if index == m.tagList.Index() {
if i == m.tagList.Index() {
marker = "> " marker = "> "
style = selectedRowStyle
} }
b.WriteString(style.Render(marker + tag.name)) lines = append(lines, marker+tag.name)
b.WriteString("\n")
} }
} return renderPaddedPanel(width, height, lines)
b.WriteString("\n") },
b.WriteString(renderHelp([]helpItem{ footer: []helpItem{{Key: "Enter", Action: "toggle"}, {Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
{Key: "Enter", Action: "toggle for selected/current"}, })
{Key: "Ctrl+A", Action: "add"},
{Key: "Ctrl+E", Action: "rename"},
{Key: "Ctrl+D", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
} }
func (m *tuiModel) viewTagInput() string { func (m *tuiModel) viewTagInput() string {
@ -1929,74 +1948,82 @@ func (m *tuiModel) viewTagInput() string {
if m.tagMode == "rename" { if m.tagMode == "rename" {
title = "Rename Tag" title = "Rename Tag"
} }
return titleStyle.Render(title) + "\n\n" + m.tagInput.View() + "\n\n" + renderHelp([]helpItem{{Key: "Enter", Action: "save"}, {Key: "Esc", Action: "cancel"}}, m.width) return renderScreenShell(screenShell{
breadcrumb: title,
status: shellStatus(m.vaultUnlocked, "Tag editor"),
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.tagInput.View()})
},
footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
} }
func (m *tuiModel) viewTemplates() string { func (m *tuiModel) viewTemplates() string {
var b strings.Builder return renderScreenShell(screenShell{
b.WriteString(titleStyle.Render("Command Templates")) breadcrumb: "Command Templates",
b.WriteString("\n\n") status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
if len(m.templates) == 0 { if len(m.templates) == 0 {
b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one.")) return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")})
b.WriteString("\n") }
} else { capacity := max(1, height-2)
for i, item := range m.templateList.Items() { start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
tpl, ok := item.(templateItem) lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
tpl, ok := m.templateList.Items()[index].(templateItem)
if !ok { if !ok {
continue continue
} }
marker := " " marker := " "
style := normalStyle if index == m.templateList.Index() {
if i == m.templateList.Index() {
marker = "> " marker = "> "
style = selectedRowStyle
} }
line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command) lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
b.WriteString(style.Render(line)) if tpl.template.Description != "" && classifyTerminal(width, height) != sizeNarrow {
b.WriteString("\n") lines = append(lines, " "+dashboardHelp(tpl.template.Description))
if tpl.template.Description != "" {
b.WriteString(helpStyle.Render(" " + tpl.template.Description))
b.WriteString("\n")
} }
} }
} return renderPaddedPanel(width, height, lines)
b.WriteString("\n") },
b.WriteString(renderHelp([]helpItem{ footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "edit"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
{Key: "Ctrl+A", Action: "add"}, })
{Key: "Ctrl+E", Action: "edit"},
{Key: "Ctrl+D", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
} }
func (m *tuiModel) viewTemplatePicker() string { func (m *tuiModel) viewTemplatePicker() string {
var b strings.Builder targets := strings.Join(serverAliases(m.targetServers()), ", ")
b.WriteString(titleStyle.Render("Run Template")) return renderScreenShell(screenShell{
b.WriteString("\n") breadcrumb: "Run Template",
b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", ")))) status: "Targets: " + targets,
b.WriteString("\n\n") width: m.width,
height: m.height,
body: func(width, height int) string {
lines := []string{dashboardSection("Choose template"), dashboardHelp("Targets: " + targets), ""}
if len(m.templates) == 0 { if len(m.templates) == 0 {
b.WriteString(helpStyle.Render(" No command templates. Press Esc, then Ctrl+P to add one.")) lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one."))
} else { } else {
for i, item := range m.templateList.Items() { capacity := max(1, height-len(lines)-2)
tpl, ok := item.(templateItem) start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
for index := start; index < end; index++ {
tpl, ok := m.templateList.Items()[index].(templateItem)
if !ok { if !ok {
continue continue
} }
marker := " " marker := " "
style := normalStyle if index == m.templateList.Index() {
if i == m.templateList.Index() {
marker = "> " marker = "> "
style = selectedRowStyle
} }
b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command))) lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
b.WriteString("\n")
} }
} }
b.WriteString("\n") return renderPaddedPanel(width, height, lines)
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width)) },
return b.String() footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
} }
func (m *tuiModel) viewTemplateMode() string { func (m *tuiModel) viewTemplateMode() string {
@ -2006,30 +2033,44 @@ func (m *tuiModel) viewTemplateMode() string {
name = m.pendingTemplate.Name name = m.pendingTemplate.Name
command = m.pendingTemplate.Command command = m.pendingTemplate.Command
} }
return titleStyle.Render("Run Mode") + "\n\n" + targets := strings.Join(serverAliases(m.targetServers()), ", ")
fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) + return renderScreenShell(screenShell{
renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width) breadcrumb: "Run Template / Mode",
status: "Targets: " + targets,
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{dashboardSection("Execution"), "", "Template: " + name, "Command: " + command, "Targets: " + targets, "", "Choose foreground for an interactive run or background to keep using sshkeeper."})
},
footer: []helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
} }
func (m *tuiModel) viewBackgroundResults() string { func (m *tuiModel) viewBackgroundResults() string {
var b strings.Builder return renderScreenShell(screenShell{
b.WriteString(titleStyle.Render("Background Results")) breadcrumb: "Background Results",
b.WriteString("\n\n") status: fmt.Sprintf("%d results", len(m.bgResults)),
width: m.width,
height: m.height,
body: func(width, height int) string {
lines := make([]string, 0)
for _, result := range m.bgResults { for _, result := range m.bgResults {
status := "OK" status := "OK"
if result.Err != "" { if result.Err != "" {
status = "FAIL: " + result.Err status = "FAIL: " + result.Err
} }
b.WriteString(sectionStyle.Render(result.Alias + " " + status)) lines = append(lines, dashboardSection(result.Alias+" "+status))
b.WriteString("\n")
if result.Output != "" { if result.Output != "" {
b.WriteString(result.Output) for _, line := range strings.Split(result.Output, "\n") {
b.WriteString("\n") lines = append(lines, strings.ReplaceAll(line, "\t", " "))
} }
} }
b.WriteString("\n") lines = append(lines, "")
b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width)) }
return b.String() return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
})
} }
func (m *tuiModel) selectedServers() []*model.Server { func (m *tuiModel) selectedServers() []*model.Server {

View File

@ -183,6 +183,40 @@ func TestHelpScreensUseUnifiedShell(t *testing.T) {
} }
} }
func TestManagerScreensUseUnifiedShell(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"}})
m.width, m.height = size.width, size.height
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
m.setTemplates([]*model.CommandTemplate{template})
m.setTags([]string{"production"})
m.pendingTemplate = template
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
screens := []struct {
name string
screen screen
}{
{"search", screenSearch},
{"tags", screenTags},
{"tag-input", screenTagInput},
{"templates", screenTemplates},
{"template-picker", screenTemplatePicker},
{"template-mode", screenTemplateMode},
{"background-results", screenBackgroundResults},
}
for _, tt := range screens {
m.screen = tt.screen
t.Run(tt.name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, m.View(), size.width, size.height)
})
}
tunnelScreen := newTunnelScreenModel(size.width, size.height)
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
}
}
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)

View File

@ -2,7 +2,6 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@ -94,19 +93,41 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
} }
func (m *tunnelScreenModel) View() string { func (m *tunnelScreenModel) View() string {
var b strings.Builder notification := ""
b.WriteString(m.list.View())
b.WriteString("\n\n")
if m.err != nil { if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err))) notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
b.WriteString("\n\n")
} }
b.WriteString(renderHelp([]helpItem{ body := func(width, height int) string {
if len(m.tunnels) == 0 {
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))
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
item := tunnelItem{state: m.tunnels[index]}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+item.Title(), " "+item.Description())
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Tunnel Manager",
status: fmt.Sprintf("%d running", len(m.tunnels)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"}, {Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"}, {Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, m.width)) },
return b.String() })
} }
type tunnelsLoadedMsg struct { type tunnelsLoadedMsg struct {