瀏覽代碼

SFTP folder downloads (#10586)

Eugene 2 月之前
父節點
當前提交
7e1905c32c

+ 1 - 1
package.json

@@ -40,7 +40,7 @@
     "cross-env": "7.0.3",
     "css-loader": "^6.7.3",
     "deep-equal": "2.0.5",
-    "electron": "^36.3",
+    "electron": "^36.4",
     "electron-builder": "^26.0",
     "electron-download": "^4.1.1",
     "electron-installer-snap": "^5.1.0",

+ 1 - 1
tabby-core/src/api/index.ts

@@ -10,7 +10,7 @@ export { Theme } from './theme'
 export { TabContextMenuItemProvider } from './tabContextMenuProvider'
 export { SelectorOption } from './selector'
 export { CLIHandler, CLIEvent } from './cli'
-export { PlatformService, ClipboardContent, MessageBoxResult, MessageBoxOptions, FileDownload, FileUpload, FileTransfer, HTMLFileUpload, FileUploadOptions, DirectoryUpload } from './platform'
+export { PlatformService, ClipboardContent, MessageBoxResult, MessageBoxOptions, FileDownload, FileUpload, FileTransfer, HTMLFileUpload, FileUploadOptions, DirectoryUpload, DirectoryDownload, PlatformTheme } from './platform'
 export { MenuItemOptions } from './menu'
 export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
 export { HostWindowService } from './hostWindow'

+ 33 - 3
tabby-core/src/api/platform.ts

@@ -22,7 +22,6 @@ export interface MessageBoxResult {
 
 export abstract class FileTransfer {
     abstract getName (): string
-    abstract getMode (): number
     abstract getSize (): number
     abstract close (): void
 
@@ -34,8 +33,16 @@ export abstract class FileTransfer {
         return this.completedBytes
     }
 
+    getStatus (): string {
+        return this.status
+    }
+
+    getTotalSize (): number {
+        return this.totalSize
+    }
+
     isComplete (): boolean {
-        return this.completedBytes >= this.getSize()
+        return this.completed
     }
 
     isCancelled (): boolean {
@@ -47,6 +54,18 @@ export abstract class FileTransfer {
         this.close()
     }
 
+    setStatus (status: string): void {
+        this.status = status
+    }
+
+    setTotalSize (size: number): void {
+        this.totalSize = size
+    }
+
+    setCompleted (completed: boolean): void {
+        this.completed = completed
+    }
+
     protected increaseProgress (bytes: number): void {
         if (!bytes) {
             return
@@ -57,16 +76,26 @@ export abstract class FileTransfer {
     }
 
     private completedBytes = 0
+    private totalSize = 0
     private lastChunkStartTime = Date.now()
     private lastChunkSpeed = 0
     private cancelled = false
+    private completed = false
+    private status = ''
 }
 
 export abstract class FileDownload extends FileTransfer {
     abstract write (buffer: Uint8Array): Promise<void>
 }
 
+export abstract class DirectoryDownload extends FileTransfer {
+    abstract createDirectory (relativePath: string): Promise<void>
+    abstract createFile (relativePath: string, mode: number, size: number): Promise<FileDownload>
+}
+
 export abstract class FileUpload extends FileTransfer {
+    abstract getMode (): number
+
     abstract read (): Promise<Uint8Array>
 
     async readAll (): Promise<Uint8Array> {
@@ -127,6 +156,7 @@ export abstract class PlatformService {
     abstract saveConfig (content: string): Promise<void>
 
     abstract startDownload (name: string, mode: number, size: number): Promise<FileDownload|null>
+    abstract startDownloadDirectory (name: string, estimatedSize?: number): Promise<DirectoryDownload|null>
     abstract startUpload (options?: FileUploadOptions): Promise<FileUpload[]>
     abstract startUploadDirectory (paths?: string[]): Promise<DirectoryUpload>
 
@@ -237,7 +267,7 @@ export abstract class PlatformService {
     abstract setErrorHandler (handler: (_: any) => void): void
     abstract popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void
     abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
-    abstract pickDirectory (): Promise<string>
+    abstract pickDirectory (): Promise<string | null>
     abstract quit (): void
 }
 

+ 3 - 1
tabby-core/src/components/transfersMenu.component.pug

@@ -5,7 +5,9 @@
     .icon(*ngIf='isDownload(transfer)') !{require('../icons/download.svg')}
     .icon(*ngIf='!isDownload(transfer)') !{require('../icons/upload.svg')}
     .main
-        label.no-wrap([ngbTooltip]='transfer.getName()') {{transfer.getName()}}
+        label.no-wrap([ngbTooltip]='transfer.getName()')
+            | {{transfer.getName()}}
+            span.ms-2.text-muted(*ngIf='transfer.getStatus()') ({{transfer.getStatus()}})
         ngb-progressbar([type]='transfer.isComplete() ? "success" : transfer.isCancelled() ? "danger" : "info"', [value]='getProgress(transfer)')
         .metadata
             .size {{transfer.getSize()|filesize}}

+ 87 - 9
tabby-electron/src/services/platform.service.ts

@@ -5,12 +5,11 @@ import * as os from 'os'
 import promiseIpc, { RendererProcessType } from 'electron-promise-ipc'
 import { execFile } from 'mz/child_process'
 import { Injectable, NgZone } from '@angular/core'
-import { PlatformService, ClipboardContent, Platform, MenuItemOptions, MessageBoxOptions, MessageBoxResult, DirectoryUpload, FileUpload, FileDownload, FileUploadOptions, wrapPromise, TranslateService } from 'tabby-core'
+import { PlatformService, ClipboardContent, Platform, MenuItemOptions, MessageBoxOptions, MessageBoxResult, DirectoryUpload, FileUpload, FileDownload, DirectoryDownload, FileUploadOptions, wrapPromise, TranslateService, FileTransfer, PlatformTheme } from 'tabby-core'
 import { ElectronService } from '../services/electron.service'
 import { ElectronHostWindow } from './hostWindow.service'
 import { ShellIntegrationService } from './shellIntegration.service'
 import { ElectronHostAppService } from './hostApp.service'
-import { PlatformTheme } from '../../../tabby-core/src/api/platform'
 import { configPath } from '../../../app/lib/config'
 const fontManager = require('fontmanager-redux') // eslint-disable-line
 
@@ -272,19 +271,48 @@ export class ElectronPlatformService extends PlatformService {
         return transfer
     }
 
+    async startDownloadDirectory (name: string, estimatedSize?: number): Promise<DirectoryDownload|null> {
+        const selectedFolder = await this.pickDirectory(this.translate.instant('Select destination folder for {name}', { name }), this.translate.instant('Download here'))
+        if (!selectedFolder) {
+            return null
+        }
+
+        let downloadPath = path.join(selectedFolder, name)
+        let counter = 1
+        while (fsSync.existsSync(downloadPath)) {
+            downloadPath = path.join(selectedFolder, `${name} (${counter})`)
+            counter++
+        }
+
+        const transfer = new ElectronDirectoryDownload(downloadPath, name, estimatedSize ?? 0, this.electron, this.zone)
+        await wrapPromise(this.zone, transfer.open())
+        this.fileTransferStarted.next(transfer)
+        return transfer
+    }
+
+    _registerFileTransfer (transfer: FileTransfer): void {
+        this.fileTransferStarted.next(transfer)
+    }
+
     setErrorHandler (handler: (_: any) => void): void {
         this.electron.ipcRenderer.on('uncaughtException', (_$event, err) => {
             handler(err)
         })
     }
 
-    async pickDirectory (): Promise<string> {
-        return (await this.electron.dialog.showOpenDialog(
+    async pickDirectory (title?: string, buttonLabel?: string): Promise<string | null> {
+        const result = await this.electron.dialog.showOpenDialog(
             this.hostWindow.getWindow(),
             {
+                title,
+                buttonLabel,
                 properties: ['openDirectory', 'showHiddenFiles'],
             },
-        )).filePaths[0]
+        )
+        if (result.canceled || !result.filePaths.length) {
+            return null
+        }
+        return result.filePaths[0]
     }
 
     getTheme (): PlatformTheme {
@@ -313,6 +341,7 @@ class ElectronFileUpload extends FileUpload {
         const stat = await fs.stat(this.filePath)
         this.size = stat.size
         this.mode = stat.mode
+        this.setTotalSize(this.size)
         this.file = await fs.open(this.filePath, 'r')
     }
 
@@ -331,6 +360,9 @@ class ElectronFileUpload extends FileUpload {
     async read (): Promise<Uint8Array> {
         const result = await this.file.read(this.buffer, 0, this.buffer.length, null)
         this.increaseProgress(result.bytesRead)
+        if (this.getCompletedBytes() >= this.getSize()) {
+            this.setCompleted(true)
+        }
         return this.buffer.slice(0, result.bytesRead)
     }
 
@@ -352,6 +384,7 @@ class ElectronFileDownload extends FileDownload {
     ) {
         super()
         this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension')
+        this.setTotalSize(size)
     }
 
     async open (): Promise<void> {
@@ -362,10 +395,6 @@ class ElectronFileDownload extends FileDownload {
         return path.basename(this.filePath)
     }
 
-    getMode (): number {
-        return this.mode
-    }
-
     getSize (): number {
         return this.size
     }
@@ -377,6 +406,9 @@ class ElectronFileDownload extends FileDownload {
             this.increaseProgress(result.bytesWritten)
             pos += result.bytesWritten
         }
+        if (this.getCompletedBytes() >= this.getSize()) {
+            this.setCompleted(true)
+        }
     }
 
     close (): void {
@@ -384,3 +416,49 @@ class ElectronFileDownload extends FileDownload {
         this.file.close()
     }
 }
+
+class ElectronDirectoryDownload extends DirectoryDownload {
+    private powerSaveBlocker = 0
+
+    constructor (
+        private basePath: string,
+        private name: string,
+        estimatedSize: number,
+        private electron: ElectronService,
+        private zone: NgZone,
+    ) {
+        super()
+        this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension')
+        this.setTotalSize(estimatedSize)
+    }
+
+    async open (): Promise<void> {
+        await fs.mkdir(this.basePath, { recursive: true })
+    }
+
+    getName (): string {
+        return this.name
+    }
+
+    getSize (): number {
+        return this.getTotalSize()
+    }
+
+    async createDirectory (relativePath: string): Promise<void> {
+        const fullPath = path.join(this.basePath, relativePath)
+        await fs.mkdir(fullPath, { recursive: true })
+    }
+
+    async createFile (relativePath: string, mode: number, size: number): Promise<FileDownload> {
+        const fullPath = path.join(this.basePath, relativePath)
+        await fs.mkdir(path.dirname(fullPath), { recursive: true })
+
+        const fileDownload = new ElectronFileDownload(fullPath, mode, size, this.electron)
+        await wrapPromise(this.zone, fileDownload.open())
+        return fileDownload
+    }
+
+    close (): void {
+        this.electron.powerSaveBlocker.stop(this.powerSaveBlocker)
+    }
+}

+ 5 - 1
tabby-local/src/components/localProfileSettings.component.ts

@@ -28,6 +28,10 @@ export class LocalProfileSettingsComponent implements ProfileSettingsComponent<L
         //     return
         // }
 
-        this.profile.options.cwd = await this.platform.pickDirectory()
+        const cwd = await this.platform.pickDirectory()
+        if (!cwd) {
+            return
+        }
+        this.profile.options.cwd = cwd
     }
 }

+ 63 - 1
tabby-ssh/src/components/sftpPanel.component.ts

@@ -1,7 +1,7 @@
 import * as C from 'constants'
 import { posix as path } from 'path'
 import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core'
-import { FileUpload, DirectoryUpload, MenuItemOptions, NotificationsService, PlatformService } from 'tabby-core'
+import { FileUpload, DirectoryUpload, DirectoryDownload, MenuItemOptions, NotificationsService, PlatformService } from 'tabby-core'
 import { SFTPSession, SFTPFile } from '../session/sftp'
 import { SSHSession } from '../session/ssh'
 import { SFTPContextMenuItemProvider } from '../api'
@@ -220,6 +220,68 @@ export class SFTPPanelComponent {
         this.sftp.download(itemPath, transfer)
     }
 
+    async downloadFolder (folder: SFTPFile): Promise<void> {
+        try {
+            const transfer = await this.platform.startDownloadDirectory(folder.name, 0)
+            if (!transfer) {
+                return
+            }
+
+            // Start background size calculation and download simultaneously
+            const sizeCalculationPromise = this.calculateFolderSizeAndUpdate(folder, transfer)
+            const downloadPromise = this.downloadFolderRecursive(folder, transfer, '')
+
+            try {
+                await Promise.all([sizeCalculationPromise, downloadPromise])
+                transfer.setStatus('')
+                transfer.setCompleted(true)
+            } catch (error) {
+                transfer.cancel()
+                throw error
+            } finally {
+                transfer.close()
+            }
+        } catch (error) {
+            this.notifications.error(`Failed to download folder: ${error.message}`)
+            throw error
+        }
+    }
+
+    private async calculateFolderSizeAndUpdate (folder: SFTPFile, transfer: DirectoryDownload) {
+        let totalSize = 0
+        const items = await this.sftp.readdir(folder.fullPath)
+        for (const item of items) {
+            if (item.isDirectory) {
+                totalSize += await this.calculateFolderSizeAndUpdate(item, transfer)
+            } else {
+                totalSize += item.size
+            }
+            transfer.setTotalSize(totalSize)
+        }
+        return totalSize
+    }
+
+    private async downloadFolderRecursive (folder: SFTPFile, transfer: DirectoryDownload, relativePath: string): Promise<void> {
+        const items = await this.sftp.readdir(folder.fullPath)
+
+        for (const item of items) {
+            if (transfer.isCancelled()) {
+                throw new Error('Download cancelled')
+            }
+
+            const itemRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name
+
+            transfer.setStatus(itemRelativePath)
+            if (item.isDirectory) {
+                await transfer.createDirectory(itemRelativePath)
+                await this.downloadFolderRecursive(item, transfer, itemRelativePath)
+            } else {
+                const fileDownload = await transfer.createFile(itemRelativePath, item.mode, item.size)
+                await this.sftp.download(item.fullPath, fileDownload)
+            }
+        }
+    }
+
     getModeString (item: SFTPFile): string {
         const s = 'SGdrwxrwxrwx'
         const e = '   ---------'

+ 32 - 20
tabby-ssh/src/sftpContextMenu.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { MenuItemOptions, PlatformService, TranslateService } from 'tabby-core'
+import { MenuItemOptions, PlatformService, TranslateService, HostAppService, Platform } from 'tabby-core'
 import { SFTPSession, SFTPFile } from './session/sftp'
 import { SFTPContextMenuItemProvider } from './api'
 import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
@@ -16,37 +16,49 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider {
         private platform: PlatformService,
         private ngbModal: NgbModal,
         private translate: TranslateService,
+        private hostApp: HostAppService,
     ) {
         super()
     }
 
     async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise<MenuItemOptions[]> {
-        return [
+        const items: MenuItemOptions[] = [
             {
                 click: async () => {
                     await panel.openCreateDirectoryModal()
                 },
                 label: this.translate.instant('Create directory'),
             },
-            {
-                click: async () => {
-                    if ((await this.platform.showMessageBox({
-                        type: 'warning',
-                        message: this.translate.instant('Delete {fullPath}?', item),
-                        defaultId: 0,
-                        cancelId: 1,
-                        buttons: [
-                            this.translate.instant('Delete'),
-                            this.translate.instant('Cancel'),
-                        ],
-                    })).response === 0) {
-                        await this.deleteItem(item, panel.sftp)
-                        panel.navigate(panel.path)
-                    }
-                },
-                label: this.translate.instant('Delete'),
-            },
         ]
+
+        // Add download folder option for directories (only in electron)
+        if (item.isDirectory && this.hostApp.platform !== Platform.Web) {
+            items.push({
+                click: () => panel.downloadFolder(item),
+                label: this.translate.instant('Download directory'),
+            })
+        }
+
+        items.push({
+            click: async () => {
+                if ((await this.platform.showMessageBox({
+                    type: 'warning',
+                    message: this.translate.instant('Delete {fullPath}?', item),
+                    defaultId: 0,
+                    cancelId: 1,
+                    buttons: [
+                        this.translate.instant('Delete'),
+                        this.translate.instant('Cancel'),
+                    ],
+                })).response === 0) {
+                    await this.deleteItem(item, panel.sftp)
+                    panel.navigate(panel.path)
+                }
+            },
+            label: this.translate.instant('Delete'),
+        })
+
+        return items
     }
 
     async deleteItem (item: SFTPFile, session: SFTPSession): Promise<void> {

+ 5 - 1
tabby-web/src/platform.ts

@@ -2,7 +2,7 @@ import '@vaadin/vaadin-context-menu'
 import copyToClipboard from 'copy-text-to-clipboard'
 import { Injectable, Inject } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { PlatformService, ClipboardContent, MenuItemOptions, MessageBoxOptions, MessageBoxResult, FileUpload, FileUploadOptions, FileDownload, HTMLFileUpload, DirectoryUpload } from 'tabby-core'
+import { PlatformService, ClipboardContent, MenuItemOptions, MessageBoxOptions, MessageBoxResult, FileUpload, FileUploadOptions, FileDownload, DirectoryDownload, HTMLFileUpload, DirectoryUpload } from 'tabby-core'
 
 // eslint-disable-next-line no-duplicate-imports
 import type { ContextMenuElement, ContextMenuItem } from '@vaadin/vaadin-context-menu'
@@ -114,6 +114,10 @@ export class WebPlatformService extends PlatformService {
         return transfer
     }
 
+    async startDownloadDirectory (_name: string, _estimatedSize?: number): Promise<DirectoryDownload|null> {
+        throw new Error('Unsupported')
+    }
+
     startUpload (options?: FileUploadOptions): Promise<FileUpload[]> {
         return new Promise(resolve => {
             this.fileSelector.onchange = () => {

+ 4 - 4
yarn.lock

@@ -3179,10 +3179,10 @@ electron-to-chromium@^1.4.284:
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.286.tgz#0e039de59135f44ab9a8ec9025e53a9135eba11f"
   integrity sha512-Vp3CVhmYpgf4iXNKAucoQUDcCrBQX3XLBtwgFqP9BUXuucgvAV9zWp1kYU7LL9j4++s9O+12cb3wMtN4SJy6UQ==
 
-electron@^36.3:
-  version "36.3.1"
-  resolved "https://registry.yarnpkg.com/electron/-/electron-36.3.1.tgz#12a8c1b1cd9163a4bd0cb60f89816243b26ab788"
-  integrity sha512-LeOZ+tVahmctHaAssLCGRRUa2SAO09GXua3pKdG+WzkbSDMh+3iOPONNVPTqGp8HlWnzGj4r6mhsIbM2RgH+eQ==
+electron@^36.4:
+  version "36.7.1"
+  resolved "https://registry.yarnpkg.com/electron/-/electron-36.7.1.tgz#73bbb460c60f529e00b9d3eff78fd135c42172ea"
+  integrity sha512-vkih7vbmWT6O8+VWFt3a9FMLUZn0O4piR20nTX0IL/d9tz9RjpzoMvHqpI2CE1Rxew9bCzrg7FpgtcTdY6dlyw==
   dependencies:
     "@electron/get" "^2.0.0"
     "@types/node" "^22.7.7"