mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
2288 lines
77 KiB
Go
2288 lines
77 KiB
Go
// 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
|
|
}
|