feat(ui): add tabbed forms with height validation

- Implement General/Advanced tabs for add/edit forms
- Add terminal height detection with user-friendly warnings
- Add Ctrl+J/K tab navigation and SSH RemoteCommand/RequestTTY fields
This commit is contained in:
Gu1llaum-3 2025-10-12 21:37:12 +02:00
parent c1457af73a
commit 93c9115f08
3 changed files with 496 additions and 105 deletions

View File

@ -1,6 +1,7 @@
package ui
import (
"fmt"
"os"
"os/user"
"path/filepath"
@ -16,6 +17,7 @@ import (
type addFormModel struct {
inputs []textinput.Model
focused int
currentTab int // 0 = General, 1 = Advanced
err string
styles Styles
success bool
@ -116,6 +118,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
return &addFormModel{
inputs: inputs,
focused: nameInput,
currentTab: tabGeneral, // Start on General tab
styles: styles,
width: width,
height: height,
@ -123,6 +126,11 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
const (
tabGeneral = iota
tabAdvanced
)
const (
nameInput = iota
hostnameInput
@ -130,8 +138,9 @@ const (
portInput
identityInput
proxyJumpInput
optionsInput
tagsInput
// Advanced tab inputs
optionsInput
remoteCommandInput
requestTTYInput
)
@ -167,36 +176,20 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
// Allow submission from any field with Ctrl+S (Save)
return m, m.submitForm()
case "ctrl+j":
// Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
return m, m.handleNavigation(msg.String())
}
case addFormSubmitMsg:
@ -220,34 +213,104 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// getFirstInputForTab returns the first input index for a given tab
func (m *addFormModel) getFirstInputForTab(tab int) int {
switch tab {
case tabGeneral:
return nameInput
case tabAdvanced:
return optionsInput
default:
return nameInput
}
}
// getInputsForCurrentTab returns the input indices for the current tab
func (m *addFormModel) getInputsForCurrentTab() []int {
switch m.currentTab {
case tabGeneral:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
case tabAdvanced:
return []int{optionsInput, remoteCommandInput, requestTTYInput}
default:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
}
}
// updateFocus updates focus for inputs
func (m *addFormModel) updateFocus() tea.Cmd {
var cmds []tea.Cmd
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
return tea.Batch(cmds...)
}
// handleNavigation handles tab/arrow navigation within the current tab
func (m *addFormModel) handleNavigation(key string) tea.Cmd {
currentTabInputs := m.getInputsForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, input := range currentTabInputs {
if input == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == tabAdvanced && currentPos == len(currentTabInputs)-1 {
return m.submitForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Wrap around within current tab
if currentPos >= len(currentTabInputs) {
currentPos = 0
} else if currentPos < 0 {
currentPos = len(currentTabInputs) - 1
}
m.focused = currentTabInputs[currentPos]
return m.updateFocus()
}
func (m *addFormModel) View() string {
if m.success {
return ""
}
// Check if terminal height is sufficient
if !m.isHeightSufficient() {
return m.renderHeightWarning()
}
var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
b.WriteString("\n\n")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
"Remote Command",
"Request TTY",
}
// Render tabs
b.WriteString(m.renderTabs())
b.WriteString("\n\n")
for i, field := range fields {
b.WriteString(m.styles.FormField.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
// Render current tab content
switch m.currentTab {
case tabGeneral:
b.WriteString(m.renderGeneralTab())
case tabAdvanced:
b.WriteString(m.renderAdvancedTab())
}
if m.err != "" {
@ -255,13 +318,133 @@ func (m *addFormModel) View() string {
b.WriteString("\n\n")
}
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel"))
// Help text
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Ctrl+J/K: switch tabs"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String()
}
// getMinimumHeight calculates the minimum height needed to display the form
func (m *addFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == tabGeneral {
fieldsCount = 7 // 7 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: label (1) + input (1) + spacing (2) = 4 lines per field, but let's be more conservative
fieldsLines := fieldsCount * 3 // Reduced from 4 to 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *addFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *addFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
// renderTabs renders the tab headers
func (m *addFormModel) renderTabs() string {
var generalTab, advancedTab string
if m.currentTab == tabGeneral {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderGeneralTab renders the general tab content
func (m *addFormModel) renderGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{nameInput, "Host Name *"},
{hostnameInput, "Hostname/IP *"},
{userInput, "User"},
{portInput, "Port"},
{identityInput, "Identity File"},
{proxyJumpInput, "ProxyJump"},
{tagsInput, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderAdvancedTab renders the advanced tab content
func (m *addFormModel) renderAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{optionsInput, "SSH Options"},
{remoteCommandInput, "Remote Command"},
{requestTTYInput, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for add form
type standaloneAddForm struct {
*addFormModel

View File

@ -28,6 +28,7 @@ type editFormModel struct {
inputs []textinput.Model
focusArea int // 0=hosts, 1=properties
focused int
currentTab int // 0=General, 1=Advanced (only applies when focusArea == focusAreaProperties)
err string
styles Styles
originalName string
@ -164,6 +165,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs: inputs,
focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing
focused: 0,
currentTab: 0, // Start on General tab
originalName: hostName,
originalHosts: hostNames,
host: host,
@ -247,6 +249,157 @@ func (m *editFormModel) updateFocus() tea.Cmd {
return textinput.Blink
}
// getPropertiesForCurrentTab returns the property input indices for the current tab
func (m *editFormModel) getPropertiesForCurrentTab() []int {
switch m.currentTab {
case 0: // General
return []int{0, 1, 2, 3, 4, 6} // hostname, user, port, identity, proxyjump, tags
case 1: // Advanced
return []int{5, 7, 8} // options, remotecommand, requesttty
default:
return []int{0, 1, 2, 3, 4, 6}
}
}
// getFirstPropertyForTab returns the first property index for a given tab
func (m *editFormModel) getFirstPropertyForTab(tab int) int {
properties := []int{0, 1, 2, 3, 4, 6} // General tab
if tab == 1 {
properties = []int{5, 7, 8} // Advanced tab
}
if len(properties) > 0 {
return properties[0]
}
return 0
}
// handleEditNavigation handles navigation in the edit form with tab support
func (m *editFormModel) handleEditNavigation(key string) tea.Cmd {
if m.focusArea == focusAreaHosts {
// Navigate in hosts area
if key == "up" || key == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused >= len(m.hostInputs) {
// Move to properties area, first tab
m.focusArea = focusAreaProperties
m.currentTab = 0
m.focused = m.getFirstPropertyForTab(0)
} else if m.focused < 0 {
m.focused = len(m.hostInputs) - 1
}
} else {
// Navigate in properties area within current tab
currentTabProperties := m.getPropertiesForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, prop := range currentTabProperties {
if prop == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == 1 && currentPos == len(currentTabProperties)-1 {
return m.submitEditForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Handle transitions between areas and tabs
if currentPos >= len(currentTabProperties) {
// Move to next area/tab
if m.currentTab == 0 {
// Move to advanced tab
m.currentTab = 1
m.focused = m.getFirstPropertyForTab(1)
} else {
// Move back to hosts area
m.focusArea = focusAreaHosts
m.focused = 0
}
} else if currentPos < 0 {
// Move to previous area/tab
if m.currentTab == 1 {
// Move to general tab
m.currentTab = 0
properties := m.getPropertiesForCurrentTab()
m.focused = properties[len(properties)-1]
} else {
// Move to hosts area
m.focusArea = focusAreaHosts
m.focused = len(m.hostInputs) - 1
}
} else {
m.focused = currentTabProperties[currentPos]
}
}
return m.updateFocus()
}
// getMinimumHeight calculates the minimum height needed to display the edit form
func (m *editFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Config file info: 1 line + 2 newlines = 3
configLines := 3
// Host Names section: title (1) + spacing (2) = 3
hostSectionLines := 3
// Host inputs: number of hosts * 3 lines each (reduced from 4)
hostLines := len(m.hostInputs) * 3
// Properties section: title (1) + spacing (2) = 3
propertiesSectionLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == 0 {
fieldsCount = 6 // 6 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: reduced from 4 to 3 lines per field
fieldsLines := fieldsCount * 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + configLines + hostSectionLines + hostLines + propertiesSectionLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *editFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *editFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
@ -265,44 +418,27 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Allow submission from any field with Ctrl+S (Save)
return m, m.submitEditForm()
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
// Handle form submission
totalFields := len(m.hostInputs) + len(m.inputs)
currentGlobalIndex := m.focused
case "ctrl+j":
// Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
// If we're in hosts area, stay there. If in properties, go to the first field of the new tab
if m.focusArea == focusAreaProperties {
currentGlobalIndex = len(m.hostInputs) + m.focused
m.focused = m.getFirstPropertyForTab(m.currentTab)
}
if s == "enter" && currentGlobalIndex == totalFields-1 {
return m, m.submitEditForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
currentGlobalIndex--
} else {
currentGlobalIndex++
}
if currentGlobalIndex >= totalFields {
currentGlobalIndex = 0
} else if currentGlobalIndex < 0 {
currentGlobalIndex = totalFields - 1
}
// Update focus area and focused index based on global index
if currentGlobalIndex < len(m.hostInputs) {
m.focusArea = focusAreaHosts
m.focused = currentGlobalIndex
} else {
m.focusArea = focusAreaProperties
m.focused = currentGlobalIndex - len(m.hostInputs)
}
return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
// If we're in hosts area, stay there. If in properties, go to the first field of the new tab
if m.focusArea == focusAreaProperties {
m.focused = m.getFirstPropertyForTab(m.currentTab)
}
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down":
return m, m.handleEditNavigation(msg.String())
case "ctrl+a":
// Add a new host input
return m, m.addHostInput()
@ -343,6 +479,11 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *editFormModel) View() string {
// Check if terminal height is sufficient
if !m.isHeightSufficient() {
return m.renderHeightWarning()
}
var b strings.Builder
if m.err != "" {
@ -381,27 +522,16 @@ func (m *editFormModel) View() string {
b.WriteString(m.styles.FormTitle.Render("Common Properties"))
b.WriteString("\n\n")
fields := []string{
"Hostname/IP *",
"User",
"Port",
"Identity File",
"Proxy Jump",
"SSH Options",
"Tags (comma-separated)",
"Remote Command",
"Request TTY",
}
// Render tabs for properties
b.WriteString(m.renderEditTabs())
b.WriteString("\n\n")
for i, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == i {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
// Render current tab content
switch m.currentTab {
case 0: // General
b.WriteString(m.renderEditGeneralTab())
case 1: // Advanced
b.WriteString(m.renderEditAdvancedTab())
}
if m.err != "" {
@ -411,10 +541,10 @@ func (m *editFormModel) View() string {
// Show different help based on number of hosts
if len(m.hostInputs) > 1 {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host • Ctrl+D: delete host"))
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host • Ctrl+D: delete host"))
b.WriteString("\n")
} else {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host"))
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host"))
b.WriteString("\n")
}
b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields"))
@ -422,6 +552,78 @@ func (m *editFormModel) View() string {
return b.String()
}
// renderEditTabs renders the tab headers for properties
func (m *editFormModel) renderEditTabs() string {
var generalTab, advancedTab string
if m.currentTab == 0 {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderEditGeneralTab renders the general tab content for properties
func (m *editFormModel) renderEditGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{0, "Hostname/IP *"},
{1, "User"},
{2, "Port"},
{3, "Identity File"},
{4, "Proxy Jump"},
{6, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderEditAdvancedTab renders the advanced tab content for properties
func (m *editFormModel) renderEditAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{5, "SSH Options"},
{7, "Remote Command"},
{8, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for edit form
type standaloneEditForm struct {
*editFormModel

View File

@ -33,7 +33,8 @@ type Styles struct {
HelpText lipgloss.Style
// Error and confirmation styles
Error lipgloss.Style
Error lipgloss.Style
ErrorText lipgloss.Style
// Form styles (for add/edit forms)
FormTitle lipgloss.Style
@ -97,6 +98,11 @@ func NewStyles(width int) Styles {
BorderForeground(lipgloss.Color(ErrorColor)).
Padding(1, 2),
// Error text style (no border, just red text)
ErrorText: lipgloss.NewStyle().
Foreground(lipgloss.Color(ErrorColor)).
Bold(true),
// Form styles
FormTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).