From dff88379d130ff46b16c00ca9329f5527f4614c1 Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:20:04 +0300 Subject: [PATCH 01/11] feat(ui): add server grouping functionality - Add Group field to Server struct and related metadata - Implement group sorting in server list UI - Display servers grouped by their group name in the list - Handle ungrouped servers by placing them last --- .../adapters/data/ssh_config_file/mapper.go | 1 + .../data/ssh_config_file/metadata_manager.go | 2 + internal/adapters/ui/server_form.go | 8 +++ internal/adapters/ui/server_list.go | 72 +++++++++++++++++-- internal/adapters/ui/sort.go | 14 ++++ internal/core/domain/server.go | 1 + 6 files changed, 91 insertions(+), 7 deletions(-) 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/server_form.go b/internal/adapters/ui/server_form.go index 286b47f..c008c3a 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -1072,6 +1072,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 +1148,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 @@ -1254,6 +1256,9 @@ func (sf *ServerForm) createBasicForm() { // Tags field sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) + // Group field + sf.addValidatedInputField(form, "Group:", "Group", defaultValues.Group, 30, GetFieldPlaceholder("Group")) + // Add save and cancel buttons form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) @@ -1645,6 +1650,7 @@ type ServerFormData struct { Port string Key string Tags string + Group string // Connection and proxy settings ProxyJump string @@ -1780,6 +1786,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 +2195,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, diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 1a58d39..e4bc208 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -15,6 +15,8 @@ package ui import ( + "fmt" + "github.com/Adembc/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -23,6 +25,7 @@ import ( type ServerList struct { *tview.List servers []domain.Server + displayedItems []*domain.Server onSelection func(domain.Server) onSelectionChange func(domain.Server) onReturnToSearch func() @@ -49,8 +52,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) + } } }) @@ -70,29 +76,81 @@ func (sl *ServerList) build() { func (sl *ServerList) UpdateServers(servers []domain.Server) { sl.servers = servers sl.List.Clear() + sl.displayedItems = make([]*domain.Server, 0) + + inPinnedSection := false + firstUnpinned := true + lastGroup := "" for i := range servers { + s := servers[i] + isPinned := !s.PinnedAt.IsZero() + + if isPinned { + if !inPinnedSection { + inPinnedSection = true + sl.List.AddItem("[yellow::b]Pinned[-]", "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + } + } else { + if inPinnedSection { + inPinnedSection = false + // Add spacer + sl.List.AddItem("", "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + } + + if firstUnpinned || s.Group != lastGroup { + groupName := s.Group + if groupName == "" { + groupName = "Ungrouped" + } + sl.List.AddItem(fmt.Sprintf("[yellow::b]%s[-]", groupName), "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + lastGroup = s.Group + firstUnpinned = false + } + } + primary, secondary := formatServerLine(servers[i]) + // Indent content + 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]) } 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 firstSelectable >= 0 { + sl.List.SetCurrentItem(firstSelectable) + if sl.onSelectionChange != 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 } 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/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 From 182d9e6ccce29e97744df73775139998b5f4ae26 Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:27:03 +0300 Subject: [PATCH 02/11] feat(ui): add group autocomplete to server form Implement group autocomplete functionality in server form to suggest existing groups when typing in the group field. This improves user experience by reducing manual input and preventing typos in group names. --- internal/adapters/ui/handlers.go | 15 ++++++ internal/adapters/ui/server_form.go | 78 +++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 897e053..f7d1497 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -234,10 +234,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 +262,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) diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index c008c3a..97fa625 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 { @@ -1238,6 +1239,33 @@ 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 { + 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 searchTerm == "" || 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() @@ -1257,7 +1285,8 @@ func (sf *ServerForm) createBasicForm() { sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) // Group field - sf.addValidatedInputField(form, "Group:", "Group", defaultValues.Group, 30, GetFieldPlaceholder("Group")) + 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) @@ -2293,3 +2322,8 @@ func (sf *ServerForm) SetVersionInfo(version, commit string) *ServerForm { } return sf } + +func (sf *ServerForm) SetExistingGroups(groups []string) *ServerForm { + sf.existingGroups = groups + return sf +} From 5d765591254d51287946c008e9100e928a3ddab7 Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:31:20 +0300 Subject: [PATCH 03/11] feat(ui): conditionally display server groups and pinned section Only show group headers and pinned section when servers have groups defined --- internal/adapters/ui/server_list.go | 32 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index e4bc208..3b41db3 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -82,6 +82,14 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { firstUnpinned := true lastGroup := "" + hasGroups := false + for _, s := range servers { + if s.Group != "" { + hasGroups = true + break + } + } + for i := range servers { s := servers[i] isPinned := !s.PinnedAt.IsZero() @@ -89,24 +97,30 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { if isPinned { if !inPinnedSection { inPinnedSection = true - sl.List.AddItem("[yellow::b]Pinned[-]", "", 0, nil) - sl.displayedItems = append(sl.displayedItems, nil) + if hasGroups { + sl.List.AddItem("[yellow::b]Pinned[-]", "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + } } } else { if inPinnedSection { inPinnedSection = false // Add spacer - sl.List.AddItem("", "", 0, nil) - sl.displayedItems = append(sl.displayedItems, nil) + if hasGroups { + sl.List.AddItem("", "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + } } if firstUnpinned || s.Group != lastGroup { - groupName := s.Group - if groupName == "" { - groupName = "Ungrouped" + if hasGroups { + groupName := s.Group + if groupName == "" { + groupName = "Ungrouped" + } + sl.List.AddItem(fmt.Sprintf("[yellow::b]%s[-]", groupName), "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) } - sl.List.AddItem(fmt.Sprintf("[yellow::b]%s[-]", groupName), "", 0, nil) - sl.displayedItems = append(sl.displayedItems, nil) lastGroup = s.Group firstUnpinned = false } From 6bd289ec2e62627787170afc379b702427715fe7 Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:34:19 +0300 Subject: [PATCH 04/11] fix(ui): prevent empty suggestions in group autocomplete to allow tab navigation Modify the group autocomplete function to return nil when the input is empty, enabling proper tab navigation. Also simplify the suggestion logic by removing redundant empty search term check. --- internal/adapters/ui/server_form.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 97fa625..b1e22b1 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -1242,6 +1242,11 @@ 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 } @@ -1254,7 +1259,7 @@ func (sf *ServerForm) createGroupAutocomplete() func(string) []string { if group == "" { continue } - if searchTerm == "" || matchesSequence(strings.ToLower(group), searchTerm) { + if matchesSequence(strings.ToLower(group), searchTerm) { filtered = append(filtered, group) } } From e6b0da1ba9d1c5d33d4186bfc7b36e67c07b88a0 Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:38:13 +0300 Subject: [PATCH 05/11] feat(ui): add keyboard navigation to server list Implement up/down arrow key navigation in server list UI --- internal/adapters/ui/server_list.go | 43 +++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 3b41db3..9d019b5 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -68,6 +68,10 @@ func (sl *ServerList) build() { sl.onReturnToSearch() } return nil + case tcell.KeyDown: + return sl.selectNext() + case tcell.KeyUp: + return sl.selectPrev() } return event }) @@ -105,11 +109,6 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { } else { if inPinnedSection { inPinnedSection = false - // Add spacer - if hasGroups { - sl.List.AddItem("", "", 0, nil) - sl.displayedItems = append(sl.displayedItems, nil) - } } if firstUnpinned || s.Group != lastGroup { @@ -183,3 +182,37 @@ func (sl *ServerList) OnReturnToSearch(fn func()) *ServerList { sl.onReturnToSearch = fn return sl } + +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.displayedItems[i] != nil { + 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.displayedItems[i] != nil { + sl.List.SetCurrentItem(i) + return nil + } + } + return nil +} From 71b35d07f750cd656c0a626a815f35312ba542ac Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:41:50 +0300 Subject: [PATCH 06/11] feat(ui): add placeholder and help text for Group field Add support for Group field in server configuration by providing placeholder text and help documentation to explain its usage for organizing servers --- internal/adapters/ui/defaults.go | 2 ++ internal/adapters/ui/field_help.go | 8 ++++++++ 2 files changed, 10 insertions(+) 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..6341e55 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 in the list view.", + Syntax: "any_string", + Examples: []string{"production", "database", "web-servers"}, + Default: "none", + Category: "Basic", + }, // Connection - IP and Address fields "IPQoS": { From b0fe2ed70f97fc8946a1cd1b87fa5c1da8800584 Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:50:38 +0300 Subject: [PATCH 07/11] feat(ui): add collapsible server groups in server list Implement expand/collapse functionality for server groups in the UI Add group display in server details when group is empty Improve navigation to work with group headers --- internal/adapters/ui/server_details.go | 9 +++- internal/adapters/ui/server_list.go | 73 +++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 48befc3..4644559 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) diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 9d019b5..37d8503 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -26,6 +26,8 @@ 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() @@ -33,7 +35,8 @@ type ServerList struct { func NewServerList() *ServerList { list := &ServerList{ - List: tview.NewList(), + List: tview.NewList(), + collapsedGroups: make(map[string]bool), } list.build() return list @@ -72,6 +75,34 @@ func (sl *ServerList) build() { 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 + + if isSpace || isEnter { + idx := sl.List.GetCurrentItem() + if idx >= 0 && idx < len(sl.displayedHeaders) { + groupName := sl.displayedHeaders[idx] + if groupName != "" { + // It is a header + 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 // Consume event + } + } + } } return event }) @@ -81,6 +112,7 @@ 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 firstUnpinned := true @@ -94,6 +126,17 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { } } + addHeader := func(name string) { + isCollapsed := sl.collapsedGroups[name] + icon := "[-]" + if isCollapsed { + icon = "[+]" + } + sl.List.AddItem(fmt.Sprintf("[yellow::b]%s %s[-]", icon, name), "", 0, nil) + sl.displayedItems = append(sl.displayedItems, nil) + sl.displayedHeaders = append(sl.displayedHeaders, name) + } + for i := range servers { s := servers[i] isPinned := !s.PinnedAt.IsZero() @@ -102,10 +145,12 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { if !inPinnedSection { inPinnedSection = true if hasGroups { - sl.List.AddItem("[yellow::b]Pinned[-]", "", 0, nil) - sl.displayedItems = append(sl.displayedItems, nil) + addHeader("Pinned") } } + if hasGroups && sl.collapsedGroups["Pinned"] { + continue + } } else { if inPinnedSection { inPinnedSection = false @@ -117,12 +162,19 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { if groupName == "" { groupName = "Ungrouped" } - sl.List.AddItem(fmt.Sprintf("[yellow::b]%s[-]", groupName), "", 0, nil) - sl.displayedItems = append(sl.displayedItems, nil) + addHeader(groupName) } lastGroup = s.Group firstUnpinned = false } + + currentGroup := s.Group + if currentGroup == "" { + currentGroup = "Ungrouped" + } + if hasGroups && sl.collapsedGroups[currentGroup] { + continue + } } primary, secondary := formatServerLine(servers[i]) @@ -136,6 +188,7 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { } }) sl.displayedItems = append(sl.displayedItems, &sl.servers[i]) + sl.displayedHeaders = append(sl.displayedHeaders, "") } if sl.List.GetItemCount() > 0 { @@ -147,10 +200,14 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { 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 { + if sl.onSelectionChange != nil && sl.displayedItems[firstSelectable] != nil { sl.onSelectionChange(*sl.displayedItems[firstSelectable]) } } @@ -192,7 +249,7 @@ func (sl *ServerList) selectNext() *tcell.EventKey { } for i := current + 1; i < count; i++ { - if i < len(sl.displayedItems) && sl.displayedItems[i] != nil { + if i < len(sl.displayedItems) { sl.List.SetCurrentItem(i) return nil } @@ -209,7 +266,7 @@ func (sl *ServerList) selectPrev() *tcell.EventKey { } for i := current - 1; i >= 0; i-- { - if i < len(sl.displayedItems) && sl.displayedItems[i] != nil { + if i < len(sl.displayedItems) { sl.List.SetCurrentItem(i) return nil } From 4bb9cf180864b69702de043d017f71a32c9b737b Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 16:55:16 +0300 Subject: [PATCH 08/11] feat(ui): support nested server groups with indentation add hierarchical group support using '/' separator in group names implement proper indentation for nested groups in server list update group help text to reflect nested group support --- internal/adapters/ui/field_help.go | 4 +- internal/adapters/ui/server_list.go | 116 ++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 24 deletions(-) diff --git a/internal/adapters/ui/field_help.go b/internal/adapters/ui/field_help.go index 6341e55..f45658d 100644 --- a/internal/adapters/ui/field_help.go +++ b/internal/adapters/ui/field_help.go @@ -413,9 +413,9 @@ var fieldHelpData = map[string]FieldHelp{ }, "Group": { Field: "Group", - Description: "Group name for organizing servers in the list view.", + Description: "Group name for organizing servers. Use '/' for nested groups (e.g. Work/ProjectA).", Syntax: "any_string", - Examples: []string{"production", "database", "web-servers"}, + Examples: []string{"production", "database", "Work/ProjectA/DB"}, Default: "none", Category: "Basic", }, diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 37d8503..95c6c88 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -16,6 +16,7 @@ package ui import ( "fmt" + "strings" "github.com/Adembc/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" @@ -115,9 +116,9 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { sl.displayedHeaders = make([]string, 0) inPinnedSection := false - firstUnpinned := true - lastGroup := "" + // Helper to track nested groups + lastGroupParts := []string{} hasGroups := false for _, s := range servers { if s.Group != "" { @@ -126,15 +127,16 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { } } - addHeader := func(name string) { - isCollapsed := sl.collapsedGroups[name] + addHeader := func(fullPath string, name string, depth int) { + isCollapsed := sl.collapsedGroups[fullPath] icon := "[-]" if isCollapsed { icon = "[+]" } - sl.List.AddItem(fmt.Sprintf("[yellow::b]%s %s[-]", icon, name), "", 0, nil) + 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, name) + sl.displayedHeaders = append(sl.displayedHeaders, fullPath) } for i := range servers { @@ -145,8 +147,10 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { if !inPinnedSection { inPinnedSection = true if hasGroups { - addHeader("Pinned") + addHeader("Pinned", "Pinned", 0) } + // Reset group context when entering pinned + lastGroupParts = []string{} } if hasGroups && sl.collapsedGroups["Pinned"] { continue @@ -154,31 +158,99 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) { } else { if inPinnedSection { inPinnedSection = false + // Reset group context when leaving pinned + lastGroupParts = []string{} } - if firstUnpinned || s.Group != lastGroup { - if hasGroups { - groupName := s.Group - if groupName == "" { - groupName = "Ungrouped" + 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 } - addHeader(groupName) } - lastGroup = s.Group - firstUnpinned = false - } - currentGroup := s.Group - if currentGroup == "" { - currentGroup = "Ungrouped" - } - if hasGroups && sl.collapsedGroups[currentGroup] { + // 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]) - // Indent content primary = " " + primary idx := i From c3c8d594aa6cac56ccfbf0684b8ecdad396e9ddb Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 17:22:53 +0300 Subject: [PATCH 09/11] feat(ui): add group actions with tmux support for server groups Add context menu for server groups with option to connect to all servers in a group via tmux. The feature includes: - Group context menu triggered by 'm' key - Tmux session creation with synchronized panes - Server alias display in pane titles - Automatic layout management --- internal/adapters/ui/handlers.go | 144 +++++++++++++++++++++++++ internal/adapters/ui/server_details.go | 2 +- internal/adapters/ui/server_list.go | 35 ++++-- internal/adapters/ui/tui.go | 3 +- 4 files changed, 175 insertions(+), 9 deletions(-) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index f7d1497..d76973d 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" @@ -644,3 +647,144 @@ 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. + + var cmdParts []string + // Start first pane with name + cmdParts = append(cmdParts, fmt.Sprintf("tmux new-session -d -s %s -n '%s' 'ssh %s'", + sessionName, groupServers[0].Alias, groupServers[0].Alias)) + // Enable pane synchronization + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-window-option -t %s synchronize-panes on", sessionName)) + + for i := 1; i < len(groupServers); i++ { + // Create split with command, but setting pane title requires extra step or different flag + // -P allows setting options on create, -F format. + // Standard way to set pane title is printf escape sequence inside the shell or -T title + // But ssh usually overwrites it. + // We can use tmux select-pane -T after creation? + // Or rename window? But we have multiple panes in one window. + // tmux allow-rename off might be needed. + + // Simple approach: execute ssh + cmdParts = append(cmdParts, fmt.Sprintf("tmux split-window -t %s 'ssh %s'", sessionName, groupServers[i].Alias)) + // We can try to set pane title if tmux version supports it, but ssh often overrides. + // Let's rely on ssh displaying the hostname. + + // If user wants "pane title" visible, we need `set -g pane-border-status top` + // Let's enable pane border status for this session + } + + // Enable pane titles + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-status top", sessionName)) + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-format \"#{pane_index} #T\"", sessionName)) + + // Set titles for all panes (trickier because ssh runs immediately) + // We can wrap ssh command: "printf '\033]2;%s\033\\'; ssh %s" + + // Let's rebuild the command loop to include title setting via tmux select-pane -T + 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\"", sessionName, 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\"", sessionName, groupServers[0].Alias)) + + // Enable pane options + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-window-option -t %s synchronize-panes on", sessionName)) + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-status top", sessionName)) + cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-format \" #{pane_title} \"", 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\"", sessionName, sshCmdI)) + // Set title for the new pane (it becomes active after split) + cmdParts = append(cmdParts, fmt.Sprintf("tmux select-pane -t %s -T \"%s\"", sessionName, groupServers[i].Alias)) + } + + cmdParts = append(cmdParts, fmt.Sprintf("tmux select-layout -t %s tiled", sessionName)) + cmdParts = append(cmdParts, fmt.Sprintf("tmux attach-session -t %s", 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 4644559..e86ef4e 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -218,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_list.go b/internal/adapters/ui/server_list.go index 95c6c88..765a9b7 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -32,6 +32,7 @@ type ServerList struct { onSelection func(domain.Server) onSelectionChange func(domain.Server) onReturnToSearch func() + onGroupAction func(groupName string, action string) } func NewServerList() *ServerList { @@ -79,13 +80,16 @@ func (sl *ServerList) build() { 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' - if isSpace || isEnter { - idx := sl.List.GetCurrentItem() - if idx >= 0 && idx < len(sl.displayedHeaders) { - groupName := sl.displayedHeaders[idx] - if groupName != "" { - // It is a header + 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) @@ -100,7 +104,11 @@ func (sl *ServerList) build() { if newIdx >= 0 { sl.List.SetCurrentItem(newIdx) } - return nil // Consume event + return nil + } else if isMenu { + // Trigger Context Menu Action + sl.showGroupContextMenu(groupName) + return nil } } } @@ -312,6 +320,19 @@ func (sl *ServerList) OnReturnToSearch(fn func()) *ServerList { 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() 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() From 019c2c10799db9e120856028f0bf91006e6f64ba Mon Sep 17 00:00:00 2001 From: mindsolo Date: Sun, 4 Jan 2026 17:32:14 +0300 Subject: [PATCH 10/11] refactor(ui): simplify tmux session creation with proper quoting Use a quote helper function to properly escape tmux session names and commands Remove redundant commands and streamline pane title setting --- internal/adapters/ui/handlers.go | 52 +++++++------------------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index d76973d..af2be0d 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -708,64 +708,34 @@ func (t *tui) handleConnectGroupTmux(groupName string) { // If inside tmux, we shouldn't nest sessions easily without care. // For simplicity, let's assume we want to launch a new tmux session. - var cmdParts []string - // Start first pane with name - cmdParts = append(cmdParts, fmt.Sprintf("tmux new-session -d -s %s -n '%s' 'ssh %s'", - sessionName, groupServers[0].Alias, groupServers[0].Alias)) - // Enable pane synchronization - cmdParts = append(cmdParts, fmt.Sprintf("tmux set-window-option -t %s synchronize-panes on", sessionName)) - - for i := 1; i < len(groupServers); i++ { - // Create split with command, but setting pane title requires extra step or different flag - // -P allows setting options on create, -F format. - // Standard way to set pane title is printf escape sequence inside the shell or -T title - // But ssh usually overwrites it. - // We can use tmux select-pane -T after creation? - // Or rename window? But we have multiple panes in one window. - // tmux allow-rename off might be needed. - - // Simple approach: execute ssh - cmdParts = append(cmdParts, fmt.Sprintf("tmux split-window -t %s 'ssh %s'", sessionName, groupServers[i].Alias)) - // We can try to set pane title if tmux version supports it, but ssh often overrides. - // Let's rely on ssh displaying the hostname. - - // If user wants "pane title" visible, we need `set -g pane-border-status top` - // Let's enable pane border status for this session + quote := func(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } - // Enable pane titles - cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-status top", sessionName)) - cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-format \"#{pane_index} #T\"", sessionName)) - - // Set titles for all panes (trickier because ssh runs immediately) - // We can wrap ssh command: "printf '\033]2;%s\033\\'; ssh %s" - - // Let's rebuild the command loop to include title setting via tmux select-pane -T - cmdParts = []string{} + 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\"", sessionName, sshCmd0)) + 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\"", sessionName, groupServers[0].Alias)) + 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-window-option -t %s synchronize-panes on", sessionName)) - cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-status top", sessionName)) - cmdParts = append(cmdParts, fmt.Sprintf("tmux set-option -t %s pane-border-format \" #{pane_title} \"", sessionName)) + 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} "))) for i := 1; i < len(groupServers); i++ { sshCmdI := BuildSSHCommand(groupServers[i]) // Split window - cmdParts = append(cmdParts, fmt.Sprintf("tmux split-window -t %s \"%s\"", sessionName, sshCmdI)) + 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\"", sessionName, groupServers[i].Alias)) + 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", sessionName)) - cmdParts = append(cmdParts, fmt.Sprintf("tmux attach-session -t %s", sessionName)) + 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, " ; ") From d57f9bcc4b1820c330ae85523fda95b5a06523cf Mon Sep 17 00:00:00 2001 From: mindsolo Date: Mon, 5 Jan 2026 20:39:59 +0300 Subject: [PATCH 11/11] feat(ui): enable mouse support in tmux session --- internal/adapters/ui/handlers.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index af2be0d..1e3d627 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -726,6 +726,9 @@ func (t *tui) handleConnectGroupTmux(groupName string) { 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