Skip to content

Commit ed5c0f8

Browse files
committed
feat: add "Add from SSH" feature to parse SSH commands from clipboard
- Add new keybinding 'v' to quickly add servers from SSH commands - Parse SSH commands from clipboard (supports various formats) - Auto-fill server form with parsed data (host, user, port, key) - Support formats: ssh user@host, ssh -p port -i key user@host, etc. - Add comprehensive tests for SSH command parser - Update UI components (hint bar, status bar, details panel) - Update README with new feature and keybinding This feature complements the existing "Copy SSH" command, allowing users to quickly add new server connections by pasting SSH commands they may have from other sources.
1 parent c9b874f commit ed5c0f8

File tree

8 files changed

+407
-9
lines changed

8 files changed

+407
-9
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
1515
### Server Management
1616
- 📜 Read & display servers from your `~/.ssh/config` in a scrollable list.
1717
- ➕ Add a new server from the UI by specifying alias, host/IP, username, port, identity file.
18+
- 📋 Add from SSH command in clipboard (e.g., paste `ssh user@host -p 2222` to quickly add).
1819
- ✏ Edit existing server entries directly from the UI.
1920
- 🗑 Delete server entries safely.
2021
- 📌 Pin / unpin servers to keep favorites at the top.
@@ -147,6 +148,7 @@ make run
147148
| g | Ping selected server |
148149
| r | Refresh background data |
149150
| a | Add server |
151+
| v | Add from SSH (parse clipboard)|
150152
| e | Edit server |
151153
| t | Edit tags |
152154
| d | Delete server |

internal/adapters/ui/handlers.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
4545
case 'a':
4646
t.handleServerAdd()
4747
return nil
48+
case 'v':
49+
t.handleAddFromSSH()
50+
return nil
4851
case 'e':
4952
t.handleServerEdit()
5053
return nil
@@ -125,6 +128,34 @@ func (t *tui) handleCopyCommand() {
125128
}
126129
}
127130

131+
func (t *tui) handleAddFromSSH() {
132+
// Read from clipboard
133+
cmd, err := clipboard.ReadAll()
134+
if err != nil {
135+
t.showStatusTempColor("Failed to read from clipboard", "#FF6B6B")
136+
return
137+
}
138+
139+
cmd = strings.TrimSpace(cmd)
140+
if cmd == "" {
141+
t.showStatusTempColor("Clipboard is empty", "#FF6B6B")
142+
return
143+
}
144+
145+
// Parse SSH command
146+
data, err := ParseSSHCommand(cmd)
147+
if err != nil {
148+
t.showStatusTempColor(fmt.Sprintf("Invalid SSH command: %v", err), "#FF6B6B")
149+
return
150+
}
151+
152+
// Show form for user to confirm and edit
153+
form := NewServerFormFromData(ServerFormAdd, data).
154+
OnSave(t.handleServerSave).
155+
OnCancel(t.handleFormCancel)
156+
t.app.SetRoot(form, true)
157+
}
158+
128159
func (t *tui) handleTagsEdit() {
129160
if server, ok := t.serverList.GetSelectedServer(); ok {
130161
t.showEditTagsForm(server)

internal/adapters/ui/hint_bar.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ import (
2222
func NewHintBar() *tview.TextView {
2323
hint := tview.NewTextView().SetDynamicColors(true)
2424
hint.SetBackgroundColor(tcell.Color233)
25-
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]")
25+
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • v Add from SSH • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]")
2626
return hint
2727
}

internal/adapters/ui/server_details.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
7171
}
7272
tagsText := renderTagChips(server.Tags)
7373
text := fmt.Sprintf(
74-
"[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\n[::b]Commands:[-]\n Enter: SSH connect\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",
74+
"[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n v: Add from SSH\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin",
7575
server.Alias, server.Host, server.User, server.Port,
7676
serverKey, tagsText, pinnedStr,
7777
lastSeen, server.SSHCount)

internal/adapters/ui/server_form.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ const (
3535

3636
type ServerForm struct {
3737
*tview.Form
38-
mode ServerFormMode
39-
original *domain.Server
40-
onSave func(domain.Server, *domain.Server)
41-
onCancel func()
38+
mode ServerFormMode
39+
original *domain.Server
40+
prefilledData *ServerFormData
41+
onSave func(domain.Server, *domain.Server)
42+
onCancel func()
4243
}
4344

4445
func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm {
@@ -51,6 +52,18 @@ func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm {
5152
return form
5253
}
5354

55+
// NewServerFormFromData creates a new ServerForm from ServerFormData
56+
func NewServerFormFromData(mode ServerFormMode, data ServerFormData) *ServerForm {
57+
form := &ServerForm{
58+
Form: tview.NewForm(),
59+
mode: mode,
60+
original: nil,
61+
prefilledData: &data,
62+
}
63+
form.build()
64+
return form
65+
}
66+
5467
func (sf *ServerForm) build() {
5568
title := sf.titleForMode()
5669

@@ -76,7 +89,11 @@ func (sf *ServerForm) titleForMode() string {
7689

7790
func (sf *ServerForm) addFormFields() {
7891
var defaultValues ServerFormData
79-
if sf.mode == ServerFormEdit && sf.original != nil {
92+
switch {
93+
case sf.prefilledData != nil:
94+
// Use prefilled data
95+
defaultValues = *sf.prefilledData
96+
case sf.mode == ServerFormEdit && sf.original != nil:
8097
defaultValues = ServerFormData{
8198
Alias: sf.original.Alias,
8299
Host: sf.original.Host,
@@ -85,7 +102,7 @@ func (sf *ServerForm) addFormFields() {
85102
Key: sf.original.Key,
86103
Tags: strings.Join(sf.original.Tags, ", "),
87104
}
88-
} else {
105+
default:
89106
defaultValues = ServerFormData{
90107
User: "root",
91108
Port: "22",

internal/adapters/ui/ssh_parser.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright 2025.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package ui
16+
17+
import (
18+
"fmt"
19+
"strconv"
20+
"strings"
21+
22+
"github.com/Adembc/lazyssh/internal/core/domain"
23+
)
24+
25+
// ParseSSHCommand parses an SSH command string and returns ServerFormData
26+
// Supported formats:
27+
// - ssh user@host
28+
// - ssh host
29+
// - ssh user@host -p port
30+
// - ssh user@host -i keyfile
31+
// - ssh -p port user@host
32+
// - ssh -i keyfile user@host
33+
func ParseSSHCommand(cmd string) (ServerFormData, error) {
34+
data := ServerFormData{
35+
User: "root",
36+
Port: "22",
37+
Key: "~/.ssh/id_ed25519",
38+
}
39+
40+
// Trim whitespace and split into tokens
41+
cmd = strings.TrimSpace(cmd)
42+
tokens := strings.Fields(cmd)
43+
44+
if len(tokens) == 0 {
45+
return data, fmt.Errorf("empty command")
46+
}
47+
48+
// Check if it starts with ssh (optional)
49+
i := 0
50+
if tokens[0] == "ssh" {
51+
i = 1
52+
}
53+
54+
if i >= len(tokens) {
55+
return data, fmt.Errorf("no host specified")
56+
}
57+
58+
// Parse arguments and host
59+
var hostPart string
60+
for i < len(tokens) {
61+
token := tokens[i]
62+
63+
// Handle arguments
64+
if strings.HasPrefix(token, "-") {
65+
switch token {
66+
case "-p":
67+
// Handle port
68+
if i+1 < len(tokens) {
69+
i++
70+
port, err := strconv.Atoi(tokens[i])
71+
if err != nil || port < 1 || port > 65535 {
72+
return data, fmt.Errorf("invalid port: %s", tokens[i])
73+
}
74+
data.Port = tokens[i]
75+
} else {
76+
return data, fmt.Errorf("missing port value after -p")
77+
}
78+
case "-i":
79+
// Handle key file
80+
if i+1 < len(tokens) {
81+
i++
82+
data.Key = tokens[i]
83+
} else {
84+
return data, fmt.Errorf("missing key file after -i")
85+
}
86+
default:
87+
// Ignore other arguments
88+
}
89+
} else if hostPart == "" {
90+
// This should be host or user@host
91+
hostPart = token
92+
}
93+
i++
94+
}
95+
96+
if hostPart == "" {
97+
return data, fmt.Errorf("no host specified")
98+
}
99+
100+
// Parse user@host
101+
if strings.Contains(hostPart, "@") {
102+
parts := strings.SplitN(hostPart, "@", 2)
103+
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
104+
data.User = parts[0]
105+
data.Host = parts[1]
106+
} else {
107+
return data, fmt.Errorf("invalid user@host format: %s", hostPart)
108+
}
109+
} else {
110+
data.Host = hostPart
111+
}
112+
113+
// Generate default alias
114+
if data.Host != "" {
115+
// If it's an IP address, use it directly
116+
alias := data.Host
117+
// Check if it's an IP address
118+
if !strings.Contains(alias, ":") && strings.Count(alias, ".") == 3 {
119+
// Might be IPv4, check if all parts are numbers
120+
parts := strings.Split(alias, ".")
121+
isIP := true
122+
for _, part := range parts {
123+
if _, err := strconv.Atoi(part); err != nil {
124+
isIP = false
125+
break
126+
}
127+
}
128+
if !isIP {
129+
// Not an IP, remove domain suffix to create short alias
130+
if idx := strings.Index(alias, "."); idx > 0 {
131+
alias = alias[:idx]
132+
}
133+
}
134+
} else if !strings.Contains(alias, ":") {
135+
// Not an IP, remove domain suffix to create short alias
136+
if idx := strings.Index(alias, "."); idx > 0 {
137+
alias = alias[:idx]
138+
}
139+
}
140+
data.Alias = alias
141+
}
142+
143+
return data, nil
144+
}
145+
146+
// BuildServerFromSSHCommand builds a Server object from an SSH command
147+
func BuildServerFromSSHCommand(cmd string) (domain.Server, error) {
148+
data, err := ParseSSHCommand(cmd)
149+
if err != nil {
150+
return domain.Server{}, err
151+
}
152+
153+
// Validate form data
154+
if errMsg := validateServerForm(data); errMsg != "" {
155+
return domain.Server{}, fmt.Errorf("%s", errMsg)
156+
}
157+
158+
// Convert port
159+
port := 22
160+
if data.Port != "" {
161+
if n, err := strconv.Atoi(data.Port); err == nil && n > 0 {
162+
port = n
163+
}
164+
}
165+
166+
// Process tags
167+
var tags []string
168+
if data.Tags != "" {
169+
for _, t := range strings.Split(data.Tags, ",") {
170+
if s := strings.TrimSpace(t); s != "" {
171+
tags = append(tags, s)
172+
}
173+
}
174+
}
175+
176+
return domain.Server{
177+
Alias: data.Alias,
178+
Host: data.Host,
179+
User: data.User,
180+
Port: port,
181+
Key: data.Key,
182+
Tags: tags,
183+
}, nil
184+
}

0 commit comments

Comments
 (0)