feat: unify tui manager screens
This commit is contained in:
parent
d8ae2d4236
commit
18d7a7c07a
|
|
@ -1213,8 +1213,7 @@ func (m *tuiModel) View() string {
|
|||
b.WriteString(m.viewServerList())
|
||||
|
||||
case screenSearch:
|
||||
b.WriteString("Search: " + m.searchInput.View() + "\n")
|
||||
b.WriteString(renderHelp([]helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Esc", Action: "cancel"}}, m.width))
|
||||
b.WriteString(m.viewSearch())
|
||||
|
||||
case screenForm:
|
||||
b.WriteString(m.form.View())
|
||||
|
|
@ -1274,13 +1273,6 @@ func (m *tuiModel) View() string {
|
|||
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()
|
||||
}
|
||||
|
||||
|
|
@ -1771,6 +1763,36 @@ func (m *tuiModel) viewServerList() string {
|
|||
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 {
|
||||
var b strings.Builder
|
||||
b.WriteString(sectionStyle.Render("Last Background Run"))
|
||||
|
|
@ -1891,37 +1913,34 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
|
|||
}
|
||||
|
||||
func (m *tuiModel) viewTags() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Tags"))
|
||||
b.WriteString("\n\n")
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Tags",
|
||||
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 {
|
||||
b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers."))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
for i, item := range m.tagList.Items() {
|
||||
tag, ok := item.(groupItem)
|
||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")})
|
||||
}
|
||||
capacity := max(1, height-2)
|
||||
start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity)
|
||||
lines := make([]string, 0, capacity)
|
||||
for index := start; index < end; index++ {
|
||||
tag, ok := m.tagList.Items()[index].(groupItem)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
style := normalStyle
|
||||
if i == m.tagList.Index() {
|
||||
if index == m.tagList.Index() {
|
||||
marker = "> "
|
||||
style = selectedRowStyle
|
||||
}
|
||||
b.WriteString(style.Render(marker + tag.name))
|
||||
b.WriteString("\n")
|
||||
lines = append(lines, marker+tag.name)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
{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()
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
},
|
||||
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"}},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewTagInput() string {
|
||||
|
|
@ -1929,74 +1948,82 @@ func (m *tuiModel) viewTagInput() string {
|
|||
if m.tagMode == "rename" {
|
||||
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 {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Command Templates"))
|
||||
b.WriteString("\n\n")
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Command Templates",
|
||||
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 {
|
||||
b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one."))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
for i, item := range m.templateList.Items() {
|
||||
tpl, ok := item.(templateItem)
|
||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")})
|
||||
}
|
||||
capacity := max(1, height-2)
|
||||
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
||||
lines := make([]string, 0, capacity)
|
||||
for index := start; index < end; index++ {
|
||||
tpl, ok := m.templateList.Items()[index].(templateItem)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
style := normalStyle
|
||||
if i == m.templateList.Index() {
|
||||
if index == m.templateList.Index() {
|
||||
marker = "> "
|
||||
style = selectedRowStyle
|
||||
}
|
||||
line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)
|
||||
b.WriteString(style.Render(line))
|
||||
b.WriteString("\n")
|
||||
if tpl.template.Description != "" {
|
||||
b.WriteString(helpStyle.Render(" " + tpl.template.Description))
|
||||
b.WriteString("\n")
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
{Key: "Ctrl+A", Action: "add"},
|
||||
{Key: "Ctrl+E", Action: "edit"},
|
||||
{Key: "Ctrl+D", Action: "delete"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, m.width))
|
||||
return b.String()
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
},
|
||||
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"}},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewTemplatePicker() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Run Template"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", "))))
|
||||
b.WriteString("\n\n")
|
||||
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Run Template",
|
||||
status: "Targets: " + targets,
|
||||
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 {
|
||||
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 {
|
||||
for i, item := range m.templateList.Items() {
|
||||
tpl, ok := item.(templateItem)
|
||||
capacity := max(1, height-len(lines)-2)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
style := normalStyle
|
||||
if i == m.templateList.Index() {
|
||||
if index == m.templateList.Index() {
|
||||
marker = "> "
|
||||
style = selectedRowStyle
|
||||
}
|
||||
b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)))
|
||||
b.WriteString("\n")
|
||||
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width))
|
||||
return b.String()
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
},
|
||||
footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewTemplateMode() string {
|
||||
|
|
@ -2006,30 +2033,44 @@ func (m *tuiModel) viewTemplateMode() string {
|
|||
name = m.pendingTemplate.Name
|
||||
command = m.pendingTemplate.Command
|
||||
}
|
||||
return titleStyle.Render("Run Mode") + "\n\n" +
|
||||
fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) +
|
||||
renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width)
|
||||
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
||||
return renderScreenShell(screenShell{
|
||||
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 {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Background Results"))
|
||||
b.WriteString("\n\n")
|
||||
return renderScreenShell(screenShell{
|
||||
breadcrumb: "Background Results",
|
||||
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 {
|
||||
status := "OK"
|
||||
if result.Err != "" {
|
||||
status = "FAIL: " + result.Err
|
||||
}
|
||||
b.WriteString(sectionStyle.Render(result.Alias + " " + status))
|
||||
b.WriteString("\n")
|
||||
lines = append(lines, dashboardSection(result.Alias+" "+status))
|
||||
if result.Output != "" {
|
||||
b.WriteString(result.Output)
|
||||
b.WriteString("\n")
|
||||
for _, line := range strings.Split(result.Output, "\n") {
|
||||
lines = append(lines, strings.ReplaceAll(line, "\t", " "))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width))
|
||||
return b.String()
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
},
|
||||
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) selectedServers() []*model.Server {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
form := newTemplateFormModel(nil, size.width, size.height)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package tui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
|
|
@ -94,19 +93,41 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
|
|||
}
|
||||
|
||||
func (m *tunnelScreenModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(m.list.View())
|
||||
b.WriteString("\n\n")
|
||||
notification := ""
|
||||
if m.err != nil {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||
b.WriteString("\n\n")
|
||||
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
||||
}
|
||||
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+R (r)", Action: "refresh"},
|
||||
{Key: "Ctrl+H", Action: "help"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, m.width))
|
||||
return b.String()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type tunnelsLoadedMsg struct {
|
||||
|
|
|
|||
Loading…
Reference in New Issue