mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
separte ui for data and business logic
This commit is contained in:
+10
-628
@@ -2,645 +2,27 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"github.com/Adembc/lazyssh/internal/adapters/data/memory"
|
||||||
"sort"
|
"github.com/Adembc/lazyssh/internal/adapters/ui"
|
||||||
"strconv"
|
"github.com/Adembc/lazyssh/internal/core/services"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
Alias string
|
|
||||||
Host string
|
|
||||||
User string
|
|
||||||
Port int
|
|
||||||
Key string
|
|
||||||
Tags []string
|
|
||||||
Status string // "online", "warn", "offline"
|
|
||||||
LastSeen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func mockServers() []Server {
|
|
||||||
return []Server{
|
|
||||||
{Alias: "web-01", Host: "192.168.1.10", User: "root", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "web"}, Status: "online", LastSeen: time.Now().Add(-2 * time.Hour)},
|
|
||||||
{Alias: "web-02", Host: "192.168.1.11", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "web"}, Status: "warn", LastSeen: time.Now().Add(-30 * time.Minute)},
|
|
||||||
{Alias: "db-01", Host: "192.168.1.20", User: "postgres", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "db"}, Status: "offline", LastSeen: time.Now().Add(-26 * time.Hour)},
|
|
||||||
{Alias: "api-01", Host: "192.168.1.30", User: "deploy", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "api"}, Status: "online", LastSeen: time.Now().Add(-10 * time.Minute)},
|
|
||||||
{Alias: "cache-01", Host: "192.168.1.40", User: "redis", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "cache"}, Status: "online", LastSeen: time.Now().Add(-1 * time.Hour)},
|
|
||||||
{Alias: "dev-web", Host: "10.0.1.10", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "web"}, Status: "online", LastSeen: time.Now().Add(-5 * time.Minute)},
|
|
||||||
{Alias: "dev-db", Host: "10.0.1.20", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "db"}, Status: "online", LastSeen: time.Now().Add(-15 * time.Minute)},
|
|
||||||
{Alias: "staging", Host: "staging.example.com", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"test"}, Status: "warn", LastSeen: time.Now().Add(-45 * time.Minute)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusIcon(s string) string {
|
|
||||||
switch s {
|
|
||||||
case "online":
|
|
||||||
return "🟢"
|
|
||||||
case "warn":
|
|
||||||
return "🟡"
|
|
||||||
case "offline":
|
|
||||||
return "🔴"
|
|
||||||
default:
|
|
||||||
return "⚪"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinTags(tags []string) string {
|
|
||||||
if len(tags) == 0 {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return strings.Join(tags, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatServerLine(s Server) (primary, secondary string) {
|
|
||||||
icon := statusIcon(s.Status)
|
|
||||||
// Choose a color per status for the alias and a subtle gray for host/time
|
|
||||||
statusColor := "white"
|
|
||||||
switch s.Status {
|
|
||||||
case "online":
|
|
||||||
statusColor = "green"
|
|
||||||
case "warn":
|
|
||||||
statusColor = "yellow"
|
|
||||||
case "offline":
|
|
||||||
statusColor = "red"
|
|
||||||
}
|
|
||||||
primary = fmt.Sprintf("%s [%s::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last:%s[-]", icon, statusColor, s.Alias, s.Host, humanizeDuration(time.Since(s.LastSeen)))
|
|
||||||
secondary = ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func humanizeDuration(d time.Duration) string {
|
|
||||||
if d < time.Minute {
|
|
||||||
return "just now"
|
|
||||||
}
|
|
||||||
h := int(d.Hours())
|
|
||||||
m := int(d.Minutes()) % 60
|
|
||||||
if h > 0 {
|
|
||||||
return fmt.Sprintf("%dh%dm ago", h, m)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dm ago", m)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppName = "lazyssh"
|
|
||||||
const AppVersion = "v0.0.1"
|
|
||||||
const RepoURL = "github.com/adembc/lazyssh"
|
|
||||||
|
|
||||||
func runTUI() error {
|
|
||||||
app := tview.NewApplication()
|
|
||||||
|
|
||||||
// Global theme (non-invasive)
|
|
||||||
tview.Styles.PrimitiveBackgroundColor = tcell.Color232 // near-black
|
|
||||||
tview.Styles.ContrastBackgroundColor = tcell.Color235 // dark gray
|
|
||||||
tview.Styles.BorderColor = tcell.Color238
|
|
||||||
tview.Styles.TitleColor = tcell.Color250
|
|
||||||
tview.Styles.PrimaryTextColor = tcell.Color252
|
|
||||||
tview.Styles.TertiaryTextColor = tcell.Color245
|
|
||||||
tview.Styles.SecondaryTextColor = tcell.Color245
|
|
||||||
tview.Styles.GraphicsColor = tcell.Color238
|
|
||||||
|
|
||||||
servers := mockServers()
|
|
||||||
// Sort by alias for deterministic order
|
|
||||||
sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
|
||||||
|
|
||||||
// Header: enhanced two-row layout (bar + separator)
|
|
||||||
version := AppVersion
|
|
||||||
repoURL := RepoURL
|
|
||||||
|
|
||||||
// Slightly lighter than body for a distinct bar
|
|
||||||
headerBg := tcell.Color234 // dark gray tint
|
|
||||||
|
|
||||||
// Left: App icon + stylized name
|
|
||||||
headerLeft := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetTextAlign(tview.AlignLeft)
|
|
||||||
headerLeft.SetBackgroundColor(headerBg)
|
|
||||||
stylizedName := "🚀 [#FFFFFF::b]lazy[-][#55D7FF::b]ssh[-]"
|
|
||||||
headerLeft.SetText(stylizedName)
|
|
||||||
|
|
||||||
// Center: badges (version + type)
|
|
||||||
headerCenter := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetTextAlign(tview.AlignCenter)
|
|
||||||
headerCenter.SetBackgroundColor(headerBg)
|
|
||||||
// Render version as a pill/badge and add a secondary badge
|
|
||||||
headerCenter.SetText("[black:#2ECC71::b] " + version + " [-] [black:#3B82F6::b] TUI [-]")
|
|
||||||
|
|
||||||
// Right: repo URL + current date/time
|
|
||||||
headerRight := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetTextAlign(tview.AlignRight)
|
|
||||||
headerRight.SetBackgroundColor(headerBg)
|
|
||||||
currentTime := time.Now().Format("Mon, 02 Jan 2006 15:04")
|
|
||||||
headerRight.SetText("[#55AAFF::u]🔗 " + repoURL + "[-] [#AAAAAA]• " + currentTime + "[-]")
|
|
||||||
|
|
||||||
// Row 1: the main header bar with three columns
|
|
||||||
headerBar := tview.NewFlex().SetDirection(tview.FlexColumn).
|
|
||||||
AddItem(headerLeft, 0, 1, false).
|
|
||||||
AddItem(headerCenter, 0, 1, false).
|
|
||||||
AddItem(headerRight, 0, 1, false)
|
|
||||||
|
|
||||||
// Row 2: a thin separator line
|
|
||||||
separator := tview.NewTextView().SetDynamicColors(true)
|
|
||||||
separator.SetBackgroundColor(tcell.Color235)
|
|
||||||
separator.SetText("[#444444]" + strings.Repeat("─", 200) + "[-]")
|
|
||||||
|
|
||||||
// Combine into a two-row header
|
|
||||||
header := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
||||||
AddItem(headerBar, 1, 0, false).
|
|
||||||
AddItem(separator, 1, 0, false)
|
|
||||||
|
|
||||||
// Tab bar removed per request
|
|
||||||
|
|
||||||
// Left panel: Search is hidden by default; a hint bar is shown until '/' is pressed
|
|
||||||
search := tview.NewInputField().
|
|
||||||
SetLabel(" 🔍 Search: ").
|
|
||||||
SetFieldWidth(30)
|
|
||||||
// When visible, give it a border and title for better UI
|
|
||||||
search.SetBorder(true).SetTitle("Search")
|
|
||||||
search.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
|
||||||
search.SetFieldBackgroundColor(tcell.Color233).SetFieldTextColor(tcell.Color252)
|
|
||||||
|
|
||||||
serverList := tview.NewList().ShowSecondaryText(false)
|
|
||||||
serverList.SetBorder(true)
|
|
||||||
serverList.SetTitle("Servers")
|
|
||||||
serverList.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
|
||||||
serverList.SetSelectedBackgroundColor(tcell.Color24).SetSelectedTextColor(tcell.Color255)
|
|
||||||
serverList.SetHighlightFullLine(true)
|
|
||||||
|
|
||||||
// Hint bar shown when search is hidden
|
|
||||||
hintBar := tview.NewTextView().SetDynamicColors(true)
|
|
||||||
hintBar.SetBackgroundColor(tcell.Color233)
|
|
||||||
hintBar.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • a Add • e Edit • d Delete • ? Help[-]")
|
|
||||||
|
|
||||||
// Right panel: Details
|
|
||||||
details := tview.NewTextView().SetDynamicColors(true).SetWrap(true)
|
|
||||||
details.SetBorder(true).SetTitle("Details")
|
|
||||||
details.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
|
||||||
|
|
||||||
// Status bar
|
|
||||||
status := tview.NewTextView().SetDynamicColors(true)
|
|
||||||
statusBg := tcell.Color235
|
|
||||||
status.SetBackgroundColor(statusBg)
|
|
||||||
status.SetTextAlign(tview.AlignCenter)
|
|
||||||
status.SetText("[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]a[-] Add • [white]e[-] Edit • [white]d[-] Delete • [white]/[-] Search • [white]q[-] Quit • [white]?[-] Help")
|
|
||||||
|
|
||||||
// Define updateDetails before populate; declare var for closure use
|
|
||||||
var updateDetails func(Server)
|
|
||||||
// Root container placeholder (assigned later)
|
|
||||||
var root *tview.Flex
|
|
||||||
|
|
||||||
// Populate list with filter logic
|
|
||||||
filtered := servers
|
|
||||||
populate := func() {
|
|
||||||
serverList.Clear()
|
|
||||||
for i := range filtered {
|
|
||||||
p, s2 := formatServerLine(filtered[i])
|
|
||||||
idx := i
|
|
||||||
serverList.AddItem(p, s2, 0, func() {
|
|
||||||
if updateDetails != nil {
|
|
||||||
updateDetails(filtered[idx])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if serverList.GetItemCount() > 0 {
|
|
||||||
serverList.SetCurrentItem(0)
|
|
||||||
if updateDetails != nil {
|
|
||||||
updateDetails(filtered[0])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
details.SetText("No servers match the current filter.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will be defined later to refresh according to current search
|
|
||||||
var reapplyFilter func()
|
|
||||||
|
|
||||||
// Delete currently selected server from memory and refresh
|
|
||||||
deleteSelected := func() {
|
|
||||||
idx := serverList.GetCurrentItem()
|
|
||||||
if idx < 0 || idx >= len(filtered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sel := filtered[idx]
|
|
||||||
// remove from servers slice
|
|
||||||
newServers := make([]Server, 0, len(servers))
|
|
||||||
for _, s := range servers {
|
|
||||||
if !(s.Alias == sel.Alias && s.Host == sel.Host) {
|
|
||||||
newServers = append(newServers, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
servers = newServers
|
|
||||||
reapplyFilter()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now define updateDetails
|
|
||||||
updateDetails = func(s Server) {
|
|
||||||
text := fmt.Sprintf("[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: [white]%s[-]\nStatus: %s\nLast: %s\n\n[::b]Commands:[-]\n Enter: SSH connect\n a: Add new server\n e: Edit entry\n d: Delete entry",
|
|
||||||
s.Alias, s.Host, s.User, s.Port, s.Key, joinTags(s.Tags), statusIcon(s.Status), s.LastSeen.Format("2006-01-02 15:04"))
|
|
||||||
details.SetText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to re-apply current filter and repopulate
|
|
||||||
reapplyFilter = func() {
|
|
||||||
q := strings.TrimSpace(strings.ToLower(search.GetText()))
|
|
||||||
if q == "" {
|
|
||||||
filtered = servers
|
|
||||||
populate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out []Server
|
|
||||||
for _, s := range servers {
|
|
||||||
hay := strings.ToLower(strings.Join([]string{
|
|
||||||
s.Alias,
|
|
||||||
s.Host,
|
|
||||||
s.User,
|
|
||||||
fmt.Sprint(s.Port),
|
|
||||||
strings.Join(s.Tags, ","),
|
|
||||||
s.Key,
|
|
||||||
}, " "))
|
|
||||||
if strings.Contains(hay, q) {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filtered = out
|
|
||||||
populate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new server form
|
|
||||||
addServer := func() {
|
|
||||||
aliasField := tview.NewInputField().SetLabel("Alias: ")
|
|
||||||
hostField := tview.NewInputField().SetLabel("Host/IP: ")
|
|
||||||
userField := tview.NewInputField().SetLabel("User: ").SetText("root")
|
|
||||||
portField := tview.NewInputField().SetLabel("Port: ").SetText("22")
|
|
||||||
keyField := tview.NewInputField().SetLabel("Key: ").SetText("~/.ssh/id_ed25519")
|
|
||||||
tagsField := tview.NewInputField().SetLabel("Tags (comma): ")
|
|
||||||
statusDD := tview.NewDropDown().SetLabel("Status: ")
|
|
||||||
statusOptions := []string{"online", "warn", "offline"}
|
|
||||||
statusDD.SetOptions(statusOptions, nil)
|
|
||||||
statusDD.SetCurrentOption(0)
|
|
||||||
|
|
||||||
form := tview.NewForm().
|
|
||||||
AddFormItem(aliasField).
|
|
||||||
AddFormItem(hostField).
|
|
||||||
AddFormItem(userField).
|
|
||||||
AddFormItem(portField).
|
|
||||||
AddFormItem(keyField).
|
|
||||||
AddFormItem(tagsField).
|
|
||||||
AddFormItem(statusDD)
|
|
||||||
form.SetBorder(true).SetTitle("Add Server").SetTitleAlign(tview.AlignLeft)
|
|
||||||
form.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
|
||||||
form.AddButton("Save", func() {
|
|
||||||
alias := strings.TrimSpace(aliasField.GetText())
|
|
||||||
host := strings.TrimSpace(hostField.GetText())
|
|
||||||
if alias == "" || host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user := strings.TrimSpace(userField.GetText())
|
|
||||||
p := 22
|
|
||||||
if v := strings.TrimSpace(portField.GetText()); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
p = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(keyField.GetText())
|
|
||||||
tags := []string{}
|
|
||||||
if ts := strings.TrimSpace(tagsField.GetText()); ts != "" {
|
|
||||||
for _, t := range strings.Split(ts, ",") {
|
|
||||||
if s := strings.TrimSpace(t); s != "" {
|
|
||||||
tags = append(tags, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, status := statusDD.GetCurrentOption()
|
|
||||||
newS := Server{Alias: alias, Host: host, User: user, Port: p, Key: key, Tags: tags, Status: status, LastSeen: time.Now()}
|
|
||||||
servers = append(servers, newS)
|
|
||||||
sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
|
||||||
reapplyFilter()
|
|
||||||
// select the new item if it is visible
|
|
||||||
for i := range filtered {
|
|
||||||
if filtered[i].Alias == newS.Alias && filtered[i].Host == newS.Host {
|
|
||||||
serverList.SetCurrentItem(i)
|
|
||||||
updateDetails(filtered[i])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.SetRoot(root, true)
|
|
||||||
})
|
|
||||||
form.AddButton("Cancel", func() { app.SetRoot(root, true) })
|
|
||||||
form.SetCancelFunc(func() { app.SetRoot(root, true) })
|
|
||||||
app.SetRoot(form, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit currently selected server
|
|
||||||
editSelected := func() {
|
|
||||||
idx := serverList.GetCurrentItem()
|
|
||||||
if idx < 0 || idx >= len(filtered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orig := filtered[idx]
|
|
||||||
aliasField := tview.NewInputField().SetLabel("Alias: ").SetText(orig.Alias)
|
|
||||||
hostField := tview.NewInputField().SetLabel("Host/IP: ").SetText(orig.Host)
|
|
||||||
userField := tview.NewInputField().SetLabel("User: ").SetText(orig.User)
|
|
||||||
portField := tview.NewInputField().SetLabel("Port: ").SetText(fmt.Sprint(orig.Port))
|
|
||||||
keyField := tview.NewInputField().SetLabel("Key: ").SetText(orig.Key)
|
|
||||||
tagsField := tview.NewInputField().SetLabel("Tags (comma): ").SetText(strings.Join(orig.Tags, ", "))
|
|
||||||
statusDD := tview.NewDropDown().SetLabel("Status: ")
|
|
||||||
statusOptions := []string{"online", "warn", "offline"}
|
|
||||||
statusDD.SetOptions(statusOptions, nil)
|
|
||||||
// set current option to existing status
|
|
||||||
curIdx := 0
|
|
||||||
for i, o := range statusOptions {
|
|
||||||
if o == orig.Status {
|
|
||||||
curIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statusDD.SetCurrentOption(curIdx)
|
|
||||||
|
|
||||||
form := tview.NewForm().
|
|
||||||
AddFormItem(aliasField).
|
|
||||||
AddFormItem(hostField).
|
|
||||||
AddFormItem(userField).
|
|
||||||
AddFormItem(portField).
|
|
||||||
AddFormItem(keyField).
|
|
||||||
AddFormItem(tagsField).
|
|
||||||
AddFormItem(statusDD)
|
|
||||||
form.SetBorder(true).SetTitle("Edit Server").SetTitleAlign(tview.AlignLeft)
|
|
||||||
form.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
|
||||||
form.AddButton("Save", func() {
|
|
||||||
alias := strings.TrimSpace(aliasField.GetText())
|
|
||||||
host := strings.TrimSpace(hostField.GetText())
|
|
||||||
if alias == "" || host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user := strings.TrimSpace(userField.GetText())
|
|
||||||
p := orig.Port
|
|
||||||
if v := strings.TrimSpace(portField.GetText()); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
p = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(keyField.GetText())
|
|
||||||
tags := []string{}
|
|
||||||
if ts := strings.TrimSpace(tagsField.GetText()); ts != "" {
|
|
||||||
for _, t := range strings.Split(ts, ",") {
|
|
||||||
if s := strings.TrimSpace(t); s != "" {
|
|
||||||
tags = append(tags, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, status := statusDD.GetCurrentOption()
|
|
||||||
updated := Server{Alias: alias, Host: host, User: user, Port: p, Key: key, Tags: tags, Status: status, LastSeen: orig.LastSeen}
|
|
||||||
// replace matching item in servers by original key alias+host
|
|
||||||
found := false
|
|
||||||
for i := range servers {
|
|
||||||
if servers[i].Alias == orig.Alias && servers[i].Host == orig.Host {
|
|
||||||
servers[i] = updated
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
servers = append(servers, updated)
|
|
||||||
}
|
|
||||||
sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
|
||||||
reapplyFilter()
|
|
||||||
// select the updated item if visible
|
|
||||||
for i := range filtered {
|
|
||||||
if filtered[i].Alias == updated.Alias && filtered[i].Host == updated.Host {
|
|
||||||
serverList.SetCurrentItem(i)
|
|
||||||
updateDetails(filtered[i])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.SetRoot(root, true)
|
|
||||||
})
|
|
||||||
form.AddButton("Cancel", func() { app.SetRoot(root, true) })
|
|
||||||
form.SetCancelFunc(func() { app.SetRoot(root, true) })
|
|
||||||
app.SetRoot(form, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := func(q string) {
|
|
||||||
q = strings.TrimSpace(strings.ToLower(q))
|
|
||||||
if q == "" {
|
|
||||||
filtered = servers
|
|
||||||
populate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out []Server
|
|
||||||
for _, s := range servers {
|
|
||||||
hay := strings.ToLower(strings.Join([]string{
|
|
||||||
s.Alias,
|
|
||||||
s.Host,
|
|
||||||
s.User,
|
|
||||||
fmt.Sprint(s.Port),
|
|
||||||
strings.Join(s.Tags, ","),
|
|
||||||
s.Key,
|
|
||||||
}, " "))
|
|
||||||
if strings.Contains(hay, q) {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filtered = out
|
|
||||||
populate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Declare togglers; define after left is created
|
|
||||||
var showSearchBar func()
|
|
||||||
var hideSearchBar func()
|
|
||||||
|
|
||||||
search.SetChangedFunc(func(text string) { filter(text) })
|
|
||||||
search.SetDoneFunc(func(key tcell.Key) {
|
|
||||||
// Return focus to list on Escape or Enter and hide the search bar
|
|
||||||
if key == tcell.KeyEsc || key == tcell.KeyEnter {
|
|
||||||
if hideSearchBar != nil {
|
|
||||||
hideSearchBar()
|
|
||||||
} else {
|
|
||||||
app.SetFocus(serverList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initial populate
|
|
||||||
populate()
|
|
||||||
|
|
||||||
// Layout main content area
|
|
||||||
var left *tview.Flex
|
|
||||||
left = tview.NewFlex().SetDirection(tview.FlexRow).
|
|
||||||
AddItem(hintBar, 1, 0, false).
|
|
||||||
AddItem(serverList, 0, 1, true)
|
|
||||||
|
|
||||||
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
||||||
AddItem(details, 0, 1, false)
|
|
||||||
|
|
||||||
content := tview.NewFlex().SetDirection(tview.FlexColumn).
|
|
||||||
AddItem(left, 0, 3, true).
|
|
||||||
AddItem(right, 0, 2, false)
|
|
||||||
|
|
||||||
// Define togglers now that 'left' exists
|
|
||||||
showSearchBar = func() {
|
|
||||||
left.Clear()
|
|
||||||
left.AddItem(search, 3, 0, true)
|
|
||||||
left.AddItem(serverList, 0, 1, false)
|
|
||||||
app.SetFocus(search)
|
|
||||||
}
|
|
||||||
hideSearchBar = func() {
|
|
||||||
left.Clear()
|
|
||||||
left.AddItem(hintBar, 1, 0, false)
|
|
||||||
left.AddItem(serverList, 0, 1, true)
|
|
||||||
app.SetFocus(serverList)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root layout: header, content, status
|
|
||||||
root = tview.NewFlex().SetDirection(tview.FlexRow).
|
|
||||||
AddItem(header, 2, 0, false).
|
|
||||||
AddItem(content, 0, 1, true).
|
|
||||||
AddItem(status, 1, 0, false)
|
|
||||||
|
|
||||||
// Keybindings
|
|
||||||
root.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
// If the search input has focus, do not trigger global shortcuts.
|
|
||||||
if app.GetFocus() == search {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
switch event.Rune() {
|
|
||||||
case 'q':
|
|
||||||
app.Stop()
|
|
||||||
return nil
|
|
||||||
case '/':
|
|
||||||
if showSearchBar != nil {
|
|
||||||
showSearchBar()
|
|
||||||
} else {
|
|
||||||
app.SetFocus(search)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case 'a':
|
|
||||||
addServer()
|
|
||||||
return nil
|
|
||||||
case 'e':
|
|
||||||
editSelected()
|
|
||||||
return nil
|
|
||||||
case 'd':
|
|
||||||
deleteSelected()
|
|
||||||
return nil
|
|
||||||
case '?':
|
|
||||||
showHelpModal(app, root)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Handle Enter at root level as connect for current selection
|
|
||||||
if event.Key() == tcell.KeyEnter {
|
|
||||||
idx := serverList.GetCurrentItem()
|
|
||||||
if idx >= 0 && idx < len(filtered) {
|
|
||||||
showConnectModal(app, root, filtered[idx])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also handle list-selected changes to update details
|
|
||||||
serverList.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
|
||||||
if index >= 0 && index < len(filtered) {
|
|
||||||
updateDetails(filtered[index])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := app.SetRoot(root, true).EnableMouse(true).Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func showHelpModal(app *tview.Application, root tview.Primitive) {
|
|
||||||
text := "Keyboard shortcuts:\n\n" +
|
|
||||||
" ↑/↓ Navigate\n" +
|
|
||||||
" Enter SSH connect (mock)\n" +
|
|
||||||
" a Add server (mock)\n" +
|
|
||||||
" e Edit server (mock)\n" +
|
|
||||||
" d Delete entry (mock)\n" +
|
|
||||||
" / Focus search\n" +
|
|
||||||
" q Quit\n" +
|
|
||||||
" ? Help\n"
|
|
||||||
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText(text).
|
|
||||||
AddButtons([]string{"Close"}).
|
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
||||||
app.SetRoot(root, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.SetRoot(modal, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func showConnectModal(app *tview.Application, root tview.Primitive, s Server) {
|
|
||||||
msg := fmt.Sprintf("SSH to %s (%s@%s:%d)\n\nThis is a mock action.", s.Alias, s.User, s.Host, s.Port)
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText(msg).
|
|
||||||
AddButtons([]string{"OK"}).
|
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
||||||
app.SetRoot(root, true)
|
|
||||||
})
|
|
||||||
app.SetRoot(modal, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// printServers prints the list of servers to stdout in a simple table-like format.
|
|
||||||
func printServers() {
|
|
||||||
servers := mockServers()
|
|
||||||
sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
|
||||||
fmt.Printf("Alias\tHost\tUser\tPort\tTags\tStatus\tLast\n")
|
|
||||||
for _, s := range servers {
|
|
||||||
fmt.Printf("%s\t%s\t%s\t%d\t%s\t%s\t%s\n", s.Alias, s.Host, s.User, s.Port, strings.Join(s.Tags, ","), s.Status, humanizeDuration(time.Since(s.LastSeen)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
|
||||||
versionFlag bool
|
serverInMemoryRepo := memory.NewServerRepository()
|
||||||
listFlag bool
|
serverService := services.NewServerService(serverInMemoryRepo)
|
||||||
)
|
tui := ui.NewTUI(serverService)
|
||||||
|
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: AppName,
|
Use: ui.AppName,
|
||||||
Short: "Lazy SSH server picker TUI",
|
Short: "Lazy SSH server picker TUI",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if versionFlag {
|
return tui.Run()
|
||||||
fmt.Println(AppVersion)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if listFlag {
|
|
||||||
printServers()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return runTUI()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rootCmd.SilenceUsage = true
|
rootCmd.SilenceUsage = true
|
||||||
rootCmd.PersistentFlags().BoolVarP(&versionFlag, "version", "v", false, "Print version and exit")
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&listFlag, "list", "l", false, "Print list of servers and exit")
|
|
||||||
|
|
||||||
versionCmd := &cobra.Command{
|
|
||||||
Use: "version",
|
|
||||||
Short: "Print version",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Println(AppVersion)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
listCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List servers",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
printServers()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rootCmd.AddCommand(versionCmd)
|
|
||||||
rootCmd.AddCommand(listCmd)
|
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serverRepository struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers = []domain.Server{
|
||||||
|
{Alias: "web-01", Host: "192.168.1.10", User: "root", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "web"}, Status: "online", LastSeen: time.Now().Add(-2 * time.Hour)},
|
||||||
|
{Alias: "web-02", Host: "192.168.1.11", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "web"}, Status: "warn", LastSeen: time.Now().Add(-30 * time.Minute)},
|
||||||
|
{Alias: "db-01", Host: "192.168.1.20", User: "postgres", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "db"}, Status: "offline", LastSeen: time.Now().Add(-26 * time.Hour)},
|
||||||
|
{Alias: "api-01", Host: "192.168.1.30", User: "deploy", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "api"}, Status: "online", LastSeen: time.Now().Add(-10 * time.Minute)},
|
||||||
|
{Alias: "cache-01", Host: "192.168.1.40", User: "redis", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "cache"}, Status: "online", LastSeen: time.Now().Add(-1 * time.Hour)},
|
||||||
|
{Alias: "dev-web", Host: "10.0.1.10", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "web"}, Status: "online", LastSeen: time.Now().Add(-5 * time.Minute)},
|
||||||
|
{Alias: "dev-db", Host: "10.0.1.20", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "db"}, Status: "online", LastSeen: time.Now().Add(-15 * time.Minute)},
|
||||||
|
{Alias: "staging", Host: "staging.example.com", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"test"}, Status: "warn", LastSeen: time.Now().Add(-45 * time.Minute)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerRepository creates a new server repository with the given file path.
|
||||||
|
func NewServerRepository() *serverRepository {
|
||||||
|
return &serverRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServers returns a list of servers from the repository.
|
||||||
|
func (r *serverRepository) ListServers(query string) ([]domain.Server, error) {
|
||||||
|
if query == "" {
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
|
||||||
|
var filteredServers []domain.Server
|
||||||
|
for _, server := range servers {
|
||||||
|
alias := strings.ToLower(server.Alias)
|
||||||
|
host := strings.ToLower(server.Host)
|
||||||
|
user := strings.ToLower(server.User)
|
||||||
|
status := strings.ToLower(server.Status)
|
||||||
|
port := strconv.Itoa(server.Port)
|
||||||
|
|
||||||
|
match := false
|
||||||
|
if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(status, q) || strings.Contains(port, q) {
|
||||||
|
match = true
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
for _, tag := range server.Tags {
|
||||||
|
if strings.Contains(strings.ToLower(tag), q) {
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
filteredServers = append(filteredServers, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredServers, nil
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServer updates an existing server with new details.
|
||||||
|
func (r *serverRepository) UpdateServer(server domain.Server, newServer domain.Server) error {
|
||||||
|
for i, s := range servers {
|
||||||
|
if s.Alias == server.Alias {
|
||||||
|
servers[i] = newServer
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddServer adds a new server to the repository.
|
||||||
|
func (r *serverRepository) AddServer(server domain.Server) error {
|
||||||
|
servers = append(servers, server)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteServer removes a server from the repository.
|
||||||
|
func (r *serverRepository) DeleteServer(server domain.Server) error {
|
||||||
|
for i, s := range servers {
|
||||||
|
if s.Alias == server.Alias {
|
||||||
|
servers = append(servers[:i], servers[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppName = "lazyssh"
|
||||||
|
AppVersion = "v0.0.1"
|
||||||
|
RepoURL = "github.com/adembc/lazyssh"
|
||||||
|
Banner = `
|
||||||
|
|
||||||
|
$$\ $$\
|
||||||
|
$$ | $$ |
|
||||||
|
$$ | $$$$$$\ $$$$$$$$\ $$\ $$\ $$$$$$$\ $$$$$$$\ $$$$$$$\
|
||||||
|
$$ | \____$$\ \____$$ |$$ | $$ |$$ _____|$$ _____|$$ __$$\
|
||||||
|
$$ | $$$$$$$ | $$$$ _/ $$ | $$ |\$$$$$$\ \$$$$$$\ $$ | $$ |
|
||||||
|
$$ |$$ __$$ | $$ _/ $$ | $$ | \____$$\ \____$$\ $$ | $$ |
|
||||||
|
$$ |\$$$$$$$ |$$$$$$$$\ \$$$$$$$ |$$$$$$$ |$$$$$$$ |$$ | $$ |
|
||||||
|
\__| \_______|\________| \____$$ |\_______/ \_______/ \__| \__|
|
||||||
|
$$\ $$ |
|
||||||
|
\$$$$$$ |
|
||||||
|
\______/
|
||||||
|
`
|
||||||
|
)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
|
||||||
|
// Don't handle global keys when search has focus
|
||||||
|
if t.app.GetFocus() == t.searchBar {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Rune() {
|
||||||
|
case 'q':
|
||||||
|
t.app.Stop()
|
||||||
|
return nil
|
||||||
|
case '/':
|
||||||
|
t.showSearchBar()
|
||||||
|
return nil
|
||||||
|
case 'a':
|
||||||
|
t.showAddServerForm()
|
||||||
|
return nil
|
||||||
|
case 'e':
|
||||||
|
t.showEditServerForm()
|
||||||
|
return nil
|
||||||
|
case 'd':
|
||||||
|
t.deleteSelectedServer()
|
||||||
|
return nil
|
||||||
|
case '?':
|
||||||
|
t.showHelpModal()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Key() == tcell.KeyEnter {
|
||||||
|
t.connectToSelectedServer()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) handleSearch(query string) {
|
||||||
|
filtered, _ := t.serverService.ListServers(query)
|
||||||
|
t.serverList.UpdateServers(filtered)
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
t.details.ShowEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) handleServerConnect(server domain.Server) {
|
||||||
|
showConnectModal(t.app, t.root, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) handleServerSelectionChange(server domain.Server) {
|
||||||
|
t.details.UpdateServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) showSearchBar() {
|
||||||
|
t.left.Clear()
|
||||||
|
t.left.AddItem(t.searchBar, 3, 0, true)
|
||||||
|
t.left.AddItem(t.serverList, 0, 1, false)
|
||||||
|
t.app.SetFocus(t.searchBar)
|
||||||
|
t.searchVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) hideSearchBar() {
|
||||||
|
t.left.Clear()
|
||||||
|
t.left.AddItem(t.hintBar, 1, 0, false)
|
||||||
|
t.left.AddItem(t.serverList, 0, 1, true)
|
||||||
|
t.app.SetFocus(t.serverList)
|
||||||
|
t.searchVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) showAddServerForm() {
|
||||||
|
form := NewServerForm(ServerFormAdd, nil).
|
||||||
|
OnSave(t.handleServerSave).
|
||||||
|
OnCancel(t.returnToMain)
|
||||||
|
t.app.SetRoot(form, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) showEditServerForm() {
|
||||||
|
if server, ok := t.serverList.GetSelectedServer(); ok {
|
||||||
|
form := NewServerForm(ServerFormEdit, &server).
|
||||||
|
OnSave(t.handleServerSave).
|
||||||
|
OnCancel(t.returnToMain)
|
||||||
|
t.app.SetRoot(form, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) handleServerSave(server domain.Server, original *domain.Server) {
|
||||||
|
if original != nil {
|
||||||
|
// Edit mode
|
||||||
|
t.serverService.UpdateServer(*original, server)
|
||||||
|
} else {
|
||||||
|
// Add mode
|
||||||
|
t.serverService.AddServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.refreshServerList()
|
||||||
|
t.returnToMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) deleteSelectedServer() {
|
||||||
|
if server, ok := t.serverList.GetSelectedServer(); ok {
|
||||||
|
_ = t.serverService.DeleteServer(server)
|
||||||
|
t.refreshServerList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) refreshServerList() {
|
||||||
|
query := ""
|
||||||
|
if t.searchVisible {
|
||||||
|
query = t.searchBar.GetText()
|
||||||
|
}
|
||||||
|
filtered, _ := t.serverService.ListServers(query)
|
||||||
|
t.serverList.UpdateServers(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) connectToSelectedServer() {
|
||||||
|
if server, ok := t.serverList.GetSelectedServer(); ok {
|
||||||
|
t.handleServerConnect(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) returnToMain() {
|
||||||
|
t.app.SetRoot(t.root, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) showHelpModal() {
|
||||||
|
showHelpModal(t.app, t.root)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppHeader struct {
|
||||||
|
*tview.Flex
|
||||||
|
version string
|
||||||
|
repoURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppHeader(version, repoURL string) *AppHeader {
|
||||||
|
header := &AppHeader{
|
||||||
|
Flex: tview.NewFlex(),
|
||||||
|
version: version,
|
||||||
|
repoURL: repoURL,
|
||||||
|
}
|
||||||
|
header.build()
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHeader) build() {
|
||||||
|
headerBg := tcell.Color234
|
||||||
|
|
||||||
|
left := h.buildLeftSection(headerBg)
|
||||||
|
center := h.buildCenterSection(headerBg)
|
||||||
|
right := h.buildRightSection(headerBg)
|
||||||
|
|
||||||
|
headerBar := tview.NewFlex().SetDirection(tview.FlexColumn).
|
||||||
|
AddItem(left, 0, 1, false).
|
||||||
|
AddItem(center, 0, 1, false).
|
||||||
|
AddItem(right, 0, 1, false)
|
||||||
|
|
||||||
|
separator := h.createSeparator()
|
||||||
|
|
||||||
|
h.SetDirection(tview.FlexRow).
|
||||||
|
AddItem(headerBar, 1, 0, false).
|
||||||
|
AddItem(separator, 1, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHeader) buildLeftSection(bg tcell.Color) *tview.TextView {
|
||||||
|
left := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignLeft)
|
||||||
|
left.SetBackgroundColor(bg)
|
||||||
|
stylizedName := "🚀 [#FFFFFF::b]lazy[-][#55D7FF::b]ssh[-]"
|
||||||
|
left.SetText(stylizedName)
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHeader) buildCenterSection(bg tcell.Color) *tview.TextView {
|
||||||
|
center := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignCenter)
|
||||||
|
center.SetBackgroundColor(bg)
|
||||||
|
center.SetText("[black:#2ECC71::b] " + h.version + " [-] [black:#3B82F6::b] TUI [-]")
|
||||||
|
return center
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHeader) buildRightSection(bg tcell.Color) *tview.TextView {
|
||||||
|
right := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignRight)
|
||||||
|
right.SetBackgroundColor(bg)
|
||||||
|
currentTime := time.Now().Format("Mon, 02 Jan 2006 15:04")
|
||||||
|
right.SetText("[#55AAFF::u]🔗 " + h.repoURL + "[-] [#AAAAAA]• " + currentTime + "[-]")
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AppHeader) createSeparator() *tview.TextView {
|
||||||
|
separator := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
separator.SetBackgroundColor(tcell.Color235)
|
||||||
|
separator.SetText("[#444444]" + strings.Repeat("─", 200) + "[-]")
|
||||||
|
return separator
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewHintBar() *tview.TextView {
|
||||||
|
hint := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
hint.SetBackgroundColor(tcell.Color233)
|
||||||
|
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • a Add • e Edit • d Delete • ? Help[-]")
|
||||||
|
return hint
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchBar struct {
|
||||||
|
*tview.InputField
|
||||||
|
onSearch func(string)
|
||||||
|
onEscape func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSearchBar() *SearchBar {
|
||||||
|
search := &SearchBar{
|
||||||
|
InputField: tview.NewInputField(),
|
||||||
|
}
|
||||||
|
search.build()
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchBar) build() {
|
||||||
|
s.SetLabel(" 🔍 Search: ").
|
||||||
|
SetFieldBackgroundColor(tcell.Color233).
|
||||||
|
SetFieldTextColor(tcell.Color252).
|
||||||
|
SetFieldWidth(30).
|
||||||
|
SetBorder(true).
|
||||||
|
SetTitle("Search").
|
||||||
|
SetBorderColor(tcell.Color238).
|
||||||
|
SetTitleColor(tcell.Color250)
|
||||||
|
|
||||||
|
s.SetChangedFunc(func(text string) {
|
||||||
|
if s.onSearch != nil {
|
||||||
|
s.onSearch(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s.SetDoneFunc(func(key tcell.Key) {
|
||||||
|
if key == tcell.KeyEsc || key == tcell.KeyEnter {
|
||||||
|
if s.onEscape != nil {
|
||||||
|
s.onEscape()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchBar) OnSearch(fn func(string)) *SearchBar {
|
||||||
|
s.onSearch = fn
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchBar) OnEscape(fn func()) *SearchBar {
|
||||||
|
s.onEscape = fn
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerDetails struct {
|
||||||
|
*tview.TextView
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerDetails() *ServerDetails {
|
||||||
|
details := &ServerDetails{
|
||||||
|
TextView: tview.NewTextView(),
|
||||||
|
}
|
||||||
|
details.build()
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sd *ServerDetails) build() {
|
||||||
|
sd.SetDynamicColors(true).
|
||||||
|
SetWrap(true).
|
||||||
|
SetBorder(true).
|
||||||
|
SetTitle("Details").
|
||||||
|
SetBorderColor(tcell.Color238).
|
||||||
|
SetTitleColor(tcell.Color250)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sd *ServerDetails) UpdateServer(server domain.Server) {
|
||||||
|
text := fmt.Sprintf(
|
||||||
|
"[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: [white]%s[-]\nStatus: %s\nLast: %s\n\n[::b]Commands:[-]\n Enter: SSH connect\n a: Add new server\n e: Edit entry\n d: Delete entry",
|
||||||
|
server.Alias, server.Host, server.User, server.Port,
|
||||||
|
server.Key, joinTags(server.Tags), statusIcon(server.Status),
|
||||||
|
server.LastSeen.Format("2006-01-02 15:04"))
|
||||||
|
sd.SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sd *ServerDetails) ShowEmpty() {
|
||||||
|
sd.SetText("No servers match the current filter.")
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerFormMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServerFormAdd ServerFormMode = iota
|
||||||
|
ServerFormEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerForm struct {
|
||||||
|
*tview.Form
|
||||||
|
mode ServerFormMode
|
||||||
|
original *domain.Server
|
||||||
|
onSave func(domain.Server, *domain.Server)
|
||||||
|
onCancel func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm {
|
||||||
|
form := &ServerForm{
|
||||||
|
Form: tview.NewForm(),
|
||||||
|
mode: mode,
|
||||||
|
original: original,
|
||||||
|
}
|
||||||
|
form.build()
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *ServerForm) build() {
|
||||||
|
title := "Add Server"
|
||||||
|
if sf.mode == ServerFormEdit {
|
||||||
|
title = "Edit Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
sf.SetBorder(true).
|
||||||
|
SetTitle(title).
|
||||||
|
SetTitleAlign(tview.AlignLeft).
|
||||||
|
SetBorderColor(tcell.Color238).
|
||||||
|
SetTitleColor(tcell.Color250)
|
||||||
|
|
||||||
|
sf.addFormFields()
|
||||||
|
|
||||||
|
sf.AddButton("Save", sf.handleSave)
|
||||||
|
sf.AddButton("Cancel", sf.handleCancel)
|
||||||
|
sf.SetCancelFunc(sf.handleCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *ServerForm) addFormFields() {
|
||||||
|
var defaultValues ServerFormData
|
||||||
|
if sf.mode == ServerFormEdit && sf.original != nil {
|
||||||
|
defaultValues = ServerFormData{
|
||||||
|
Alias: sf.original.Alias,
|
||||||
|
Host: sf.original.Host,
|
||||||
|
User: sf.original.User,
|
||||||
|
Port: fmt.Sprint(sf.original.Port),
|
||||||
|
Key: sf.original.Key,
|
||||||
|
Tags: strings.Join(sf.original.Tags, ", "),
|
||||||
|
Status: sf.original.Status,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
defaultValues = ServerFormData{
|
||||||
|
User: "root",
|
||||||
|
Port: "22",
|
||||||
|
Key: "~/.ssh/id_ed25519",
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sf.AddInputField("Alias:", defaultValues.Alias, 20, nil, nil)
|
||||||
|
sf.AddInputField("Host/IP:", defaultValues.Host, 20, nil, nil)
|
||||||
|
sf.AddInputField("User:", defaultValues.User, 20, nil, nil)
|
||||||
|
sf.AddInputField("Port:", defaultValues.Port, 20, nil, nil)
|
||||||
|
sf.AddInputField("Key:", defaultValues.Key, 40, nil, nil)
|
||||||
|
sf.AddInputField("Tags (comma):", defaultValues.Tags, 30, nil, nil)
|
||||||
|
|
||||||
|
statusDD := tview.NewDropDown().SetLabel("Status: ")
|
||||||
|
statusOptions := []string{"online", "warn", "offline"}
|
||||||
|
statusDD.SetOptions(statusOptions, nil)
|
||||||
|
|
||||||
|
for i, opt := range statusOptions {
|
||||||
|
if opt == defaultValues.Status {
|
||||||
|
statusDD.SetCurrentOption(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sf.AddFormItem(statusDD)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerFormData struct {
|
||||||
|
Alias string
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Port string
|
||||||
|
Key string
|
||||||
|
Tags string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *ServerForm) getFormData() ServerFormData {
|
||||||
|
return ServerFormData{
|
||||||
|
Alias: strings.TrimSpace(sf.GetFormItem(0).(*tview.InputField).GetText()),
|
||||||
|
Host: strings.TrimSpace(sf.GetFormItem(1).(*tview.InputField).GetText()),
|
||||||
|
User: strings.TrimSpace(sf.GetFormItem(2).(*tview.InputField).GetText()),
|
||||||
|
Port: strings.TrimSpace(sf.GetFormItem(3).(*tview.InputField).GetText()),
|
||||||
|
Key: strings.TrimSpace(sf.GetFormItem(4).(*tview.InputField).GetText()),
|
||||||
|
Tags: strings.TrimSpace(sf.GetFormItem(5).(*tview.InputField).GetText()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *ServerForm) handleSave() {
|
||||||
|
data := sf.getFormData()
|
||||||
|
if data.Alias == "" || data.Host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server := sf.dataToServer(data)
|
||||||
|
if sf.onSave != nil {
|
||||||
|
sf.onSave(server, sf.original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *ServerForm) handleCancel() {
|
||||||
|
if sf.onCancel != nil {
|
||||||
|
sf.onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
if data.Tags != "" {
|
||||||
|
for _, t := range strings.Split(data.Tags, ",") {
|
||||||
|
if s := strings.TrimSpace(t); s != "" {
|
||||||
|
tags = append(tags, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, status := sf.GetFormItem(6).(*tview.DropDown).GetCurrentOption()
|
||||||
|
|
||||||
|
return domain.Server{
|
||||||
|
Alias: data.Alias,
|
||||||
|
Host: data.Host,
|
||||||
|
User: data.User,
|
||||||
|
Port: port,
|
||||||
|
Key: data.Key,
|
||||||
|
Tags: tags,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerList struct {
|
||||||
|
*tview.List
|
||||||
|
servers []domain.Server
|
||||||
|
onSelection func(domain.Server)
|
||||||
|
onSelectionChange func(domain.Server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerList() *ServerList {
|
||||||
|
list := &ServerList{
|
||||||
|
List: tview.NewList(),
|
||||||
|
}
|
||||||
|
list.build()
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *ServerList) build() {
|
||||||
|
sl.ShowSecondaryText(false)
|
||||||
|
sl.SetBorder(true).
|
||||||
|
SetTitle("Servers").
|
||||||
|
SetBorderColor(tcell.Color238).
|
||||||
|
SetTitleColor(tcell.Color250)
|
||||||
|
sl.
|
||||||
|
SetSelectedBackgroundColor(tcell.Color24).
|
||||||
|
SetSelectedTextColor(tcell.Color255).
|
||||||
|
SetHighlightFullLine(true)
|
||||||
|
|
||||||
|
sl.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||||
|
if index >= 0 && index < len(sl.servers) && sl.onSelectionChange != nil {
|
||||||
|
sl.onSelectionChange(sl.servers[index])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *ServerList) UpdateServers(servers []domain.Server) {
|
||||||
|
sl.servers = servers
|
||||||
|
sl.Clear()
|
||||||
|
|
||||||
|
for i := range servers {
|
||||||
|
primary, secondary := formatServerLine(servers[i])
|
||||||
|
idx := i
|
||||||
|
sl.AddItem(primary, secondary, 0, func() {
|
||||||
|
if sl.onSelection != nil {
|
||||||
|
sl.onSelection(sl.servers[idx])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if sl.GetItemCount() > 0 {
|
||||||
|
sl.SetCurrentItem(0)
|
||||||
|
if sl.onSelectionChange != nil {
|
||||||
|
sl.onSelectionChange(sl.servers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *ServerList) GetSelectedServer() (domain.Server, bool) {
|
||||||
|
idx := sl.GetCurrentItem()
|
||||||
|
if idx >= 0 && idx < len(sl.servers) {
|
||||||
|
return sl.servers[idx], true
|
||||||
|
}
|
||||||
|
return domain.Server{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *ServerList) OnSelection(fn func(server domain.Server)) *ServerList {
|
||||||
|
sl.onSelection = fn
|
||||||
|
return sl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *ServerList) OnSelectionChange(fn func(server domain.Server)) *ServerList {
|
||||||
|
sl.onSelectionChange = fn
|
||||||
|
return sl
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewStatusBar() *tview.TextView {
|
||||||
|
status := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
status.SetBackgroundColor(tcell.Color235)
|
||||||
|
status.SetTextAlign(tview.AlignCenter)
|
||||||
|
status.SetText("[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]a[-] Add • [white]e[-] Edit • [white]d[-] Delete • [white]/[-] Search • [white]q[-] Quit • [white]?[-] Help")
|
||||||
|
return status
|
||||||
|
}
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/ports"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// splash screen with spinner; returns primitive and a stop function
|
||||||
|
func buildSplash(app *tview.Application) (tview.Primitive, func()) {
|
||||||
|
// Brand styling
|
||||||
|
title := "[#FFFFFF::b]lazy[-][#55D7FF::b]ssh[-]"
|
||||||
|
tagline := "[#AAAAAA]Fast server picker TUI • Loading your servers…[-]"
|
||||||
|
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
// Helpful rotating tips
|
||||||
|
tips := []string{
|
||||||
|
"Press [::b]/[-] to search instantly",
|
||||||
|
"Use [::b]↑/↓[-] to navigate",
|
||||||
|
"Hit [::b]Enter[-] to open a connection (mock)",
|
||||||
|
"Press [::b]?[-] for help",
|
||||||
|
"Press [::b]q[-] to quit",
|
||||||
|
}
|
||||||
|
|
||||||
|
text := tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetBorder(true).
|
||||||
|
SetTitle(" 🚀 lazyssh ").
|
||||||
|
SetBorderColor(tcell.Color238).
|
||||||
|
SetTitleColor(tcell.Color250)
|
||||||
|
|
||||||
|
// layout to center the card
|
||||||
|
box := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(text, 9, 0, false).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
|
||||||
|
container := tview.NewFlex().SetDirection(tview.FlexColumn).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false).
|
||||||
|
AddItem(box, 0, 2, false).
|
||||||
|
AddItem(tview.NewBox(), 0, 1, false)
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
i := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-time.After(90 * time.Millisecond):
|
||||||
|
frame := spinnerFrames[i%len(spinnerFrames)]
|
||||||
|
// simple progress bar animation (width 20)
|
||||||
|
width := 20
|
||||||
|
filled := i % (width + 1)
|
||||||
|
prog := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||||||
|
// rotate tips every ~12 frames
|
||||||
|
tip := tips[(i/12)%len(tips)]
|
||||||
|
i++
|
||||||
|
app.QueueUpdateDraw(func() {
|
||||||
|
text.SetText(
|
||||||
|
"\n" +
|
||||||
|
"[::b]" + title + "[-]\n" +
|
||||||
|
"\n" + frame + " " + tagline + "\n" +
|
||||||
|
"\n[#55D7FF]" + prog + "[-]\n" +
|
||||||
|
"\n[#777777]" + tip + "[-]\n" +
|
||||||
|
"\n[#555555]v" + AppVersion + " • " + RepoURL + "[-]",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stopFunc := func() { close(stop) }
|
||||||
|
return container, stopFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func showHelpModal(app *tview.Application, root tview.Primitive) {
|
||||||
|
text := "Keyboard shortcuts:\n\n" +
|
||||||
|
" ↑/↓ Navigate\n" +
|
||||||
|
" Enter SSH connect (mock)\n" +
|
||||||
|
" a Add server (mock)\n" +
|
||||||
|
" e Edit server (mock)\n" +
|
||||||
|
" d Delete entry (mock)\n" +
|
||||||
|
" / Focus search\n" +
|
||||||
|
" q Quit\n" +
|
||||||
|
" ? Help\n"
|
||||||
|
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(text).
|
||||||
|
AddButtons([]string{"Close"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
app.SetRoot(root, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.SetRoot(modal, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showConnectModal(app *tview.Application, root tview.Primitive, s domain.Server) {
|
||||||
|
msg := fmt.Sprintf("SSH to %s (%s@%s:%d)\n\nThis is a mock action.", s.Alias, s.User, s.Host, s.Port)
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(msg).
|
||||||
|
AddButtons([]string{"OK"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
app.SetRoot(root, true)
|
||||||
|
})
|
||||||
|
app.SetRoot(modal, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tui struct {
|
||||||
|
app *tview.Application
|
||||||
|
serverService ports.ServerService
|
||||||
|
|
||||||
|
header *AppHeader
|
||||||
|
searchBar *SearchBar
|
||||||
|
hintBar *tview.TextView
|
||||||
|
serverList *ServerList
|
||||||
|
details *ServerDetails
|
||||||
|
statusBar *tview.TextView
|
||||||
|
|
||||||
|
root *tview.Flex
|
||||||
|
left *tview.Flex
|
||||||
|
content *tview.Flex
|
||||||
|
|
||||||
|
searchVisible bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTUI(ss ports.ServerService) *tui {
|
||||||
|
return &tui{
|
||||||
|
app: tview.NewApplication(),
|
||||||
|
serverService: ss,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) Run() error {
|
||||||
|
splash, stop := buildSplash(t.app)
|
||||||
|
t.app.SetRoot(splash, true)
|
||||||
|
time.AfterFunc(1*time.Second, func() {
|
||||||
|
stop()
|
||||||
|
t.app.QueueUpdateDraw(func() {
|
||||||
|
t.app.SetRoot(t.root, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.app.EnableMouse(true)
|
||||||
|
t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData()
|
||||||
|
|
||||||
|
return t.app.Run()
|
||||||
|
}
|
||||||
|
func (t *tui) initializeTheme() *tui {
|
||||||
|
tview.Styles.PrimitiveBackgroundColor = tcell.Color232
|
||||||
|
tview.Styles.ContrastBackgroundColor = tcell.Color235
|
||||||
|
tview.Styles.BorderColor = tcell.Color238
|
||||||
|
tview.Styles.TitleColor = tcell.Color250
|
||||||
|
tview.Styles.PrimaryTextColor = tcell.Color252
|
||||||
|
tview.Styles.TertiaryTextColor = tcell.Color245
|
||||||
|
tview.Styles.SecondaryTextColor = tcell.Color245
|
||||||
|
tview.Styles.GraphicsColor = tcell.Color238
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
func (t *tui) buildComponents() *tui {
|
||||||
|
t.header = NewAppHeader(AppVersion, RepoURL)
|
||||||
|
t.searchBar = NewSearchBar().
|
||||||
|
OnSearch(t.handleSearch).
|
||||||
|
OnEscape(t.hideSearchBar)
|
||||||
|
t.hintBar = NewHintBar()
|
||||||
|
t.serverList = NewServerList().
|
||||||
|
OnSelection(t.handleServerConnect).
|
||||||
|
OnSelectionChange(t.handleServerSelectionChange)
|
||||||
|
t.details = NewServerDetails()
|
||||||
|
t.statusBar = NewStatusBar()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) buildLayout() *tui {
|
||||||
|
|
||||||
|
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(t.hintBar, 1, 0, false).
|
||||||
|
AddItem(t.serverList, 0, 1, true)
|
||||||
|
|
||||||
|
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(t.details, 0, 1, false)
|
||||||
|
|
||||||
|
t.content = tview.NewFlex().SetDirection(tview.FlexColumn).
|
||||||
|
AddItem(t.left, 0, 3, true).
|
||||||
|
AddItem(right, 0, 2, false)
|
||||||
|
|
||||||
|
t.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(t.header, 2, 0, false).
|
||||||
|
AddItem(t.content, 0, 1, true).
|
||||||
|
AddItem(t.statusBar, 1, 0, false)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
func (t *tui) bindEvents() *tui {
|
||||||
|
t.root.SetInputCapture(t.handleGlobalKeys)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) loadInitialData() *tui {
|
||||||
|
servers, _ := t.serverService.ListServers("")
|
||||||
|
t.serverList.UpdateServers(servers)
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
//func (t *tui) Run() error {
|
||||||
|
// // Global theme (non-invasive)
|
||||||
|
// tview.Styles.PrimitiveBackgroundColor = tcell.Color232 // near-black
|
||||||
|
// tview.Styles.ContrastBackgroundColor = tcell.Color235 // dark gray
|
||||||
|
// tview.Styles.BorderColor = tcell.Color238
|
||||||
|
// tview.Styles.TitleColor = tcell.Color250
|
||||||
|
// tview.Styles.PrimaryTextColor = tcell.Color252
|
||||||
|
// tview.Styles.TertiaryTextColor = tcell.Color245
|
||||||
|
// tview.Styles.SecondaryTextColor = tcell.Color245
|
||||||
|
// tview.Styles.GraphicsColor = tcell.Color238
|
||||||
|
//
|
||||||
|
// servers, err := t.serverService.ListServers()
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// // Sort by alias for deterministic order
|
||||||
|
// sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
||||||
|
//
|
||||||
|
// // Header: enhanced two-row layout (bar + separator)
|
||||||
|
// version := AppVersion
|
||||||
|
// repoURL := RepoURL
|
||||||
|
//
|
||||||
|
// // Slightly lighter than body for a distinct bar
|
||||||
|
// headerBg := tcell.Color234 // dark gray tint
|
||||||
|
// // Left: App icon + stylized name
|
||||||
|
// headerLeft := tview.NewTextView().
|
||||||
|
// SetDynamicColors(true).
|
||||||
|
// SetTextAlign(tview.AlignLeft)
|
||||||
|
// headerLeft.SetBackgroundColor(headerBg)
|
||||||
|
// stylizedName := "🚀 [#FFFFFF::b]lazy[-][#55D7FF::b]ssh[-]"
|
||||||
|
// headerLeft.SetText(stylizedName)
|
||||||
|
//
|
||||||
|
// // Center: badges (version + type)
|
||||||
|
// headerCenter := tview.NewTextView().
|
||||||
|
// SetDynamicColors(true).
|
||||||
|
// SetTextAlign(tview.AlignCenter)
|
||||||
|
// headerCenter.SetBackgroundColor(headerBg)
|
||||||
|
// // Render version as a pill/badge and add a secondary badge
|
||||||
|
// headerCenter.SetText("[black:#2ECC71::b] " + version + " [-] [black:#3B82F6::b] TUI [-]")
|
||||||
|
//
|
||||||
|
// // Right: repo URL + current date/time
|
||||||
|
// headerRight := tview.NewTextView().
|
||||||
|
// SetDynamicColors(true).
|
||||||
|
// SetTextAlign(tview.AlignRight)
|
||||||
|
// headerRight.SetBackgroundColor(headerBg)
|
||||||
|
// currentTime := time.Now().Format("Mon, 02 Jan 2006 15:04")
|
||||||
|
// headerRight.SetText("[#55AAFF::u]🔗 " + repoURL + "[-] [#AAAAAA]• " + currentTime + "[-]")
|
||||||
|
//
|
||||||
|
// // Row 1: the main header bar with three columns
|
||||||
|
// headerBar := tview.NewFlex().SetDirection(tview.FlexColumn).
|
||||||
|
// AddItem(headerLeft, 0, 1, false).
|
||||||
|
// AddItem(headerCenter, 0, 1, false).
|
||||||
|
// AddItem(headerRight, 0, 1, false)
|
||||||
|
//
|
||||||
|
// // Row 2: a thin separator line
|
||||||
|
// separator := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
// separator.SetBackgroundColor(tcell.Color235)
|
||||||
|
// separator.SetText("[#444444]" + strings.Repeat("─", 200) + "[-]")
|
||||||
|
//
|
||||||
|
// // Combine into a two-row header
|
||||||
|
// header := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
// AddItem(headerBar, 1, 0, false).
|
||||||
|
// AddItem(separator, 1, 0, false)
|
||||||
|
//
|
||||||
|
// // Tab bar removed per request
|
||||||
|
//
|
||||||
|
// // Left panel: Search is hidden by default; a hint bar is shown until '/' is pressed
|
||||||
|
// search := tview.NewInputField().
|
||||||
|
// SetLabel(" 🔍 Search: ").
|
||||||
|
// SetFieldWidth(30)
|
||||||
|
// // When visible, give it a border and title for better UI
|
||||||
|
// search.SetBorder(true).SetTitle("Search")
|
||||||
|
// search.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
||||||
|
// search.SetFieldBackgroundColor(tcell.Color233).SetFieldTextColor(tcell.Color252)
|
||||||
|
//
|
||||||
|
// serverList := tview.NewList().ShowSecondaryText(false)
|
||||||
|
// serverList.SetBorder(true)
|
||||||
|
// serverList.SetTitle("Servers")
|
||||||
|
// serverList.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
||||||
|
// serverList.SetSelectedBackgroundColor(tcell.Color24).SetSelectedTextColor(tcell.Color255)
|
||||||
|
// serverList.SetHighlightFullLine(true)
|
||||||
|
//
|
||||||
|
// // Hint bar shown when search is hidden
|
||||||
|
// hintBar := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
// hintBar.SetBackgroundColor(tcell.Color233)
|
||||||
|
// hintBar.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • a Add • e Edit • d Delete • ? Help[-]")
|
||||||
|
//
|
||||||
|
// // Right panel: Details
|
||||||
|
// details := tview.NewTextView().SetDynamicColors(true).SetWrap(true)
|
||||||
|
// details.SetBorder(true).SetTitle("Details")
|
||||||
|
// details.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
||||||
|
//
|
||||||
|
// // Status bar
|
||||||
|
// status := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
// statusBg := tcell.Color235
|
||||||
|
// status.SetBackgroundColor(statusBg)
|
||||||
|
// status.SetTextAlign(tview.AlignCenter)
|
||||||
|
// status.SetText("[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]a[-] Add • [white]e[-] Edit • [white]d[-] Delete • [white]/[-] Search • [white]q[-] Quit • [white]?[-] Help")
|
||||||
|
//
|
||||||
|
// // Define updateDetails before populate; declare var for closure use
|
||||||
|
// var updateDetails func(server domain.Server)
|
||||||
|
// // Root container placeholder (assigned later)
|
||||||
|
// var root *tview.Flex
|
||||||
|
//
|
||||||
|
// // Populate list with filter logic
|
||||||
|
// filtered := servers
|
||||||
|
// populate := func() {
|
||||||
|
// serverList.Clear()
|
||||||
|
// for i := range filtered {
|
||||||
|
// p, s2 := formatServerLine(filtered[i])
|
||||||
|
// idx := i
|
||||||
|
// serverList.AddItem(p, s2, 0, func() {
|
||||||
|
// if updateDetails != nil {
|
||||||
|
// updateDetails(filtered[idx])
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// if serverList.GetItemCount() > 0 {
|
||||||
|
// serverList.SetCurrentItem(0)
|
||||||
|
// if updateDetails != nil {
|
||||||
|
// updateDetails(filtered[0])
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// details.SetText("No servers match the current filter.")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Will be defined later to refresh according to current search
|
||||||
|
// var reapplyFilter func()
|
||||||
|
//
|
||||||
|
// // Delete currently selected server from memory and refresh
|
||||||
|
// deleteSelected := func() {
|
||||||
|
// idx := serverList.GetCurrentItem()
|
||||||
|
// if idx < 0 || idx >= len(filtered) {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// sel := filtered[idx]
|
||||||
|
// // remove from servers slice
|
||||||
|
// newServers := make([]domain.Server, 0, len(servers))
|
||||||
|
// for _, s := range servers {
|
||||||
|
// if !(s.Alias == sel.Alias && s.Host == sel.Host) {
|
||||||
|
// newServers = append(newServers, s)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// servers = newServers
|
||||||
|
// reapplyFilter()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Now define updateDetails
|
||||||
|
// updateDetails = func(s domain.Server) {
|
||||||
|
// text := fmt.Sprintf("[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: [white]%s[-]\nStatus: %s\nLast: %s\n\n[::b]Commands:[-]\n Enter: SSH connect\n a: Add new server\n e: Edit entry\n d: Delete entry",
|
||||||
|
// s.Alias, s.Host, s.User, s.Port, s.Key, joinTags(s.Tags), statusIcon(s.Status), s.LastSeen.Format("2006-01-02 15:04"))
|
||||||
|
// details.SetText(text)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Helper to re-apply current filter and repopulate
|
||||||
|
// reapplyFilter = func() {
|
||||||
|
// q := strings.TrimSpace(strings.ToLower(search.GetText()))
|
||||||
|
// if q == "" {
|
||||||
|
// filtered = servers
|
||||||
|
// populate()
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// var out []domain.Server
|
||||||
|
// for _, s := range servers {
|
||||||
|
// hay := strings.ToLower(strings.Join([]string{
|
||||||
|
// s.Alias,
|
||||||
|
// s.Host,
|
||||||
|
// s.User,
|
||||||
|
// fmt.Sprint(s.Port),
|
||||||
|
// strings.Join(s.Tags, ","),
|
||||||
|
// s.Key,
|
||||||
|
// }, " "))
|
||||||
|
// if strings.Contains(hay, q) {
|
||||||
|
// out = append(out, s)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// filtered = out
|
||||||
|
// populate()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Add new server form
|
||||||
|
// addServer := func() {
|
||||||
|
// aliasField := tview.NewInputField().SetLabel("Alias: ")
|
||||||
|
// hostField := tview.NewInputField().SetLabel("Host/IP: ")
|
||||||
|
// userField := tview.NewInputField().SetLabel("User: ").SetText("root")
|
||||||
|
// portField := tview.NewInputField().SetLabel("Port: ").SetText("22")
|
||||||
|
// keyField := tview.NewInputField().SetLabel("Key: ").SetText("~/.ssh/id_ed25519")
|
||||||
|
// tagsField := tview.NewInputField().SetLabel("Tags (comma): ")
|
||||||
|
// statusDD := tview.NewDropDown().SetLabel("Status: ")
|
||||||
|
// statusOptions := []string{"online", "warn", "offline"}
|
||||||
|
// statusDD.SetOptions(statusOptions, nil)
|
||||||
|
// statusDD.SetCurrentOption(0)
|
||||||
|
//
|
||||||
|
// form := tview.NewForm().
|
||||||
|
// AddFormItem(aliasField).
|
||||||
|
// AddFormItem(hostField).
|
||||||
|
// AddFormItem(userField).
|
||||||
|
// AddFormItem(portField).
|
||||||
|
// AddFormItem(keyField).
|
||||||
|
// AddFormItem(tagsField).
|
||||||
|
// AddFormItem(statusDD)
|
||||||
|
// form.SetBorder(true).SetTitle("Add Server").SetTitleAlign(tview.AlignLeft)
|
||||||
|
// form.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
||||||
|
// form.AddButton("Save", func() {
|
||||||
|
// alias := strings.TrimSpace(aliasField.GetText())
|
||||||
|
// host := strings.TrimSpace(hostField.GetText())
|
||||||
|
// if alias == "" || host == "" {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// user := strings.TrimSpace(userField.GetText())
|
||||||
|
// p := 22
|
||||||
|
// if v := strings.TrimSpace(portField.GetText()); v != "" {
|
||||||
|
// if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
// p = n
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// key := strings.TrimSpace(keyField.GetText())
|
||||||
|
// tags := []string{}
|
||||||
|
// if ts := strings.TrimSpace(tagsField.GetText()); ts != "" {
|
||||||
|
// for _, t := range strings.Split(ts, ",") {
|
||||||
|
// if s := strings.TrimSpace(t); s != "" {
|
||||||
|
// tags = append(tags, s)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// _, status := statusDD.GetCurrentOption()
|
||||||
|
// newS := domain.Server{Alias: alias, Host: host, User: user, Port: p, Key: key, Tags: tags, Status: status, LastSeen: time.Now()}
|
||||||
|
// servers = append(servers, newS)
|
||||||
|
// sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
||||||
|
// reapplyFilter()
|
||||||
|
// // select the new item if it is visible
|
||||||
|
// for i := range filtered {
|
||||||
|
// if filtered[i].Alias == newS.Alias && filtered[i].Host == newS.Host {
|
||||||
|
// serverList.SetCurrentItem(i)
|
||||||
|
// updateDetails(filtered[i])
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// t.app.SetRoot(root, true)
|
||||||
|
// })
|
||||||
|
// form.AddButton("Cancel", func() { t.app.SetRoot(root, true) })
|
||||||
|
// form.SetCancelFunc(func() { t.app.SetRoot(root, true) })
|
||||||
|
// t.app.SetRoot(form, true)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Edit currently selected server
|
||||||
|
// editSelected := func() {
|
||||||
|
// idx := serverList.GetCurrentItem()
|
||||||
|
// if idx < 0 || idx >= len(filtered) {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// orig := filtered[idx]
|
||||||
|
// aliasField := tview.NewInputField().SetLabel("Alias: ").SetText(orig.Alias)
|
||||||
|
// hostField := tview.NewInputField().SetLabel("Host/IP: ").SetText(orig.Host)
|
||||||
|
// userField := tview.NewInputField().SetLabel("User: ").SetText(orig.User)
|
||||||
|
// portField := tview.NewInputField().SetLabel("Port: ").SetText(fmt.Sprint(orig.Port))
|
||||||
|
// keyField := tview.NewInputField().SetLabel("Key: ").SetText(orig.Key)
|
||||||
|
// tagsField := tview.NewInputField().SetLabel("Tags (comma): ").SetText(strings.Join(orig.Tags, ", "))
|
||||||
|
// statusDD := tview.NewDropDown().SetLabel("Status: ")
|
||||||
|
// statusOptions := []string{"online", "warn", "offline"}
|
||||||
|
// statusDD.SetOptions(statusOptions, nil)
|
||||||
|
// // set current option to existing status
|
||||||
|
// curIdx := 0
|
||||||
|
// for i, o := range statusOptions {
|
||||||
|
// if o == orig.Status {
|
||||||
|
// curIdx = i
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// statusDD.SetCurrentOption(curIdx)
|
||||||
|
//
|
||||||
|
// form := tview.NewForm().
|
||||||
|
// AddFormItem(aliasField).
|
||||||
|
// AddFormItem(hostField).
|
||||||
|
// AddFormItem(userField).
|
||||||
|
// AddFormItem(portField).
|
||||||
|
// AddFormItem(keyField).
|
||||||
|
// AddFormItem(tagsField).
|
||||||
|
// AddFormItem(statusDD)
|
||||||
|
// form.SetBorder(true).SetTitle("Edit Server").SetTitleAlign(tview.AlignLeft)
|
||||||
|
// form.SetBorderColor(tcell.Color238).SetTitleColor(tcell.Color250)
|
||||||
|
// form.AddButton("Save", func() {
|
||||||
|
// alias := strings.TrimSpace(aliasField.GetText())
|
||||||
|
// host := strings.TrimSpace(hostField.GetText())
|
||||||
|
// if alias == "" || host == "" {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// user := strings.TrimSpace(userField.GetText())
|
||||||
|
// p := orig.Port
|
||||||
|
// if v := strings.TrimSpace(portField.GetText()); v != "" {
|
||||||
|
// if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
// p = n
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// key := strings.TrimSpace(keyField.GetText())
|
||||||
|
// tags := []string{}
|
||||||
|
// if ts := strings.TrimSpace(tagsField.GetText()); ts != "" {
|
||||||
|
// for _, t := range strings.Split(ts, ",") {
|
||||||
|
// if s := strings.TrimSpace(t); s != "" {
|
||||||
|
// tags = append(tags, s)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// _, status := statusDD.GetCurrentOption()
|
||||||
|
// updated := domain.Server{Alias: alias, Host: host, User: user, Port: p, Key: key, Tags: tags, Status: status, LastSeen: orig.LastSeen}
|
||||||
|
// // replace matching item in servers by original key alias+host
|
||||||
|
// found := false
|
||||||
|
// for i := range servers {
|
||||||
|
// if servers[i].Alias == orig.Alias && servers[i].Host == orig.Host {
|
||||||
|
// servers[i] = updated
|
||||||
|
// found = true
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if !found {
|
||||||
|
// servers = append(servers, updated)
|
||||||
|
// }
|
||||||
|
// sort.Slice(servers, func(i, j int) bool { return servers[i].Alias < servers[j].Alias })
|
||||||
|
// reapplyFilter()
|
||||||
|
// // select the updated item if visible
|
||||||
|
// for i := range filtered {
|
||||||
|
// if filtered[i].Alias == updated.Alias && filtered[i].Host == updated.Host {
|
||||||
|
// serverList.SetCurrentItem(i)
|
||||||
|
// updateDetails(filtered[i])
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// t.app.SetRoot(root, true)
|
||||||
|
// })
|
||||||
|
// form.AddButton("Cancel", func() { t.app.SetRoot(root, true) })
|
||||||
|
// form.SetCancelFunc(func() { t.app.SetRoot(root, true) })
|
||||||
|
// t.app.SetRoot(form, true)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// filter := func(q string) {
|
||||||
|
// q = strings.TrimSpace(strings.ToLower(q))
|
||||||
|
// if q == "" {
|
||||||
|
// filtered = servers
|
||||||
|
// populate()
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// var out []domain.Server
|
||||||
|
// for _, s := range servers {
|
||||||
|
// hay := strings.ToLower(strings.Join([]string{
|
||||||
|
// s.Alias,
|
||||||
|
// s.Host,
|
||||||
|
// s.User,
|
||||||
|
// fmt.Sprint(s.Port),
|
||||||
|
// strings.Join(s.Tags, ","),
|
||||||
|
// s.Key,
|
||||||
|
// }, " "))
|
||||||
|
// if strings.Contains(hay, q) {
|
||||||
|
// out = append(out, s)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// filtered = out
|
||||||
|
// populate()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Declare togglers; define after left is created
|
||||||
|
// var showSearchBar func()
|
||||||
|
// var hideSearchBar func()
|
||||||
|
//
|
||||||
|
// search.SetChangedFunc(func(text string) { filter(text) })
|
||||||
|
// search.SetDoneFunc(func(key tcell.Key) {
|
||||||
|
// // Return focus to list on Escape or Enter and hide the search bar
|
||||||
|
// if key == tcell.KeyEsc || key == tcell.KeyEnter {
|
||||||
|
// if hideSearchBar != nil {
|
||||||
|
// hideSearchBar()
|
||||||
|
// } else {
|
||||||
|
// t.app.SetFocus(serverList)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// // Initial populate
|
||||||
|
// populate()
|
||||||
|
//
|
||||||
|
// // Layout main content area
|
||||||
|
// var left *tview.Flex
|
||||||
|
// left = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
// AddItem(hintBar, 1, 0, false).
|
||||||
|
// AddItem(serverList, 0, 1, true)
|
||||||
|
//
|
||||||
|
// right := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
// AddItem(details, 0, 1, false)
|
||||||
|
//
|
||||||
|
// content := tview.NewFlex().SetDirection(tview.FlexColumn).
|
||||||
|
// AddItem(left, 0, 3, true).
|
||||||
|
// AddItem(right, 0, 2, false)
|
||||||
|
//
|
||||||
|
// // Define togglers now that 'left' exists
|
||||||
|
// showSearchBar = func() {
|
||||||
|
// left.Clear()
|
||||||
|
// left.AddItem(search, 3, 0, true)
|
||||||
|
// left.AddItem(serverList, 0, 1, false)
|
||||||
|
// t.app.SetFocus(search)
|
||||||
|
// }
|
||||||
|
// hideSearchBar = func() {
|
||||||
|
// left.Clear()
|
||||||
|
// left.AddItem(hintBar, 1, 0, false)
|
||||||
|
// left.AddItem(serverList, 0, 1, true)
|
||||||
|
// t.app.SetFocus(serverList)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Root layout: header, content, status
|
||||||
|
// root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
// AddItem(header, 2, 0, false).
|
||||||
|
// AddItem(content, 0, 1, true).
|
||||||
|
// AddItem(status, 1, 0, false)
|
||||||
|
//
|
||||||
|
// // Keybindings
|
||||||
|
// root.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
// // If the search input has focus, do not trigger global shortcuts.
|
||||||
|
// if t.app.GetFocus() == search {
|
||||||
|
// return event
|
||||||
|
// }
|
||||||
|
// switch event.Rune() {
|
||||||
|
// case 'q':
|
||||||
|
// t.app.Stop()
|
||||||
|
// return nil
|
||||||
|
// case '/':
|
||||||
|
// if showSearchBar != nil {
|
||||||
|
// showSearchBar()
|
||||||
|
// } else {
|
||||||
|
// t.app.SetFocus(search)
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
// case 'a':
|
||||||
|
// addServer()
|
||||||
|
// return nil
|
||||||
|
// case 'e':
|
||||||
|
// editSelected()
|
||||||
|
// return nil
|
||||||
|
// case 'd':
|
||||||
|
// deleteSelected()
|
||||||
|
// return nil
|
||||||
|
// case '?':
|
||||||
|
// showHelpModal(t.app, root)
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// // Handle Enter at root level as connect for current selection
|
||||||
|
// if event.Key() == tcell.KeyEnter {
|
||||||
|
// idx := serverList.GetCurrentItem()
|
||||||
|
// if idx >= 0 && idx < len(filtered) {
|
||||||
|
// showConnectModal(t.app, root, filtered[idx])
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return event
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// // Also handle list-selected changes to update details
|
||||||
|
// serverList.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||||
|
// if index >= 0 && index < len(filtered) {
|
||||||
|
// updateDetails(filtered[index])
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// // Always show the splash screen for 1 second on startup
|
||||||
|
// splash, stop := buildSplash(t.app)
|
||||||
|
// t.app.SetRoot(splash, true)
|
||||||
|
// time.AfterFunc(1*time.Second, func() {
|
||||||
|
// stop()
|
||||||
|
// t.app.QueueUpdateDraw(func() {
|
||||||
|
// t.app.SetRoot(root, true)
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
// t.app.EnableMouse(true)
|
||||||
|
// if err := t.app.Run(); err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func statusIcon(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "online":
|
||||||
|
return "🟢"
|
||||||
|
case "warn":
|
||||||
|
return "🟡"
|
||||||
|
case "offline":
|
||||||
|
return "🔴"
|
||||||
|
default:
|
||||||
|
return "⚪"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinTags(tags []string) string {
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return strings.Join(tags, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatServerLine(s domain.Server) (primary, secondary string) {
|
||||||
|
icon := statusIcon(s.Status)
|
||||||
|
// Choose a color per status for the alias and a subtle gray for host/time
|
||||||
|
statusColor := "white"
|
||||||
|
switch s.Status {
|
||||||
|
case "online":
|
||||||
|
statusColor = "green"
|
||||||
|
case "warn":
|
||||||
|
statusColor = "yellow"
|
||||||
|
case "offline":
|
||||||
|
statusColor = "red"
|
||||||
|
}
|
||||||
|
primary = fmt.Sprintf("%s [%s::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last:%s[-]", icon, statusColor, s.Alias, s.Host, humanizeDuration(time.Since(s.LastSeen)))
|
||||||
|
secondary = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanizeDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%dh%dm ago", h, m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm ago", m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Alias string
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Port int
|
||||||
|
Key string
|
||||||
|
Tags []string
|
||||||
|
Status string
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ports
|
||||||
|
|
||||||
|
import "github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
|
||||||
|
type ServerRepository interface {
|
||||||
|
ListServers(query string) ([]domain.Server, error)
|
||||||
|
UpdateServer(server domain.Server, newServer domain.Server) error
|
||||||
|
AddServer(server domain.Server) error
|
||||||
|
DeleteServer(server domain.Server) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ports
|
||||||
|
|
||||||
|
import "github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
|
||||||
|
type ServerService interface {
|
||||||
|
ListServers(query string) ([]domain.Server, error)
|
||||||
|
UpdateServer(server domain.Server, newServer domain.Server) error
|
||||||
|
AddServer(server domain.Server) error
|
||||||
|
DeleteServer(server domain.Server) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"github.com/Adembc/lazyssh/internal/core/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serverService struct {
|
||||||
|
serverRepository ports.ServerRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerService creates a new instance of serverService.
|
||||||
|
func NewServerService(sr ports.ServerRepository) *serverService {
|
||||||
|
return &serverService{
|
||||||
|
serverRepository: sr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServers returns a list of servers.
|
||||||
|
func (s *serverService) ListServers(query string) ([]domain.Server, error) {
|
||||||
|
|
||||||
|
// do any relevant business logic here if needed
|
||||||
|
servers, err := s.serverRepository.ListServers(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServer updates an existing server with new details.
|
||||||
|
func (s *serverService) UpdateServer(server domain.Server, newServer domain.Server) error {
|
||||||
|
return s.serverRepository.UpdateServer(server, newServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddServer adds a new server to the repository.
|
||||||
|
func (s *serverService) AddServer(server domain.Server) error {
|
||||||
|
return s.serverRepository.AddServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteServer removes a server from the repository.
|
||||||
|
func (s *serverService) DeleteServer(server domain.Server) error {
|
||||||
|
return s.serverRepository.DeleteServer(server)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user