SSH connection status indicators
- Add Connected bool field to vfs.Entry and RemoteMount - Track connection status in sshState.connectedHosts - Show status icon (connected/disconnected) in pane header when browsing remote host - Async SSH connection test with cancel support for Add Host dialog - Colored labels and styled help text in SSH dialogs - Confirmation dialog when deleting manually-added SSH hosts
This commit is contained in:
parent
df4df6b8f6
commit
1ed2d3defb
224 changed files with 33447 additions and 236 deletions
222
internal/ui/ssh.go
Normal file
222
internal/ui/ssh.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue