mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-06 21:00:45 +02:00
635 lines
16 KiB
Go
635 lines
16 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"time"
|
|
|
|
"sshm/internal/config"
|
|
"sshm/internal/connectivity"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// Messages for SSH ping functionality
|
|
type (
|
|
pingResultMsg *connectivity.HostPingResult
|
|
)
|
|
|
|
// startPingAllCmd creates a command to ping all hosts concurrently
|
|
func (m Model) startPingAllCmd() tea.Cmd {
|
|
if m.pingManager == nil {
|
|
return nil
|
|
}
|
|
|
|
return tea.Batch(
|
|
// Create individual ping commands for each host
|
|
func() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
for _, host := range m.hosts {
|
|
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}(),
|
|
)
|
|
}
|
|
|
|
// listenForPingResultsCmd is no longer needed since we use individual ping commands
|
|
|
|
// pingSingleHostCmd creates a command to ping a single host
|
|
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
result := pingManager.PingHost(ctx, host)
|
|
return pingResultMsg(result)
|
|
}
|
|
}
|
|
|
|
// Init initializes the model
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
textinput.Blink,
|
|
// Ping is now optional - use 'p' key to start ping
|
|
)
|
|
}
|
|
|
|
// Update handles model updates
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
|
|
// Handle different message types
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
// Update terminal size and recalculate styles
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.styles = NewStyles(m.width)
|
|
m.ready = true
|
|
|
|
// Update table height and columns based on new window size
|
|
m.updateTableHeight()
|
|
m.updateTableColumns()
|
|
|
|
// Update sub-forms if they exist
|
|
if m.addForm != nil {
|
|
m.addForm.width = m.width
|
|
m.addForm.height = m.height
|
|
m.addForm.styles = m.styles
|
|
}
|
|
if m.editForm != nil {
|
|
m.editForm.width = m.width
|
|
m.editForm.height = m.height
|
|
m.editForm.styles = m.styles
|
|
}
|
|
if m.infoForm != nil {
|
|
m.infoForm.width = m.width
|
|
m.infoForm.height = m.height
|
|
m.infoForm.styles = m.styles
|
|
}
|
|
if m.portForwardForm != nil {
|
|
m.portForwardForm.width = m.width
|
|
m.portForwardForm.height = m.height
|
|
m.portForwardForm.styles = m.styles
|
|
}
|
|
if m.helpForm != nil {
|
|
m.helpForm.width = m.width
|
|
m.helpForm.height = m.height
|
|
m.helpForm.styles = m.styles
|
|
}
|
|
if m.fileSelectorForm != nil {
|
|
m.fileSelectorForm.width = m.width
|
|
m.fileSelectorForm.height = m.height
|
|
m.fileSelectorForm.styles = m.styles
|
|
}
|
|
return m, nil
|
|
|
|
case pingResultMsg:
|
|
// Handle ping result - update table display
|
|
if msg != nil {
|
|
// Update the table to reflect the new ping status
|
|
m.updateTableRows()
|
|
}
|
|
return m, nil
|
|
|
|
case addFormSubmitMsg:
|
|
if msg.err != nil {
|
|
// Show error in form
|
|
if m.addForm != nil {
|
|
m.addForm.err = msg.err.Error()
|
|
}
|
|
return m, nil
|
|
} else {
|
|
// Success: refresh hosts and return to list view
|
|
var hosts []config.SSHHost
|
|
var err error
|
|
|
|
if m.configFile != "" {
|
|
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
|
} else {
|
|
hosts, err = config.ParseSSHConfig()
|
|
}
|
|
|
|
if err != nil {
|
|
return m, tea.Quit
|
|
}
|
|
m.hosts = m.sortHosts(hosts)
|
|
|
|
// Reapply search filter if there is one active
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.hosts
|
|
}
|
|
|
|
m.updateTableRows()
|
|
m.viewMode = ViewList
|
|
m.addForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
|
|
case addFormCancelMsg:
|
|
// Cancel: return to list view
|
|
m.viewMode = ViewList
|
|
m.addForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
|
|
case editFormSubmitMsg:
|
|
if msg.err != nil {
|
|
// Show error in form
|
|
if m.editForm != nil {
|
|
m.editForm.err = msg.err.Error()
|
|
}
|
|
return m, nil
|
|
} else {
|
|
// Success: refresh hosts and return to list view
|
|
var hosts []config.SSHHost
|
|
var err error
|
|
|
|
if m.configFile != "" {
|
|
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
|
} else {
|
|
hosts, err = config.ParseSSHConfig()
|
|
}
|
|
|
|
if err != nil {
|
|
return m, tea.Quit
|
|
}
|
|
m.hosts = m.sortHosts(hosts)
|
|
|
|
// Reapply search filter if there is one active
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.hosts
|
|
}
|
|
|
|
m.updateTableRows()
|
|
m.viewMode = ViewList
|
|
m.editForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
|
|
case editFormCancelMsg:
|
|
// Cancel: return to list view
|
|
m.viewMode = ViewList
|
|
m.editForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
|
|
case infoFormCancelMsg:
|
|
// Cancel: return to list view
|
|
m.viewMode = ViewList
|
|
m.infoForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
|
|
case fileSelectorMsg:
|
|
if msg.cancelled {
|
|
// Cancel: return to list view
|
|
m.viewMode = ViewList
|
|
m.fileSelectorForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
} else {
|
|
// File selected: proceed to add form with selected file
|
|
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
|
|
m.viewMode = ViewAdd
|
|
m.fileSelectorForm = nil
|
|
return m, textinput.Blink
|
|
}
|
|
|
|
case infoFormEditMsg:
|
|
// Switch from info to edit mode
|
|
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
|
if err != nil {
|
|
// Handle error - could show in UI, for now just go back to list
|
|
m.viewMode = ViewList
|
|
m.infoForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
m.editForm = editForm
|
|
m.infoForm = nil
|
|
m.viewMode = ViewEdit
|
|
return m, textinput.Blink
|
|
|
|
case portForwardSubmitMsg:
|
|
if msg.err != nil {
|
|
// Show error in form
|
|
if m.portForwardForm != nil {
|
|
m.portForwardForm.err = msg.err.Error()
|
|
}
|
|
return m, nil
|
|
} else {
|
|
// Success: execute SSH command with port forwarding
|
|
if len(msg.sshArgs) > 0 {
|
|
sshCmd := exec.Command("ssh", msg.sshArgs...)
|
|
|
|
// Record the connection in history
|
|
if m.historyManager != nil && m.portForwardForm != nil {
|
|
err := m.historyManager.RecordConnection(m.portForwardForm.hostName)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
|
}
|
|
}
|
|
|
|
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
|
|
return tea.Quit()
|
|
})
|
|
}
|
|
|
|
// If no SSH args, just return to list view
|
|
m.viewMode = ViewList
|
|
m.portForwardForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
|
|
case portForwardCancelMsg:
|
|
// Cancel: return to list view
|
|
m.viewMode = ViewList
|
|
m.portForwardForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
|
|
case helpCloseMsg:
|
|
// Close help: return to list view
|
|
m.viewMode = ViewList
|
|
m.helpForm = nil
|
|
m.table.Focus()
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
// Handle view-specific key presses
|
|
switch m.viewMode {
|
|
case ViewAdd:
|
|
if m.addForm != nil {
|
|
var newForm *addFormModel
|
|
newForm, cmd = m.addForm.Update(msg)
|
|
m.addForm = newForm
|
|
return m, cmd
|
|
}
|
|
case ViewEdit:
|
|
if m.editForm != nil {
|
|
var newForm *editFormModel
|
|
newForm, cmd = m.editForm.Update(msg)
|
|
m.editForm = newForm
|
|
return m, cmd
|
|
}
|
|
case ViewInfo:
|
|
if m.infoForm != nil {
|
|
var newForm *infoFormModel
|
|
newForm, cmd = m.infoForm.Update(msg)
|
|
m.infoForm = newForm
|
|
return m, cmd
|
|
}
|
|
case ViewPortForward:
|
|
if m.portForwardForm != nil {
|
|
var newForm *portForwardModel
|
|
newForm, cmd = m.portForwardForm.Update(msg)
|
|
m.portForwardForm = newForm
|
|
return m, cmd
|
|
}
|
|
case ViewHelp:
|
|
if m.helpForm != nil {
|
|
var newForm *helpModel
|
|
newForm, cmd = m.helpForm.Update(msg)
|
|
m.helpForm = newForm
|
|
return m, cmd
|
|
}
|
|
case ViewFileSelector:
|
|
if m.fileSelectorForm != nil {
|
|
var newForm *fileSelectorModel
|
|
newForm, cmd = m.fileSelectorForm.Update(msg)
|
|
m.fileSelectorForm = newForm
|
|
return m, cmd
|
|
}
|
|
case ViewList:
|
|
// Handle list view keys
|
|
return m.handleListViewKeys(msg)
|
|
}
|
|
}
|
|
|
|
return m, cmd
|
|
}
|
|
|
|
func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
|
|
switch msg.String() {
|
|
case "esc", "ctrl+c":
|
|
if m.deleteMode {
|
|
// Exit delete mode
|
|
m.deleteMode = false
|
|
m.deleteHost = ""
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
case "q":
|
|
if !m.searchMode && !m.deleteMode {
|
|
return m, tea.Quit
|
|
}
|
|
case "/", "ctrl+f":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Enter search mode
|
|
m.searchMode = true
|
|
m.updateTableStyles()
|
|
m.table.Blur()
|
|
m.searchInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
case "tab":
|
|
if !m.deleteMode {
|
|
// Switch focus between search input and table
|
|
if m.searchMode {
|
|
// Switch from search to table
|
|
m.searchMode = false
|
|
m.updateTableStyles()
|
|
m.searchInput.Blur()
|
|
m.table.Focus()
|
|
} else {
|
|
// Switch from table to search
|
|
m.searchMode = true
|
|
m.updateTableStyles()
|
|
m.table.Blur()
|
|
m.searchInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
return m, nil
|
|
}
|
|
case "enter":
|
|
if m.searchMode {
|
|
// Validate search and return to table mode to allow commands
|
|
m.searchMode = false
|
|
m.updateTableStyles()
|
|
m.searchInput.Blur()
|
|
m.table.Focus()
|
|
return m, nil
|
|
} else if m.deleteMode {
|
|
// Confirm deletion
|
|
var err error
|
|
if m.configFile != "" {
|
|
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile)
|
|
} else {
|
|
err = config.DeleteSSHHost(m.deleteHost)
|
|
}
|
|
if err != nil {
|
|
// Could display an error message here
|
|
m.deleteMode = false
|
|
m.deleteHost = ""
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
// Refresh the hosts list
|
|
var hosts []config.SSHHost
|
|
var parseErr error
|
|
|
|
if m.configFile != "" {
|
|
hosts, parseErr = config.ParseSSHConfigFile(m.configFile)
|
|
} else {
|
|
hosts, parseErr = config.ParseSSHConfig()
|
|
}
|
|
|
|
if parseErr != nil {
|
|
// Could display an error message here
|
|
m.deleteMode = false
|
|
m.deleteHost = ""
|
|
m.table.Focus()
|
|
return m, nil
|
|
}
|
|
m.hosts = m.sortHosts(hosts)
|
|
|
|
// Reapply search filter if there is one active
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.hosts
|
|
}
|
|
|
|
m.updateTableRows()
|
|
m.deleteMode = false
|
|
m.deleteHost = ""
|
|
m.table.Focus()
|
|
return m, nil
|
|
} else {
|
|
// Connect to the selected host
|
|
selected := m.table.SelectedRow()
|
|
if len(selected) > 0 {
|
|
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
|
|
|
// Record the connection in history
|
|
if m.historyManager != nil {
|
|
err := m.historyManager.RecordConnection(hostName)
|
|
if err != nil {
|
|
// Log the error but don't prevent the connection
|
|
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Build the SSH command with the appropriate config file
|
|
var sshCmd *exec.Cmd
|
|
if m.configFile != "" {
|
|
sshCmd = exec.Command("ssh", "-F", m.configFile, hostName)
|
|
} else {
|
|
sshCmd = exec.Command("ssh", hostName)
|
|
}
|
|
|
|
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
|
|
return tea.Quit()
|
|
})
|
|
}
|
|
}
|
|
case "e":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Edit the selected host
|
|
selected := m.table.SelectedRow()
|
|
if len(selected) > 0 {
|
|
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
|
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
|
if err != nil {
|
|
// Handle error - could show in UI
|
|
return m, nil
|
|
}
|
|
m.editForm = editForm
|
|
m.viewMode = ViewEdit
|
|
return m, textinput.Blink
|
|
}
|
|
}
|
|
case "i":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Show info for the selected host
|
|
selected := m.table.SelectedRow()
|
|
if len(selected) > 0 {
|
|
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
|
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
|
if err != nil {
|
|
// Handle error - could show in UI
|
|
return m, nil
|
|
}
|
|
m.infoForm = infoForm
|
|
m.viewMode = ViewInfo
|
|
return m, nil
|
|
}
|
|
}
|
|
case "a":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Check if there are multiple config files starting from the current base config
|
|
var configFiles []string
|
|
var err error
|
|
|
|
if m.configFile != "" {
|
|
// Use the specified config file as base
|
|
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
|
|
} else {
|
|
// Use the default config file as base
|
|
configFiles, err = config.GetAllConfigFiles()
|
|
}
|
|
|
|
if err != nil || len(configFiles) <= 1 {
|
|
// Only one config file (or error), go directly to add form
|
|
var configFile string
|
|
if len(configFiles) == 1 {
|
|
configFile = configFiles[0]
|
|
} else {
|
|
configFile = m.configFile
|
|
}
|
|
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
|
|
m.viewMode = ViewAdd
|
|
} else {
|
|
// Multiple config files, show file selector
|
|
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
|
|
if err != nil {
|
|
// Fallback to default behavior if file selector fails
|
|
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
|
m.viewMode = ViewAdd
|
|
} else {
|
|
m.fileSelectorForm = fileSelectorForm
|
|
m.viewMode = ViewFileSelector
|
|
}
|
|
}
|
|
return m, textinput.Blink
|
|
}
|
|
case "d":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Delete the selected host
|
|
selected := m.table.SelectedRow()
|
|
if len(selected) > 0 {
|
|
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
|
m.deleteMode = true
|
|
m.deleteHost = hostName
|
|
m.table.Blur()
|
|
return m, nil
|
|
}
|
|
}
|
|
case "p":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Ping all hosts
|
|
return m, m.startPingAllCmd()
|
|
}
|
|
case "f":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Port forwarding for the selected host
|
|
selected := m.table.SelectedRow()
|
|
if len(selected) > 0 {
|
|
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
|
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
|
m.viewMode = ViewPortForward
|
|
return m, textinput.Blink
|
|
}
|
|
}
|
|
case "h":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Show help
|
|
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
|
|
m.viewMode = ViewHelp
|
|
return m, nil
|
|
}
|
|
case "s":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Cycle through sort modes (only 2 modes now)
|
|
m.sortMode = (m.sortMode + 1) % 2
|
|
// Re-apply the current filter with the new sort mode
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.sortHosts(m.hosts)
|
|
}
|
|
m.updateTableRows()
|
|
return m, nil
|
|
}
|
|
case "r":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Switch to sort by recent (last used)
|
|
m.sortMode = SortByLastUsed
|
|
// Re-apply the current filter with the new sort mode
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.sortHosts(m.hosts)
|
|
}
|
|
m.updateTableRows()
|
|
return m, nil
|
|
}
|
|
case "n":
|
|
if !m.searchMode && !m.deleteMode {
|
|
// Switch to sort by name
|
|
m.sortMode = SortByName
|
|
// Re-apply the current filter with the new sort mode
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.sortHosts(m.hosts)
|
|
}
|
|
m.updateTableRows()
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Update the appropriate component based on mode
|
|
if m.searchMode {
|
|
oldValue := m.searchInput.Value()
|
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
|
// Update filtered hosts only if the search value has changed
|
|
if m.searchInput.Value() != oldValue {
|
|
if m.searchInput.Value() != "" {
|
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
} else {
|
|
m.filteredHosts = m.sortHosts(m.hosts)
|
|
}
|
|
m.updateTableRows()
|
|
}
|
|
} else {
|
|
m.table, cmd = m.table.Update(msg)
|
|
}
|
|
|
|
return m, cmd
|
|
}
|