From 0d6d069bf311544a949152d6b8309570229f60ea Mon Sep 17 00:00:00 2001 From: Mitchell Scott Date: Wed, 11 Feb 2026 07:13:57 -0700 Subject: [PATCH] feat: add folder upload/download. Closes #19 --- app.go | 609 +++++++++++++++++++++++- frontend/src/App.tsx | 3 + frontend/src/components/FileBrowser.tsx | 95 +++- frontend/src/components/ui/progress.tsx | 41 +- 4 files changed, 721 insertions(+), 27 deletions(-) diff --git a/app.go b/app.go index 6301c96c..3eb1da8f 100644 --- a/app.go +++ b/app.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net" "os" "path" @@ -84,6 +85,28 @@ func saveFileDialog(ctx context.Context, title, defaultFilename string) (string, }) } +func openDirectoryDialog(ctx context.Context, title string) (string, error) { + if platform.IsRunningInFlatpak() { + home, _ := os.UserHomeDir() + files, err := filechooser.OpenFile("", title, &filechooser.OpenFileOptions{ + CurrentFolder: home, + Directory: true, + }) + if err != nil { + return "", err + } + if len(files) == 0 { + return "", nil + } + return strings.TrimPrefix(files[0], "file://"), nil + } + home, _ := os.UserHomeDir() + return runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{ + Title: title, + DefaultDirectory: home, + }) +} + func sanitizeFilename(name string) string { replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "-", "<", "_", ">", "_", "\"", "_", "|", "_", "?", "_", "*", "_") return replacer.Replace(name) @@ -109,9 +132,11 @@ type App struct { reconnecting bool reconnectMu sync.Mutex fastDialMode bool - installCancelCh chan struct{} - backupCancelCh chan struct{} - backupMu sync.Mutex + installCancelCh chan struct{} + backupCancelCh chan struct{} + backupMu sync.Mutex + folderTransferCancelCh chan struct{} + folderTransferMu sync.Mutex shellSession *ssh.Session shellStdin io.WriteCloser @@ -2006,6 +2031,16 @@ type TransferProgress struct { Status string `json:"status"` } +type FolderTransferProgress struct { + CurrentFile string `json:"currentFile"` + FilesDone int `json:"filesDone"` + FilesTotal int `json:"filesTotal"` + BytesDone int64 `json:"bytesDone"` + BytesTotal int64 `json:"bytesTotal"` + Percentage float64 `json:"percentage"` + Status string `json:"status"` +} + type DialogRequest struct { Title string `json:"title"` Message string `json:"message"` @@ -3042,12 +3077,223 @@ func (a *App) DownloadFile(remotePath string) { } } + runtime.EventsEmit(a.ctx, "filebrowser:progress", TransferProgress{ + Filename: filename, + BytesSent: totalBytes, + TotalBytes: totalBytes, + Percentage: 100, + Status: "downloading", + }) + runtime.EventsEmit(a.ctx, "filebrowser:download-complete", map[string]string{ "path": remotePath, }) }() } +func (a *App) DownloadFolder(remotePath string) { + go func() { + a.mu.Lock() + client := a.client + a.mu.Unlock() + + if client == nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]interface{}{ + "message": "Not connected.", + "code": apperrors.ErrHostDown, + }) + return + } + + folderName := path.Base(remotePath) + localDir, err := openDirectoryDialog(a.ctx, "Save Folder To") + if err != nil || localDir == "" { + return + } + localBasePath := filepath.Join(localDir, folderName) + + a.folderTransferMu.Lock() + a.folderTransferCancelCh = make(chan struct{}) + cancelCh := a.folderTransferCancelCh + a.folderTransferMu.Unlock() + + sftpClient, err := sftp.NewClient(client) + if err != nil { + ue := apperrors.Classify(err) + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]interface{}{ + "message": ue.Message, + "code": ue.Code, + }) + return + } + defer sftpClient.Close() + + filesTotal, bytesTotal, err := a.countRemoteFolder(sftpClient, remotePath) + if err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Failed to scan folder: %v", err), + }) + return + } + + var filesDone int + var bytesDone int64 + var failedFiles []string + + err = a.downloadFolderRecursive(sftpClient, remotePath, localBasePath, &filesDone, &bytesDone, filesTotal, bytesTotal, &failedFiles, cancelCh) + + select { + case <-cancelCh: + runtime.EventsEmit(a.ctx, "filebrowser:folder-download-complete", map[string]interface{}{ + "cancelled": true, + }) + return + default: + } + + if err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Download failed: %v", err), + }) + return + } + + runtime.EventsEmit(a.ctx, "filebrowser:folder-download-complete", map[string]interface{}{ + "path": remotePath, + "failedFiles": failedFiles, + }) + }() +} + +func (a *App) countRemoteFolder(sftpClient *sftp.Client, remotePath string) (int, int64, error) { + var filesTotal int + var bytesTotal int64 + + walker := sftpClient.Walk(remotePath) + for walker.Step() { + if err := walker.Err(); err != nil { + continue + } + info := walker.Stat() + if info.Mode()&os.ModeSymlink != 0 { + filesTotal++ + } else if !info.IsDir() { + filesTotal++ + bytesTotal += info.Size() + } + } + + return filesTotal, bytesTotal, nil +} + +func (a *App) downloadFolderRecursive(sftpClient *sftp.Client, remotePath, localPath string, filesDone *int, bytesDone *int64, filesTotal int, bytesTotal int64, failedFiles *[]string, cancelCh chan struct{}) error { + select { + case <-cancelCh: + return fmt.Errorf("cancelled") + default: + } + + info, err := sftpClient.Lstat(remotePath) + if err != nil { + return err + } + + if info.Mode()&os.ModeSymlink != 0 { + target, err := sftpClient.ReadLink(remotePath) + if err != nil { + *failedFiles = append(*failedFiles, remotePath) + return nil + } + if err := os.Symlink(target, localPath); err != nil { + *failedFiles = append(*failedFiles, remotePath) + } + *filesDone++ + a.emitFolderProgress(path.Base(remotePath), *filesDone, filesTotal, *bytesDone, bytesTotal, "downloading") + return nil + } + + if info.IsDir() { + if err := os.MkdirAll(localPath, 0755); err != nil { + return err + } + + entries, err := sftpClient.ReadDir(remotePath) + if err != nil { + return err + } + + for _, entry := range entries { + remoteEntryPath := path.Join(remotePath, entry.Name()) + localEntryPath := filepath.Join(localPath, entry.Name()) + + if err := a.downloadFolderRecursive(sftpClient, remoteEntryPath, localEntryPath, filesDone, bytesDone, filesTotal, bytesTotal, failedFiles, cancelCh); err != nil { + return err + } + } + return nil + } + + remoteFile, err := sftpClient.Open(remotePath) + if err != nil { + *failedFiles = append(*failedFiles, remotePath) + return nil + } + defer remoteFile.Close() + + localFile, err := os.Create(localPath) + if err != nil { + *failedFiles = append(*failedFiles, remotePath) + return nil + } + defer localFile.Close() + + buffer := make([]byte, 32*1024) + for { + select { + case <-cancelCh: + return fmt.Errorf("cancelled") + default: + } + + n, err := remoteFile.Read(buffer) + if n > 0 { + if _, writeErr := localFile.Write(buffer[:n]); writeErr != nil { + *failedFiles = append(*failedFiles, remotePath) + return nil + } + *bytesDone += int64(n) + a.emitFolderProgress(path.Base(remotePath), *filesDone, filesTotal, *bytesDone, bytesTotal, "downloading") + } + if err == io.EOF { + break + } + if err != nil { + *failedFiles = append(*failedFiles, remotePath) + return nil + } + } + + *filesDone++ + a.emitFolderProgress(path.Base(remotePath), *filesDone, filesTotal, *bytesDone, bytesTotal, "downloading") + return nil +} + +func (a *App) emitFolderProgress(currentFile string, filesDone, filesTotal int, bytesDone, bytesTotal int64, status string) { + var percentage float64 + if bytesTotal > 0 { + percentage = float64(bytesDone) / float64(bytesTotal) * 100 + } + runtime.EventsEmit(a.ctx, "filebrowser:folder-progress", FolderTransferProgress{ + CurrentFile: currentFile, + FilesDone: filesDone, + FilesTotal: filesTotal, + BytesDone: bytesDone, + BytesTotal: bytesTotal, + Percentage: percentage, + Status: status, + }) +} + func (a *App) UploadFile(remotePath string) { go func() { a.mu.Lock() @@ -3157,12 +3403,231 @@ func (a *App) UploadFile(remotePath string) { } } + runtime.EventsEmit(a.ctx, "filebrowser:progress", TransferProgress{ + Filename: filename, + BytesSent: totalBytes, + TotalBytes: totalBytes, + Percentage: 100, + Status: "uploading", + }) + runtime.EventsEmit(a.ctx, "filebrowser:upload-complete", map[string]string{ "path": destPath, }) }() } +func (a *App) UploadFolder(remotePath string) { + go func() { + a.mu.Lock() + client := a.client + a.mu.Unlock() + + if client == nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": "Not connected", + }) + return + } + + localDir, err := openDirectoryDialog(a.ctx, "Select Folder to Upload") + if err != nil || localDir == "" { + return + } + + a.folderTransferMu.Lock() + a.folderTransferCancelCh = make(chan struct{}) + cancelCh := a.folderTransferCancelCh + a.folderTransferMu.Unlock() + + folderName := filepath.Base(localDir) + destPath := remotePath + if strings.HasSuffix(remotePath, "/") || remotePath == "" { + destPath = path.Join(remotePath, folderName) + } + + needsWritable := isSystemPath(destPath) + if needsWritable { + if err := a.makeFilesystemWritable(client); err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Failed to prepare filesystem: %v", err), + }) + return + } + defer a.restoreFilesystemDeferred(client) + } + + sftpClient, err := sftp.NewClient(client) + if err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Failed to create SFTP client: %v", err), + }) + return + } + defer sftpClient.Close() + + filesTotal, bytesTotal, err := a.countLocalFolder(localDir) + if err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Failed to scan folder: %v", err), + }) + return + } + + var filesDone int + var bytesDone int64 + var failedFiles []string + + err = a.uploadFolderRecursive(sftpClient, localDir, destPath, &filesDone, &bytesDone, filesTotal, bytesTotal, &failedFiles, cancelCh) + + select { + case <-cancelCh: + runtime.EventsEmit(a.ctx, "filebrowser:folder-upload-complete", map[string]interface{}{ + "cancelled": true, + }) + return + default: + } + + if err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Upload failed: %v", err), + }) + return + } + + runtime.EventsEmit(a.ctx, "filebrowser:folder-upload-complete", map[string]interface{}{ + "path": destPath, + "failedFiles": failedFiles, + }) + }() +} + +func (a *App) countLocalFolder(localPath string) (int, int64, error) { + var filesTotal int + var bytesTotal int64 + + err := filepath.WalkDir(localPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + filesTotal++ + } else if !d.IsDir() { + filesTotal++ + bytesTotal += info.Size() + } + return nil + }) + + return filesTotal, bytesTotal, err +} + +func (a *App) uploadFolderRecursive(sftpClient *sftp.Client, localPath, remotePath string, filesDone *int, bytesDone *int64, filesTotal int, bytesTotal int64, failedFiles *[]string, cancelCh chan struct{}) error { + select { + case <-cancelCh: + return fmt.Errorf("cancelled") + default: + } + + info, err := os.Lstat(localPath) + if err != nil { + return err + } + + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(localPath) + if err != nil { + *failedFiles = append(*failedFiles, localPath) + return nil + } + if err := sftpClient.Symlink(target, remotePath); err != nil { + *failedFiles = append(*failedFiles, localPath) + } + *filesDone++ + a.emitFolderProgress(filepath.Base(localPath), *filesDone, filesTotal, *bytesDone, bytesTotal, "uploading") + return nil + } + + if info.IsDir() { + if err := sftpClient.MkdirAll(remotePath); err != nil { + return err + } + + entries, err := os.ReadDir(localPath) + if err != nil { + return err + } + + for _, entry := range entries { + localEntryPath := filepath.Join(localPath, entry.Name()) + remoteEntryPath := path.Join(remotePath, entry.Name()) + + if err := a.uploadFolderRecursive(sftpClient, localEntryPath, remoteEntryPath, filesDone, bytesDone, filesTotal, bytesTotal, failedFiles, cancelCh); err != nil { + return err + } + } + return nil + } + + localFile, err := os.Open(localPath) + if err != nil { + *failedFiles = append(*failedFiles, localPath) + return nil + } + defer localFile.Close() + + remoteFile, err := sftpClient.Create(remotePath) + if err != nil { + *failedFiles = append(*failedFiles, localPath) + return nil + } + defer remoteFile.Close() + + buffer := make([]byte, 32*1024) + for { + select { + case <-cancelCh: + return fmt.Errorf("cancelled") + default: + } + + n, err := localFile.Read(buffer) + if n > 0 { + if _, writeErr := remoteFile.Write(buffer[:n]); writeErr != nil { + *failedFiles = append(*failedFiles, localPath) + return nil + } + *bytesDone += int64(n) + a.emitFolderProgress(filepath.Base(localPath), *filesDone, filesTotal, *bytesDone, bytesTotal, "uploading") + } + if err == io.EOF { + break + } + if err != nil { + *failedFiles = append(*failedFiles, localPath) + return nil + } + } + + *filesDone++ + a.emitFolderProgress(filepath.Base(localPath), *filesDone, filesTotal, *bytesDone, bytesTotal, "uploading") + return nil +} + +func (a *App) CancelFolderTransfer() { + a.folderTransferMu.Lock() + defer a.folderTransferMu.Unlock() + if a.folderTransferCancelCh != nil { + close(a.folderTransferCancelCh) + a.folderTransferCancelCh = nil + } +} + func (a *App) UploadFilesFromPaths(localPaths []string, remotePath string) { go func() { a.mu.Lock() @@ -3180,6 +3645,20 @@ func (a *App) UploadFilesFromPaths(localPaths []string, remotePath string) { return } + var hasFolder bool + for _, localPath := range localPaths { + info, err := os.Stat(localPath) + if err == nil && info.IsDir() { + hasFolder = true + break + } + } + + if hasFolder { + a.uploadMixedPaths(client, localPaths, remotePath) + return + } + sftpClient, err := sftp.NewClient(client) if err != nil { runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ @@ -3203,6 +3682,122 @@ func (a *App) UploadFilesFromPaths(localPaths []string, remotePath string) { }() } +func (a *App) uploadMixedPaths(client *ssh.Client, localPaths []string, remotePath string) { + a.folderTransferMu.Lock() + a.folderTransferCancelCh = make(chan struct{}) + cancelCh := a.folderTransferCancelCh + a.folderTransferMu.Unlock() + + needsWritable := isSystemPath(remotePath) + if needsWritable { + if err := a.makeFilesystemWritable(client); err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Failed to prepare filesystem: %v", err), + }) + return + } + defer a.restoreFilesystemDeferred(client) + } + + sftpClient, err := sftp.NewClient(client) + if err != nil { + runtime.EventsEmit(a.ctx, "filebrowser:error", map[string]string{ + "message": fmt.Sprintf("Failed to create SFTP client: %v", err), + }) + return + } + defer sftpClient.Close() + + var filesTotal int + var bytesTotal int64 + for _, localPath := range localPaths { + info, err := os.Stat(localPath) + if err != nil { + continue + } + if info.IsDir() { + f, b, _ := a.countLocalFolder(localPath) + filesTotal += f + bytesTotal += b + } else { + filesTotal++ + bytesTotal += info.Size() + } + } + + var filesDone int + var bytesDone int64 + var failedFiles []string + + for _, localPath := range localPaths { + select { + case <-cancelCh: + runtime.EventsEmit(a.ctx, "filebrowser:folder-upload-complete", map[string]interface{}{ + "cancelled": true, + }) + return + default: + } + + info, err := os.Stat(localPath) + if err != nil { + failedFiles = append(failedFiles, localPath) + continue + } + + if info.IsDir() { + folderName := filepath.Base(localPath) + destPath := path.Join(remotePath, folderName) + if err := a.uploadFolderRecursive(sftpClient, localPath, destPath, &filesDone, &bytesDone, filesTotal, bytesTotal, &failedFiles, cancelCh); err != nil { + continue + } + } else { + destPath := path.Join(remotePath, info.Name()) + localFile, err := os.Open(localPath) + if err != nil { + failedFiles = append(failedFiles, localPath) + continue + } + + remoteFile, err := sftpClient.Create(destPath) + if err != nil { + localFile.Close() + failedFiles = append(failedFiles, localPath) + continue + } + + buffer := make([]byte, 32*1024) + for { + n, err := localFile.Read(buffer) + if n > 0 { + if _, writeErr := remoteFile.Write(buffer[:n]); writeErr != nil { + failedFiles = append(failedFiles, localPath) + break + } + bytesDone += int64(n) + a.emitFolderProgress(info.Name(), filesDone, filesTotal, bytesDone, bytesTotal, "uploading") + } + if err == io.EOF { + filesDone++ + a.emitFolderProgress(info.Name(), filesDone, filesTotal, bytesDone, bytesTotal, "uploading") + break + } + if err != nil { + failedFiles = append(failedFiles, localPath) + break + } + } + localFile.Close() + remoteFile.Close() + } + } + + runtime.EventsEmit(a.ctx, "filebrowser:folder-upload-complete", map[string]interface{}{ + "path": remotePath, + "failedFiles": failedFiles, + }) +} + func (a *App) uploadSingleFile(client *ssh.Client, sftpClient *sftp.Client, localPath string, remotePath string) error { localFile, err := os.Open(localPath) if err != nil { @@ -3272,6 +3867,14 @@ func (a *App) uploadSingleFile(client *ssh.Client, sftpClient *sftp.Client, loca } } + runtime.EventsEmit(a.ctx, "filebrowser:progress", TransferProgress{ + Filename: filename, + BytesSent: totalBytes, + TotalBytes: totalBytes, + Percentage: 100, + Status: "uploading", + }) + return nil } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 462c7541..b9be15aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -209,6 +209,9 @@ declare global { DownloadFile(remotePath: string): void UploadFile(remotePath: string): void UploadFilesFromPaths(localPaths: string[], remotePath: string): void + DownloadFolder(remotePath: string): void + UploadFolder(remotePath: string): void + CancelFolderTransfer(): void DeletePath(path: string): Promise RenamePath(oldPath: string, newPath: string): Promise CreateDirectory(path: string): Promise diff --git a/frontend/src/components/FileBrowser.tsx b/frontend/src/components/FileBrowser.tsx index 49cfc5d5..6c6a9c5f 100644 --- a/frontend/src/components/FileBrowser.tsx +++ b/frontend/src/components/FileBrowser.tsx @@ -47,6 +47,7 @@ import { Archive, Upload, FolderPlus, + FolderUp, RefreshCw, Download, Trash2, @@ -60,6 +61,7 @@ import { Eye, EyeOff, Terminal, + X, } from 'lucide-react' interface FileInfo { @@ -79,6 +81,16 @@ interface TransferProgress { status: string } +interface FolderTransferProgress { + currentFile: string + filesDone: number + filesTotal: number + bytesDone: number + bytesTotal: number + percentage: number + status: string +} + interface FileBrowserProps { isConnected: boolean suppressSystemFileWarnings: boolean @@ -147,6 +159,7 @@ export function FileBrowser({ isConnected, suppressSystemFileWarnings, isVisible const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [transferProgress, setTransferProgress] = useState(null) + const [folderTransferProgress, setFolderTransferProgress] = useState(null) const [deleteDialog, setDeleteDialog] = useState(null) const [renameDialog, setRenameDialog] = useState(null) @@ -216,18 +229,34 @@ export function FileBrowser({ isConnected, suppressSystemFileWarnings, isVisible const errorObj = err as { message: string; code?: string } setError(errorObj.code ? handleError(errorObj, 'File transfer') : handleError(errorObj.message, 'File transfer')) setTransferProgress(null) + setFolderTransferProgress(null) + } + + const handleFolderProgress = (progress: unknown) => { + setFolderTransferProgress(progress as FolderTransferProgress) + } + + const handleFolderComplete = () => { + setFolderTransferProgress(null) + loadDirectory(currentPath) } const unsubProgress = window.runtime.EventsOn('filebrowser:progress', handleProgress) const unsubDownload = window.runtime.EventsOn('filebrowser:download-complete', handleComplete) const unsubUpload = window.runtime.EventsOn('filebrowser:upload-complete', handleComplete) const unsubError = window.runtime.EventsOn('filebrowser:error', handleFileBrowserError) + const unsubFolderProgress = window.runtime.EventsOn('filebrowser:folder-progress', handleFolderProgress) + const unsubFolderDownload = window.runtime.EventsOn('filebrowser:folder-download-complete', handleFolderComplete) + const unsubFolderUpload = window.runtime.EventsOn('filebrowser:folder-upload-complete', handleFolderComplete) return () => { unsubProgress() unsubDownload() unsubUpload() unsubError() + unsubFolderProgress() + unsubFolderDownload() + unsubFolderUpload() } }, [currentPath, loadDirectory]) @@ -366,6 +395,22 @@ export function FileBrowser({ isConnected, suppressSystemFileWarnings, isVisible } } + const handleDownloadFolder = (file: FileInfo) => { + if (file.isDir) { + window.go.main.App.DownloadFolder(file.path) + } + } + + const handleUploadFolder = () => { + confirmSystemFileAction('upload', currentPath, () => { + window.go.main.App.UploadFolder(currentPath + '/') + }) + } + + const handleCancelFolderTransfer = () => { + window.go.main.App.CancelFolderTransfer() + } + const initiateDelete = (file: FileInfo) => { if (isSystemPath(file.path) && !suppressSystemFileWarnings) { confirmSystemFileAction('delete', file.path, () => setDeleteDialog(file)) @@ -594,9 +639,13 @@ export function FileBrowser({ isConnected, suppressSystemFileWarnings, isVisible Home - + + + + +
+
Current: {folderTransferProgress.currentFile}
+
Files: {folderTransferProgress.filesDone} / {folderTransferProgress.filesTotal}
+
{formatSize(folderTransferProgress.bytesDone)} / {formatSize(folderTransferProgress.bytesTotal)}
+
+ + )} + {/* File table */}
@@ -708,7 +785,19 @@ export function FileBrowser({ isConnected, suppressSystemFileWarnings, isVisible - {!file.isDir && ( + {file.isDir ? ( + + + + ) : (