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
|
|
@ -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 {
|
||||
|
|
|
|||
1355
internal/ui/model.go
1355
internal/ui/model.go
File diff suppressed because it is too large
Load diff
|
|
@ -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
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