Phase 4: SFTP file browser, SSH config import, and update checking
SFTP file browser: - Created internal/sftpclient wrapping github.com/pkg/sftp for remote file operations (list, stat, mkdir, remove, rename, download, upload, pwd) - Integrated SFTP into session manager with lazy client creation per session - Added API methods for all SFTP operations with native file dialogs - Built SFTPBrowser frontend component — right-side resizable panel with breadcrumb navigation, sorted file listing, context actions (download, rename, delete), upload, new folder, and custom modal dialogs - Toggle via Cmd+Shift+F on active SSH sessions - Auto-refits terminal when panel opens/closes/resizes - Panel cleaned up automatically when session closes OpenSSH config import: - Created internal/sshconfig parser for ~/.ssh/config Host blocks - Converts to profiles with hostname, port, user, identity file, ProxyJump (first hop), ForwardAgent, and Compression support - Skips wildcard-only hosts and Match blocks - Import button added to Settings > About tab Automatic update checking: - Created internal/updater querying GitHub Releases API - Simple semver comparison, 10-second timeout - Dev builds never trigger update prompts - Check button added to Settings > About tab
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/copilot-ssh-client/app/internal/rawclient"
|
||||
"github.com/copilot-ssh-client/app/internal/recording"
|
||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
||||
"github.com/copilot-ssh-client/app/internal/sftpclient"
|
||||
"github.com/copilot-ssh-client/app/internal/sshclient"
|
||||
"github.com/copilot-ssh-client/app/internal/storage"
|
||||
"github.com/copilot-ssh-client/app/internal/telnetclient"
|
||||
@@ -32,6 +33,7 @@ type Session struct {
|
||||
rawClient *rawclient.Client
|
||||
githubClient *githubclient.Client
|
||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||
profile *models.Profile
|
||||
recorder *recording.Recorder // active recording, nil if not recording
|
||||
}
|
||||
@@ -826,6 +828,137 @@ func (m *Manager) DeleteRecording(id string) error {
|
||||
return m.recordStore.Delete(id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP file browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// getSFTP returns or lazily creates the SFTP client for an SSH session.
|
||||
func (m *Manager) getSFTP(sessionID string) (*sftpclient.Client, error) {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
if sess.sshClient == nil {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
|
||||
}
|
||||
|
||||
// Return existing SFTP client if already open.
|
||||
if sess.sftpClient != nil {
|
||||
c := sess.sftpClient
|
||||
m.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Create a new SFTP subsystem.
|
||||
sshConn := sess.sshClient.SSHConn()
|
||||
if sshConn == nil {
|
||||
return nil, fmt.Errorf("sessions: SSH connection is not established")
|
||||
}
|
||||
|
||||
sc, err := sftpclient.NewClient(sshConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
sess.sftpClient = sc
|
||||
m.mu.Unlock()
|
||||
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// SFTPList lists the contents of a remote directory.
|
||||
func (m *Manager) SFTPList(sessionID, remotePath string) ([]sftpclient.FileEntry, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sc.List(remotePath)
|
||||
}
|
||||
|
||||
// SFTPStat returns information about a remote path.
|
||||
func (m *Manager) SFTPStat(sessionID, remotePath string) (*sftpclient.FileEntry, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sc.Stat(remotePath)
|
||||
}
|
||||
|
||||
// SFTPMkdir creates a remote directory.
|
||||
func (m *Manager) SFTPMkdir(sessionID, remotePath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.MkdirAll(remotePath)
|
||||
}
|
||||
|
||||
// SFTPRemove deletes a remote file or empty directory.
|
||||
func (m *Manager) SFTPRemove(sessionID, remotePath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Remove(remotePath)
|
||||
}
|
||||
|
||||
// SFTPRename moves or renames a remote file/directory.
|
||||
func (m *Manager) SFTPRename(sessionID, oldPath, newPath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
// SFTPDownload downloads a remote file to a local path.
|
||||
func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
"done": p.Done,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// SFTPUpload uploads a local file to a remote path.
|
||||
func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
"done": p.Done,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// SFTPPwd returns the current working directory on the remote host.
|
||||
func (m *Manager) SFTPPwd(sessionID string) (string, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sc.Pwd()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port-forwarding management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -992,6 +1125,12 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
sess.recorder = nil
|
||||
}
|
||||
|
||||
// Close SFTP subsystem if open.
|
||||
if sess.sftpClient != nil {
|
||||
sess.sftpClient.Close()
|
||||
sess.sftpClient = nil
|
||||
}
|
||||
|
||||
if sess.sshClient != nil {
|
||||
sess.sshClient.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user