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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
### Quick Server Navigation
- 🔍 Fuzzy search by alias, IP, or tags.
- 🖥 One‑keypress SSH into the selected server (Enter).
- 📋 Copy SSH command to clipboard & paste SSH commands from clipboard to quickly add servers.
- 🏷 Tag servers (e.g., prod, dev, test) for quick filtering.
- ↕️ Sort by alias or last SSH (toggle + reverse).

Expand Down Expand Up @@ -168,6 +169,7 @@ make run
| ↑↓/jk | Navigate servers |
| Enter | SSH into selected server |
| c | Copy SSH command to clipboard |
| v | Paste SSH command from clipboard |
| g | Ping selected server |
| r | Refresh background data |
| a | Add server |
Expand Down
151 changes: 151 additions & 0 deletions internal/adapters/ui/alias_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ui

import (
"fmt"
"strconv"
"strings"
)

// GenerateUniqueAlias generates a unique alias by appending a suffix if necessary
func GenerateUniqueAlias(baseAlias string, existingAliases []string) string {
// Build a map for faster lookup
aliasMap := make(map[string]bool, len(existingAliases))
for _, alias := range existingAliases {
aliasMap[alias] = true
}

// If the base alias is unique, return it
if !aliasMap[baseAlias] {
return baseAlias
}

// Extract the base name and current suffix (if any)
// e.g., "server_1" -> base: "server", suffix: 1
// e.g., "server" -> base: "server", suffix: 0
baseName := baseAlias

// Check if the alias already has a numeric suffix
if lastUnderscore := strings.LastIndex(baseAlias, "_"); lastUnderscore != -1 {
possibleSuffix := baseAlias[lastUnderscore+1:]
if _, err := strconv.Atoi(possibleSuffix); err == nil {
// It has a valid numeric suffix
baseName = baseAlias[:lastUnderscore]
}
}

// Find the highest suffix number for this base name
maxSuffix := 0
basePattern := baseName + "_"

// Check all existing aliases to find the max suffix
for _, alias := range existingAliases {
if alias == baseName {
// The base name without suffix exists, so we need at least _1
if maxSuffix < 0 {
maxSuffix = 0
}
} else if strings.HasPrefix(alias, basePattern) {
suffixStr := strings.TrimPrefix(alias, basePattern)
if suffix, err := strconv.Atoi(suffixStr); err == nil && suffix > maxSuffix {
maxSuffix = suffix
}
}
}

// Return the base name with the next available suffix
return fmt.Sprintf("%s_%d", baseName, maxSuffix+1)
}

// GenerateSmartAlias generates a smart alias from host, user, and port
// It simplifies domain names and handles IP addresses intelligently
func GenerateSmartAlias(host, user string, port int) string {
alias := host

// Simplify common domain patterns
// Remove www. prefix
alias = strings.TrimPrefix(alias, "www.")

// For FQDN, extract the meaningful part
// e.g., server.example.com -> server or example
if strings.Contains(alias, ".") && !IsIPAddress(alias) {
parts := strings.Split(alias, ".")
// If it has subdomain, use the subdomain
if len(parts) > 2 {
// e.g., api.github.com -> api.github
// or dev.server.example.com -> dev.server
if parts[0] != "www" {
alias = strings.Join(parts[:2], ".")
} else {
alias = parts[1] // Skip www
}
} else if len(parts) == 2 {
// Simple domain like example.com -> example
alias = parts[0]
}
}

// Optionally prepend user if it's not a common one
if user != "" && !isCommonUser(user) {
alias = fmt.Sprintf("%s@%s", user, alias)
}

// Append port if non-standard
if port != 0 && port != 22 {
alias = fmt.Sprintf("%s:%d", alias, port)
}

return alias
}

// isCommonUser checks if a username is a common default username
func isCommonUser(user string) bool {
// Common usernames that don't need to be included in alias
// These are default users for various cloud providers and Linux distributions
commonUsers := map[string]bool{
// Common administrative users
"root": true,
"admin": true,
"administrator": true,

// Linux distribution default users
"ubuntu": true, // Ubuntu
"debian": true, // Debian
"centos": true, // CentOS
"fedora": true, // Fedora
"alpine": true, // Alpine Linux
"arch": true, // Arch Linux

// Cloud provider default users
"ec2-user": true, // AWS Amazon Linux
"azureuser": true, // Azure
"opc": true, // Oracle Cloud
"cloud-user": true, // Various cloud images
"cloud_user": true, // Alternative format

// Container/orchestration platforms
"core": true, // CoreOS
"rancher": true, // RancherOS
"docker": true, // Docker

// Other common defaults
"user": true, // Generic
"guest": true, // Guest account
"vagrant": true, // Vagrant boxes
}

return commonUsers[strings.ToLower(user)]
}
220 changes: 220 additions & 0 deletions internal/adapters/ui/alias_utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ui

import "testing"

func TestGenerateUniqueAlias(t *testing.T) {
tests := []struct {
name string
baseAlias string
existingAliases []string
want string
}{
{
name: "no duplicates",
baseAlias: "server",
existingAliases: []string{"other", "another"},
want: "server",
},
{
name: "one duplicate",
baseAlias: "server",
existingAliases: []string{"server", "other"},
want: "server_1",
},
{
name: "multiple duplicates in sequence",
baseAlias: "server",
existingAliases: []string{"server", "server_1", "server_2"},
want: "server_3",
},
{
name: "duplicates with gap",
baseAlias: "server",
existingAliases: []string{"server", "server_1", "server_5"},
want: "server_6",
},
{
name: "empty existing aliases",
baseAlias: "server",
existingAliases: []string{},
want: "server",
},
{
name: "nil existing aliases",
baseAlias: "server",
existingAliases: nil,
want: "server",
},
{
name: "copying alias with suffix",
baseAlias: "123_1",
existingAliases: []string{"123", "123_1"},
want: "123_2",
},
{
name: "copying alias with suffix and gap",
baseAlias: "server_2",
existingAliases: []string{"server", "server_1", "server_2", "server_5"},
want: "server_6",
},
{
name: "alias with underscore but not numeric suffix",
baseAlias: "my_server",
existingAliases: []string{"my_server"},
want: "my_server_1",
},
{
name: "complex case with numeric alias",
baseAlias: "123",
existingAliases: []string{"123"},
want: "123_1",
},
{
name: "copy already suffixed numeric alias",
baseAlias: "123_1",
existingAliases: []string{"123", "123_1", "123_2"},
want: "123_3",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GenerateUniqueAlias(tt.baseAlias, tt.existingAliases)
if got != tt.want {
t.Errorf("GenerateUniqueAlias() = %v, want %v", got, tt.want)
}
})
}
}

func TestGenerateSmartAlias(t *testing.T) {
tests := []struct {
name string
host string
user string
port int
want string
}{
{
name: "simple domain",
host: "example.com",
user: "",
port: 22,
want: "example",
},
{
name: "www prefix",
host: "www.example.com",
user: "",
port: 22,
want: "example",
},
{
name: "subdomain",
host: "api.github.com",
user: "",
port: 22,
want: "api.github",
},
{
name: "complex subdomain",
host: "dev.server.example.com",
user: "",
port: 22,
want: "dev.server",
},
{
name: "IP address",
host: "192.168.1.1",
user: "",
port: 22,
want: "192.168.1.1",
},
{
name: "with non-standard port",
host: "example.com",
user: "",
port: 2222,
want: "example:2222",
},
{
name: "with custom user",
host: "example.com",
user: "developer",
port: 22,
want: "developer@example",
},
{
name: "with common user (root)",
host: "example.com",
user: "root",
port: 22,
want: "example",
},
{
name: "with common user (ubuntu)",
host: "example.com",
user: "ubuntu",
port: 22,
want: "example",
},
{
name: "with common user (ec2-user)",
host: "example.com",
user: "ec2-user",
port: 22,
want: "example",
},
{
name: "with common user (centos)",
host: "example.com",
user: "centos",
port: 22,
want: "example",
},
{
name: "with common user (azureuser)",
host: "example.com",
user: "azureuser",
port: 22,
want: "example",
},
{
name: "full combination",
host: "api.github.com",
user: "developer",
port: 2222,
want: "[email protected]:2222",
},
{
name: "IPv6 address",
host: "2001:db8::1",
user: "",
port: 22,
want: "2001:db8::1",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GenerateSmartAlias(tt.host, tt.user, tt.port)
if got != tt.want {
t.Errorf("GenerateSmartAlias() = %v, want %v", got, tt.want)
}
})
}
}
Loading