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
+140
View File
@@ -21,7 +21,9 @@ import (
"github.com/copilot-ssh-client/app/internal/preferences"
"github.com/copilot-ssh-client/app/internal/secrets"
"github.com/copilot-ssh-client/app/internal/sessions"
"github.com/copilot-ssh-client/app/internal/sshconfig"
"github.com/copilot-ssh-client/app/internal/storage"
"github.com/copilot-ssh-client/app/internal/updater"
"github.com/google/uuid"
)
@@ -852,6 +854,144 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int
return imported, nil
}
// ---------------------------------------------------------------------------
// SFTP file browser
// ---------------------------------------------------------------------------
// SFTPList lists the contents of a remote directory.
func (s *Service) SFTPList(sessionID string, remotePath string) ([]map[string]interface{}, error) {
entries, err := s.sessions.SFTPList(sessionID, remotePath)
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(entries))
for i, e := range entries {
result[i] = map[string]interface{}{
"name": e.Name,
"path": e.Path,
"size": e.Size,
"isDir": e.IsDir,
"mode": e.Mode,
"modifiedAt": e.ModifiedAt,
}
}
return result, nil
}
// SFTPMkdir creates a remote directory (including parents).
func (s *Service) SFTPMkdir(sessionID string, remotePath string) error {
return s.sessions.SFTPMkdir(sessionID, remotePath)
}
// SFTPRemove deletes a remote file or empty directory.
func (s *Service) SFTPRemove(sessionID string, remotePath string) error {
return s.sessions.SFTPRemove(sessionID, remotePath)
}
// SFTPRename moves or renames a remote file/directory.
func (s *Service) SFTPRename(sessionID string, oldPath string, newPath string) error {
return s.sessions.SFTPRename(sessionID, oldPath, newPath)
}
// SFTPDownload downloads a remote file. Opens a native save dialog for the
// local destination.
func (s *Service) SFTPDownload(sessionID string, remotePath string) error {
if s.ctx == nil {
return fmt.Errorf("application context not set")
}
localPath, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Save File",
DefaultFilename: filepath.Base(remotePath),
})
if err != nil {
return fmt.Errorf("save dialog failed: %w", err)
}
if localPath == "" {
return fmt.Errorf("download cancelled")
}
return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
}
// SFTPUpload uploads a local file to a remote directory. Opens a native file
// dialog to select the local file.
func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
if s.ctx == nil {
return fmt.Errorf("application context not set")
}
localPath, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Select File to Upload",
})
if err != nil {
return fmt.Errorf("open dialog failed: %w", err)
}
if localPath == "" {
return fmt.Errorf("upload cancelled")
}
destPath := remotePath + "/" + filepath.Base(localPath)
return s.sessions.SFTPUpload(sessionID, localPath, destPath)
}
// SFTPPwd returns the current working directory on the remote host.
func (s *Service) SFTPPwd(sessionID string) (string, error) {
return s.sessions.SFTPPwd(sessionID)
}
// ---------------------------------------------------------------------------
// OpenSSH config import
// ---------------------------------------------------------------------------
// ImportSSHConfig parses the given SSH config file (or the default
// ~/.ssh/config if path is empty) and imports the Host blocks as profiles.
func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) {
if configPath == "" {
configPath = sshconfig.DefaultConfigPath()
}
blocks, err := sshconfig.ParseFile(configPath)
if err != nil {
return 0, err
}
profiles := sshconfig.ToProfiles(blocks, folder)
imported := 0
for _, p := range profiles {
if err := s.store.CreateProfile(&p); err != nil {
continue // skip duplicates or errors
}
imported++
}
s.auditLog.LogImport(imported)
return imported, nil
}
// ---------------------------------------------------------------------------
// Automatic update checking
// ---------------------------------------------------------------------------
// CheckForUpdates queries GitHub Releases for a newer version.
func (s *Service) CheckForUpdates() (map[string]interface{}, error) {
checker := updater.NewChecker(appVersion)
info, err := checker.Check(context.Background())
if err != nil {
return nil, err
}
return map[string]interface{}{
"available": info.Available,
"currentVersion": info.CurrentVersion,
"latestVersion": info.LatestVersion,
"releaseURL": info.ReleaseURL,
"releaseName": info.ReleaseName,
"releaseNotes": info.ReleaseNotes,
"publishedAt": info.PublishedAt,
}, nil
}
// TestConnection performs a basic TCP reachability check to the given host and port.
// This verifies the network path without establishing a full protocol session.
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {