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:
vrubelroman 2026-04-29 03:11:53 +03:00
parent df4df6b8f6
commit 1ed2d3defb
224 changed files with 33447 additions and 236 deletions

View file

@ -15,6 +15,7 @@ type KeyMap struct {
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
SSH key.Binding
Up key.Binding
Down key.Binding
SelectUp key.Binding
@ -54,7 +55,8 @@ func DefaultKeyMap() KeyMap {
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")),
SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")),
@ -83,7 +85,7 @@ func DefaultKeyMap() KeyMap {
}
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Rename, k.View, k.Archive, k.Copy, k.Move, k.Mkdir, k.Delete, k.Info, k.Quit, k.PermanentDelete}
return []key.Binding{k.Help, k.Rename, k.View, k.Archive, k.Copy, k.Move, k.Mkdir, k.Delete, k.Info, k.Quit, k.SSH}
}
func (k KeyMap) FullHelp() [][]key.Binding {

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ import (
"vcom/internal/config"
vfs "vcom/internal/fs"
"vcom/internal/fs/remote"
"vcom/internal/theme"
)
@ -27,6 +28,7 @@ type BrowserPane struct {
Offset int
Marked map[string]struct{}
Archive []ArchiveMount
Remote []RemoteMount
dirHistory []string
dirFuture []string
@ -39,6 +41,14 @@ type ArchiveMount struct {
TempDir string
}
// RemoteMount represents an active SSH/SFTP remote filesystem connection.
type RemoteMount struct {
Host remote.SSHHost
RemotePath string
Client *remote.SSHClient
Connected bool
}
func (p *BrowserPane) Selected() (vfs.Entry, bool) {
if len(p.Entries) == 0 || p.Cursor < 0 || p.Cursor >= len(p.Entries) {
return vfs.Entry{}, false
@ -268,7 +278,52 @@ func (p *BrowserPane) ClearArchives() []ArchiveMount {
return out
}
func (p *BrowserPane) PushRemote(mount RemoteMount) {
p.Remote = append(p.Remote, mount)
}
func (p *BrowserPane) PopRemote() (RemoteMount, bool) {
if len(p.Remote) == 0 {
return RemoteMount{}, false
}
last := p.Remote[len(p.Remote)-1]
p.Remote = p.Remote[:len(p.Remote)-1]
return last, true
}
func (p *BrowserPane) CurrentRemote() (RemoteMount, bool) {
if len(p.Remote) == 0 {
return RemoteMount{}, false
}
return p.Remote[len(p.Remote)-1], true
}
func (p *BrowserPane) InRemote() bool {
return len(p.Remote) > 0
}
func (p *BrowserPane) ClearRemotes() []RemoteMount {
if len(p.Remote) == 0 {
return nil
}
out := make([]RemoteMount, len(p.Remote))
copy(out, p.Remote)
p.Remote = nil
return out
}
func (p *BrowserPane) DisplayPath() string {
if len(p.Remote) > 0 {
top := p.Remote[len(p.Remote)-1]
statusIcon := "󰖩"
if !top.Connected {
statusIcon = "󰤭"
}
if top.RemotePath == "/" || top.RemotePath == "" {
return fmt.Sprintf("%s %s:", statusIcon, top.Host.Name)
}
return fmt.Sprintf("%s %s:%s", statusIcon, top.Host.Name, top.RemotePath)
}
if len(p.Archive) == 0 {
return p.Path
}
@ -705,6 +760,8 @@ func entryIcon(entry vfs.Entry, useNerdIcons bool) string {
switch entry.Category() {
case "parent":
return "<-"
case "remote":
return "[SV]"
case "directory":
return "[D]"
case "config":
@ -724,6 +781,8 @@ func entryIcon(entry vfs.Entry, useNerdIcons bool) string {
switch entry.Category() {
case "parent":
return "↩"
case "remote":
return "󰒋"
case "directory":
return ""
case "config":
@ -743,6 +802,8 @@ func entryIcon(entry vfs.Entry, useNerdIcons bool) string {
func entryColor(entry vfs.Entry, palette theme.Palette) lipgloss.Color {
switch entry.Category() {
case "remote":
return palette.ExecFile
case "directory", "parent":
return palette.Folder
case "config":

222
internal/ui/ssh.go Normal file
View 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
}