Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/adapters/data/ssh_config_file/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/data/ssh_config_file/metadata_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/ui/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
8 changes: 8 additions & 0 deletions internal/adapters/ui/field_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
132 changes: 132 additions & 0 deletions internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ package ui

import (
"fmt"
"os"
"os/exec"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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()
}
})
}
11 changes: 8 additions & 3 deletions internal/adapters/ui/server_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ", ")

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
89 changes: 68 additions & 21 deletions internal/adapters/ui/server_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -1645,6 +1684,7 @@ type ServerFormData struct {
Port string
Key string
Tags string
Group string

// Connection and proxy settings
ProxyJump string
Expand Down Expand Up @@ -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:"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Loading