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:
2026-04-16 00:00:00 +01:00
parent 8c9ce67684
commit 5e40d06a6c
17 changed files with 2472 additions and 16 deletions
+139
View File
@@ -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()
}