add pin and sort feature

This commit is contained in:
Adem Baccara
2025-08-21 08:42:20 +01:00
parent ce14e81140
commit adc843f1cf
16 changed files with 499 additions and 97 deletions
+2 -1
View File
@@ -3,7 +3,9 @@ module github.com/Adembc/lazyssh
go 1.24.4 go 1.24.4
require ( require (
github.com/atotto/clipboard v0.1.4
github.com/gdamore/tcell/v2 v2.8.1 github.com/gdamore/tcell/v2 v2.8.1
github.com/mattn/go-runewidth v0.0.16
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
@@ -13,7 +15,6 @@ require (
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/pflag v1.0.7 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -37,8 +37,8 @@ const (
type ServerMetadata struct { type ServerMetadata struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
LastSeen string `json:"last_seen,omitempty"` LastSeen string `json:"last_seen,omitempty"`
Favorite bool `json:"favorite,omitempty"` PinnedAt string `json:"pinned_at,omitempty"`
Notes string `json:"notes,omitempty"` SSHCount int `json:"ssh_count,omitempty"`
} }
type serverRepo struct { type serverRepo struct {
@@ -65,16 +65,21 @@ func (s serverRepo) ListServers(query string) ([]domain.Server, error) {
// Merge metadata with servers // Merge metadata with servers
for i, server := range servers { for i, server := range servers {
servers[i].Status = "online"
servers[i].LastSeen = time.Time{} servers[i].LastSeen = time.Time{}
if meta, exists := metadata[server.Alias]; exists { if meta, exists := metadata[server.Alias]; exists {
servers[i].Tags = meta.Tags servers[i].Tags = meta.Tags
servers[i].SSHCount = meta.SSHCount
if meta.LastSeen != "" { if meta.LastSeen != "" {
if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil { if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil {
servers[i].LastSeen = lastSeen servers[i].LastSeen = lastSeen
} }
} }
if meta.PinnedAt != "" {
if pinnedAt, err := time.Parse(time.RFC3339, meta.PinnedAt); err == nil {
servers[i].PinnedAt = pinnedAt
}
}
} }
} }
@@ -87,6 +92,13 @@ func (s serverRepo) ListServers(query string) ([]domain.Server, error) {
strings.Contains(strings.ToLower(server.Host), queryLower) || strings.Contains(strings.ToLower(server.Host), queryLower) ||
strings.Contains(strings.ToLower(server.User), queryLower) { strings.Contains(strings.ToLower(server.User), queryLower) {
filtered = append(filtered, server) filtered = append(filtered, server)
continue
}
for _, tag := range server.Tags {
if strings.Contains(strings.ToLower(tag), queryLower) {
filtered = append(filtered, server)
break
}
} }
} }
return filtered, nil return filtered, nil
@@ -249,7 +261,6 @@ func (s serverRepo) parseSSHConfig() ([]domain.Server, error) {
} }
if currentServer != nil { if currentServer != nil {
servers = append(servers, *currentServer) servers = append(servers, *currentServer)
} }
@@ -334,6 +345,11 @@ func (s serverRepo) updateMetadata(server domain.Server) error {
LastSeen: server.LastSeen.Format(time.RFC3339), LastSeen: server.LastSeen.Format(time.RFC3339),
} }
// Only set PinnedAt if it is not zero
if !server.PinnedAt.IsZero() {
serverMeta.PinnedAt = server.PinnedAt.Format(time.RFC3339)
}
metadata[server.Alias] = serverMeta metadata[server.Alias] = serverMeta
return s.saveMetadata(metadata) return s.saveMetadata(metadata)
@@ -363,3 +379,32 @@ func (s serverRepo) saveMetadata(metadata map[string]ServerMetadata) error {
return os.WriteFile(s.metaDataFilePath, data, 0o600) return os.WriteFile(s.metaDataFilePath, data, 0o600)
} }
// SetPinned updates the pinned state for a server alias by writing pinned_at to metadata.
func (s serverRepo) SetPinned(alias string, pinned bool) error {
metadata, err := s.loadMetadata()
if err != nil {
metadata = make(map[string]ServerMetadata)
}
m := metadata[alias]
if pinned {
m.PinnedAt = time.Now().Format(time.RFC3339)
} else {
m.PinnedAt = ""
}
metadata[alias] = m
return s.saveMetadata(metadata)
}
// RecordSSH updates last_seen and increments ssh_count for an alias after successful SSH.
func (s serverRepo) RecordSSH(alias string) error {
metadata, err := s.loadMetadata()
if err != nil {
metadata = make(map[string]ServerMetadata)
}
m := metadata[alias]
m.LastSeen = time.Now().Format(time.RFC3339)
m.SSHCount++
metadata[alias] = m
return s.saveMetadata(metadata)
}
@@ -29,14 +29,14 @@ type serverRepository struct {
} }
var servers = []domain.Server{ 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-01", Host: "192.168.1.10", User: "root", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "web"}, 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: "web-02", Host: "192.168.1.11", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "web"}, 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: "db-01", Host: "192.168.1.20", User: "postgres", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "db"}, 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: "api-01", Host: "192.168.1.30", User: "deploy", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "api"}, 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: "cache-01", Host: "192.168.1.40", User: "redis", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "cache"}, 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-web", Host: "10.0.1.10", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "web"}, 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: "dev-db", Host: "10.0.1.20", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "db"}, 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)}, {Alias: "staging", Host: "staging.example.com", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"test"}, LastSeen: time.Now().Add(-45 * time.Minute)},
} }
// NewServerRepository creates a new server repository with the given file path. // NewServerRepository creates a new server repository with the given file path.
@@ -58,11 +58,10 @@ func (r *serverRepository) ListServers(query string) ([]domain.Server, error) {
alias := strings.ToLower(server.Alias) alias := strings.ToLower(server.Alias)
host := strings.ToLower(server.Host) host := strings.ToLower(server.Host)
user := strings.ToLower(server.User) user := strings.ToLower(server.User)
status := strings.ToLower(server.Status)
port := strconv.Itoa(server.Port) port := strconv.Itoa(server.Port)
match := false match := false
if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(status, q) || strings.Contains(port, q) { if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(port, q) {
match = true match = true
} }
if !match { if !match {
@@ -107,3 +106,27 @@ func (r *serverRepository) DeleteServer(server domain.Server) error {
} }
return nil return nil
} }
func (r *serverRepository) SetPinned(alias string, pinned bool) error {
for i, s := range servers {
if s.Alias == alias {
if pinned {
servers[i].PinnedAt = time.Now()
} else {
servers[i].PinnedAt = time.Time{}
}
return nil
}
}
return nil
}
func (r *serverRepository) RecordSSH(alias string) error {
for i, s := range servers {
if s.Alias == alias {
servers[i].LastSeen = time.Now()
return nil
}
}
return nil
}
+110 -10
View File
@@ -16,7 +16,11 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"time"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/atotto/clipboard"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@@ -47,6 +51,43 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 'd': case 'd':
t.handleServerDelete() t.handleServerDelete()
return nil return nil
case 'p':
if server, ok := t.serverList.GetSelectedServer(); ok {
pinned := server.PinnedAt.IsZero()
_ = t.serverService.SetPinned(server.Alias, pinned)
t.refreshServerList()
}
return nil
case 's':
t.sortMode = t.sortMode.ToggleField()
t.showStatusTemp("Sort: " + t.sortMode.String())
t.updateListTitle()
t.refreshServerList()
return nil
case 'S':
t.sortMode = t.sortMode.Reverse()
t.showStatusTemp("Sort: " + t.sortMode.String())
t.updateListTitle()
t.refreshServerList()
return nil
case 'c':
if server, ok := t.serverList.GetSelectedServer(); ok {
cmd := BuildSSHCommand(server)
if err := clipboard.WriteAll(cmd); err == nil {
t.showStatusTemp("Copied: " + cmd)
} else {
t.showStatusTemp("Failed to copy to clipboard")
}
}
return nil
case 't':
if server, ok := t.serverList.GetSelectedServer(); ok {
// Quick edit tags for current server
t.showEditTagsForm(server)
}
return nil
case '?': case '?':
t.handleHelpShow() t.handleHelpShow()
return nil return nil
@@ -62,6 +103,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
func (t *tui) handleSearchInput(query string) { func (t *tui) handleSearchInput(query string) {
filtered, _ := t.serverService.ListServers(query) filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
t.serverList.UpdateServers(filtered) t.serverList.UpdateServers(filtered)
if len(filtered) == 0 { if len(filtered) == 0 {
t.details.ShowEmpty() t.details.ShowEmpty()
@@ -75,7 +117,6 @@ func (t *tui) handleSearchToggle() {
func (t *tui) handleServerConnect() { func (t *tui) handleServerConnect() {
if server, ok := t.serverList.GetSelectedServer(); ok { if server, ok := t.serverList.GetSelectedServer(); ok {
t.showConnectModal(server) t.showConnectModal(server)
} }
} }
@@ -148,13 +189,15 @@ func (t *tui) showConnectModal(server domain.Server) {
modal := tview.NewModal(). modal := tview.NewModal().
SetText(msg). SetText(msg).
AddButtons([]string{"Cancel", "Confirm"}). AddButtons([]string{"Confirm", "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) { SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonIndex == 1 { if buttonIndex == 0 {
// Suspend the TUI while running the external ssh command. // Suspend the TUI while running the external ssh command.
t.app.Suspend(func() { t.app.Suspend(func() {
_ = t.serverService.SSH(server.Alias) _ = t.serverService.SSH(server.Alias)
}) })
// Refresh to reflect updated last seen and ssh count
t.refreshServerList()
} }
t.handleModalClose() t.handleModalClose()
}) })
@@ -180,16 +223,55 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) {
t.app.SetRoot(modal, true) t.app.SetRoot(modal, true)
} }
func (t *tui) showEditTagsForm(server domain.Server) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(fmt.Sprintf("Edit Tags: %s", server.Alias)).
SetTitleAlign(tview.AlignLeft)
defaultTags := strings.Join(server.Tags, ", ")
form.AddInputField("Tags (comma):", defaultTags, 40, nil, nil)
form.AddButton("Save", func() {
text := strings.TrimSpace(form.GetFormItem(0).(*tview.InputField).GetText())
var tags []string
if text != "" {
for _, part := range strings.Split(text, ",") {
if s := strings.TrimSpace(part); s != "" {
tags = append(tags, s)
}
}
}
newServer := server
newServer.Tags = tags
_ = t.serverService.UpdateServer(server, newServer)
// Refresh UI and go back
t.refreshServerList()
t.returnToMain()
t.showStatusTemp("Tags updated")
})
form.AddButton("Cancel", func() { t.returnToMain() })
form.SetCancelFunc(func() { t.returnToMain() })
t.app.SetRoot(form, true)
t.app.SetFocus(form)
}
func (t *tui) showHelpModal() { func (t *tui) showHelpModal() {
text := "Keyboard shortcuts:\n\n" + text := "Keyboard shortcuts:\n\n" +
" ↑/↓ Navigate\n" + " ↑/↓ Navigate\n" +
" Enter SSH connect \n" + " Enter SSH connect \n" +
" a Add server \n" + " c Copy SSH command \n" +
" e Edit server \n" + " a Add server \n" +
" d Delete entry \n" + " e Edit server \n" +
" / Focus search\n" + " t Edit tags (quick)\n" +
"q Quit\n" + " d Delete entry \n" +
"? Help\n" " p Pin/Unpin server \n" +
" s Sort field (Alias / Last SSH)\n" +
" Shift+S Reverse order (↑/↓)\n" +
" / Focus search\n" +
" q Quit\n" +
" ? Help\n"
modal := tview.NewModal(). modal := tview.NewModal().
SetText(text). SetText(text).
@@ -223,9 +305,27 @@ func (t *tui) refreshServerList() {
query = t.searchBar.InputField.GetText() query = t.searchBar.InputField.GetText()
} }
filtered, _ := t.serverService.ListServers(query) filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
t.serverList.UpdateServers(filtered) t.serverList.UpdateServers(filtered)
} }
func (t *tui) returnToMain() { func (t *tui) returnToMain() {
t.app.SetRoot(t.root, true) t.app.SetRoot(t.root, true)
} }
// showStatusTemp displays a temporary message in the status bar and then restores the default text.
func (t *tui) showStatusTemp(msg string) {
if t.statusBar == nil {
return
}
t.statusBar.SetText("[#A0FFA0]" + msg + "[-]")
time.AfterFunc(2*time.Second, func() {
if t.app != nil {
t.app.QueueUpdateDraw(func() {
if t.statusBar != nil {
t.statusBar.SetText(DefaultStatusText())
}
})
}
})
}
+1 -1
View File
@@ -22,6 +22,6 @@ import (
func NewHintBar() *tview.TextView { func NewHintBar() *tview.TextView {
hint := tview.NewTextView().SetDynamicColors(true) hint := tview.NewTextView().SetDynamicColors(true)
hint.SetBackgroundColor(tcell.Color233) hint.SetBackgroundColor(tcell.Color233)
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • a Add • e Edit • d Delete • ? Help[-]") hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort • ? Help[-]")
return hint return hint
} }
+23 -4
View File
@@ -16,6 +16,8 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -42,8 +44,20 @@ func (sd *ServerDetails) build() {
SetTitleColor(tcell.Color250) SetTitleColor(tcell.Color250)
} }
func (sd *ServerDetails) UpdateServer(server domain.Server) { // renderTagChips builds colored tag chips for details view.
func renderTagChips(tags []string) string {
if len(tags) == 0 {
return "-"
}
chips := make([]string, 0, len(tags))
for _, t := range tags {
// Foreground black on a bluish background to resemble a tag/chip.
chips = append(chips, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t))
}
return strings.Join(chips, " ")
}
func (sd *ServerDetails) UpdateServer(server domain.Server) {
lastSeen := server.LastSeen.Format("2006-01-02 15:04:05") lastSeen := server.LastSeen.Format("2006-01-02 15:04:05")
if server.LastSeen.IsZero() { if server.LastSeen.IsZero() {
lastSeen = "Never" lastSeen = "Never"
@@ -52,11 +66,16 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
if serverKey == "" { if serverKey == "" {
serverKey = "(default: ~/.ssh/id_{rsa,ed25519,ecdsa})" serverKey = "(default: ~/.ssh/id_{rsa,ed25519,ecdsa})"
} }
pinnedStr := "true"
if server.PinnedAt.IsZero() {
pinnedStr = "false"
}
tagsText := renderTagChips(server.Tags)
text := fmt.Sprintf( text := fmt.Sprintf(
"[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: [white]%s[-]\nStatus: %s\nLast SSH: %s\n\n[::b]Commands:[-]\n Enter: SSH connect\n a: Add new server\n e: Edit entry\n d: Delete entry", "[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin",
server.Alias, server.Host, server.User, server.Port, server.Alias, server.Host, server.User, server.Port,
serverKey, joinTags(server.Tags), statusIcon(server.Status), serverKey, tagsText, pinnedStr,
lastSeen) lastSeen, server.SSHCount)
sd.TextView.SetText(text) sd.TextView.SetText(text)
} }
+21 -38
View File
@@ -73,20 +73,18 @@ func (sf *ServerForm) addFormFields() {
var defaultValues ServerFormData var defaultValues ServerFormData
if sf.mode == ServerFormEdit && sf.original != nil { if sf.mode == ServerFormEdit && sf.original != nil {
defaultValues = ServerFormData{ defaultValues = ServerFormData{
Alias: sf.original.Alias, Alias: sf.original.Alias,
Host: sf.original.Host, Host: sf.original.Host,
User: sf.original.User, User: sf.original.User,
Port: fmt.Sprint(sf.original.Port), Port: fmt.Sprint(sf.original.Port),
Key: sf.original.Key, Key: sf.original.Key,
Tags: strings.Join(sf.original.Tags, ", "), Tags: strings.Join(sf.original.Tags, ", "),
Status: sf.original.Status,
} }
} else { } else {
defaultValues = ServerFormData{ defaultValues = ServerFormData{
User: "root", User: "root",
Port: "22", Port: "22",
Key: "~/.ssh/id_ed25519", Key: "~/.ssh/id_ed25519",
Status: "online",
} }
} }
@@ -96,28 +94,15 @@ func (sf *ServerForm) addFormFields() {
sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil) sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil)
sf.Form.AddInputField("Key:", defaultValues.Key, 40, nil, nil) sf.Form.AddInputField("Key:", defaultValues.Key, 40, nil, nil)
sf.Form.AddInputField("Tags (comma):", defaultValues.Tags, 30, nil, nil) sf.Form.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.Form.AddFormItem(statusDD)
} }
type ServerFormData struct { type ServerFormData struct {
Alias string Alias string
Host string Host string
User string User string
Port string Port string
Key string Key string
Tags string Tags string
Status string
} }
func (sf *ServerForm) getFormData() ServerFormData { func (sf *ServerForm) getFormData() ServerFormData {
@@ -169,15 +154,13 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server {
} }
} }
_, status := sf.Form.GetFormItem(6).(*tview.DropDown).GetCurrentOption()
return domain.Server{ return domain.Server{
Alias: data.Alias, Alias: data.Alias,
Host: data.Host, Host: data.Host,
User: data.User, User: data.User,
Port: port, Port: port,
Key: data.Key, Key: data.Key,
Tags: tags, Tags: tags,
Status: status,
} }
} }
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"sort"
"strings"
"github.com/Adembc/lazyssh/internal/core/domain"
)
// SortMode controls how unpinned servers are ordered in the UI.
type SortMode int
const (
SortByAliasAsc SortMode = iota
SortByAliasDesc
SortByLastSeenDesc
SortByLastSeenAsc
)
func (m SortMode) String() string {
switch m {
case SortByAliasAsc:
return "Alias ↑"
case SortByAliasDesc:
return "Alias ↓"
case SortByLastSeenAsc:
return "Last SSH ↑"
case SortByLastSeenDesc:
return "Last SSH ↓"
default:
return "Alias ↑"
}
}
// ToggleField switches between Alias and LastSeen while preserving direction.
func (m SortMode) ToggleField() SortMode {
switch m {
case SortByAliasAsc:
return SortByLastSeenAsc
case SortByAliasDesc:
return SortByLastSeenDesc
case SortByLastSeenAsc:
return SortByAliasAsc
case SortByLastSeenDesc:
return SortByAliasDesc
default:
return SortByAliasAsc
}
}
// Reverse flips the direction within the current field.
func (m SortMode) Reverse() SortMode {
switch m {
case SortByAliasAsc:
return SortByAliasDesc
case SortByAliasDesc:
return SortByAliasAsc
case SortByLastSeenAsc:
return SortByLastSeenDesc
case SortByLastSeenDesc:
return SortByLastSeenAsc
default:
return SortByAliasAsc
}
}
// sortServersForUI sorts servers according to the rules required by the UI.
// Pinned servers are always at the top, ordered by pinned date (newest first).
// Unpinned servers are sorted by the selected mode. "Never" (zero time) goes to
// the bottom when sorting by last seen asc/desc accordingly. Ties break by Alias asc.
func sortServersForUI(servers []domain.Server, mode SortMode) {
sort.SliceStable(servers, func(i, j int) bool {
si, sj := servers[i], servers[j]
pi, pj := !si.PinnedAt.IsZero(), !sj.PinnedAt.IsZero()
if pi != pj {
return pi
}
if pi && pj { // both pinned: newer pinned first, tie-break alias
if !si.PinnedAt.Equal(sj.PinnedAt) {
return si.PinnedAt.After(sj.PinnedAt)
}
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
}
// both unpinned
switch mode {
case SortByLastSeenDesc, SortByLastSeenAsc:
zi := si.LastSeen.IsZero()
zj := sj.LastSeen.IsZero()
if zi != zj {
// when sorting by last seen, entries with zero (never) should be bottom in either direction
return !zi // non-zero first
}
if !zi && !zj && !si.LastSeen.Equal(sj.LastSeen) {
if mode == SortByLastSeenDesc {
return si.LastSeen.After(sj.LastSeen)
}
return si.LastSeen.Before(sj.LastSeen)
}
// tie-break by alias asc
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
case SortByAliasAsc:
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
case SortByAliasDesc:
ai := strings.ToLower(si.Alias)
aj := strings.ToLower(sj.Alias)
if ai != aj {
return ai > aj
}
return false
default:
return strings.ToLower(si.Alias) < strings.ToLower(sj.Alias)
}
})
}
+5 -1
View File
@@ -19,10 +19,14 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
) )
func DefaultStatusText() string {
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit • [white]?[-] Help"
}
func NewStatusBar() *tview.TextView { func NewStatusBar() *tview.TextView {
status := tview.NewTextView().SetDynamicColors(true) status := tview.NewTextView().SetDynamicColors(true)
status.SetBackgroundColor(tcell.Color235) status.SetBackgroundColor(tcell.Color235)
status.SetTextAlign(tview.AlignCenter) 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") status.SetText(DefaultStatusText())
return status return status
} }
+13
View File
@@ -45,6 +45,7 @@ type tui struct {
left *tview.Flex left *tview.Flex
content *tview.Flex content *tview.Flex
sortMode SortMode
searchVisible bool searchVisible bool
} }
@@ -97,6 +98,10 @@ func (t *tui) buildComponents() *tui {
OnSelectionChange(t.handleServerSelectionChange) OnSelectionChange(t.handleServerSelectionChange)
t.details = NewServerDetails() t.details = NewServerDetails()
t.statusBar = NewStatusBar() t.statusBar = NewStatusBar()
// default sort mode
t.sortMode = SortByAliasAsc
return t return t
} }
@@ -126,11 +131,19 @@ func (t *tui) bindEvents() *tui {
func (t *tui) loadInitialData() *tui { func (t *tui) loadInitialData() *tui {
servers, _ := t.serverService.ListServers("") servers, _ := t.serverService.ListServers("")
sortServersForUI(servers, t.sortMode)
t.updateListTitle()
t.serverList.UpdateServers(servers) t.serverList.UpdateServers(servers)
return t return t
} }
func (t *tui) updateListTitle() {
if t.serverList != nil {
t.serverList.SetTitle("Servers — Sort: " + t.sortMode.String())
}
}
func (t *tui) loadSplashScreen() *tui { func (t *tui) loadSplashScreen() *tui {
splash, stop := buildSplash(t.app) splash, stop := buildSplash(t.app)
t.app.SetRoot(splash, true) t.app.SetRoot(splash, true)
+76 -25
View File
@@ -20,21 +20,9 @@ import (
"time" "time"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/mattn/go-runewidth"
) )
func statusIcon(s string) string {
switch s {
case "online":
return "🟢"
case "warn":
return "🟡"
case "offline":
return "🔴"
default:
return "⚪"
}
}
func joinTags(tags []string) string { func joinTags(tags []string) string {
if len(tags) == 0 { if len(tags) == 0 {
return "-" return "-"
@@ -42,19 +30,51 @@ func joinTags(tags []string) string {
return strings.Join(tags, ",") return strings.Join(tags, ",")
} }
func formatServerLine(s domain.Server) (primary, secondary string) { // renderTagBadgesForList renders up to two colored tag chips for the server list.
icon := statusIcon(s.Status) // If there are more tags, it appends a subtle gray "+N" badge. Returns an empty
// Choose a color per status for the alias and a subtle gray for host/time // string when there are no tags to avoid cluttering the list.
statusColor := "white" func renderTagBadgesForList(tags []string) string {
switch s.Status { if len(tags) == 0 {
case "online": return ""
statusColor = "green"
case "warn":
statusColor = "yellow"
case "offline":
statusColor = "red"
} }
primary = fmt.Sprintf("%s [%s::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-]", icon, statusColor, s.Alias, s.Host, humanizeDuration(s.LastSeen)) max := 2
shown := tags
if len(tags) > max {
shown = tags[:max]
}
parts := make([]string, 0, len(shown)+1)
for _, t := range shown {
// Light blue background chip, similar to details view.
parts = append(parts, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t))
}
if extra := len(tags) - len(shown); extra > 0 {
parts = append(parts, fmt.Sprintf("[#8A8A8A]+%d[-]", extra))
}
return strings.Join(parts, " ")
}
// cellPad pads a string with spaces so its display width is at least `width` cells.
// This keeps emoji-based icons from breaking alignment in tview.
func cellPad(s string, width int) string {
w := runewidth.StringWidth(s)
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
func pinnedIcon(pinnedAt time.Time) string {
// Use emojis for a nicer UI; combined with cellPad to keep widths consistent in tview.
if pinnedAt.IsZero() {
return "📡" // not pinned
}
return "📌" // pinned
}
func formatServerLine(s domain.Server) (primary, secondary string) {
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
// Use a consistent color for alias; the icon reflects pinning
primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
secondary = "" secondary = ""
return return
} }
@@ -74,3 +94,34 @@ func humanizeDuration(t time.Time) string {
} }
return fmt.Sprintf("%dm ago", m) return fmt.Sprintf("%dm ago", m)
} }
// BuildSSHCommand constructs a ready-to-run ssh command for the given server.
// Format: ssh [user@]host [-p PORT if not 22] [-i KEY if provided]
func BuildSSHCommand(s domain.Server) string {
parts := []string{"ssh"}
userHost := ""
if s.User != "" && s.Host != "" {
userHost = fmt.Sprintf("%s@%s", s.User, s.Host)
} else if s.Host != "" {
userHost = s.Host
} else {
userHost = s.Alias
}
parts = append(parts, userHost)
if s.Port != 0 && s.Port != 22 {
parts = append(parts, "-p", fmt.Sprintf("%d", s.Port))
}
if s.Key != "" {
parts = append(parts, "-i", quoteIfNeeded(s.Key))
}
return strings.Join(parts, " ")
}
// quoteIfNeeded returns the value quoted if it contains spaces.
func quoteIfNeeded(val string) string {
if strings.ContainsAny(val, " \t") {
return fmt.Sprintf("\"%s\"", val)
}
return val
}
+2 -1
View File
@@ -23,6 +23,7 @@ type Server struct {
Port int Port int
Key string Key string
Tags []string Tags []string
Status string
LastSeen time.Time LastSeen time.Time
PinnedAt time.Time
SSHCount int
} }
+2
View File
@@ -21,4 +21,6 @@ type ServerRepository interface {
UpdateServer(server domain.Server, newServer domain.Server) error UpdateServer(server domain.Server, newServer domain.Server) error
AddServer(server domain.Server) error AddServer(server domain.Server) error
DeleteServer(server domain.Server) error DeleteServer(server domain.Server) error
SetPinned(alias string, pinned bool) error
RecordSSH(alias string) error
} }
+1
View File
@@ -21,5 +21,6 @@ type ServerService interface {
UpdateServer(server domain.Server, newServer domain.Server) error UpdateServer(server domain.Server, newServer domain.Server) error
AddServer(server domain.Server) error AddServer(server domain.Server) error
DeleteServer(server domain.Server) error DeleteServer(server domain.Server) error
SetPinned(alias string, pinned bool) error
SSH(alias string) error SSH(alias string) error
} }
+29 -2
View File
@@ -17,6 +17,7 @@ package services
import ( import (
"os" "os"
"os/exec" "os/exec"
"sort"
"github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/domain"
"github.com/Adembc/lazyssh/internal/core/ports" "github.com/Adembc/lazyssh/internal/core/ports"
@@ -36,15 +37,27 @@ func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) *ser
} }
} }
// ListServers returns a list of servers. // ListServers returns a list of servers sorted with pinned on top.
func (s *serverService) ListServers(query string) ([]domain.Server, error) { func (s *serverService) ListServers(query string) ([]domain.Server, error) {
// do any relevant business logic here if needed
servers, err := s.serverRepository.ListServers(query) servers, err := s.serverRepository.ListServers(query)
if err != nil { if err != nil {
s.logger.Errorw("failed to list servers", "error", err) s.logger.Errorw("failed to list servers", "error", err)
return nil, err return nil, err
} }
// Sort: pinned first (PinnedAt non-zero), then by PinnedAt desc, then by Alias asc.
sort.SliceStable(servers, func(i, j int) bool {
pi := !servers[i].PinnedAt.IsZero()
pj := !servers[j].PinnedAt.IsZero()
if pi != pj {
return pi
}
if pi && pj {
return servers[i].PinnedAt.After(servers[j].PinnedAt)
}
return servers[i].Alias < servers[j].Alias
})
return servers, nil return servers, nil
} }
@@ -75,6 +88,15 @@ func (s *serverService) DeleteServer(server domain.Server) error {
return err return err
} }
// SetPinned sets or clears a pin timestamp for the server alias.
func (s *serverService) SetPinned(alias string, pinned bool) error {
err := s.serverRepository.SetPinned(alias, pinned)
if err != nil {
s.logger.Errorw("failed to set pin state", "error", err, "alias", alias, "pinned", pinned)
}
return err
}
// SSH starts an interactive SSH session to the given alias using the system's ssh client. // SSH starts an interactive SSH session to the given alias using the system's ssh client.
func (s *serverService) SSH(alias string) error { func (s *serverService) SSH(alias string) error {
s.logger.Infow("ssh start", "alias", alias) s.logger.Infow("ssh start", "alias", alias)
@@ -86,6 +108,11 @@ func (s *serverService) SSH(alias string) error {
s.logger.Errorw("ssh command failed", "alias", alias, "error", err) s.logger.Errorw("ssh command failed", "alias", alias, "error", err)
return err return err
} }
if err := s.serverRepository.RecordSSH(alias); err != nil {
s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err)
}
s.logger.Infow("ssh end", "alias", alias) s.logger.Infow("ssh end", "alias", alias)
return nil return nil
} }