From 78b14d4ad46aed96fa42b284ea66d1d45c7e35e7 Mon Sep 17 00:00:00 2001 From: Adem Baccara <71262172+Adembc@users.noreply.github.com> Date: Sun, 17 Aug 2025 11:09:20 +0100 Subject: [PATCH] separte ui for data and business logic --- cmd/main.go | 638 +---------------- internal/adapters/data/memory/server_repo.go | 90 +++ internal/adapters/ui/const.go | 21 + internal/adapters/ui/handlers.go | 133 ++++ internal/adapters/ui/header.go | 79 +++ internal/adapters/ui/hint_bar.go | 13 + internal/adapters/ui/search_bar.go | 55 ++ internal/adapters/ui/server_details.go | 42 ++ internal/adapters/ui/server_form.go | 174 +++++ internal/adapters/ui/server_list.go | 80 +++ internal/adapters/ui/status_bar.go | 14 + internal/adapters/ui/tui.go | 684 +++++++++++++++++++ internal/adapters/ui/utils.go | 57 ++ internal/core/domain/server.go | 14 + internal/core/ports/repositories.go | 10 + internal/core/ports/services.go | 10 + internal/core/services/server_service.go | 43 ++ 17 files changed, 1529 insertions(+), 628 deletions(-) create mode 100644 internal/adapters/data/memory/server_repo.go create mode 100644 internal/adapters/ui/const.go create mode 100644 internal/adapters/ui/handlers.go create mode 100644 internal/adapters/ui/header.go create mode 100644 internal/adapters/ui/hint_bar.go create mode 100644 internal/adapters/ui/search_bar.go create mode 100644 internal/adapters/ui/server_details.go create mode 100644 internal/adapters/ui/server_form.go create mode 100644 internal/adapters/ui/server_list.go create mode 100644 internal/adapters/ui/status_bar.go create mode 100644 internal/adapters/ui/tui.go create mode 100644 internal/adapters/ui/utils.go create mode 100644 internal/core/domain/server.go create mode 100644 internal/core/ports/repositories.go create mode 100644 internal/core/ports/services.go create mode 100644 internal/core/services/server_service.go diff --git a/cmd/main.go b/cmd/main.go index b5a65f0..616f392 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,645 +2,27 @@ package main import ( "fmt" - "os" - "sort" - "strconv" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" + "github.com/Adembc/lazyssh/internal/adapters/data/memory" + "github.com/Adembc/lazyssh/internal/adapters/ui" + "github.com/Adembc/lazyssh/internal/core/services" "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() { - var ( - versionFlag bool - listFlag bool - ) + + serverInMemoryRepo := memory.NewServerRepository() + serverService := services.NewServerService(serverInMemoryRepo) + tui := ui.NewTUI(serverService) rootCmd := &cobra.Command{ - Use: AppName, + Use: ui.AppName, Short: "Lazy SSH server picker TUI", RunE: func(cmd *cobra.Command, args []string) error { - if versionFlag { - fmt.Println(AppVersion) - return nil - } - if listFlag { - printServers() - return nil - } - return runTUI() + return tui.Run() }, } 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 { fmt.Fprintln(os.Stderr, err) diff --git a/internal/adapters/data/memory/server_repo.go b/internal/adapters/data/memory/server_repo.go new file mode 100644 index 0000000..e5f24e5 --- /dev/null +++ b/internal/adapters/data/memory/server_repo.go @@ -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 +} diff --git a/internal/adapters/ui/const.go b/internal/adapters/ui/const.go new file mode 100644 index 0000000..e65668a --- /dev/null +++ b/internal/adapters/ui/const.go @@ -0,0 +1,21 @@ +package ui + +const ( + AppName = "lazyssh" + AppVersion = "v0.0.1" + RepoURL = "github.com/adembc/lazyssh" + Banner = ` + + $$\ $$\ + $$ | $$ | + $$ | $$$$$$\ $$$$$$$$\ $$\ $$\ $$$$$$$\ $$$$$$$\ $$$$$$$\ + $$ | \____$$\ \____$$ |$$ | $$ |$$ _____|$$ _____|$$ __$$\ + $$ | $$$$$$$ | $$$$ _/ $$ | $$ |\$$$$$$\ \$$$$$$\ $$ | $$ | + $$ |$$ __$$ | $$ _/ $$ | $$ | \____$$\ \____$$\ $$ | $$ | + $$ |\$$$$$$$ |$$$$$$$$\ \$$$$$$$ |$$$$$$$ |$$$$$$$ |$$ | $$ | + \__| \_______|\________| \____$$ |\_______/ \_______/ \__| \__| + $$\ $$ | + \$$$$$$ | + \______/ +` +) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go new file mode 100644 index 0000000..c60948f --- /dev/null +++ b/internal/adapters/ui/handlers.go @@ -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) +} diff --git a/internal/adapters/ui/header.go b/internal/adapters/ui/header.go new file mode 100644 index 0000000..6d45c22 --- /dev/null +++ b/internal/adapters/ui/header.go @@ -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 +} diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go new file mode 100644 index 0000000..4ff8430 --- /dev/null +++ b/internal/adapters/ui/hint_bar.go @@ -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 +} diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go new file mode 100644 index 0000000..8647c01 --- /dev/null +++ b/internal/adapters/ui/search_bar.go @@ -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 +} diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go new file mode 100644 index 0000000..1761273 --- /dev/null +++ b/internal/adapters/ui/server_details.go @@ -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.") +} diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go new file mode 100644 index 0000000..7a5716f --- /dev/null +++ b/internal/adapters/ui/server_form.go @@ -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 +} diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go new file mode 100644 index 0000000..4ea08a2 --- /dev/null +++ b/internal/adapters/ui/server_list.go @@ -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 +} diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go new file mode 100644 index 0000000..6bf67d9 --- /dev/null +++ b/internal/adapters/ui/status_bar.go @@ -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 +} diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go new file mode 100644 index 0000000..2b6d3df --- /dev/null +++ b/internal/adapters/ui/tui.go @@ -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 +//} diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go new file mode 100644 index 0000000..d4f0f9d --- /dev/null +++ b/internal/adapters/ui/utils.go @@ -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) +} diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go new file mode 100644 index 0000000..a86cfa2 --- /dev/null +++ b/internal/core/domain/server.go @@ -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 +} diff --git a/internal/core/ports/repositories.go b/internal/core/ports/repositories.go new file mode 100644 index 0000000..0e21e79 --- /dev/null +++ b/internal/core/ports/repositories.go @@ -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 +} diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go new file mode 100644 index 0000000..ed8cc9b --- /dev/null +++ b/internal/core/ports/services.go @@ -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 +} diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go new file mode 100644 index 0000000..c9feb1a --- /dev/null +++ b/internal/core/services/server_service.go @@ -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) +}