feat: unify tui editor screens

This commit is contained in:
mirivlad 2026-08-14 08:02:23 +08:00
parent 18d7a7c07a
commit 5e83300ea8
4 changed files with 120 additions and 86 deletions

View File

@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) {
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") { if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view) t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
} }
if strings.Contains(view, "│") { if !strings.Contains(view, "│") {
t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view) t.Fatalf("expected auth method dropdown inside the unified frame\nview:\n%s", view)
} }
for _, method := range []model.AuthMethod{ for _, method := range []model.AuthMethod{
model.AuthPassword, model.AuthPassword,
@ -383,8 +383,8 @@ func TestGroupListViewRendersDirectlyUnderGroupField(t *testing.T) {
if between := view[groupPos:listPos]; strings.Contains(between, "Password") { if between := view[groupPos:listPos]; strings.Contains(between, "Password") {
t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view) t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view)
} }
if strings.Contains(view, "│") { if !strings.Contains(view, "│") {
t.Fatalf("expected compact group dropdown without default list border\nview:\n%s", view) t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view)
} }
} }

View File

@ -589,14 +589,6 @@ func (fm *formModel) View() string {
if fm.edit { if fm.edit {
title = "Edit Server: " + fm.server.Alias title = "Edit Server: " + fm.server.Alias
} }
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, fm.width)
if fm.showAuthList || fm.showGroupList { if fm.showAuthList || fm.showGroupList {
var dropdown list.Model var dropdown list.Model
fieldIndex := 8 fieldIndex := 8
@ -606,10 +598,18 @@ func (fm *formModel) View() string {
} else { } else {
dropdown = fm.groupList dropdown = fm.groupList
} }
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" + return renderScreenShell(screenShell{
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" + breadcrumb: title + " / Picker",
fitLine(renderDropdown(dropdown), fm.width) + "\n" + status: "Choose a value",
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width) width: fm.width,
height: fm.height,
body: func(width, height int) string {
lines := []string{fm.inputs[fieldIndex].View(), ""}
lines = append(lines, splitBlock(renderDropdown(dropdown))...)
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
} }
status := fm.formStatusLine() status := fm.formStatusLine()
@ -620,60 +620,68 @@ func (fm *formModel) View() string {
if fm.focusIdx == len(fm.inputs)+2 { if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render("> [ Save ]") saveBtn = selectedStyle.Render("> [ Save ]")
} }
actions := fitLine(testBtn+" "+saveBtn, fm.width) actions := testBtn + " " + saveBtn
reserved := 1 + displayLineCount(footer) + 1 body := func(width, height int) string {
if status != "" { richLayout := width >= 90 && height >= 20
reserved++ allFields := make([]string, 0, len(fm.inputs)+5)
} focusRows := make([]int, len(fm.inputs)+1)
fieldRows := max(4, fm.height-reserved) for i := range fm.inputs {
richLayout := fm.width >= 90 && fm.height >= 24 if richLayout {
allFields := make([]string, 0, len(fm.inputs)+5) if section := formSectionTitle(i); section != "" {
focusRows := make([]int, len(fm.inputs)+1) allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
for i := range fm.inputs { }
if richLayout {
if section := formSectionTitle(i); section != "" {
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
} }
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 {
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
}
focusRows[i] = len(allFields)
allFields = append(allFields, fm.inputs[i].View())
} }
if i == 5 { focusRows[len(fm.inputs)] = len(allFields)
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent" allFields = append(allFields, fm.password.View())
focusField := len(allFields) - 1
if fm.focusIdx <= len(fm.inputs) {
focusField = focusRows[fm.focusIdx]
} }
if i == 8 && len(fm.groups) > 0 { actionRows := 1
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25) if richLayout {
actionRows = 2
} }
focusRows[i] = len(allFields) fieldRows := max(1, height-2-actionRows)
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width)) start, end := visibleServerRange(len(allFields), focusField, fieldRows)
visible := append([]string(nil), allFields[start:end]...)
if start > 0 && len(visible) > 0 {
visible[0] = "↑ more fields · " + visible[0]
}
if end < len(allFields) && len(visible) > 0 {
visible[len(visible)-1] += " · more ↓"
}
if richLayout {
visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions"))
}
visible = append(visible, actions)
return renderPaddedPanel(width, height, visible)
} }
focusRows[len(fm.inputs)] = len(allFields) return renderScreenShell(screenShell{
allFields = append(allFields, fitLine(fm.password.View(), fm.width)) breadcrumb: title,
focusField := len(allFields) - 1 status: "Server profile",
if fm.focusIdx <= len(fm.inputs) { notification: status,
focusField = focusRows[fm.focusIdx] width: fm.width,
} height: fm.height,
start, end := visibleServerRange(len(allFields), focusField, fieldRows) body: body,
visible := append([]string(nil), allFields[start:end]...) footer: []helpItem{
if start > 0 && len(visible) > 0 { {Key: "Tab/↓", Action: "next"},
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width) {Key: "↑", Action: "prev"},
} {Key: "/", Action: "pick list"},
if end < len(allFields) && len(visible) > 0 { {Key: "Enter", Action: "select"},
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width) {Key: "Ctrl+H", Action: "help"},
} {Key: "Esc", Action: "back"},
},
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))} })
lines = append(lines, visible...)
if status != "" {
lines = append(lines, fitLine(status, fm.width))
}
if richLayout {
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
}
lines = append(lines, actions)
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
} }
func (fm *formModel) formStatusLine() string { func (fm *formModel) formStatusLine() string {
@ -701,7 +709,7 @@ func (fm *formModel) formStatusLine() string {
func renderDropdown(l list.Model) string { func renderDropdown(l list.Model) string {
var b strings.Builder var b strings.Builder
b.WriteString(sectionStyle.Render(l.Title)) b.WriteString(dashboardSection(l.Title))
b.WriteString("\n") b.WriteString("\n")
for i, item := range l.Items() { for i, item := range l.Items() {
group, ok := item.(groupItem) group, ok := item.(groupItem)

View File

@ -90,6 +90,7 @@ func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
fm.updateFocus() fm.updateFocus()
view := fm.View() view := fm.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{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} { for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -224,6 +225,7 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
form.inputs[1].SetValue("printf 'a very long command that remains editable'") form.inputs[1].SetValue("printf 'a very long command that remains editable'")
view := form.View() view := form.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{"Template", "Name *", "Save", "Esc"} { for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -232,6 +234,21 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
} }
} }
func TestServerFormDropdownUsesUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newFormModel(size.width, size.height)
form.focusIdx = 5
form.showAuthList = true
view := form.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Select auth method", "password", "agent", "Enter", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("dropdown at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) { func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper() t.Helper()
lines := strings.Split(strings.TrimRight(view, "\n"), "\n") lines := strings.Split(strings.TrimRight(view, "\n"), "\n")

View File

@ -151,31 +151,40 @@ func (tf *templateFormModel) save() tea.Cmd {
} }
func (tf *templateFormModel) View() string { func (tf *templateFormModel) View() string {
var b strings.Builder
title := "Add Template" title := "Add Template"
if tf.edit { if tf.edit {
title = "Edit Template" title = "Edit Template"
} }
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width))) notification := ""
b.WriteString("\n\n")
for i := range tf.inputs {
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
b.WriteString("\n")
}
button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]")
}
b.WriteString("\n" + button + "\n\n")
if tf.err != nil { if tf.err != nil {
b.WriteString(errorStyle.Render(tf.err.Error())) notification = errorStyle.Render(tf.err.Error())
b.WriteString("\n") } else if tf.saved {
notification = successStyle.Render("✓ Saved.")
} }
b.WriteString(renderHelp([]helpItem{ return renderScreenShell(screenShell{
{Key: "Tab/↓", Action: "next"}, breadcrumb: "Command Templates / " + title,
{Key: "↑", Action: "prev"}, status: "Template editor",
{Key: "Enter", Action: "select"}, notification: notification,
{Key: "Esc", Action: "back"}, width: tf.width,
}, tf.width)) height: tf.height,
return b.String() body: func(width, height int) string {
lines := make([]string, 0, len(tf.inputs)+3)
for i := range tf.inputs {
lines = append(lines, tf.inputs[i].View())
}
button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]")
}
lines = append(lines, "", button)
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
} }