From adc843f1cfe1a76dbcabc47c264bcf7b442617d8 Mon Sep 17 00:00:00 2001 From: Adem Baccara <71262172+Adembc@users.noreply.github.com> Date: Thu, 21 Aug 2025 08:42:20 +0100 Subject: [PATCH] add pin and sort feature --- go.mod | 3 +- go.sum | 2 + .../adapters/data/file/server_file_repo.go | 53 ++++++- .../data/memory/server_in_memory_repo.go | 43 ++++-- internal/adapters/ui/handlers.go | 120 ++++++++++++++-- internal/adapters/ui/hint_bar.go | 2 +- internal/adapters/ui/server_details.go | 27 +++- internal/adapters/ui/server_form.go | 59 +++----- internal/adapters/ui/sort.go | 130 ++++++++++++++++++ internal/adapters/ui/status_bar.go | 6 +- internal/adapters/ui/tui.go | 13 ++ internal/adapters/ui/utils.go | 101 ++++++++++---- internal/core/domain/server.go | 3 +- internal/core/ports/repositories.go | 2 + internal/core/ports/services.go | 1 + internal/core/services/server_service.go | 31 ++++- 16 files changed, 499 insertions(+), 97 deletions(-) create mode 100644 internal/adapters/ui/sort.go diff --git a/go.mod b/go.mod index 2fd14e2..132704c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/Adembc/lazyssh go 1.24.4 require ( + github.com/atotto/clipboard v0.1.4 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/spf13/cobra v1.9.1 go.uber.org/zap v1.27.0 @@ -13,7 +15,6 @@ require ( github.com/gdamore/encoding v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.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/spf13/pflag v1.0.7 // indirect go.uber.org/multierr v1.10.0 // indirect diff --git a/go.sum b/go.sum index bed85cd..d5d44d6 100644 --- a/go.sum +++ b/go.sum @@ -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/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= diff --git a/internal/adapters/data/file/server_file_repo.go b/internal/adapters/data/file/server_file_repo.go index 7086b60..f958705 100644 --- a/internal/adapters/data/file/server_file_repo.go +++ b/internal/adapters/data/file/server_file_repo.go @@ -37,8 +37,8 @@ const ( type ServerMetadata struct { Tags []string `json:"tags,omitempty"` LastSeen string `json:"last_seen,omitempty"` - Favorite bool `json:"favorite,omitempty"` - Notes string `json:"notes,omitempty"` + PinnedAt string `json:"pinned_at,omitempty"` + SSHCount int `json:"ssh_count,omitempty"` } type serverRepo struct { @@ -65,16 +65,21 @@ func (s serverRepo) ListServers(query string) ([]domain.Server, error) { // Merge metadata with servers for i, server := range servers { - servers[i].Status = "online" servers[i].LastSeen = time.Time{} if meta, exists := metadata[server.Alias]; exists { servers[i].Tags = meta.Tags + servers[i].SSHCount = meta.SSHCount if meta.LastSeen != "" { if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil { 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.User), queryLower) { 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 @@ -249,7 +261,6 @@ func (s serverRepo) parseSSHConfig() ([]domain.Server, error) { } if currentServer != nil { - servers = append(servers, *currentServer) } @@ -334,6 +345,11 @@ func (s serverRepo) updateMetadata(server domain.Server) error { 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 return s.saveMetadata(metadata) @@ -363,3 +379,32 @@ func (s serverRepo) saveMetadata(metadata map[string]ServerMetadata) error { 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) +} diff --git a/internal/adapters/data/memory/server_in_memory_repo.go b/internal/adapters/data/memory/server_in_memory_repo.go index 20690e9..cae29c9 100644 --- a/internal/adapters/data/memory/server_in_memory_repo.go +++ b/internal/adapters/data/memory/server_in_memory_repo.go @@ -29,14 +29,14 @@ 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)}, + {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"}, 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"}, 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"}, 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"}, 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"}, 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"}, LastSeen: time.Now().Add(-15 * 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. @@ -58,11 +58,10 @@ func (r *serverRepository) ListServers(query string) ([]domain.Server, error) { 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) { + if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(port, q) { match = true } if !match { @@ -107,3 +106,27 @@ func (r *serverRepository) DeleteServer(server domain.Server) error { } 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 +} diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 479ff9e..e49cad2 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -16,7 +16,11 @@ package ui import ( "fmt" + "strings" + "time" + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/atotto/clipboard" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -47,6 +51,43 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { case 'd': t.handleServerDelete() 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 '?': t.handleHelpShow() return nil @@ -62,6 +103,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { func (t *tui) handleSearchInput(query string) { filtered, _ := t.serverService.ListServers(query) + sortServersForUI(filtered, t.sortMode) t.serverList.UpdateServers(filtered) if len(filtered) == 0 { t.details.ShowEmpty() @@ -75,7 +117,6 @@ func (t *tui) handleSearchToggle() { func (t *tui) handleServerConnect() { if server, ok := t.serverList.GetSelectedServer(); ok { t.showConnectModal(server) - } } @@ -148,13 +189,15 @@ func (t *tui) showConnectModal(server domain.Server) { modal := tview.NewModal(). SetText(msg). - AddButtons([]string{"Cancel", "Confirm"}). + AddButtons([]string{"Confirm", "Cancel"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { - if buttonIndex == 1 { + if buttonIndex == 0 { // Suspend the TUI while running the external ssh command. t.app.Suspend(func() { _ = t.serverService.SSH(server.Alias) }) + // Refresh to reflect updated last seen and ssh count + t.refreshServerList() } t.handleModalClose() }) @@ -180,16 +223,55 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) { 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() { text := "Keyboard shortcuts:\n\n" + " ↑/↓ Navigate\n" + - " Enter SSH connect \n" + - " a Add server \n" + - " e Edit server \n" + - " d Delete entry \n" + - " / Focus search\n" + - "q Quit\n" + - "? Help\n" + " Enter SSH connect \n" + + " c Copy SSH command \n" + + " a Add server \n" + + " e Edit server \n" + + " t Edit tags (quick)\n" + + " d Delete entry \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(). SetText(text). @@ -223,9 +305,27 @@ func (t *tui) refreshServerList() { query = t.searchBar.InputField.GetText() } filtered, _ := t.serverService.ListServers(query) + sortServersForUI(filtered, t.sortMode) t.serverList.UpdateServers(filtered) } func (t *tui) returnToMain() { 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()) + } + }) + } + }) +} diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go index bd06b72..64cbaee 100644 --- a/internal/adapters/ui/hint_bar.go +++ b/internal/adapters/ui/hint_bar.go @@ -22,6 +22,6 @@ import ( 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[-]") + 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 } diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 13f89f9..03a30ba 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -16,6 +16,8 @@ package ui import ( "fmt" + "strings" + "github.com/Adembc/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -42,8 +44,20 @@ func (sd *ServerDetails) build() { 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") if server.LastSeen.IsZero() { lastSeen = "Never" @@ -52,11 +66,16 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { if serverKey == "" { serverKey = "(default: ~/.ssh/id_{rsa,ed25519,ecdsa})" } + pinnedStr := "true" + if server.PinnedAt.IsZero() { + pinnedStr = "false" + } + tagsText := renderTagChips(server.Tags) 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, - serverKey, joinTags(server.Tags), statusIcon(server.Status), - lastSeen) + serverKey, tagsText, pinnedStr, + lastSeen, server.SSHCount) sd.TextView.SetText(text) } diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 2bdd305..2b183bf 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -73,20 +73,18 @@ 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, + 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, ", "), } } else { defaultValues = ServerFormData{ - User: "root", - Port: "22", - Key: "~/.ssh/id_ed25519", - Status: "online", + User: "root", + Port: "22", + Key: "~/.ssh/id_ed25519", } } @@ -96,28 +94,15 @@ func (sf *ServerForm) addFormFields() { sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil) sf.Form.AddInputField("Key:", defaultValues.Key, 40, 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 { - Alias string - Host string - User string - Port string - Key string - Tags string - Status string + Alias string + Host string + User string + Port string + Key string + Tags string } 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{ - Alias: data.Alias, - Host: data.Host, - User: data.User, - Port: port, - Key: data.Key, - Tags: tags, - Status: status, + Alias: data.Alias, + Host: data.Host, + User: data.User, + Port: port, + Key: data.Key, + Tags: tags, } } diff --git a/internal/adapters/ui/sort.go b/internal/adapters/ui/sort.go new file mode 100644 index 0000000..58bc1e7 --- /dev/null +++ b/internal/adapters/ui/sort.go @@ -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) + } + }) +} diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index 01f6864..b3519fe 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -19,10 +19,14 @@ import ( "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 { 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") + status.SetText(DefaultStatusText()) return status } diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 1bad297..3119393 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -45,6 +45,7 @@ type tui struct { left *tview.Flex content *tview.Flex + sortMode SortMode searchVisible bool } @@ -97,6 +98,10 @@ func (t *tui) buildComponents() *tui { OnSelectionChange(t.handleServerSelectionChange) t.details = NewServerDetails() t.statusBar = NewStatusBar() + + // default sort mode + t.sortMode = SortByAliasAsc + return t } @@ -126,11 +131,19 @@ func (t *tui) bindEvents() *tui { func (t *tui) loadInitialData() *tui { servers, _ := t.serverService.ListServers("") + sortServersForUI(servers, t.sortMode) + t.updateListTitle() t.serverList.UpdateServers(servers) return t } +func (t *tui) updateListTitle() { + if t.serverList != nil { + t.serverList.SetTitle("Servers — Sort: " + t.sortMode.String()) + } +} + func (t *tui) loadSplashScreen() *tui { splash, stop := buildSplash(t.app) t.app.SetRoot(splash, true) diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index ad465ce..d008df3 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -20,21 +20,9 @@ import ( "time" "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 { if len(tags) == 0 { return "-" @@ -42,19 +30,51 @@ func joinTags(tags []string) string { 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" +// renderTagBadgesForList renders up to two colored tag chips for the server list. +// If there are more tags, it appends a subtle gray "+N" badge. Returns an empty +// string when there are no tags to avoid cluttering the list. +func renderTagBadgesForList(tags []string) string { + if len(tags) == 0 { + return "" } - 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 = "" return } @@ -74,3 +94,34 @@ func humanizeDuration(t time.Time) string { } 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 +} diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index 1d5d6f9..f69bb7a 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -23,6 +23,7 @@ type Server struct { Port int Key string Tags []string - Status string LastSeen time.Time + PinnedAt time.Time + SSHCount int } diff --git a/internal/core/ports/repositories.go b/internal/core/ports/repositories.go index 3b883fa..133e4f8 100644 --- a/internal/core/ports/repositories.go +++ b/internal/core/ports/repositories.go @@ -21,4 +21,6 @@ type ServerRepository interface { UpdateServer(server domain.Server, newServer domain.Server) error AddServer(server domain.Server) error DeleteServer(server domain.Server) error + SetPinned(alias string, pinned bool) error + RecordSSH(alias string) error } diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index fc55d8a..14b2559 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -21,5 +21,6 @@ type ServerService interface { UpdateServer(server domain.Server, newServer domain.Server) error AddServer(server domain.Server) error DeleteServer(server domain.Server) error + SetPinned(alias string, pinned bool) error SSH(alias string) error } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index 878b5c8..10bf8a7 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -17,6 +17,7 @@ package services import ( "os" "os/exec" + "sort" "github.com/Adembc/lazyssh/internal/core/domain" "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) { - // do any relevant business logic here if needed servers, err := s.serverRepository.ListServers(query) if err != nil { s.logger.Errorw("failed to list servers", "error", 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 } @@ -75,6 +88,15 @@ func (s *serverService) DeleteServer(server domain.Server) error { 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. func (s *serverService) SSH(alias string) error { 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) 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) return nil }