- 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
222 lines
5.8 KiB
Go
222 lines
5.8 KiB
Go
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
|
|
}
|