vcom/internal/ui/ssh.go

223 lines
5.8 KiB
Go
Raw Normal View History

package ui
import (
"path"
"strings"
vfs "vcom/internal/fs"
"vcom/internal/fs/remote"
"github.com/charmbracelet/bubbles/textinput"
)
// isRemoteHostEntry returns true if the entry represents an SSH host.
func isRemoteHostEntry(entry vfs.Entry) bool {
return entry.IsRemote
}
// isRemoteAddHostEntry returns true if the entry is the "Add host" special item.
func isRemoteAddHostEntry(entry vfs.Entry) bool {
return entry.IsRemote && entry.Name == "+ Add host"
}
// buildSSHHostEntries creates virtual directory entries for SSH hosts.
// connectedHosts optionally specifies which hosts have active connections
// (host name -> true). When non-nil, entries show a connection status prefix.
func buildSSHHostEntries(store *remote.HostStore, connectedHosts map[string]bool) []vfs.Entry {
hosts := store.AllHosts()
entries := make([]vfs.Entry, 0, len(hosts)+1)
for _, h := range hosts {
isConnected := connectedHosts != nil && connectedHosts[h.Name]
entries = append(entries, vfs.Entry{
Name: h.DisplayName(),
Path: "ssh://" + h.Name,
IsDir: true,
IsRemote: true,
Connected: isConnected,
RemoteHostName: h.Name,
Mode: 0o755,
})
}
// Add "Add host" entry at the bottom
entries = append(entries, vfs.Entry{
Name: "+ Add host",
Path: "ssh://add-host",
IsDir: false,
IsRemote: true,
})
return entries
}
// SSHConnectDialogInputs creates the text input fields for the SSH connect dialog.
func SSHConnectDialogInputs() []textinput.Model {
fields := make([]textinput.Model, 5)
placeholders := []string{"my-server", "192.168.1.100", "22", "root", ""}
for i := range fields {
ti := textinput.New()
ti.Placeholder = placeholders[i]
ti.CharLimit = 128
ti.Width = 40
if i == 4 {
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
}
if i == 0 {
ti.Focus()
}
fields[i] = ti
}
return fields
}
// sshDialogLabel returns the label for an SSH dialog field at the given index.
func sshDialogLabel(index int) string {
lbls := []string{"Name:", "Hostname/IP:", "Port:", "User:", "Password:"}
if index >= 0 && index < len(lbls) {
return lbls[index]
}
return ""
}
// sshDialogValue returns the value for an SSH dialog field.
func sshDialogValue(inputs []textinput.Model, index int) string {
if index >= 0 && index < len(inputs) {
return inputs[index].Value()
}
return ""
}
// buildSSHHostFromDialog creates an SSHHost from dialog input values.
func buildSSHHostFromDialog(inputs []textinput.Model) remote.SSHHost {
host := remote.SSHHost{
Name: strings.TrimSpace(sshDialogValue(inputs, 0)),
HostName: strings.TrimSpace(sshDialogValue(inputs, 1)),
Port: strings.TrimSpace(sshDialogValue(inputs, 2)),
User: strings.TrimSpace(sshDialogValue(inputs, 3)),
Password: sshDialogValue(inputs, 4),
}
if host.Port == "" {
host.Port = "22"
}
return host
}
// formatSSHConnectHelp returns the help text for the SSH connect dialog.
func formatSSHConnectHelp() string {
return `Fields:
Name alias for this host (required)
Hostname/IP server address (required)
Port SSH port, default 22
User login username (required)
Password password for password-based auth
Navigation: Tab / Shift+Tab between fields
Confirm: Enter
Close: Esc / q
Help: F1 / ?`
}
// remoteDirToEntries reads a remote directory via SFTP and converts to vfs.Entry slice.
func remoteDirToEntries(remotePath string, sshClient *remote.SSHClient) ([]vfs.Entry, error) {
fileInfos, err := sshClient.ReadDir(remotePath)
if err != nil {
return nil, err
}
entries := make([]vfs.Entry, 0, len(fileInfos)+1)
// Add parent directory entry if not at root
if remotePath != "/" {
parent := path.Dir(remotePath)
if parent == "" {
parent = "/"
}
entries = append(entries, vfs.Entry{
Name: "..",
Path: parent,
IsDir: true,
IsParent: true,
})
}
for _, info := range fileInfos {
name := info.Name()
isDir := info.IsDir()
fullPath := path.Join(remotePath, name)
ext := ""
if idx := strings.LastIndex(name, "."); idx > 0 {
ext = strings.ToLower(name[idx+1:])
}
entry := vfs.Entry{
Name: name,
Path: fullPath,
Extension: ext,
Mode: info.Mode(),
Size: info.Size(),
ModifiedAt: info.ModTime(),
IsDir: isDir,
IsHidden: strings.HasPrefix(name, "."),
}
entries = append(entries, entry)
}
return entries, nil
}
// sshState holds SSH-related state for the model.
type sshState struct {
store *remote.HostStore
inputs []textinput.Model
inputFocus int
showHelp bool
// Connection test state
testingConn bool // true while an async connection test is in progress
cancelTest func() // call to abort a pending connection test
// connectedHosts tracks which host names have active SSH connections.
// Updated when connections are established or closed.
connectedHosts map[string]bool
// activeClients stores SSH clients for hosts whose connections are kept alive
// after returning to the host list. Clients are closed on full SSH mode exit.
activeClients map[string]*remote.SSHClient
}
// cycleInput shifts focus between dialog inputs by delta (+1/-1).
func (s *sshState) cycleInput(delta int) {
if s == nil || len(s.inputs) == 0 {
return
}
s.inputs[s.inputFocus].Blur()
s.inputFocus = (s.inputFocus + delta) % len(s.inputs)
if s.inputFocus < 0 {
s.inputFocus += len(s.inputs)
}
s.inputs[s.inputFocus].Focus()
}
// newSSHState creates a new sshState with a HostStore.
func newSSHState() (*sshState, error) {
store, err := remote.NewHostStore()
if err != nil {
return nil, err
}
return &sshState{
store: store,
inputs: SSHConnectDialogInputs(),
inputFocus: 0,
activeClients: make(map[string]*remote.SSHClient),
showHelp: false,
connectedHosts: make(map[string]bool),
}, nil
}