mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
first commit
This commit is contained in:
302
internal/ui/add_form.go
Normal file
302
internal/ui/add_form.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/validation"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color("#25A065")).
|
||||
Padding(0, 1)
|
||||
|
||||
fieldStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575"))
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000"))
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
type addFormModel struct {
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
success bool
|
||||
}
|
||||
|
||||
const (
|
||||
nameInput = iota
|
||||
hostnameInput
|
||||
userInput
|
||||
portInput
|
||||
identityInput
|
||||
proxyJumpInput
|
||||
optionsInput
|
||||
tagsInput
|
||||
)
|
||||
|
||||
func RunAddForm(hostname string) error {
|
||||
// Get current user for default
|
||||
currentUser, _ := user.Current()
|
||||
defaultUser := "root"
|
||||
if currentUser != nil {
|
||||
defaultUser = currentUser.Username
|
||||
}
|
||||
|
||||
// Find default identity file
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
defaultIdentity := filepath.Join(homeDir, ".ssh", "id_rsa")
|
||||
|
||||
// Check for other common key types
|
||||
keyTypes := []string{"id_ed25519", "id_ecdsa", "id_rsa"}
|
||||
for _, keyType := range keyTypes {
|
||||
keyPath := filepath.Join(homeDir, ".ssh", keyType)
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
defaultIdentity = keyPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
inputs := make([]textinput.Model, 8)
|
||||
|
||||
// Name input
|
||||
inputs[nameInput] = textinput.New()
|
||||
inputs[nameInput].Placeholder = "server-name"
|
||||
inputs[nameInput].Focus()
|
||||
inputs[nameInput].CharLimit = 50
|
||||
inputs[nameInput].Width = 30
|
||||
if hostname != "" {
|
||||
inputs[nameInput].SetValue(hostname)
|
||||
}
|
||||
|
||||
// Hostname input
|
||||
inputs[hostnameInput] = textinput.New()
|
||||
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com"
|
||||
inputs[hostnameInput].CharLimit = 100
|
||||
inputs[hostnameInput].Width = 30
|
||||
|
||||
// User input
|
||||
inputs[userInput] = textinput.New()
|
||||
inputs[userInput].Placeholder = defaultUser
|
||||
inputs[userInput].CharLimit = 50
|
||||
inputs[userInput].Width = 30
|
||||
|
||||
// Port input
|
||||
inputs[portInput] = textinput.New()
|
||||
inputs[portInput].Placeholder = "22"
|
||||
inputs[portInput].CharLimit = 5
|
||||
inputs[portInput].Width = 30
|
||||
|
||||
// Identity input
|
||||
inputs[identityInput] = textinput.New()
|
||||
inputs[identityInput].Placeholder = defaultIdentity
|
||||
inputs[identityInput].CharLimit = 200
|
||||
inputs[identityInput].Width = 50
|
||||
|
||||
// ProxyJump input
|
||||
inputs[proxyJumpInput] = textinput.New()
|
||||
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name"
|
||||
inputs[proxyJumpInput].CharLimit = 200
|
||||
inputs[proxyJumpInput].Width = 50
|
||||
|
||||
// SSH Options input
|
||||
inputs[optionsInput] = textinput.New()
|
||||
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
|
||||
inputs[optionsInput].CharLimit = 500
|
||||
inputs[optionsInput].Width = 70
|
||||
|
||||
// Tags input
|
||||
inputs[tagsInput] = textinput.New()
|
||||
inputs[tagsInput].Placeholder = "production, web, database"
|
||||
inputs[tagsInput].CharLimit = 200
|
||||
inputs[tagsInput].Width = 50
|
||||
|
||||
m := addFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *addFormModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
|
||||
case "ctrl+enter":
|
||||
// Allow submission from any field with Ctrl+Enter
|
||||
return m, m.submitForm()
|
||||
|
||||
case "tab", "shift+tab", "enter", "up", "down":
|
||||
s := msg.String()
|
||||
|
||||
// Handle form submission
|
||||
if s == "enter" && m.focused == len(m.inputs)-1 {
|
||||
return m, m.submitForm()
|
||||
}
|
||||
|
||||
// Cycle inputs
|
||||
if s == "up" || s == "shift+tab" {
|
||||
m.focused--
|
||||
} else {
|
||||
m.focused++
|
||||
}
|
||||
|
||||
if m.focused > len(m.inputs)-1 {
|
||||
m.focused = 0
|
||||
} else if m.focused < 0 {
|
||||
m.focused = len(m.inputs) - 1
|
||||
}
|
||||
|
||||
for i := range m.inputs {
|
||||
if i == m.focused {
|
||||
cmds = append(cmds, m.inputs[i].Focus())
|
||||
continue
|
||||
}
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case submitResult:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.success = true
|
||||
m.err = ""
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
cmd := make([]tea.Cmd, len(m.inputs))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
|
||||
}
|
||||
cmds = append(cmds, cmd...)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *addFormModel) View() string {
|
||||
if m.success {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render("Add SSH Host Configuration"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
"Host Name *",
|
||||
"Hostname/IP *",
|
||||
"User",
|
||||
"Port",
|
||||
"Identity File",
|
||||
"ProxyJump",
|
||||
"SSH Options",
|
||||
"Tags (comma-separated)",
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(fieldStyle.Render(field))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(errorStyle.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("* Required fields"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type submitResult struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *addFormModel) submitForm() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Get values
|
||||
name := strings.TrimSpace(m.inputs[nameInput].Value())
|
||||
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value())
|
||||
user := strings.TrimSpace(m.inputs[userInput].Value())
|
||||
port := strings.TrimSpace(m.inputs[portInput].Value())
|
||||
identity := strings.TrimSpace(m.inputs[identityInput].Value())
|
||||
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
|
||||
options := strings.TrimSpace(m.inputs[optionsInput].Value())
|
||||
|
||||
// Set defaults
|
||||
if user == "" {
|
||||
user = m.inputs[userInput].Placeholder
|
||||
}
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
|
||||
|
||||
// Validate all fields
|
||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||
return submitResult{err: err}
|
||||
}
|
||||
|
||||
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
for _, tag := range strings.Split(tagsStr, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create host configuration
|
||||
host := config.SSHHost{
|
||||
Name: name,
|
||||
Hostname: hostname,
|
||||
User: user,
|
||||
Port: port,
|
||||
Identity: identity,
|
||||
ProxyJump: proxyJump,
|
||||
Options: config.ParseSSHOptionsFromCommand(options),
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
// Add to config
|
||||
err := config.AddSSHHost(host)
|
||||
return submitResult{hostname: name, err: err}
|
||||
}
|
||||
}
|
||||
281
internal/ui/edit_form.go
Normal file
281
internal/ui/edit_form.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/validation"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color("#25A065")).
|
||||
Padding(0, 1)
|
||||
|
||||
fieldStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575"))
|
||||
|
||||
errorStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000"))
|
||||
|
||||
helpStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
type editFormModel struct {
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
success bool
|
||||
originalName string
|
||||
}
|
||||
|
||||
func RunEditForm(hostName string) error {
|
||||
// Get the existing host configuration
|
||||
host, err := config.GetSSHHost(hostName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inputs := make([]textinput.Model, 8)
|
||||
|
||||
// Name input
|
||||
inputs[nameInput] = textinput.New()
|
||||
inputs[nameInput].Placeholder = "server-name"
|
||||
inputs[nameInput].Focus()
|
||||
inputs[nameInput].CharLimit = 50
|
||||
inputs[nameInput].Width = 30
|
||||
inputs[nameInput].SetValue(host.Name)
|
||||
|
||||
// Hostname input
|
||||
inputs[hostnameInput] = textinput.New()
|
||||
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com"
|
||||
inputs[hostnameInput].CharLimit = 100
|
||||
inputs[hostnameInput].Width = 30
|
||||
inputs[hostnameInput].SetValue(host.Hostname)
|
||||
|
||||
// User input
|
||||
inputs[userInput] = textinput.New()
|
||||
inputs[userInput].Placeholder = "root"
|
||||
inputs[userInput].CharLimit = 50
|
||||
inputs[userInput].Width = 30
|
||||
inputs[userInput].SetValue(host.User)
|
||||
|
||||
// Port input
|
||||
inputs[portInput] = textinput.New()
|
||||
inputs[portInput].Placeholder = "22"
|
||||
inputs[portInput].CharLimit = 5
|
||||
inputs[portInput].Width = 30
|
||||
inputs[portInput].SetValue(host.Port)
|
||||
|
||||
// Identity input
|
||||
inputs[identityInput] = textinput.New()
|
||||
inputs[identityInput].Placeholder = "~/.ssh/id_rsa"
|
||||
inputs[identityInput].CharLimit = 200
|
||||
inputs[identityInput].Width = 50
|
||||
inputs[identityInput].SetValue(host.Identity)
|
||||
|
||||
// ProxyJump input
|
||||
inputs[proxyJumpInput] = textinput.New()
|
||||
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name"
|
||||
inputs[proxyJumpInput].CharLimit = 200
|
||||
inputs[proxyJumpInput].Width = 50
|
||||
inputs[proxyJumpInput].SetValue(host.ProxyJump)
|
||||
|
||||
// SSH Options input
|
||||
inputs[optionsInput] = textinput.New()
|
||||
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
|
||||
inputs[optionsInput].CharLimit = 500
|
||||
inputs[optionsInput].Width = 70
|
||||
inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options))
|
||||
|
||||
// Tags input
|
||||
inputs[tagsInput] = textinput.New()
|
||||
inputs[tagsInput].Placeholder = "production, web, database"
|
||||
inputs[tagsInput].CharLimit = 200
|
||||
inputs[tagsInput].Width = 50
|
||||
if len(host.Tags) > 0 {
|
||||
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
||||
}
|
||||
|
||||
m := editFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
originalName: hostName,
|
||||
}
|
||||
|
||||
// Open in separate window like add form
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *editFormModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
|
||||
case "ctrl+enter":
|
||||
// Allow submission from any field with Ctrl+Enter
|
||||
return m, m.submitEditForm()
|
||||
|
||||
case "tab", "shift+tab", "enter", "up", "down":
|
||||
s := msg.String()
|
||||
|
||||
// Handle form submission
|
||||
if s == "enter" && m.focused == len(m.inputs)-1 {
|
||||
return m, m.submitEditForm()
|
||||
}
|
||||
|
||||
// Cycle inputs
|
||||
if s == "up" || s == "shift+tab" {
|
||||
m.focused--
|
||||
} else {
|
||||
m.focused++
|
||||
}
|
||||
|
||||
if m.focused > len(m.inputs)-1 {
|
||||
m.focused = 0
|
||||
} else if m.focused < 0 {
|
||||
m.focused = len(m.inputs) - 1
|
||||
}
|
||||
|
||||
for i := range m.inputs {
|
||||
if i == m.focused {
|
||||
cmds = append(cmds, m.inputs[i].Focus())
|
||||
continue
|
||||
}
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case editResult:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.success = true
|
||||
m.err = ""
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
cmd := make([]tea.Cmd, len(m.inputs))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
|
||||
}
|
||||
cmds = append(cmds, cmd...)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editFormModel) View() string {
|
||||
if m.success {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
"Host Name *",
|
||||
"Hostname/IP *",
|
||||
"User",
|
||||
"Port",
|
||||
"Identity File",
|
||||
"ProxyJump",
|
||||
"SSH Options",
|
||||
"Tags (comma-separated)",
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(fieldStyleEdit.Render(field))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(errorStyleEdit.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyleEdit.Render("* Required fields"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type editResult struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Get values
|
||||
name := strings.TrimSpace(m.inputs[nameInput].Value())
|
||||
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value())
|
||||
user := strings.TrimSpace(m.inputs[userInput].Value())
|
||||
port := strings.TrimSpace(m.inputs[portInput].Value())
|
||||
identity := strings.TrimSpace(m.inputs[identityInput].Value())
|
||||
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
|
||||
options := strings.TrimSpace(m.inputs[optionsInput].Value())
|
||||
|
||||
// Set defaults
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
|
||||
|
||||
// Validate all fields
|
||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||
return editResult{err: err}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
for _, tag := range strings.Split(tagsStr, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create updated host configuration
|
||||
host := config.SSHHost{
|
||||
Name: name,
|
||||
Hostname: hostname,
|
||||
User: user,
|
||||
Port: port,
|
||||
Identity: identity,
|
||||
ProxyJump: proxyJump,
|
||||
Options: config.ParseSSHOptionsFromCommand(options),
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
// Update the configuration
|
||||
err := config.UpdateSSHHost(m.originalName, host)
|
||||
return editResult{hostname: name, err: err}
|
||||
}
|
||||
}
|
||||
496
internal/ui/tui.go
Normal file
496
internal/ui/tui.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var baseStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240"))
|
||||
|
||||
var searchStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("36")).
|
||||
Padding(0, 1)
|
||||
|
||||
type Model struct {
|
||||
table table.Model
|
||||
searchInput textinput.Model
|
||||
hosts []config.SSHHost
|
||||
filteredHosts []config.SSHHost
|
||||
searchMode bool
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
exitAction string
|
||||
exitHostName string
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle key messages
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
if m.searchMode {
|
||||
// Exit search mode
|
||||
m.searchMode = false
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
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.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "enter":
|
||||
if m.searchMode {
|
||||
// Exit search mode and focus table
|
||||
m.searchMode = false
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else if m.deleteMode {
|
||||
// Confirm deletion
|
||||
err := config.DeleteSSHHost(m.deleteHost)
|
||||
if err != nil {
|
||||
// Could show error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
// Refresh the host list
|
||||
hosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
// Could show error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.hosts = sortHostsByName(hosts)
|
||||
m.filteredHosts = m.hosts
|
||||
m.updateTableRows()
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// Connect to selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
||||
return tea.Quit()
|
||||
})
|
||||
}
|
||||
}
|
||||
case "e":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Edit selected host using dedicated edit form
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
// Store the edit action and exit
|
||||
m.exitAction = "edit"
|
||||
m.exitHostName = hostName
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
case "a":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add new host using dedicated add form
|
||||
m.exitAction = "add"
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "d":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Delete selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update components based on mode
|
||||
if m.searchMode {
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Filter hosts when search input changes
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
m.updateTableRows()
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.deleteMode {
|
||||
return m.renderDeleteConfirmation()
|
||||
}
|
||||
|
||||
var view strings.Builder
|
||||
|
||||
// Add search bar
|
||||
searchPrompt := "Search (/ to search, ESC to exit search): "
|
||||
if m.searchMode {
|
||||
view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n")
|
||||
}
|
||||
|
||||
// Add table
|
||||
view.WriteString(baseStyle.Render(m.table.View()))
|
||||
|
||||
// Add help text
|
||||
if !m.searchMode {
|
||||
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • (q)uit")
|
||||
} else {
|
||||
view.WriteString("\nType to filter hosts by name or tag • Enter to select • ESC to exit search")
|
||||
}
|
||||
|
||||
return view.String()
|
||||
}
|
||||
|
||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||
// based on the longest host name, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Name" header
|
||||
|
||||
for _, host := range hosts {
|
||||
if len(host.Name) > maxLength {
|
||||
maxLength = len(host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Cap the maximum width to avoid extremely wide columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||
// based on the longest tags string, with a minimum of 8 and maximum of 50 characters
|
||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Format tags exactly the same way they appear in the table
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
if len(tagsStr) > maxLength {
|
||||
maxLength = len(tagsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Cap the maximum width to avoid extremely wide columns
|
||||
if maxLength > 50 {
|
||||
maxLength = 50
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// NewModel creates a new TUI model with the given SSH hosts
|
||||
func NewModel(hosts []config.SSHHost) Model {
|
||||
// Sort hosts alphabetically by name
|
||||
sortedHosts := sortHostsByName(hosts)
|
||||
|
||||
// Create search input
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hosts or tags..."
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 50
|
||||
|
||||
// Calculate optimal width for the Name column
|
||||
nameWidth := calculateNameColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Tags column
|
||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||
|
||||
// Create table columns
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: 25},
|
||||
{Title: "User", Width: 12},
|
||||
{Title: "Port", Width: 6},
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
}
|
||||
|
||||
// Convert hosts to table rows
|
||||
var rows []table.Row
|
||||
for _, host := range sortedHosts {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
|
||||
hostCount := len(rows)
|
||||
tableHeight := 1 // header
|
||||
if hostCount < 10 {
|
||||
tableHeight += hostCount
|
||||
} else {
|
||||
tableHeight += 10
|
||||
}
|
||||
|
||||
// Create table
|
||||
t := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(tableHeight),
|
||||
)
|
||||
|
||||
// Style the table
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
t.SetStyles(s)
|
||||
|
||||
return Model{
|
||||
table: t,
|
||||
searchInput: ti,
|
||||
hosts: sortedHosts,
|
||||
filteredHosts: sortedHosts,
|
||||
searchMode: false,
|
||||
}
|
||||
}
|
||||
|
||||
// RunInteractiveMode starts the interactive TUI
|
||||
func RunInteractiveMode(hosts []config.SSHHost) error {
|
||||
for {
|
||||
m := NewModel(hosts)
|
||||
|
||||
// Start the application in terminal (without alt screen)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running TUI: %w", err)
|
||||
}
|
||||
|
||||
// Check if the final model indicates an action
|
||||
if model, ok := finalModel.(Model); ok {
|
||||
if model.exitAction == "edit" && model.exitHostName != "" {
|
||||
// Launch the dedicated edit form (opens in separate window)
|
||||
if err := RunEditForm(model.exitHostName); err != nil {
|
||||
fmt.Printf("Error editing host: %v\n", err)
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear screen before returning to TUI
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Refresh the hosts list after editing
|
||||
refreshedHosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error refreshing hosts after edit: %w", err)
|
||||
}
|
||||
hosts = refreshedHosts
|
||||
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
} else if model.exitAction == "add" {
|
||||
// Launch the dedicated add form (opens in separate window)
|
||||
if err := RunAddForm(""); err != nil {
|
||||
fmt.Printf("Error adding host: %v\n", err)
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear screen before returning to TUI
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Refresh the hosts list after adding
|
||||
refreshedHosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error refreshing hosts after add: %w", err)
|
||||
}
|
||||
hosts = refreshedHosts
|
||||
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If no special command, exit normally
|
||||
break
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterHosts filters hosts based on search query (name or tags)
|
||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||
if query == "" {
|
||||
return sortHostsByName(m.hosts)
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
var filtered []config.SSHHost
|
||||
|
||||
for _, host := range m.hosts {
|
||||
// Check host name
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check hostname
|
||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check tags
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
filtered = append(filtered, host)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortHostsByName(filtered)
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Sort hosts alphabetically by name
|
||||
sortedHosts := sortHostsByName(hostsToShow)
|
||||
|
||||
for _, host := range sortedHosts {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
}
|
||||
|
||||
// enterEditMode initializes edit mode for a specific host
|
||||
// renderDeleteConfirmation renders the delete confirmation dialog
|
||||
func (m Model) renderDeleteConfirmation() string {
|
||||
var view strings.Builder
|
||||
|
||||
view.WriteString(lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("1")). // Red border
|
||||
Padding(1, 2).
|
||||
Render(fmt.Sprintf("⚠️ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost)))
|
||||
|
||||
return view.String()
|
||||
}
|
||||
Reference in New Issue
Block a user