// Copyright 2025. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ui import ( "fmt" "reflect" "strconv" "strings" "github.com/Adembc/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) // sshDefaults is now replaced by SSHFieldDefaults in defaults.go // This variable references the centralized defaults for consistency var sshDefaults = SSHFieldDefaults type ServerFormMode int const ( ServerFormAdd ServerFormMode = iota ServerFormEdit ) const ( tabSeparator = "[gray]|[-] " // Tab separator with gray color // SessionType values (sessionTypeNone and sessionTypeSubsystem are in utils.go) sessionTypeDefault = "default" ) type ServerForm struct { *tview.Flex // The root container (includes header, form panel and hint bar) header *AppHeader // The app header formPanel *tview.Flex // The actual form panel pages *tview.Pages tabBar *tview.TextView forms map[string]*tview.Form currentTab string tabs []string tabAbbrev map[string]string // Abbreviated tab names for narrow views mode ServerFormMode original *domain.Server onSave func(domain.Server, *domain.Server) onCancel func() app *tview.Application // Reference to app for showing modals version string // Version for header commit string // Commit for header validation *ValidationState // Validation state for all fields helpPanel *tview.TextView // Help panel for field descriptions helpMode HelpDisplayMode // Current help display mode currentField string // Currently focused field mainContainer *tview.Flex // Container for form and help panel } func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { // Create help panel helpPanel := tview.NewTextView(). SetDynamicColors(true). SetWordWrap(true). SetScrollable(true) helpPanel.SetBorder(true). SetBorderPadding(0, 0, 1, 1). SetTitle(" Help "). SetTitleAlign(tview.AlignCenter) // Create main container for form and help mainContainer := tview.NewFlex().SetDirection(tview.FlexColumn) form := &ServerForm{ Flex: tview.NewFlex().SetDirection(tview.FlexRow), formPanel: tview.NewFlex().SetDirection(tview.FlexRow), pages: tview.NewPages(), tabBar: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignCenter).SetRegions(true), forms: make(map[string]*tview.Form), mode: mode, original: original, validation: NewValidationState(), helpPanel: helpPanel, helpMode: HelpModeNormal, // Show help panel by default mainContainer: mainContainer, tabs: []string{ "Basic", "Connection", "Forwarding", "Authentication", "Advanced", }, tabAbbrev: map[string]string{ "Basic": "Basic", "Connection": "Conn", "Forwarding": "Fwd", "Authentication": "Auth", "Advanced": "Adv", }, } form.currentTab = "Basic" // Don't build here, wait for version info to be set return form } func (sf *ServerForm) build() { // Create header sf.header = NewAppHeader(sf.version, sf.commit, RepoURL) // Create forms for each tab sf.createBasicForm() sf.createConnectionForm() sf.createForwardingForm() sf.createAuthenticationForm() sf.createAdvancedForm() // Setup tab bar sf.updateTabBar() // Setup form panel sf.formPanel.SetBorder(true). SetTitle(" " + sf.titleForMode() + " "). SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) sf.formPanel.AddItem(sf.tabBar, 1, 0, false). AddItem(sf.pages, 0, 1, true) // Setup main container with form and help panel sf.mainContainer.Clear() // Responsive layout: hide help panel if window is too narrow sf.mainContainer.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { // Minimum width for showing help panel minWidthForHelp := 120 // Clear and rebuild based on width sf.mainContainer.Clear() if width < minWidthForHelp || sf.helpMode == HelpModeOff { // Window too narrow or help is off - only show form sf.mainContainer.AddItem(sf.formPanel, 0, 1, true) } else { // Window wide enough - show both form and help sf.mainContainer.AddItem(sf.formPanel, 0, 3, true) sf.mainContainer.AddItem(sf.helpPanel, 0, 2, false) // Refresh help content to recalculate separator width on resize sf.updateHelp(sf.currentField) } return x, y, width, height }) // Initial setup sf.mainContainer.AddItem(sf.formPanel, 0, 3, true) if sf.helpMode != HelpModeOff { sf.mainContainer.AddItem(sf.helpPanel, 0, 2, false) } // Create hint bar with same background as main screen's status bar hintBar := tview.NewTextView().SetDynamicColors(true) hintBar.SetBackgroundColor(tcell.Color235) hintBar.SetTextAlign(tview.AlignCenter) hintBar.SetText("[white]^H/^L[-] Navigate • [white]^S[-] Save • [white]Esc[-] Cancel") // Setup main container - header at top, hint bar at bottom sf.Flex.AddItem(sf.header, 2, 0, false). AddItem(sf.mainContainer, 0, 1, true). AddItem(hintBar, 1, 0, false) // Initialize help with first field sf.updateHelp("Alias") // Setup keyboard shortcuts sf.setupKeyboardShortcuts() // Set a draw function for the tab bar to update on each draw // This ensures the tab bar updates when the window is resized sf.tabBar.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { // Update tab bar if size changed sf.updateTabBar() // Return the original dimensions return x, y, width, height }) } func (sf *ServerForm) titleForMode() string { if sf.mode == ServerFormEdit { return "Edit Server" } return "Add Server" } func (sf *ServerForm) getCurrentTabIndex() int { for i, tab := range sf.tabs { if tab == sf.currentTab { return i } } return 0 } func (sf *ServerForm) calculateTabsWidth(useAbbrev bool) int { width := 0 for i, tab := range sf.tabs { tabName := tab if useAbbrev { tabName = sf.tabAbbrev[tab] } width += len(tabName) + 2 // space + name + space if i < len(sf.tabs)-1 { width += 3 // " | " separator } } return width } func (sf *ServerForm) determineDisplayMode(width int) string { if width <= 20 { // Width unknown or too small return "full" } fullWidth := sf.calculateTabsWidth(false) if fullWidth <= width-10 { return "full" } abbrevWidth := sf.calculateTabsWidth(true) if abbrevWidth <= width-10 { return "abbrev" } return "scroll" } func (sf *ServerForm) renderTab(tab string, isCurrent bool, useAbbrev bool, index int) string { tabName := tab if useAbbrev { tabName = sf.tabAbbrev[tab] } regionID := fmt.Sprintf("tab_%d", index) if isCurrent { return fmt.Sprintf("[%q][black:white:b] %s [-:-:-][%q] ", regionID, tabName, "") } return fmt.Sprintf("[%q][gray::u] %s [-:-:-][%q] ", regionID, tabName, "") } func (sf *ServerForm) renderScrollableTabs(currentIdx, width int) string { var tabText string availableWidth := width - 8 // Reserve space for scroll indicators // Calculate visible count visibleCount := sf.calculateVisibleTabCount(availableWidth) if visibleCount < 2 { visibleCount = 2 } // Add left scroll indicator if currentIdx > 0 { tabText = "[gray]◀ [-]" } // Calculate range start, end := sf.calculateVisibleRange(currentIdx, visibleCount, len(sf.tabs)) // Render visible tabs for i := start; i < end && i < len(sf.tabs); i++ { tabText += sf.renderTab(sf.tabs[i], sf.tabs[i] == sf.currentTab, true, i) if i < end-1 && i < len(sf.tabs)-1 { tabText += tabSeparator } } // Add right scroll indicator if currentIdx < len(sf.tabs)-1 { tabText += " [gray]▶[-]" } return tabText } func (sf *ServerForm) calculateVisibleTabCount(availableWidth int) int { visibleCount := 0 currentWidth := 0 for i := 0; i < len(sf.tabs) && currentWidth < availableWidth; i++ { abbrev := sf.tabAbbrev[sf.tabs[i]] tabWidth := len(abbrev) + 2 if i > 0 { tabWidth += 3 // separator } if currentWidth+tabWidth <= availableWidth { visibleCount++ currentWidth += tabWidth } else { break } } return visibleCount } func (sf *ServerForm) calculateVisibleRange(currentIdx, visibleCount, totalTabs int) (int, int) { halfVisible := visibleCount / 2 start := currentIdx - halfVisible + 1 end := start + visibleCount // Adjust boundaries if start < 0 { start = 0 end = visibleCount } if end > totalTabs { end = totalTabs start = end - visibleCount if start < 0 { start = 0 } } return start, end } func (sf *ServerForm) updateTabBar() { currentIdx := sf.getCurrentTabIndex() // Build tab text with scroll indicator if needed var tabText string // Check if we need to show scroll indicators x, y, width, height := sf.tabBar.GetInnerRect() _ = x _ = y _ = height displayMode := sf.determineDisplayMode(width) switch displayMode { case "scroll": tabText = sf.renderScrollableTabs(currentIdx, width) case "abbrev": // Show all tabs with abbreviated names for i, tab := range sf.tabs { tabText += sf.renderTab(tab, tab == sf.currentTab, true, i) if i < len(sf.tabs)-1 { tabText += tabSeparator } } default: // "full" // Show all tabs with full names for i, tab := range sf.tabs { tabText += sf.renderTab(tab, tab == sf.currentTab, false, i) if i < len(sf.tabs)-1 { tabText += tabSeparator } } } sf.tabBar.SetText(tabText) // Set up mouse click handler using highlight regions sf.tabBar.SetHighlightedFunc(func(added, removed, remaining []string) { if len(added) > 0 { // Extract tab index from region ID (format: "tab_0", "tab_1", etc) for _, regionID := range added { if len(regionID) > 4 && regionID[:4] == "tab_" { idx := int(regionID[4] - '0') if idx < len(sf.tabs) { sf.switchToTab(sf.tabs[idx]) } } } } }) } func (sf *ServerForm) switchToTab(tabName string) { for _, tab := range sf.tabs { if tab != tabName { continue } sf.currentTab = tabName sf.pages.SwitchToPage(tabName) sf.updateTabBar() // Set focus to the form in the newly selected tab if form, exists := sf.forms[tabName]; exists && sf.app != nil { sf.app.SetFocus(form) } break } } func (sf *ServerForm) nextTab() { for i, tab := range sf.tabs { if tab == sf.currentTab { // Loop to first tab if at the last tab if i == len(sf.tabs)-1 { sf.switchToTab(sf.tabs[0]) } else { sf.switchToTab(sf.tabs[i+1]) } break } } } func (sf *ServerForm) prevTab() { for i, tab := range sf.tabs { if tab == sf.currentTab { // Loop to last tab if at the first tab if i == 0 { sf.switchToTab(sf.tabs[len(sf.tabs)-1]) } else { sf.switchToTab(sf.tabs[i-1]) } break } } } // updateHelp updates the help panel with information for the given field func (sf *ServerForm) updateHelp(fieldName string) { if sf.helpPanel == nil || sf.helpMode == HelpModeOff { return } sf.currentField = fieldName help := GetFieldHelp(fieldName) if help == nil { sf.helpPanel.SetText("[dim]No help available for this field[-]") return } var content string if sf.helpMode == HelpModeCompact { // Compact mode: single line example := "" if len(help.Examples) > 0 { example = help.Examples[0] } content = fmt.Sprintf("[yellow]%s:[-] %s", help.Field, escapeForTview(help.Description)) if example != "" { content += fmt.Sprintf(" [dim](e.g., %s)[-]", escapeForTview(example)) } } else { // Normal/Full mode: detailed help content = sf.formatDetailedHelp(help) } sf.helpPanel.SetText(content) } // escapeForTview escapes special characters for tview display func escapeForTview(text string) string { // Use tview's own Escape function to properly escape text return tview.Escape(text) } // formatDetailedHelp formats detailed help content for a field func (sf *ServerForm) formatDetailedHelp(help *FieldHelp) string { var b strings.Builder // Calculate separator width dynamically // Get the actual width of the help panel if possible separatorWidth := 40 // Default width if sf.helpPanel != nil { _, _, width, _ := sf.helpPanel.GetInnerRect() if width > 0 { separatorWidth = width // Fill entire width } } // Title with field name and separator below b.WriteString(fmt.Sprintf("[yellow::b]📖 %s[-::-]\n", help.Field)) b.WriteString("[#444444]" + strings.Repeat("─", separatorWidth) + "[-]\n\n") // Description - needs escaping as it might contain brackets b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Description))) // Syntax - needs escaping as it often contains brackets like [user@] if help.Syntax != "" { b.WriteString("[cyan]Syntax:[-] ") b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Syntax))) } // Examples - needs escaping as they might contain special characters if len(help.Examples) > 0 { b.WriteString("[cyan]Examples:[-]\n") for _, ex := range help.Examples { b.WriteString(fmt.Sprintf(" • %s\n", escapeForTview(ex))) } b.WriteString("\n") } // Default value - already processed by formatDefaultValue, no additional escaping needed if help.Default != "" { b.WriteString(fmt.Sprintf("[dim]Default: %s[-]\n", help.Default)) } // Version info - unlikely to contain brackets, but escape for safety if help.Since != "" { b.WriteString(fmt.Sprintf("[dim]Available since: %s[-]\n", escapeForTview(help.Since))) } return b.String() } // Note: toggleHelp and rebuildLayout were removed due to hang issues // The help panel is now always visible on the right side func (sf *ServerForm) setupKeyboardShortcuts() { // Set input capture for the main flex container sf.Flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // Help panel is always visible - no toggle needed // Check for Ctrl key combinations with regular keys if event.Key() == tcell.KeyRune && event.Modifiers()&tcell.ModCtrl != 0 { switch event.Rune() { case 'h', 'H', 8: // 8 is ASCII for Ctrl+H (backspace) // Ctrl+H: Previous tab sf.prevTab() return nil case 'l', 'L', 12: // 12 is ASCII for Ctrl+L (form feed) // Ctrl+L: Next tab sf.nextTab() return nil case 's', 'S', 19: // 19 is ASCII for Ctrl+S // Ctrl+S: Save sf.handleSave() return nil } } // Handle special keys //nolint:exhaustive // We only handle specific keys and pass through others switch event.Key() { case tcell.KeyCtrlS: // Ctrl+S: Save (backup handler) sf.handleSave() return nil case tcell.KeyEscape: // ESC: Cancel sf.handleCancel() return nil case tcell.KeyCtrlH: // Ctrl+H: Previous tab (backup handler) sf.prevTab() return nil case tcell.KeyCtrlL: // Ctrl+L: Next tab (backup handler) sf.nextTab() return nil default: // Pass through all other keys } return event }) } // setupFormShortcuts sets up keyboard shortcuts for a form func (sf *ServerForm) setupFormShortcuts(form *tview.Form) { form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // Check for Ctrl key combinations if event.Key() == tcell.KeyRune && event.Modifiers()&tcell.ModCtrl != 0 { switch event.Rune() { case 'h', 'H', 8: // Ctrl+H: Previous tab sf.prevTab() return nil case 'l', 'L', 12: // Ctrl+L: Next tab sf.nextTab() return nil case 's', 'S', 19: // Ctrl+S: Save sf.handleSave() return nil } } // Handle special keys //nolint:exhaustive // We only handle specific keys and pass through others switch event.Key() { case tcell.KeyEscape: sf.handleCancel() return nil case tcell.KeyCtrlH: sf.prevTab() return nil case tcell.KeyCtrlL: sf.nextTab() return nil case tcell.KeyCtrlS: sf.handleSave() return nil default: // Pass through all other keys } return event }) } // createOptionsWithDefault creates dropdown options with default value indicated func createOptionsWithDefault(fieldName string, baseOptions []string) []string { defaultValue, hasDefault := sshDefaults[fieldName] if !hasDefault { return baseOptions } options := make([]string, len(baseOptions)) for i, opt := range baseOptions { if opt == "" { options[i] = fmt.Sprintf("default [gray](%s)[-]", defaultValue) } else { options[i] = opt } } return options } // parseOptionValue extracts the actual value from an option (handles "default [gray](value)[-]" format) func parseOptionValue(option string) string { // Check for colored default format if strings.HasPrefix(option, "default [gray](") && strings.HasSuffix(option, ")[-]") { return "" // Return empty string for default values } // Check for plain default format (backward compatibility) if strings.HasPrefix(option, "default (") && strings.HasSuffix(option, ")") { return "" // Return empty string for default values } return option } // findOptionIndex finds the index of a value in options slice func (sf *ServerForm) findOptionIndex(options []string, value string) int { // Empty value should match "default [gray](...)[-]" or "default (...)" option if value == "" { for i, opt := range options { if strings.HasPrefix(opt, "default [gray](") || strings.HasPrefix(opt, "default (") { return i } } } // Look for exact match first for i, opt := range options { if strings.EqualFold(opt, value) { return i } } // Then look for options with descriptions (e.g., "none (-N)" matches "none") for i, opt := range options { // Extract the base value from options like "none (-N)" if spaceIdx := strings.Index(opt, " "); spaceIdx > 0 { baseOpt := opt[:spaceIdx] if strings.EqualFold(baseOpt, value) { return i } } } return 0 // Default to first option } // matchesSequence checks if all characters in pattern appear in sequence within text func matchesSequence(text, pattern string) bool { if pattern == "" { return true } textIdx := 0 for _, ch := range pattern { found := false for textIdx < len(text) { if rune(text[textIdx]) == ch { found = true textIdx++ break } textIdx++ } if !found { return false } } return true } // createSSHKeyAutocomplete creates an autocomplete function for SSH key file paths func (sf *ServerForm) createSSHKeyAutocomplete() func(string) []string { return func(currentText string) []string { if currentText == "" { // Show available keys when field is empty availableKeys := GetAvailableSSHKeys() if len(availableKeys) == 0 { return nil } return availableKeys } // Split by comma to handle multiple keys keys := strings.Split(currentText, ",") lastKey := strings.TrimSpace(keys[len(keys)-1]) // If the last key is empty (after a comma and space), show all available keys if lastKey == "" { availableKeys := GetAvailableSSHKeys() if len(availableKeys) == 0 { return nil } // Build suggestions with existing keys var suggestions []string prefix := "" if len(keys) > 1 { // Join all keys except the last empty one existingKeys := keys[:len(keys)-1] for i := range existingKeys { existingKeys[i] = strings.TrimSpace(existingKeys[i]) } prefix = strings.Join(existingKeys, ", ") + ", " } for _, key := range availableKeys { suggestions = append(suggestions, prefix+key) } return suggestions } // Get available keys and filter based on what's being typed availableKeys := GetAvailableSSHKeys() if len(availableKeys) == 0 { return nil } // Convert to lowercase for case-insensitive matching searchTerm := strings.ToLower(lastKey) // Filter available keys var filtered []string prefix := "" if len(keys) > 1 { // Join all keys except the last one being typed existingKeys := keys[:len(keys)-1] for i := range existingKeys { existingKeys[i] = strings.TrimSpace(existingKeys[i]) } prefix = strings.Join(existingKeys, ", ") + ", " } for _, key := range availableKeys { lowerKey := strings.ToLower(key) // Check if the key matches the search term if strings.Contains(lowerKey, searchTerm) || matchesSequence(lowerKey, searchTerm) { filtered = append(filtered, prefix+key) } } // If no matches found, return nil to allow Tab navigation if len(filtered) == 0 { return nil } return filtered } } // createKnownHostsAutocomplete creates an autocomplete function for known_hosts file paths func (sf *ServerForm) createKnownHostsAutocomplete() func(string) []string { return func(currentText string) []string { if currentText == "" { // Show available known_hosts files when field is empty availableFiles := GetAvailableKnownHostsFiles() if len(availableFiles) == 0 { return nil } return availableFiles } // Split by space to handle multiple files files := strings.Split(currentText, " ") lastFile := strings.TrimSpace(files[len(files)-1]) // If the last file is empty (after a space), show all available files if lastFile == "" { availableFiles := GetAvailableKnownHostsFiles() if len(availableFiles) == 0 { return nil } // Build suggestions with existing files var suggestions []string prefix := "" if len(files) > 1 { // Join all files except the last empty one existingFiles := files[:len(files)-1] for i := range existingFiles { existingFiles[i] = strings.TrimSpace(existingFiles[i]) } prefix = strings.Join(existingFiles, " ") + " " } for _, file := range availableFiles { suggestions = append(suggestions, prefix+file) } return suggestions } // Get available files and filter based on what's being typed availableFiles := GetAvailableKnownHostsFiles() if len(availableFiles) == 0 { return nil } // Convert to lowercase for case-insensitive matching searchTerm := strings.ToLower(lastFile) // Filter available files var filtered []string prefix := "" if len(files) > 1 { // Join all files except the last one being typed existingFiles := files[:len(files)-1] for i := range existingFiles { existingFiles[i] = strings.TrimSpace(existingFiles[i]) } prefix = strings.Join(existingFiles, " ") + " " } for _, file := range availableFiles { lowerFile := strings.ToLower(file) // Check if the file matches the search term if strings.Contains(lowerFile, searchTerm) || matchesSequence(lowerFile, searchTerm) { filtered = append(filtered, prefix+file) } } // If no matches found, return nil to allow Tab navigation if len(filtered) == 0 { return nil } return filtered } } // createAlgorithmAutocomplete creates an autocomplete function for algorithm input fields func (sf *ServerForm) createAlgorithmAutocomplete(suggestions []string) func(string) []string { return func(currentText string) []string { if currentText == "" { // Return nil when empty to disable autocomplete, allowing Tab to navigate return nil } // Find the current word being typed words := strings.Split(currentText, ",") lastWord := strings.TrimSpace(words[len(words)-1]) // If the last word is empty (after a comma), return nil to allow Tab navigation if lastWord == "" { return nil } // Handle prefix characters prefix := "" searchTerm := lastWord if lastWord[0] == '+' || lastWord[0] == '-' || lastWord[0] == '^' { prefix = string(lastWord[0]) if len(lastWord) > 1 { searchTerm = lastWord[1:] } else { // Just a prefix character, show all suggestions searchTerm = "" } } // Filter suggestions - check if all characters appear in sequence var filtered []string for _, s := range suggestions { if searchTerm == "" || matchesSequence(strings.ToLower(s), strings.ToLower(searchTerm)) { // Build the complete text with the suggestion newWords := make([]string, len(words)-1) copy(newWords, words[:len(words)-1]) newWords = append(newWords, prefix+s) filtered = append(filtered, strings.Join(newWords, ",")) } } // If no matches found, return nil to allow Tab navigation if len(filtered) == 0 { return nil } return filtered } } // validateField validates a single field and updates the validation state func (sf *ServerForm) validateField(fieldName, value string) string { fieldValidators := GetFieldValidators() validator, exists := fieldValidators[fieldName] if !exists { // No validator for this field, it's valid sf.validation.SetError(fieldName, "") return "" } // Check required if validator.Required && strings.TrimSpace(value) == "" { err := fmt.Sprintf("%s is required", fieldName) sf.validation.SetError(fieldName, err) return err } // If field is empty and not required, it's valid if value == "" { sf.validation.SetError(fieldName, "") return "" } // Check custom validation function if validator.Validate != nil { if err := validator.Validate(value); err != nil { sf.validation.SetError(fieldName, err.Error()) return err.Error() } } // Check regex pattern if validator.Pattern != nil && !validator.Pattern.MatchString(value) { sf.validation.SetError(fieldName, validator.Message) return validator.Message } // Field is valid sf.validation.SetError(fieldName, "") return "" } // addDropDownWithHelp adds a dropdown field with help support func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName string, options []string, initialOption int) { dropdown := tview.NewDropDown(). SetLabel(label). SetOptions(options, nil). SetCurrentOption(initialOption) // Add focus handler to show help dropdown.SetFocusFunc(func() { sf.updateHelp(fieldName) }) form.AddFormItem(dropdown) } // addInputFieldWithHelp adds a regular input field with help support func (sf *ServerForm) addInputFieldWithHelp(form *tview.Form, label, fieldName, defaultValue string, width int, placeholder string) *tview.InputField { field := tview.NewInputField(). SetLabel(label). SetText(defaultValue). SetFieldWidth(width) if placeholder != "" { field.SetPlaceholder(placeholder) } // Add focus handler to show help field.SetFocusFunc(func() { sf.updateHelp(fieldName) }) form.AddFormItem(field) return field } // addValidatedInputField adds an input field with real-time validation func (sf *ServerForm) addValidatedInputField(form *tview.Form, label, fieldName, defaultValue string, width int, placeholder string) *tview.InputField { // Store the original label without color tags originalLabel := label field := tview.NewInputField(). SetLabel(label). SetText(defaultValue). SetFieldWidth(width) if placeholder != "" { field.SetPlaceholder(placeholder) } // Add change handler for real-time validation field.SetChangedFunc(func(text string) { if err := sf.validateField(fieldName, text); err != "" { // Show error in the label with red color field.SetLabel(fmt.Sprintf("[red]%s[-]", originalLabel)) } else { // Clear error indication, restore original label field.SetLabel(originalLabel) } }) // Add focus handler to show help field.SetFocusFunc(func() { sf.updateHelp(fieldName) }) // Validate on blur (when field loses focus) field.SetFinishedFunc(func(key tcell.Key) { sf.validateField(fieldName, field.GetText()) }) form.AddFormItem(field) return field } // validateAllFields validates all fields in the current form func (sf *ServerForm) validateAllFields() bool { // Clear all previous errors first sf.validation = NewValidationState() data := sf.getFormData() // Validate each field based on form data // Don't return early - validate all fields sf.validateField("Alias", data.Alias) sf.validateField("Host", data.Host) sf.validateField("Port", data.Port) sf.validateField("User", data.User) sf.validateField("Keys", data.Key) sf.validateField("Tags", data.Tags) // Connection fields sf.validateField("ConnectTimeout", data.ConnectTimeout) sf.validateField("ConnectionAttempts", data.ConnectionAttempts) sf.validateField("ServerAliveInterval", data.ServerAliveInterval) sf.validateField("ServerAliveCountMax", data.ServerAliveCountMax) sf.validateField("IPQoS", data.IPQoS) sf.validateField("BindAddress", data.BindAddress) // Port forwarding fields sf.validateField("LocalForward", data.LocalForward) sf.validateField("RemoteForward", data.RemoteForward) sf.validateField("DynamicForward", data.DynamicForward) // Authentication fields sf.validateField("NumberOfPasswordPrompts", data.NumberOfPasswordPrompts) // Advanced fields sf.validateField("CanonicalizeMaxDots", data.CanonicalizeMaxDots) sf.validateField("EscapeChar", data.EscapeChar) // Security fields sf.validateField("UserKnownHostsFile", data.UserKnownHostsFile) return !sf.validation.HasErrors() } // getDefaultValues returns default form values based on mode func (sf *ServerForm) getDefaultValues() ServerFormData { if sf.mode == ServerFormEdit && sf.original != nil { return ServerFormData{ Alias: sf.original.Alias, Host: sf.original.Host, User: sf.original.User, Port: fmt.Sprint(sf.original.Port), Key: strings.Join(sf.original.IdentityFiles, ", "), Tags: strings.Join(sf.original.Tags, ", "), ProxyJump: sf.original.ProxyJump, ProxyCommand: sf.original.ProxyCommand, RemoteCommand: sf.original.RemoteCommand, RequestTTY: sf.original.RequestTTY, SessionType: sf.original.SessionType, ConnectTimeout: sf.original.ConnectTimeout, ConnectionAttempts: sf.original.ConnectionAttempts, BindAddress: sf.original.BindAddress, BindInterface: sf.original.BindInterface, AddressFamily: sf.original.AddressFamily, ExitOnForwardFailure: sf.original.ExitOnForwardFailure, IPQoS: sf.original.IPQoS, // Hostname canonicalization CanonicalizeHostname: sf.original.CanonicalizeHostname, CanonicalDomains: sf.original.CanonicalDomains, CanonicalizeFallbackLocal: sf.original.CanonicalizeFallbackLocal, CanonicalizeMaxDots: sf.original.CanonicalizeMaxDots, CanonicalizePermittedCNAMEs: sf.original.CanonicalizePermittedCNAMEs, GatewayPorts: sf.original.GatewayPorts, LocalForward: strings.Join(sf.original.LocalForward, ", "), RemoteForward: strings.Join(sf.original.RemoteForward, ", "), DynamicForward: strings.Join(sf.original.DynamicForward, ", "), ClearAllForwardings: sf.original.ClearAllForwardings, // Public key PubkeyAuthentication: sf.original.PubkeyAuthentication, IdentitiesOnly: sf.original.IdentitiesOnly, // SSH Agent AddKeysToAgent: sf.original.AddKeysToAgent, IdentityAgent: sf.original.IdentityAgent, // Password & Interactive PasswordAuthentication: sf.original.PasswordAuthentication, KbdInteractiveAuthentication: sf.original.KbdInteractiveAuthentication, NumberOfPasswordPrompts: sf.original.NumberOfPasswordPrompts, // Advanced PreferredAuthentications: sf.original.PreferredAuthentications, ForwardAgent: sf.original.ForwardAgent, ForwardX11: sf.original.ForwardX11, ForwardX11Trusted: sf.original.ForwardX11Trusted, ControlMaster: sf.original.ControlMaster, ControlPath: sf.original.ControlPath, ControlPersist: sf.original.ControlPersist, ServerAliveInterval: sf.original.ServerAliveInterval, ServerAliveCountMax: sf.original.ServerAliveCountMax, Compression: sf.original.Compression, TCPKeepAlive: sf.original.TCPKeepAlive, BatchMode: sf.original.BatchMode, StrictHostKeyChecking: sf.original.StrictHostKeyChecking, UserKnownHostsFile: sf.original.UserKnownHostsFile, HostKeyAlgorithms: sf.original.HostKeyAlgorithms, PubkeyAcceptedAlgorithms: sf.original.PubkeyAcceptedAlgorithms, HostbasedAcceptedAlgorithms: sf.original.HostbasedAcceptedAlgorithms, MACs: sf.original.MACs, Ciphers: sf.original.Ciphers, KexAlgorithms: sf.original.KexAlgorithms, VerifyHostKeyDNS: sf.original.VerifyHostKeyDNS, UpdateHostKeys: sf.original.UpdateHostKeys, HashKnownHosts: sf.original.HashKnownHosts, VisualHostKey: sf.original.VisualHostKey, LocalCommand: sf.original.LocalCommand, PermitLocalCommand: sf.original.PermitLocalCommand, EscapeChar: sf.original.EscapeChar, SendEnv: strings.Join(sf.original.SendEnv, ", "), SetEnv: strings.Join(sf.original.SetEnv, ", "), LogLevel: sf.original.LogLevel, } } // For new servers, use empty values instead of SSH defaults // SSH defaults will be applied by the SSH client if values are not specified return ServerFormData{ Alias: "", // Explicitly empty for new servers Host: "", // Explicitly empty for new servers User: "", // Empty for new servers (SSH will use current username) Port: "22", // Keep port 22 as it's the standard SSH port Key: "", // Empty for new servers (SSH will try default keys) Tags: "", // All other fields should be empty for new servers // The SSH client will use its defaults when these are not specified ProxyJump: "", ProxyCommand: "", RemoteCommand: "", RequestTTY: "", SessionType: "", ConnectTimeout: "", ConnectionAttempts: "", BindAddress: "", BindInterface: "", AddressFamily: "", ExitOnForwardFailure: "", IPQoS: "", // Hostname canonicalization CanonicalizeHostname: "", CanonicalDomains: "", CanonicalizeFallbackLocal: "", CanonicalizeMaxDots: "", CanonicalizePermittedCNAMEs: "", // Port forwarding LocalForward: "", RemoteForward: "", DynamicForward: "", ClearAllForwardings: "", GatewayPorts: "", // Authentication PubkeyAuthentication: "", IdentitiesOnly: "", AddKeysToAgent: "", IdentityAgent: "", PasswordAuthentication: "", KbdInteractiveAuthentication: "", NumberOfPasswordPrompts: "", PreferredAuthentications: "", PubkeyAcceptedAlgorithms: "", HostbasedAcceptedAlgorithms: "", // Forwarding ForwardAgent: "", ForwardX11: "", ForwardX11Trusted: "", // Multiplexing ControlMaster: "", ControlPath: "", ControlPersist: "", // Keep-alive ServerAliveInterval: "", ServerAliveCountMax: "", TCPKeepAlive: "", // Connection Compression: "", BatchMode: "", // Security StrictHostKeyChecking: "", CheckHostIP: "", FingerprintHash: "", UserKnownHostsFile: "", HostKeyAlgorithms: "", MACs: "", Ciphers: "", KexAlgorithms: "", VerifyHostKeyDNS: "", UpdateHostKeys: "", HashKnownHosts: "", VisualHostKey: "", // Command execution LocalCommand: "", PermitLocalCommand: "", EscapeChar: "", // Environment SendEnv: "", SetEnv: "", // Debugging LogLevel: "", } } // createBasicForm creates the Basic configuration tab func (sf *ServerForm) createBasicForm() { form := tview.NewForm() defaultValues := sf.getDefaultValues() // Add validated input fields sf.addValidatedInputField(form, "Alias:", "Alias", defaultValues.Alias, 20, GetFieldPlaceholder("Alias")) sf.addValidatedInputField(form, "Host/IP:", "Host", defaultValues.Host, 20, GetFieldPlaceholder("Host")) sf.addValidatedInputField(form, "User:", "User", defaultValues.User, 20, GetFieldPlaceholder("User")) sf.addValidatedInputField(form, "Port:", "Port", defaultValues.Port, 20, GetFieldPlaceholder("Port")) // Keys field with autocomplete keysField := sf.addValidatedInputField(form, "Keys:", "Keys", defaultValues.Key, 40, GetFieldPlaceholder("Keys")) keysField.SetAutocompleteFunc(sf.createSSHKeyAutocomplete()) // Tags field sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) // Set up form-level input capture for shortcuts sf.setupFormShortcuts(form) sf.forms["Basic"] = form sf.pages.AddPage("Basic", form, true, true) } // createConnectionForm creates the Connection & Proxy tab func (sf *ServerForm) createConnectionForm() { form := tview.NewForm() defaultValues := sf.getDefaultValues() form.AddTextView("\n[yellow]▶ Proxy & Command[-]", "", 0, 1, true, false) sf.addInputFieldWithHelp(form, "ProxyJump:", "ProxyJump", defaultValues.ProxyJump, 40, GetFieldPlaceholder("ProxyJump")) sf.addInputFieldWithHelp(form, "ProxyCommand:", "ProxyCommand", defaultValues.ProxyCommand, 40, GetFieldPlaceholder("ProxyCommand")) sf.addInputFieldWithHelp(form, "RemoteCommand:", "RemoteCommand", defaultValues.RemoteCommand, 40, GetFieldPlaceholder("RemoteCommand")) // RequestTTY dropdown requestTTYOptions := createOptionsWithDefault("RequestTTY", []string{"", "yes", "no", "force", "auto"}) requestTTYIndex := sf.findOptionIndex(requestTTYOptions, defaultValues.RequestTTY) sf.addDropDownWithHelp(form, "RequestTTY:", "RequestTTY", requestTTYOptions, requestTTYIndex) // SessionType dropdown (OpenSSH 8.7+) sessionTypeOptions := createOptionsWithDefault("SessionType", []string{"", "none (-N)", "subsystem (-s)", "default"}) sessionTypeIndex := sf.findOptionIndex(sessionTypeOptions, defaultValues.SessionType) sf.addDropDownWithHelp(form, "SessionType:", "SessionType", sessionTypeOptions, sessionTypeIndex) form.AddTextView("\n[yellow]▶ Connection Settings[-]", "", 0, 1, true, false) sf.addValidatedInputField(form, "ConnectTimeout:", "ConnectTimeout", defaultValues.ConnectTimeout, 10, GetFieldPlaceholder("ConnectTimeout")) sf.addValidatedInputField(form, "ConnectionAttempts:", "ConnectionAttempts", defaultValues.ConnectionAttempts, 10, GetFieldPlaceholder("ConnectionAttempts")) sf.addValidatedInputField(form, "IPQoS:", "IPQoS", defaultValues.IPQoS, 20, GetFieldPlaceholder("IPQoS")) // BatchMode dropdown (moved from Keep-Alive) batchModeOptions := createOptionsWithDefault("BatchMode", []string{"", "yes", "no"}) batchModeIndex := sf.findOptionIndex(batchModeOptions, defaultValues.BatchMode) sf.addDropDownWithHelp(form, "BatchMode:", "BatchMode", batchModeOptions, batchModeIndex) form.AddTextView("\n[yellow]▶ Bind Options[-]", "", 0, 1, true, false) sf.addValidatedInputField(form, "BindAddress:", "BindAddress", defaultValues.BindAddress, 40, GetFieldPlaceholder("BindAddress")) // BindInterface dropdown with available network interfaces interfaceOptions := append([]string{""}, GetNetworkInterfaces()...) bindInterfaceIndex := sf.findOptionIndex(interfaceOptions, defaultValues.BindInterface) sf.addDropDownWithHelp(form, "BindInterface:", "BindInterface", interfaceOptions, bindInterfaceIndex) // AddressFamily dropdown addressFamilyOptions := createOptionsWithDefault("AddressFamily", []string{"", "any", "inet", "inet6"}) addressFamilyIndex := sf.findOptionIndex(addressFamilyOptions, defaultValues.AddressFamily) sf.addDropDownWithHelp(form, "AddressFamily:", "AddressFamily", addressFamilyOptions, addressFamilyIndex) form.AddTextView("\n[yellow]▶ Hostname Canonicalization[-]", "", 0, 1, true, false) // CanonicalizeHostname dropdown canonicalizeOptions := createOptionsWithDefault("CanonicalizeHostname", []string{"", "yes", "no", "always"}) canonicalizeIndex := sf.findOptionIndex(canonicalizeOptions, defaultValues.CanonicalizeHostname) sf.addDropDownWithHelp(form, "CanonicalizeHostname:", "CanonicalizeHostname", canonicalizeOptions, canonicalizeIndex) sf.addInputFieldWithHelp(form, "CanonicalDomains:", "CanonicalDomains", defaultValues.CanonicalDomains, 40, GetFieldPlaceholder("CanonicalDomains")) // CanonicalizeFallbackLocal dropdown fallbackOptions := createOptionsWithDefault("CanonicalizeFallbackLocal", []string{"", "yes", "no"}) fallbackIndex := sf.findOptionIndex(fallbackOptions, defaultValues.CanonicalizeFallbackLocal) sf.addDropDownWithHelp(form, "CanonicalizeFallbackLocal:", "CanonicalizeFallbackLocal", fallbackOptions, fallbackIndex) sf.addValidatedInputField(form, "CanonicalizeMaxDots:", "CanonicalizeMaxDots", defaultValues.CanonicalizeMaxDots, 10, GetFieldPlaceholder("CanonicalizeMaxDots")) sf.addInputFieldWithHelp(form, "CanonicalizePermittedCNAMEs:", "CanonicalizePermittedCNAMEs", defaultValues.CanonicalizePermittedCNAMEs, 40, GetFieldPlaceholder("CanonicalizePermittedCNAMEs")) form.AddTextView("\n[yellow]▶ Keep-Alive[-]", "", 0, 1, true, false) sf.addValidatedInputField(form, "ServerAliveInterval:", "ServerAliveInterval", defaultValues.ServerAliveInterval, 10, GetFieldPlaceholder("ServerAliveInterval")) sf.addValidatedInputField(form, "ServerAliveCountMax:", "ServerAliveCountMax", defaultValues.ServerAliveCountMax, 10, GetFieldPlaceholder("ServerAliveCountMax")) // Compression dropdown compressionOptions := createOptionsWithDefault("Compression", []string{"", "yes", "no"}) compressionIndex := sf.findOptionIndex(compressionOptions, defaultValues.Compression) sf.addDropDownWithHelp(form, "Compression:", "Compression", compressionOptions, compressionIndex) // TCPKeepAlive dropdown tcpKeepAliveOptions := createOptionsWithDefault("TCPKeepAlive", []string{"", "yes", "no"}) tcpKeepAliveIndex := sf.findOptionIndex(tcpKeepAliveOptions, defaultValues.TCPKeepAlive) sf.addDropDownWithHelp(form, "TCPKeepAlive:", "TCPKeepAlive", tcpKeepAliveOptions, tcpKeepAliveIndex) form.AddTextView("\n[yellow]▶ Multiplexing[-]", "", 0, 1, true, false) // ControlMaster dropdown controlMasterOptions := createOptionsWithDefault("ControlMaster", []string{"", "yes", "no", "auto", "ask", "autoask"}) controlMasterIndex := sf.findOptionIndex(controlMasterOptions, defaultValues.ControlMaster) sf.addDropDownWithHelp(form, "ControlMaster:", "ControlMaster", controlMasterOptions, controlMasterIndex) sf.addInputFieldWithHelp(form, "ControlPath:", "ControlPath", defaultValues.ControlPath, 40, GetFieldPlaceholder("ControlPath")) sf.addInputFieldWithHelp(form, "ControlPersist:", "ControlPersist", defaultValues.ControlPersist, 20, GetFieldPlaceholder("ControlPersist")) // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) // Set up form-level input capture for shortcuts sf.setupFormShortcuts(form) sf.forms["Connection"] = form sf.pages.AddPage("Connection", form, true, false) } // createForwardingForm creates the Port Forwarding tab func (sf *ServerForm) createForwardingForm() { form := tview.NewForm() defaultValues := sf.getDefaultValues() form.AddTextView("\n[yellow]▶ Port Forwarding[-]", "", 0, 1, true, false) sf.addValidatedInputField(form, "LocalForward:", "LocalForward", defaultValues.LocalForward, 40, GetFieldPlaceholder("LocalForward")) sf.addValidatedInputField(form, "RemoteForward:", "RemoteForward", defaultValues.RemoteForward, 40, GetFieldPlaceholder("RemoteForward")) sf.addValidatedInputField(form, "DynamicForward:", "DynamicForward", defaultValues.DynamicForward, 40, GetFieldPlaceholder("DynamicForward")) // ClearAllForwardings dropdown clearAllForwardingsOptions := createOptionsWithDefault("ClearAllForwardings", []string{"", "yes", "no"}) clearAllForwardingsIndex := sf.findOptionIndex(clearAllForwardingsOptions, defaultValues.ClearAllForwardings) sf.addDropDownWithHelp(form, "ClearAllForwardings:", "ClearAllForwardings", clearAllForwardingsOptions, clearAllForwardingsIndex) // ExitOnForwardFailure dropdown exitOnForwardFailureOptions := createOptionsWithDefault("ExitOnForwardFailure", []string{"", "yes", "no"}) exitOnForwardFailureIndex := sf.findOptionIndex(exitOnForwardFailureOptions, defaultValues.ExitOnForwardFailure) sf.addDropDownWithHelp(form, "ExitOnForwardFailure:", "ExitOnForwardFailure", exitOnForwardFailureOptions, exitOnForwardFailureIndex) // GatewayPorts dropdown gatewayPortsOptions := createOptionsWithDefault("GatewayPorts", []string{"", "yes", "no", "clientspecified"}) gatewayPortsIndex := sf.findOptionIndex(gatewayPortsOptions, defaultValues.GatewayPorts) sf.addDropDownWithHelp(form, "GatewayPorts:", "GatewayPorts", gatewayPortsOptions, gatewayPortsIndex) form.AddTextView("\n[yellow]▶ Agent & X11 Forwarding[-]", "", 0, 1, true, false) // ForwardAgent dropdown forwardAgentOptions := createOptionsWithDefault("ForwardAgent", []string{"", "yes", "no"}) forwardAgentIndex := sf.findOptionIndex(forwardAgentOptions, defaultValues.ForwardAgent) sf.addDropDownWithHelp(form, "ForwardAgent:", "ForwardAgent", forwardAgentOptions, forwardAgentIndex) // ForwardX11 dropdown forwardX11Options := createOptionsWithDefault("ForwardX11", []string{"", "yes", "no"}) forwardX11Index := sf.findOptionIndex(forwardX11Options, defaultValues.ForwardX11) sf.addDropDownWithHelp(form, "ForwardX11:", "ForwardX11", forwardX11Options, forwardX11Index) // ForwardX11Trusted dropdown forwardX11TrustedOptions := createOptionsWithDefault("ForwardX11Trusted", []string{"", "yes", "no"}) forwardX11TrustedIndex := sf.findOptionIndex(forwardX11TrustedOptions, defaultValues.ForwardX11Trusted) sf.addDropDownWithHelp(form, "ForwardX11Trusted:", "ForwardX11Trusted", forwardX11TrustedOptions, forwardX11TrustedIndex) // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) // Set up form-level input capture for shortcuts sf.setupFormShortcuts(form) sf.forms["Forwarding"] = form sf.pages.AddPage("Forwarding", form, true, false) } // Algorithm suggestions for autocomplete var ( pubkeyAlgorithms = []string{ "ssh-ed25519", "ssh-ed25519-cert-v01@openssh.com", "sk-ssh-ed25519@openssh.com", "sk-ssh-ed25519-cert-v01@openssh.com", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp384-cert-v01@openssh.com", "ecdsa-sha2-nistp521-cert-v01@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com", "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", "rsa-sha2-512", "rsa-sha2-256", "rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa", "ssh-rsa-cert-v01@openssh.com", "ssh-dss", "ssh-dss-cert-v01@openssh.com", } cipherAlgorithms = []string{ "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", "chacha20-poly1305@openssh.com", "aes128-cbc", "aes192-cbc", "aes256-cbc", "3des-cbc", } macAlgorithms = []string{ "hmac-sha2-256", "hmac-sha2-512", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "umac-64@openssh.com", "umac-128@openssh.com", "umac-64-etm@openssh.com", "umac-128-etm@openssh.com", "hmac-sha1", "hmac-sha1-96", "hmac-sha1-etm@openssh.com", "hmac-sha1-96-etm@openssh.com", "hmac-md5", "hmac-md5-96", "hmac-md5-etm@openssh.com", "hmac-md5-96-etm@openssh.com", } kexAlgorithms = []string{ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group-exchange-sha256", "diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group1-sha1", } hostKeyAlgorithms = []string{ "ssh-ed25519", "ssh-ed25519-cert-v01@openssh.com", "sk-ssh-ed25519@openssh.com", "sk-ssh-ed25519-cert-v01@openssh.com", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp384-cert-v01@openssh.com", "ecdsa-sha2-nistp521-cert-v01@openssh.com", "rsa-sha2-512", "rsa-sha2-256", "rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa", "ssh-rsa-cert-v01@openssh.com", "ssh-dss", "ssh-dss-cert-v01@openssh.com", } ) // createAuthenticationForm creates the Authentication tab func (sf *ServerForm) createAuthenticationForm() { form := tview.NewForm() defaultValues := sf.getDefaultValues() // Most common: Public key authentication form.AddTextView("\n[yellow]▶ Public Key Authentication[-]", "", 0, 1, true, false) // PubkeyAuthentication dropdown pubkeyOptions := createOptionsWithDefault("PubkeyAuthentication", []string{"", "yes", "no"}) pubkeyIndex := sf.findOptionIndex(pubkeyOptions, defaultValues.PubkeyAuthentication) sf.addDropDownWithHelp(form, "PubkeyAuthentication:", "PubkeyAuthentication", pubkeyOptions, pubkeyIndex) // IdentitiesOnly dropdown - controls whether to use only specified identity files identitiesOnlyOptions := createOptionsWithDefault("IdentitiesOnly", []string{"", "yes", "no"}) identitiesOnlyIndex := sf.findOptionIndex(identitiesOnlyOptions, defaultValues.IdentitiesOnly) sf.addDropDownWithHelp(form, "IdentitiesOnly:", "IdentitiesOnly", identitiesOnlyOptions, identitiesOnlyIndex) // SSH Agent settings form.AddTextView("\n[yellow]▶ SSH Agent[-]", "", 0, 1, true, false) // AddKeysToAgent dropdown addKeysOptions := createOptionsWithDefault("AddKeysToAgent", []string{"", "yes", "no", "ask", "confirm"}) addKeysIndex := sf.findOptionIndex(addKeysOptions, defaultValues.AddKeysToAgent) sf.addDropDownWithHelp(form, "AddKeysToAgent:", "AddKeysToAgent", addKeysOptions, addKeysIndex) sf.addInputFieldWithHelp(form, "IdentityAgent:", "IdentityAgent", defaultValues.IdentityAgent, 40, GetFieldPlaceholder("IdentityAgent")) // Password/Interactive authentication form.AddTextView("\n[yellow]▶ Password & Interactive[-]", "", 0, 1, true, false) // PasswordAuthentication dropdown passwordOptions := createOptionsWithDefault("PasswordAuthentication", []string{"", "yes", "no"}) passwordIndex := sf.findOptionIndex(passwordOptions, defaultValues.PasswordAuthentication) sf.addDropDownWithHelp(form, "PasswordAuthentication:", "PasswordAuthentication", passwordOptions, passwordIndex) // KbdInteractiveAuthentication dropdown kbdInteractiveOptions := createOptionsWithDefault("KbdInteractiveAuthentication", []string{"", "yes", "no"}) kbdInteractiveIndex := sf.findOptionIndex(kbdInteractiveOptions, defaultValues.KbdInteractiveAuthentication) sf.addDropDownWithHelp(form, "KbdInteractiveAuthentication:", "KbdInteractiveAuthentication", kbdInteractiveOptions, kbdInteractiveIndex) // NumberOfPasswordPrompts field sf.addValidatedInputField(form, "NumberOfPasswordPrompts:", "NumberOfPasswordPrompts", defaultValues.NumberOfPasswordPrompts, 10, GetFieldPlaceholder("NumberOfPasswordPrompts")) // Advanced: Authentication order preference form.AddTextView("\n[yellow]▶ Advanced[-]", "", 0, 1, true, false) sf.addInputFieldWithHelp(form, "PreferredAuthentications:", "PreferredAuthentications", defaultValues.PreferredAuthentications, 40, GetFieldPlaceholder("PreferredAuthentications")) // PubkeyAcceptedAlgorithms with autocomplete support (moved from Advanced/Cryptography) pubkeyAlgField := sf.addInputFieldWithHelp(form, "PubkeyAcceptedAlgorithms:", "PubkeyAcceptedAlgorithms", defaultValues.PubkeyAcceptedAlgorithms, 40, GetFieldPlaceholder("PubkeyAcceptedAlgorithms")) pubkeyAlgField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(pubkeyAlgorithms)) // HostbasedAcceptedAlgorithms with autocomplete support (moved from Advanced/Cryptography) hostbasedAlgField := sf.addInputFieldWithHelp(form, "HostbasedAcceptedAlgorithms:", "HostbasedAcceptedAlgorithms", defaultValues.HostbasedAcceptedAlgorithms, 40, GetFieldPlaceholder("HostbasedAcceptedAlgorithms")) hostbasedAlgField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(pubkeyAlgorithms)) // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) // Set up form-level input capture for shortcuts sf.setupFormShortcuts(form) sf.forms["Authentication"] = form sf.pages.AddPage("Authentication", form, true, false) } // createAdvancedForm creates the Advanced settings tab func (sf *ServerForm) createAdvancedForm() { form := tview.NewForm() defaultValues := sf.getDefaultValues() form.AddTextView("\n[yellow]▶ Security[-]", "", 0, 1, true, false) // StrictHostKeyChecking dropdown strictHostKeyOptions := createOptionsWithDefault("StrictHostKeyChecking", []string{"", "yes", "no", "ask", "accept-new"}) strictHostKeyIndex := sf.findOptionIndex(strictHostKeyOptions, defaultValues.StrictHostKeyChecking) sf.addDropDownWithHelp(form, "StrictHostKeyChecking:", "StrictHostKeyChecking", strictHostKeyOptions, strictHostKeyIndex) // CheckHostIP dropdown checkHostIPOptions := createOptionsWithDefault("CheckHostIP", []string{"", "yes", "no"}) checkHostIPIndex := sf.findOptionIndex(checkHostIPOptions, defaultValues.CheckHostIP) sf.addDropDownWithHelp(form, "CheckHostIP:", "CheckHostIP", checkHostIPOptions, checkHostIPIndex) // FingerprintHash dropdown fingerprintHashOptions := createOptionsWithDefault("FingerprintHash", []string{"", "md5", "sha256"}) fingerprintHashIndex := sf.findOptionIndex(fingerprintHashOptions, defaultValues.FingerprintHash) sf.addDropDownWithHelp(form, "FingerprintHash:", "FingerprintHash", fingerprintHashOptions, fingerprintHashIndex) // VerifyHostKeyDNS dropdown verifyHostKeyDNSOptions := createOptionsWithDefault("VerifyHostKeyDNS", []string{"", "yes", "no", "ask"}) verifyHostKeyDNSIndex := sf.findOptionIndex(verifyHostKeyDNSOptions, defaultValues.VerifyHostKeyDNS) sf.addDropDownWithHelp(form, "VerifyHostKeyDNS:", "VerifyHostKeyDNS", verifyHostKeyDNSOptions, verifyHostKeyDNSIndex) // UpdateHostKeys dropdown updateHostKeysOptions := createOptionsWithDefault("UpdateHostKeys", []string{"", "yes", "no", "ask"}) updateHostKeysIndex := sf.findOptionIndex(updateHostKeysOptions, defaultValues.UpdateHostKeys) sf.addDropDownWithHelp(form, "UpdateHostKeys:", "UpdateHostKeys", updateHostKeysOptions, updateHostKeysIndex) // HashKnownHosts dropdown hashKnownHostsOptions := createOptionsWithDefault("HashKnownHosts", []string{"", "yes", "no"}) hashKnownHostsIndex := sf.findOptionIndex(hashKnownHostsOptions, defaultValues.HashKnownHosts) sf.addDropDownWithHelp(form, "HashKnownHosts:", "HashKnownHosts", hashKnownHostsOptions, hashKnownHostsIndex) // VisualHostKey dropdown visualHostKeyOptions := createOptionsWithDefault("VisualHostKey", []string{"", "yes", "no"}) visualHostKeyIndex := sf.findOptionIndex(visualHostKeyOptions, defaultValues.VisualHostKey) sf.addDropDownWithHelp(form, "VisualHostKey:", "VisualHostKey", visualHostKeyOptions, visualHostKeyIndex) // UserKnownHostsFile field with autocomplete and validation knownHostsField := sf.addValidatedInputField(form, "UserKnownHostsFile:", "UserKnownHostsFile", defaultValues.UserKnownHostsFile, 40, GetFieldPlaceholder("UserKnownHostsFile")) knownHostsField.SetAutocompleteFunc(sf.createKnownHostsAutocomplete()) form.AddTextView("\n[yellow]▶ Cryptography[-]", "", 0, 1, true, false) // Ciphers with autocomplete support ciphersField := sf.addInputFieldWithHelp(form, "Ciphers:", "Ciphers", defaultValues.Ciphers, 40, GetFieldPlaceholder("Ciphers")) ciphersField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(cipherAlgorithms)) // MACs with autocomplete support macsField := sf.addInputFieldWithHelp(form, "MACs:", "MACs", defaultValues.MACs, 40, GetFieldPlaceholder("MACs")) macsField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(macAlgorithms)) // KexAlgorithms with autocomplete support kexField := sf.addInputFieldWithHelp(form, "KexAlgorithms:", "KexAlgorithms", defaultValues.KexAlgorithms, 40, GetFieldPlaceholder("KexAlgorithms")) kexField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(kexAlgorithms)) // HostKeyAlgorithms with autocomplete support hostKeyField := sf.addInputFieldWithHelp(form, "HostKeyAlgorithms:", "HostKeyAlgorithms", defaultValues.HostKeyAlgorithms, 40, GetFieldPlaceholder("HostKeyAlgorithms")) hostKeyField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(hostKeyAlgorithms)) form.AddTextView("\n[yellow]▶ Command Execution[-]", "", 0, 1, true, false) sf.addInputFieldWithHelp(form, "LocalCommand:", "LocalCommand", defaultValues.LocalCommand, 40, GetFieldPlaceholder("LocalCommand")) // PermitLocalCommand dropdown permitLocalCommandOptions := createOptionsWithDefault("PermitLocalCommand", []string{"", "yes", "no"}) permitLocalCommandIndex := sf.findOptionIndex(permitLocalCommandOptions, defaultValues.PermitLocalCommand) sf.addDropDownWithHelp(form, "PermitLocalCommand:", "PermitLocalCommand", permitLocalCommandOptions, permitLocalCommandIndex) // EscapeChar input field sf.addValidatedInputField(form, "EscapeChar:", "EscapeChar", defaultValues.EscapeChar, 10, GetFieldPlaceholder("EscapeChar")) form.AddTextView("\n[yellow]▶ Environment[-]", "", 0, 1, true, false) sf.addInputFieldWithHelp(form, "SendEnv:", "SendEnv", defaultValues.SendEnv, 40, GetFieldPlaceholder("SendEnv")) sf.addInputFieldWithHelp(form, "SetEnv:", "SetEnv", defaultValues.SetEnv, 40, GetFieldPlaceholder("SetEnv")) form.AddTextView("\n[yellow]▶ Debugging[-]", "", 0, 1, true, false) // LogLevel dropdown logLevelOptions := createOptionsWithDefault("LogLevel", []string{"", "QUIET", "FATAL", "ERROR", "INFO", "VERBOSE", "DEBUG", "DEBUG1", "DEBUG2", "DEBUG3"}) logLevelIndex := sf.findOptionIndex(logLevelOptions, defaultValues.LogLevel) sf.addDropDownWithHelp(form, "LogLevel:", "LogLevel", logLevelOptions, logLevelIndex) // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) // Set up form-level input capture for shortcuts sf.setupFormShortcuts(form) sf.forms["Advanced"] = form sf.pages.AddPage("Advanced", form, true, false) } type ServerFormData struct { Alias string Host string User string Port string Key string Tags string // Connection and proxy settings ProxyJump string ProxyCommand string RemoteCommand string RequestTTY string SessionType string ConnectTimeout string ConnectionAttempts string BindAddress string BindInterface string AddressFamily string ExitOnForwardFailure string IPQoS string // Hostname canonicalization CanonicalizeHostname string CanonicalDomains string CanonicalizeFallbackLocal string CanonicalizeMaxDots string CanonicalizePermittedCNAMEs string // Port forwarding LocalForward string RemoteForward string DynamicForward string ClearAllForwardings string GatewayPorts string // Authentication and key management // Public key PubkeyAuthentication string IdentitiesOnly string // SSH Agent AddKeysToAgent string IdentityAgent string // Password & Interactive PasswordAuthentication string KbdInteractiveAuthentication string NumberOfPasswordPrompts string // Advanced PreferredAuthentications string // Agent and X11 forwarding ForwardAgent string ForwardX11 string ForwardX11Trusted string // Connection multiplexing ControlMaster string ControlPath string ControlPersist string // Connection reliability ServerAliveInterval string ServerAliveCountMax string Compression string TCPKeepAlive string BatchMode string // Security settings StrictHostKeyChecking string CheckHostIP string FingerprintHash string UserKnownHostsFile string HostKeyAlgorithms string PubkeyAcceptedAlgorithms string HostbasedAcceptedAlgorithms string MACs string Ciphers string KexAlgorithms string VerifyHostKeyDNS string UpdateHostKeys string HashKnownHosts string VisualHostKey string // Command execution LocalCommand string PermitLocalCommand string EscapeChar string // Environment settings SendEnv string SetEnv string // Debugging settings LogLevel string } // stripColorTags removes tview color tags from a string // e.g., "[red]Port:[-]" becomes "Port:" func (sf *ServerForm) getFormData() ServerFormData { // Helper function to get text from InputField across all forms getFieldText := func(fieldName string) string { for _, form := range sf.forms { for i := 0; i < form.GetFormItemCount(); i++ { if field, ok := form.GetFormItem(i).(*tview.InputField); ok { label := strings.TrimSpace(field.GetLabel()) // Strip color tags from label for comparison // Labels can be: "Port:", "[red]Port:[-]", "[green]Port:[-]" cleanLabel := stripColorTags(label) if strings.HasPrefix(cleanLabel, fieldName) { return strings.TrimSpace(field.GetText()) } } } } return "" } // Helper function to get selected option from DropDown across all forms getDropdownValue := func(fieldName string) string { for _, form := range sf.forms { for i := 0; i < form.GetFormItemCount(); i++ { if dropdown, ok := form.GetFormItem(i).(*tview.DropDown); ok { label := strings.TrimSpace(dropdown.GetLabel()) // Strip color tags from label for comparison cleanLabel := stripColorTags(label) if strings.HasPrefix(cleanLabel, fieldName) { _, text := dropdown.GetCurrentOption() // Parse the option value to handle "default (value)" format return parseOptionValue(text) } } } } return "" } return ServerFormData{ Alias: getFieldText("Alias:"), Host: getFieldText("Host/IP:"), User: getFieldText("User:"), Port: getFieldText("Port:"), Key: getFieldText("Keys:"), Tags: getFieldText("Tags:"), // Connection and proxy settings ProxyJump: getFieldText("ProxyJump:"), ProxyCommand: getFieldText("ProxyCommand:"), RemoteCommand: getFieldText("RemoteCommand:"), RequestTTY: getDropdownValue("RequestTTY:"), SessionType: sf.parseSessionType(getDropdownValue("SessionType:")), ConnectTimeout: getFieldText("ConnectTimeout:"), ConnectionAttempts: getFieldText("ConnectionAttempts:"), BindAddress: getFieldText("BindAddress:"), BindInterface: getDropdownValue("BindInterface:"), AddressFamily: getDropdownValue("AddressFamily:"), ExitOnForwardFailure: getDropdownValue("ExitOnForwardFailure:"), // Port forwarding LocalForward: getFieldText("LocalForward:"), RemoteForward: getFieldText("RemoteForward:"), DynamicForward: getFieldText("DynamicForward:"), ClearAllForwardings: getDropdownValue("ClearAllForwardings:"), // Authentication and key management // Public key PubkeyAuthentication: getDropdownValue("PubkeyAuthentication:"), IdentitiesOnly: getDropdownValue("IdentitiesOnly:"), // SSH Agent AddKeysToAgent: getDropdownValue("AddKeysToAgent:"), IdentityAgent: getFieldText("IdentityAgent:"), // Password & Interactive PasswordAuthentication: getDropdownValue("PasswordAuthentication:"), KbdInteractiveAuthentication: getDropdownValue("KbdInteractiveAuthentication:"), NumberOfPasswordPrompts: getFieldText("NumberOfPasswordPrompts:"), // Advanced PreferredAuthentications: getFieldText("PreferredAuthentications:"), // Agent and X11 forwarding ForwardAgent: getDropdownValue("ForwardAgent:"), ForwardX11: getDropdownValue("ForwardX11:"), ForwardX11Trusted: getDropdownValue("ForwardX11Trusted:"), // Connection multiplexing ControlMaster: getDropdownValue("ControlMaster:"), ControlPath: getFieldText("ControlPath:"), ControlPersist: getFieldText("ControlPersist:"), // Connection reliability settings ServerAliveInterval: getFieldText("ServerAliveInterval:"), ServerAliveCountMax: getFieldText("ServerAliveCountMax:"), Compression: getDropdownValue("Compression:"), TCPKeepAlive: getDropdownValue("TCPKeepAlive:"), BatchMode: getDropdownValue("BatchMode:"), // Security settings StrictHostKeyChecking: getDropdownValue("StrictHostKeyChecking:"), UserKnownHostsFile: getFieldText("UserKnownHostsFile:"), HostKeyAlgorithms: getFieldText("HostKeyAlgorithms:"), PubkeyAcceptedAlgorithms: getFieldText("PubkeyAcceptedAlgorithms:"), MACs: getFieldText("MACs:"), Ciphers: getFieldText("Ciphers:"), KexAlgorithms: getFieldText("KexAlgorithms:"), VerifyHostKeyDNS: getDropdownValue("VerifyHostKeyDNS:"), UpdateHostKeys: getDropdownValue("UpdateHostKeys:"), HashKnownHosts: getDropdownValue("HashKnownHosts:"), VisualHostKey: getDropdownValue("VisualHostKey:"), // Command execution LocalCommand: getFieldText("LocalCommand:"), PermitLocalCommand: getDropdownValue("PermitLocalCommand:"), EscapeChar: getFieldText("EscapeChar:"), // Environment settings SendEnv: getFieldText("SendEnv:"), SetEnv: getFieldText("SetEnv:"), // Debugging settings LogLevel: getDropdownValue("LogLevel:"), } } // parseSessionType converts dropdown display value to actual value func (sf *ServerForm) parseSessionType(value string) string { // First handle the default value format if strings.HasPrefix(value, "default (") && strings.HasSuffix(value, ")") { return "" // Return empty string for default values } // Then handle specific display values switch value { case "none (-N)": return sessionTypeNone case "subsystem (-s)": return sessionTypeSubsystem case "default": return sessionTypeDefault default: return value } } // handleSaveButton is a wrapper for button callback (no return value) func (sf *ServerForm) handleSaveButton() { sf.handleSave() } // handleSave validates and saves the form, returns true if successful func (sf *ServerForm) handleSave() bool { // First validate all fields with the new validation system if !sf.validateAllFields() { // Show validation errors if sf.app != nil { errors := sf.validation.GetAllErrors() if len(errors) > 0 { // Limit the number of errors to display to prevent overflow maxErrorsToShow := 5 truncated := false if len(errors) > maxErrorsToShow { errors = errors[:maxErrorsToShow] truncated = true } // Build error message errorMsg := fmt.Sprintf("Validation failed (%d error%s):\n\n", sf.validation.GetErrorCount(), func() string { if sf.validation.GetErrorCount() == 1 { return "" } return "s" }()) for i, err := range errors { errorMsg += fmt.Sprintf("%d. %s\n", i+1, err) } if truncated { errorMsg += fmt.Sprintf("\n... and %d more error%s", sf.validation.GetErrorCount()-maxErrorsToShow, func() string { if sf.validation.GetErrorCount()-maxErrorsToShow == 1 { return "" } return "s" }()) } // Use tview's built-in Modal modal := tview.NewModal(). SetText(errorMsg). AddButtons([]string{"OK"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { sf.app.SetRoot(sf.Flex, true) }) sf.app.SetRoot(modal, true) } } return false // Validation failed } data := sf.getFormData() // Reset title and border (validation already done above) sf.formPanel.SetTitle(" " + sf.titleForMode() + " ") sf.formPanel.SetBorderColor(tcell.Color238) server := sf.dataToServer(data) if sf.onSave != nil { sf.onSave(server, sf.original) } return true // Save successful } func (sf *ServerForm) handleCancel() { // Check if there are unsaved changes if sf.hasUnsavedChanges() { // If app reference is available, show confirmation dialog if sf.app != nil { modal := tview.NewModal(). SetText("You have unsaved changes. Are you sure you want to exit?"). AddButtons([]string{"[yellow]S[-]ave", "[yellow]D[-]iscard", "[yellow]C[-]ancel"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { switch buttonIndex { case 0: // Save // Try to save, if successful it will exit if sf.handleSave() { // Save successful, modal will be replaced by onSave callback } else { // Validation failed, return to form sf.app.SetRoot(sf.Flex, true) } case 1: // Discard if sf.onCancel != nil { sf.onCancel() } case 2: // Cancel // Restore the form view sf.app.SetRoot(sf.Flex, true) } }) // Set up keyboard shortcuts for the modal modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Rune() { case 's', 'S': if sf.handleSave() { // Save successful } else { // Validation failed, return to form sf.app.SetRoot(sf.Flex, true) } return nil case 'd', 'D': if sf.onCancel != nil { sf.onCancel() } return nil case 'c', 'C': sf.app.SetRoot(sf.Flex, true) return nil } return event }) // Show modal sf.app.SetRoot(modal, true) } else if sf.onCancel != nil { // No app reference, fallback to direct cancel (shouldn't happen in normal use) sf.onCancel() } } else { // No unsaved changes, just exit if sf.onCancel != nil { sf.onCancel() } } } // hasUnsavedChanges checks if current form data differs from original func (sf *ServerForm) hasUnsavedChanges() bool { // If creating new server, any non-empty required fields mean changes if sf.mode == ServerFormAdd { data := sf.getFormData() return data.Alias != "" || data.Host != "" || data.User != "" } // If editing, compare with original if sf.original == nil { return false } currentData := sf.getFormData() currentServer := sf.dataToServer(currentData) // Use DeepEqual for simple comparison first if reflect.DeepEqual(currentServer, *sf.original) { return false } // If DeepEqual says they're different, use our custom comparison // that handles nil vs empty slice and other normalization return sf.serversDiffer(currentServer, *sf.original) } // serversDiffer compares two servers for differences using reflection func (sf *ServerForm) serversDiffer(a, b domain.Server) bool { // Use reflection to compare all fields valA := reflect.ValueOf(a) valB := reflect.ValueOf(b) typeA := valA.Type() // Fields to skip during comparison (lazyssh metadata fields) skipFields := map[string]bool{ "Aliases": true, // Computed field "LastSeen": true, // Metadata field "PinnedAt": true, // Metadata field "SSHCount": true, // Metadata field } // Iterate through all fields for i := 0; i < valA.NumField(); i++ { fieldA := valA.Field(i) fieldB := valB.Field(i) fieldName := typeA.Field(i).Name // Skip unexported fields and metadata fields if !fieldA.CanInterface() || skipFields[fieldName] { continue } // Compare based on field type differs := false switch fieldA.Kind() { case reflect.String: if fieldA.String() != fieldB.String() { differs = true } case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if fieldA.Int() != fieldB.Int() { differs = true } case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: if fieldA.Uint() != fieldB.Uint() { differs = true } case reflect.Slice: if !sf.slicesEqual(fieldA, fieldB) { differs = true } case reflect.Bool: if fieldA.Bool() != fieldB.Bool() { differs = true } case reflect.Float32, reflect.Float64: if fieldA.Float() != fieldB.Float() { differs = true } case reflect.Complex64, reflect.Complex128: if fieldA.Complex() != fieldB.Complex() { differs = true } case reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Struct, reflect.UnsafePointer, reflect.Invalid: // For these types, use reflect.DeepEqual if !reflect.DeepEqual(fieldA.Interface(), fieldB.Interface()) { differs = true } } if differs { return true } } return false } // slicesEqual compares two reflect.Value slices for equality func (sf *ServerForm) slicesEqual(a, b reflect.Value) bool { // Handle nil slices - treat nil and empty slice as equal if a.IsNil() && b.IsNil() { return true } if a.IsNil() && b.Len() == 0 { return true } if b.IsNil() && a.Len() == 0 { return true } if a.Len() != b.Len() { return false } for i := 0; i < a.Len(); i++ { // For string slices if a.Index(i).Kind() == reflect.String { if a.Index(i).String() != b.Index(i).String() { return false } } else { // For other types, use DeepEqual if !reflect.DeepEqual(a.Index(i).Interface(), b.Index(i).Interface()) { return false } } } return true } func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server { port := 22 if data.Port != "" { if n, err := strconv.Atoi(data.Port); err == nil && n > 0 { port = n } } // Use nil for empty slices to match original state var tags []string if data.Tags != "" { for _, t := range strings.Split(data.Tags, ",") { if s := strings.TrimSpace(t); s != "" { tags = append(tags, s) } } } var keys []string if data.Key != "" { parts := strings.Split(data.Key, ",") for _, p := range parts { if k := strings.TrimSpace(p); k != "" { keys = append(keys, k) } } } // Helper to split comma-separated values splitComma := func(s string) []string { if s == "" { return nil } var result []string for _, item := range strings.Split(s, ",") { if trimmed := strings.TrimSpace(item); trimmed != "" { result = append(result, trimmed) } } return result } server := domain.Server{ Alias: data.Alias, Host: data.Host, User: data.User, Port: port, IdentityFiles: keys, Tags: tags, ProxyJump: data.ProxyJump, ProxyCommand: data.ProxyCommand, RemoteCommand: data.RemoteCommand, RequestTTY: data.RequestTTY, SessionType: data.SessionType, ConnectTimeout: data.ConnectTimeout, ConnectionAttempts: data.ConnectionAttempts, BindAddress: data.BindAddress, BindInterface: data.BindInterface, AddressFamily: data.AddressFamily, ExitOnForwardFailure: data.ExitOnForwardFailure, LocalForward: splitComma(data.LocalForward), RemoteForward: splitComma(data.RemoteForward), DynamicForward: splitComma(data.DynamicForward), ClearAllForwardings: data.ClearAllForwardings, // Public key PubkeyAuthentication: data.PubkeyAuthentication, IdentitiesOnly: data.IdentitiesOnly, // SSH Agent AddKeysToAgent: data.AddKeysToAgent, IdentityAgent: data.IdentityAgent, // Password & Interactive PasswordAuthentication: data.PasswordAuthentication, KbdInteractiveAuthentication: data.KbdInteractiveAuthentication, NumberOfPasswordPrompts: data.NumberOfPasswordPrompts, // Advanced PreferredAuthentications: data.PreferredAuthentications, ForwardAgent: data.ForwardAgent, ForwardX11: data.ForwardX11, ForwardX11Trusted: data.ForwardX11Trusted, ControlMaster: data.ControlMaster, ControlPath: data.ControlPath, ControlPersist: data.ControlPersist, ServerAliveInterval: data.ServerAliveInterval, ServerAliveCountMax: data.ServerAliveCountMax, Compression: data.Compression, TCPKeepAlive: data.TCPKeepAlive, BatchMode: data.BatchMode, StrictHostKeyChecking: data.StrictHostKeyChecking, UserKnownHostsFile: data.UserKnownHostsFile, HostKeyAlgorithms: data.HostKeyAlgorithms, PubkeyAcceptedAlgorithms: data.PubkeyAcceptedAlgorithms, HostbasedAcceptedAlgorithms: data.HostbasedAcceptedAlgorithms, MACs: data.MACs, Ciphers: data.Ciphers, KexAlgorithms: data.KexAlgorithms, VerifyHostKeyDNS: data.VerifyHostKeyDNS, UpdateHostKeys: data.UpdateHostKeys, HashKnownHosts: data.HashKnownHosts, VisualHostKey: data.VisualHostKey, LocalCommand: data.LocalCommand, PermitLocalCommand: data.PermitLocalCommand, EscapeChar: data.EscapeChar, SendEnv: splitComma(data.SendEnv), SetEnv: splitComma(data.SetEnv), LogLevel: data.LogLevel, } // Preserve metadata fields from original if in edit mode if sf.mode == ServerFormEdit && sf.original != nil { server.PinnedAt = sf.original.PinnedAt server.LastSeen = sf.original.LastSeen server.SSHCount = sf.original.SSHCount // Also preserve Aliases (computed field) server.Aliases = sf.original.Aliases } return server } func (sf *ServerForm) OnSave(fn func(domain.Server, *domain.Server)) *ServerForm { sf.onSave = fn return sf } func (sf *ServerForm) OnCancel(fn func()) *ServerForm { sf.onCancel = fn return sf } func (sf *ServerForm) SetApp(app *tview.Application) *ServerForm { sf.app = app return sf } func (sf *ServerForm) SetVersionInfo(version, commit string) *ServerForm { sf.version = version sf.commit = commit // Build the form now that we have version info if sf.header == nil { sf.build() } else { // Rebuild header if already exists sf.header = NewAppHeader(sf.version, sf.commit, RepoURL) } return sf }