package main import ( "fmt" "os" "os/exec" "strconv" "strings" "pdev-ssh/internal/config" "pdev-ssh/internal/models" "pdev-ssh/internal/secret" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "golang.org/x/term" ) type viewMode string const ( ViewServers viewMode = "servers" ViewSettings viewMode = "settings" ViewHelp viewMode = "help" ) type model struct { cfg models.AppConfig servers []models.Server selected int view viewMode width int height int err error } var ( green = lipgloss.Color("#00ff99") cyan = lipgloss.Color("#33ccff") gray = lipgloss.Color("#777777") text = lipgloss.Color("#d7ffe9") dimText = lipgloss.Color("#8aa99b") panelBg = lipgloss.Color("#07110d") border = lipgloss.Color("#00aa66") warn = lipgloss.Color("#ffaa00") baseStyle = lipgloss.NewStyle(). Foreground(text) headerStyle = lipgloss.NewStyle(). Bold(true). Foreground(green). Border(lipgloss.RoundedBorder()). BorderForeground(border). Padding(1, 2) panelStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). Padding(1, 2) selectedStyle = lipgloss.NewStyle(). Foreground(green). Bold(true) normalStyle = lipgloss.NewStyle(). Foreground(text) mutedStyle = lipgloss.NewStyle(). Foreground(gray) helpStyle = lipgloss.NewStyle(). Foreground(dimText) badgeStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#001a10")). Background(green). Bold(true). Padding(0, 1) ) func initialModel() model { cfg, err := config.LoadConfig("config.yaml") if err != nil { return model{err: err} } return model{ cfg: cfg, servers: cfg.Servers, selected: 0, view: ViewServers, } } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.err != nil { return m, nil } switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case tea.KeyMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit case "q": if m.view != ViewServers { m.view = ViewServers return m, nil } return m, tea.Quit case "tab": m.view = nextView(m.view) case "s": m.view = ViewSettings case "h": m.view = ViewHelp case "up", "k": if m.view == ViewServers && m.selected > 0 { m.selected-- } case "down", "j": if m.view == ViewServers && m.selected < len(m.servers)-1 { m.selected++ } case "enter": if m.view == ViewServers && len(m.servers) > 0 { server := m.servers[m.selected] return m, connectSSH(server, m.cfg.Settings) } } } return m, nil } func nextView(v viewMode) viewMode { switch v { case ViewServers: return ViewSettings case ViewSettings: return ViewHelp default: return ViewServers } } func (m model) View() string { if m.err != nil { return "Fehler: " + m.err.Error() } if m.width == 0 { m.width = 100 } if m.height == 0 { m.height = 30 } header := m.renderHeader() var right string switch m.view { case ViewServers: right = m.renderServerList() case ViewSettings: right = m.renderSettingsContent() case ViewHelp: right = m.renderHelpContent() default: right = m.renderServerList() } body := lipgloss.JoinHorizontal( lipgloss.Top, m.renderNavigation(), " ", right, ) footer := helpStyle.Render("↑/↓ Auswahl Enter Verbinden Tab Ansicht wechseln s Settings h Hilfe q Zurück/Beenden") content := lipgloss.JoinVertical( lipgloss.Left, header, body, footer, ) return baseStyle.Render(content) } func (m model) renderHeader() string { title := "󰣀 PDEV SSH Manager" subtitle := fmt.Sprintf( "Homelab SSH Control Center • Server: %d • Theme: %s • TERM: %s", len(m.servers), m.cfg.Settings.Theme, m.cfg.Settings.Terminal.Term, ) return headerStyle. Width(max(m.width-6, 60)). Render(title + "\n" + mutedStyle.Render(subtitle)) } /*func (m model) renderServerView() string { left := m.renderNavigation() right := m.renderServerList() return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) }*/ func (m model) renderNavigation() string { items := []struct { icon string name string view viewMode }{ {"󰒋", "Server", ViewServers}, {"󰢹", "Settings", ViewSettings}, {"󰋖", "Help", ViewHelp}, } var lines []string lines = append(lines, badgeStyle.Render(" NAVIGATION ")) lines = append(lines, "") for _, item := range items { line := fmt.Sprintf("%s %s", item.icon, item.name) if m.view == item.view { lines = append(lines, selectedStyle.Render("> "+line)) } else { lines = append(lines, normalStyle.Render(" "+line)) } } return panelStyle. Width(24). Height(max(m.height-9, 12)). Render(strings.Join(lines, "\n")) } func (m model) renderServerList() string { var lines []string lines = append(lines, badgeStyle.Render(" SERVERS ")) lines = append(lines, "") for i, server := range m.servers { authIcon := "󰌾" if server.Auth == "key" { authIcon = "󰌆" } kitty := "" if server.KittyFix { kitty = " 󰄛 kitty" } line := fmt.Sprintf( "%s %s %s@%s:%d [%s]%s", authIcon, server.Name, server.User, server.Host, server.Port, server.Group, kitty, ) if i == m.selected { lines = append(lines, selectedStyle.Render("> "+line)) } else { lines = append(lines, normalStyle.Render(" "+line)) } } if len(m.servers) == 0 { lines = append(lines, mutedStyle.Render("Keine Server in config.yaml gefunden.")) } width := max(m.width-36, 50) return panelStyle. Width(width). Height(max(m.height-9, 12)). Render(strings.Join(lines, "\n")) } func (m model) renderSettingsContent() string { lines := []string{ badgeStyle.Render(" SETTINGS "), "", selectedStyle.Render("Terminal"), fmt.Sprintf(" Kitty Fix global: %v", m.cfg.Settings.Terminal.EnableKittyFix), fmt.Sprintf(" TERM Override: %s", m.cfg.Settings.Terminal.Term), "", selectedStyle.Render("Hinweis"), " Wenn Kitty Fix aktiv ist, startet SSH mit TERM=xterm-256color.", " Das verhindert auf Ubuntu/Debian oft:", " 'Error opening terminal: xterm-kitty'", "", mutedStyle.Render("Später bauen wir hier Toggle-Optionen mit Enter ein."), } return panelStyle. Width(max(m.width-6, 70)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func (m model) renderHelpContent() string { lines := []string{ badgeStyle.Render(" HELP "), "", "Enter Ausgewählten Server verbinden", "↑/↓ / j/k Server auswählen", "Tab Ansicht wechseln", "s Settings öffnen", "h Hilfe öffnen", "q Zurück oder Beenden", "", selectedStyle.Render("Kitty Fix"), "Wenn du per Kitty auf Ubuntu SSH nutzt und nano/clear Fehler machen:", "TERM=xterm-256color hilft meistens.", } return panelStyle. Width(max(m.width-6, 70)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func connectSSH(server models.Server, settings models.Settings) tea.Cmd { switch server.Auth { case "key": args := []string{ "-p", strconv.Itoa(server.Port), } if server.Key != "" { args = append(args, "-i", expandHome(server.Key)) } args = append(args, server.User+"@"+server.Host) cmd := exec.Command("ssh", args...) cmd.Env = buildSSHEnv(server, settings) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return tea.ExecProcess(cmd, func(err error) tea.Msg { return err }) case "password": password, err := secret.GetPassword(server.PasswordID) if err != nil { fmt.Printf("\nPasswort für %s@%s: ", server.User, server.Host) pw, inputErr := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if inputErr != nil { return func() tea.Msg { return inputErr } } password = string(pw) saveErr := secret.SavePassword(server.PasswordID, password) if saveErr != nil { fmt.Println("Fehler beim Speichern:", saveErr) } else { fmt.Println("Passwort gespeichert.") } } args := []string{ "-e", "ssh", "-p", strconv.Itoa(server.Port), server.User + "@" + server.Host, } cmd := exec.Command("sshpass", args...) cmd.Env = append(buildSSHEnv(server, settings), "SSHPASS="+password) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return tea.ExecProcess(cmd, func(err error) tea.Msg { return err }) default: return func() tea.Msg { return fmt.Errorf("unbekannte auth methode: %s", server.Auth) } } } func buildSSHEnv(server models.Server, settings models.Settings) []string { env := os.Environ() if settings.Terminal.EnableKittyFix && server.KittyFix { termValue := settings.Terminal.Term if termValue == "" { termValue = "xterm-256color" } env = append(env, "TERM="+termValue) } return env } func expandHome(path string) string { if len(path) >= 2 && path[:2] == "~/" { home, _ := os.UserHomeDir() return home + path[1:] } return path } func max(a, b int) int { if a > b { return a } return b } func main() { p := tea.NewProgram( initialModel(), tea.WithAltScreen(), ) if _, err := p.Run(); err != nil { fmt.Println("Fehler:", err) os.Exit(1) } }