diff --git a/internal/adapters/data/ssh_config_file/mapper.go b/internal/adapters/data/ssh_config_file/mapper.go index f8a31f4..fc064ae 100644 --- a/internal/adapters/data/ssh_config_file/mapper.go +++ b/internal/adapters/data/ssh_config_file/mapper.go @@ -298,6 +298,7 @@ func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string] if meta, exists := metadata[server.Alias]; exists { servers[i].Tags = meta.Tags + servers[i].Group = meta.Group servers[i].SSHCount = meta.SSHCount if meta.LastSeen != "" { diff --git a/internal/adapters/data/ssh_config_file/metadata_manager.go b/internal/adapters/data/ssh_config_file/metadata_manager.go index a4e7be7..ed6d18d 100644 --- a/internal/adapters/data/ssh_config_file/metadata_manager.go +++ b/internal/adapters/data/ssh_config_file/metadata_manager.go @@ -27,6 +27,7 @@ import ( type ServerMetadata struct { Tags []string `json:"tags,omitempty"` + Group string `json:"group,omitempty"` LastSeen string `json:"last_seen,omitempty"` PinnedAt string `json:"pinned_at,omitempty"` SSHCount int `json:"ssh_count,omitempty"` @@ -103,6 +104,7 @@ func (m *metadataManager) updateServer(server domain.Server, oldAlias string) er merged := existing merged.Tags = server.Tags + merged.Group = server.Group if !server.LastSeen.IsZero() { merged.LastSeen = server.LastSeen.Format(time.RFC3339) diff --git a/internal/adapters/ui/defaults.go b/internal/adapters/ui/defaults.go index 94af3cb..9613f87 100644 --- a/internal/adapters/ui/defaults.go +++ b/internal/adapters/ui/defaults.go @@ -182,6 +182,8 @@ func GetFieldPlaceholder(fieldName string) string { return "e.g., ~/.ssh/id_rsa, ~/.ssh/id_ed25519" case "Tags": return "comma-separated tags" + case "Group": + return "e.g., production, database" case "ProxyJump": //nolint:goconst // Field name used in switch case return "e.g., bastion.example.com" case "ProxyCommand": diff --git a/internal/adapters/ui/field_help.go b/internal/adapters/ui/field_help.go index b55210a..f45658d 100644 --- a/internal/adapters/ui/field_help.go +++ b/internal/adapters/ui/field_help.go @@ -411,6 +411,14 @@ var fieldHelpData = map[string]FieldHelp{ Default: "none", Category: "Basic", }, + "Group": { + Field: "Group", + Description: "Group name for organizing servers. Use '/' for nested groups (e.g. Work/ProjectA).", + Syntax: "any_string", + Examples: []string{"production", "database", "Work/ProjectA/DB"}, + Default: "none", + Category: "Basic", + }, // Connection - IP and Address fields "IPQoS": { diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 897e053..1e3d627 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -16,6 +16,9 @@ package ui import ( "fmt" + "os" + "os/exec" + "sort" "strings" "time" @@ -234,10 +237,24 @@ func (t *tui) handleServerSelectionChange(server domain.Server) { t.details.UpdateServer(server) } +func (t *tui) getUniqueGroups() []string { + servers, _ := t.serverService.ListServers("") + uniqueGroups := make(map[string]bool) + var groups []string + for _, s := range servers { + if s.Group != "" && !uniqueGroups[s.Group] { + uniqueGroups[s.Group] = true + groups = append(groups, s.Group) + } + } + return groups +} + func (t *tui) handleServerAdd() { form := NewServerForm(ServerFormAdd, nil). SetApp(t.app). SetVersionInfo(t.version, t.commit). + SetExistingGroups(t.getUniqueGroups()). OnSave(t.handleServerSave). OnCancel(t.handleFormCancel) t.app.SetRoot(form, true) @@ -248,6 +265,7 @@ func (t *tui) handleServerEdit() { form := NewServerForm(ServerFormEdit, &server). SetApp(t.app). SetVersionInfo(t.version, t.commit). + SetExistingGroups(t.getUniqueGroups()). OnSave(t.handleServerSave). OnCancel(t.handleFormCancel) t.app.SetRoot(form, true) @@ -629,3 +647,117 @@ func (t *tui) handleStopForwarding() { }() } } + +func (t *tui) handleGroupAction(groupName string, action string) { + if action == "menu" { + t.showGroupContextMenu(groupName) + } else if action == "tmux-all" { + t.handleConnectGroupTmux(groupName) + } +} + +func (t *tui) showGroupContextMenu(groupName string) { + menu := tview.NewModal(). + SetText(fmt.Sprintf("Group Actions: %s", groupName)). + AddButtons([]string{"Connect to All (tmux)", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Connect to All (tmux)" { + t.handleConnectGroupTmux(groupName) + } + t.handleModalClose() + }) + t.app.SetRoot(menu, true) +} + +func (t *tui) handleConnectGroupTmux(groupName string) { + servers, _ := t.serverService.ListServers("") + var groupServers []domain.Server + + for _, s := range servers { + // Check if server belongs to the group or any sub-group + if s.Group == groupName || strings.HasPrefix(s.Group, groupName+"/") { + groupServers = append(groupServers, s) + } + } + + if len(groupServers) == 0 { + t.showStatusTempColor("No servers in group "+groupName, "#FF6B6B") + return + } + + // Sort by alias for deterministic order + sort.Slice(groupServers, func(i, j int) bool { + return groupServers[i].Alias < groupServers[j].Alias + }) + + // Build tmux command + // tmux new-session -d -s groupname 'ssh alias1' + // tmux split-window -t groupname 'ssh alias2' + // tmux select-layout -t groupname tiled + // tmux attach -t groupname + + // We'll generate a script or command string to copy to clipboard or run? + // The requirement says "Connect to all (tmux)". Usually implies running tmux locally. + // Since we are inside the TUI, replacing the TUI with tmux session might be complex directly. + // However, we can suspend the app and run the command. + + // Use timestamp to ensure unique session name + sessionName := fmt.Sprintf("lazyssh-%s-%d", strings.ReplaceAll(groupName, "/", "-"), time.Now().Unix()) + + // Check if we are inside tmux already? + // If inside tmux, we shouldn't nest sessions easily without care. + // For simplicity, let's assume we want to launch a new tmux session. + + quote := func(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" + } + + var cmdParts []string + + // Use BuildSSHCommand to get the full SSH command string + sshCmd0 := BuildSSHCommand(groupServers[0]) + + // Start session with first server + cmdParts = append(cmdParts, fmt.Sprintf("tmux new-session -d -s %s %s", quote(sessionName), quote(sshCmd0))) + // Set title for the first pane (which is active immediately after creation) + cmdParts = append(cmdParts, fmt.Sprintf("tmux select-pane -t %s -T %s", quote(sessionName), quote(groupServers[0].Alias))) + + // Enable pane options + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-status top", quote(sessionName))) + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-format %s", quote(sessionName), quote(" #{pane_title} "))) + + // Enable mouse support + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s mouse on", quote(sessionName))) + + for i := 1; i < len(groupServers); i++ { + sshCmdI := BuildSSHCommand(groupServers[i]) + // Split window + cmdParts = append(cmdParts, fmt.Sprintf("tmux split-window -t %s %s", quote(sessionName), quote(sshCmdI))) + // Set title for the new pane (it becomes active after split) + cmdParts = append(cmdParts, fmt.Sprintf("tmux select-pane -t %s -T %s", quote(sessionName), quote(groupServers[i].Alias))) + } + + cmdParts = append(cmdParts, fmt.Sprintf("tmux select-layout -t %s tiled", quote(sessionName))) + cmdParts = append(cmdParts, fmt.Sprintf("tmux attach-session -t %s", quote(sessionName))) + + fullCmd := strings.Join(cmdParts, " ; ") + + // Execute the command in the shell + t.app.Suspend(func() { + fmt.Printf("Launching tmux session for group %s (%d servers)...\n", groupName, len(groupServers)) + for _, s := range groupServers { + fmt.Printf(" - %s\n", s.Alias) + } + + cmd := exec.Command("sh", "-c", fullCmd) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("Error launching tmux: %v\n", err) + fmt.Println("Press Enter to continue...") + fmt.Scanln() + } + }) +} diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 48befc3..e86ef4e 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -70,6 +70,11 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { } tagsText := renderTagChips(server.Tags) + groupText := server.Group + if groupText == "" { + groupText = "-" + } + // Basic information aliasText := strings.Join(server.Aliases, ", ") @@ -83,9 +88,9 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { } text := fmt.Sprintf( - "[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n", + "[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Group: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n", aliasText, hostText, userText, portText, - serverKey, tagsText, pinnedStr, + serverKey, groupText, tagsText, pinnedStr, lastSeen, server.SSHCount) // Advanced settings section (only show non-empty fields) @@ -213,7 +218,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { } // Commands list - text += "\n[::b]Commands:[-]\n Enter: SSH connect\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin" + text += "\n[::b]Commands:[-]\n Enter: SSH connect\n Space: Toggle group\n m: Group menu\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin" sd.TextView.SetText(text) } diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f..b1e22b1 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -44,27 +44,28 @@ const ( ) type ServerForm struct { - *tview.Flex // The root container (includes header, form panel and hint bar) - header *AppHeader // The app header - formPanel *tview.Flex // The actual form panel - pages *tview.Pages - tabBar *tview.TextView - forms map[string]*tview.Form - currentTab string - tabs []string - tabAbbrev map[string]string // Abbreviated tab names for narrow views - mode ServerFormMode - original *domain.Server - onSave func(domain.Server, *domain.Server) - onCancel func() - app *tview.Application // Reference to app for showing modals - version string // Version for header - commit string // Commit for header - validation *ValidationState // Validation state for all fields - helpPanel *tview.TextView // Help panel for field descriptions - helpMode HelpDisplayMode // Current help display mode - currentField string // Currently focused field - mainContainer *tview.Flex // Container for form and help panel + *tview.Flex // The root container (includes header, form panel and hint bar) + header *AppHeader // The app header + formPanel *tview.Flex // The actual form panel + pages *tview.Pages + tabBar *tview.TextView + forms map[string]*tview.Form + currentTab string + tabs []string + tabAbbrev map[string]string // Abbreviated tab names for narrow views + mode ServerFormMode + original *domain.Server + onSave func(domain.Server, *domain.Server) + onCancel func() + app *tview.Application // Reference to app for showing modals + version string // Version for header + commit string // Commit for header + validation *ValidationState // Validation state for all fields + helpPanel *tview.TextView // Help panel for field descriptions + helpMode HelpDisplayMode // Current help display mode + currentField string // Currently focused field + mainContainer *tview.Flex // Container for form and help panel + existingGroups []string // List of existing groups for autocomplete } func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { @@ -1072,6 +1073,7 @@ func (sf *ServerForm) getDefaultValues() ServerFormData { Port: fmt.Sprint(sf.original.Port), Key: strings.Join(sf.original.IdentityFiles, ", "), Tags: strings.Join(sf.original.Tags, ", "), + Group: sf.original.Group, ProxyJump: sf.original.ProxyJump, ProxyCommand: sf.original.ProxyCommand, RemoteCommand: sf.original.RemoteCommand, @@ -1147,6 +1149,7 @@ func (sf *ServerForm) getDefaultValues() ServerFormData { Port: "22", // Keep port 22 as it's the standard SSH port Key: "", // Empty for new servers (SSH will try default keys) Tags: "", + Group: "", // All other fields should be empty for new servers // The SSH client will use its defaults when these are not specified @@ -1236,6 +1239,38 @@ func (sf *ServerForm) getDefaultValues() ServerFormData { } } +// createGroupAutocomplete creates an autocomplete function for the Group field +func (sf *ServerForm) createGroupAutocomplete() func(string) []string { + return func(currentText string) []string { + // Don't show suggestions if the field is empty to allow Tab navigation + if currentText == "" { + return nil + } + + if len(sf.existingGroups) == 0 { + return nil + } + + // Filter suggestions + var filtered []string + searchTerm := strings.ToLower(currentText) + + for _, group := range sf.existingGroups { + if group == "" { + continue + } + if matchesSequence(strings.ToLower(group), searchTerm) { + filtered = append(filtered, group) + } + } + + if len(filtered) == 0 { + return nil + } + return filtered + } +} + // createBasicForm creates the Basic configuration tab func (sf *ServerForm) createBasicForm() { form := tview.NewForm() @@ -1254,6 +1289,10 @@ func (sf *ServerForm) createBasicForm() { // Tags field sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) + // Group field + groupField := sf.addValidatedInputField(form, "Group:", "Group", defaultValues.Group, 30, GetFieldPlaceholder("Group")) + groupField.SetAutocompleteFunc(sf.createGroupAutocomplete()) + // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) @@ -1645,6 +1684,7 @@ type ServerFormData struct { Port string Key string Tags string + Group string // Connection and proxy settings ProxyJump string @@ -1780,6 +1820,7 @@ func (sf *ServerForm) getFormData() ServerFormData { Port: getFieldText("Port:"), Key: getFieldText("Keys:"), Tags: getFieldText("Tags:"), + Group: getFieldText("Group:"), // Connection and proxy settings ProxyJump: getFieldText("ProxyJump:"), ProxyCommand: getFieldText("ProxyCommand:"), @@ -2188,6 +2229,7 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server { Port: port, IdentityFiles: keys, Tags: tags, + Group: data.Group, ProxyJump: data.ProxyJump, ProxyCommand: data.ProxyCommand, RemoteCommand: data.RemoteCommand, @@ -2285,3 +2327,8 @@ func (sf *ServerForm) SetVersionInfo(version, commit string) *ServerForm { } return sf } + +func (sf *ServerForm) SetExistingGroups(groups []string) *ServerForm { + sf.existingGroups = groups + return sf +} diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 1a58d39..765a9b7 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -15,6 +15,9 @@ package ui import ( + "fmt" + "strings" + "github.com/Adembc/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -23,14 +26,19 @@ import ( type ServerList struct { *tview.List servers []domain.Server + displayedItems []*domain.Server + displayedHeaders []string + collapsedGroups map[string]bool onSelection func(domain.Server) onSelectionChange func(domain.Server) onReturnToSearch func() + onGroupAction func(groupName string, action string) } func NewServerList() *ServerList { list := &ServerList{ - List: tview.NewList(), + List: tview.NewList(), + collapsedGroups: make(map[string]bool), } list.build() return list @@ -49,8 +57,11 @@ func (sl *ServerList) build() { SetHighlightFullLine(true) sl.List.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]) + if index >= 0 && index < len(sl.displayedItems) { + item := sl.displayedItems[index] + if item != nil && sl.onSelectionChange != nil { + sl.onSelectionChange(*item) + } } }) @@ -62,6 +73,45 @@ func (sl *ServerList) build() { sl.onReturnToSearch() } return nil + case tcell.KeyDown: + return sl.selectNext() + case tcell.KeyUp: + return sl.selectPrev() + case tcell.KeyEnter, tcell.KeyRune: + isSpace := event.Key() == tcell.KeyRune && event.Rune() == ' ' + isEnter := event.Key() == tcell.KeyEnter + isMenu := event.Key() == tcell.KeyRune && event.Rune() == 'm' + + idx := sl.List.GetCurrentItem() + if idx >= 0 && idx < len(sl.displayedHeaders) { + groupName := sl.displayedHeaders[idx] + + // Handle Group Actions + if groupName != "" { + if isSpace || isEnter { + // Toggle Collapse + sl.collapsedGroups[groupName] = !sl.collapsedGroups[groupName] + sl.UpdateServers(sl.servers) + + // Try to find the header again to restore selection + newIdx := -1 + for i, h := range sl.displayedHeaders { + if h == groupName { + newIdx = i + break + } + } + if newIdx >= 0 { + sl.List.SetCurrentItem(newIdx) + } + return nil + } else if isMenu { + // Trigger Context Menu Action + sl.showGroupContextMenu(groupName) + return nil + } + } + } } return event }) @@ -70,29 +120,187 @@ func (sl *ServerList) build() { func (sl *ServerList) UpdateServers(servers []domain.Server) { sl.servers = servers sl.List.Clear() + sl.displayedItems = make([]*domain.Server, 0) + sl.displayedHeaders = make([]string, 0) + + inPinnedSection := false + + // Helper to track nested groups + lastGroupParts := []string{} + hasGroups := false + for _, s := range servers { + if s.Group != "" { + hasGroups = true + break + } + } + + addHeader := func(fullPath string, name string, depth int) { + isCollapsed := sl.collapsedGroups[fullPath] + icon := "[-]" + if isCollapsed { + icon = "[+]" + } + indent := strings.Repeat(" ", depth) + sl.List.AddItem(fmt.Sprintf("%s[yellow::b]%s %s[-]", indent, icon, name), "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + sl.displayedHeaders = append(sl.displayedHeaders, fullPath) + } for i := range servers { + s := servers[i] + isPinned := !s.PinnedAt.IsZero() + + if isPinned { + if !inPinnedSection { + inPinnedSection = true + if hasGroups { + addHeader("Pinned", "Pinned", 0) + } + // Reset group context when entering pinned + lastGroupParts = []string{} + } + if hasGroups && sl.collapsedGroups["Pinned"] { + continue + } + } else { + if inPinnedSection { + inPinnedSection = false + // Reset group context when leaving pinned + lastGroupParts = []string{} + } + + if hasGroups { + currentGroup := s.Group + if currentGroup == "" { + currentGroup = "Ungrouped" + } + + currentParts := strings.Split(currentGroup, "/") + + // Calculate common prefix with previous server's group + commonLen := 0 + for j := 0; j < len(lastGroupParts) && j < len(currentParts); j++ { + if lastGroupParts[j] == currentParts[j] { + commonLen++ + } else { + break + } + } + + // Determine visibility based on parent groups + serverVisible := true + fullPath := "" + + // Check visibility and render headers for divergent parts + for j, part := range currentParts { + if j > 0 { + fullPath += "/" + } + fullPath += part + + // Check if any parent up to this point is collapsed + // But we only care if a *parent* is collapsed to hide *this* header. + // The header itself being collapsed affects its children. + + // Wait, we need to check if the PARENT of the current header is collapsed + // to decide if we show THIS header. + parentPath := "" + if j > 0 { + parentPath = fullPath[:strings.LastIndex(fullPath, "/")] + } + + parentCollapsed := false + if parentPath != "" && sl.collapsedGroups[parentPath] { + parentCollapsed = true + } else if j == 0 && inPinnedSection { + // Should not happen as we handle pinned separately, but conceptually + } + + // If parent is collapsed, we stop everything down this path + if parentCollapsed { + serverVisible = false + break + } + + // Render header if it's new (divergent from last) + if j >= commonLen { + addHeader(fullPath, part, j) + } + + // Check if THIS group is collapsed (affects children and server) + if sl.collapsedGroups[fullPath] { + serverVisible = false + } + } + + lastGroupParts = currentParts + + if !serverVisible { + continue + } + + // Update indent for server + primary, secondary := formatServerLine(servers[i]) + indent := strings.Repeat(" ", len(currentParts)+1) + primary = indent + primary + + idx := i + sl.List.AddItem(primary, secondary, 0, func() { + if sl.onSelection != nil { + sl.onSelection(sl.servers[idx]) + } + }) + sl.displayedItems = append(sl.displayedItems, &sl.servers[i]) + sl.displayedHeaders = append(sl.displayedHeaders, "") + continue + } + } + + // Fallback for no groups or Pinned items rendering primary, secondary := formatServerLine(servers[i]) + primary = " " + primary + idx := i sl.List.AddItem(primary, secondary, 0, func() { if sl.onSelection != nil { sl.onSelection(sl.servers[idx]) } }) + sl.displayedItems = append(sl.displayedItems, &sl.servers[i]) + sl.displayedHeaders = append(sl.displayedHeaders, "") } if sl.List.GetItemCount() > 0 { - sl.List.SetCurrentItem(0) - if sl.onSelectionChange != nil { - sl.onSelectionChange(sl.servers[0]) + // Find first selectable item + firstSelectable := -1 + for i, item := range sl.displayedItems { + if item != nil { + firstSelectable = i + break + } + } + // If no items found (all collapsed), select first header + if firstSelectable == -1 { + firstSelectable = 0 + } + + if firstSelectable >= 0 { + sl.List.SetCurrentItem(firstSelectable) + if sl.onSelectionChange != nil && sl.displayedItems[firstSelectable] != nil { + sl.onSelectionChange(*sl.displayedItems[firstSelectable]) + } } } } func (sl *ServerList) GetSelectedServer() (domain.Server, bool) { idx := sl.List.GetCurrentItem() - if idx >= 0 && idx < len(sl.servers) { - return sl.servers[idx], true + if idx >= 0 && idx < len(sl.displayedItems) { + item := sl.displayedItems[idx] + if item != nil { + return *item, true + } } return domain.Server{}, false } @@ -111,3 +319,50 @@ func (sl *ServerList) OnReturnToSearch(fn func()) *ServerList { sl.onReturnToSearch = fn return sl } + +func (sl *ServerList) OnGroupAction(fn func(groupName string, action string)) *ServerList { + sl.onGroupAction = fn + return sl +} + +func (sl *ServerList) showGroupContextMenu(groupName string) { + // Trigger the callback to let the parent (TUI) handle the menu display + // We pass "menu" action to indicate that a context menu is requested + if sl.onGroupAction != nil { + sl.onGroupAction(groupName, "menu") + } +} + +func (sl *ServerList) selectNext() *tcell.EventKey { + current := sl.List.GetCurrentItem() + count := sl.List.GetItemCount() + + if count == 0 { + return nil + } + + for i := current + 1; i < count; i++ { + if i < len(sl.displayedItems) { + sl.List.SetCurrentItem(i) + return nil + } + } + return nil +} + +func (sl *ServerList) selectPrev() *tcell.EventKey { + current := sl.List.GetCurrentItem() + count := sl.List.GetItemCount() + + if count == 0 { + return nil + } + + for i := current - 1; i >= 0; i-- { + if i < len(sl.displayedItems) { + sl.List.SetCurrentItem(i) + return nil + } + } + return nil +} diff --git a/internal/adapters/ui/sort.go b/internal/adapters/ui/sort.go index 58bc1e7..af61b88 100644 --- a/internal/adapters/ui/sort.go +++ b/internal/adapters/ui/sort.go @@ -98,6 +98,20 @@ func sortServersForUI(servers []domain.Server, mode SortMode) { } // both unpinned + // Group sorting + gi := strings.ToLower(si.Group) + gj := strings.ToLower(sj.Group) + if gi != gj { + // Put empty group (ungrouped) last + if gi == "" { + return false + } + if gj == "" { + return true + } + return gi < gj + } + switch mode { case SortByLastSeenDesc, SortByLastSeenAsc: zi := si.LastSeen.IsZero() diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index d938e6f..65a868a 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -97,7 +97,8 @@ func (t *tui) buildComponents() *tui { t.serverList = NewServerList(). OnSelectionChange(t.handleServerSelectionChange). - OnReturnToSearch(t.handleReturnToSearch) + OnReturnToSearch(t.handleReturnToSearch). + OnGroupAction(t.handleGroupAction) t.details = NewServerDetails() t.statusBar = NewStatusBar() diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index c23b301..154c7d8 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -24,6 +24,7 @@ type Server struct { Port int IdentityFiles []string Tags []string + Group string LastSeen time.Time PinnedAt time.Time SSHCount int