diff --git a/package.json b/package.json index 2ef4de2e..41ef5f07 100644 --- a/package.json +++ b/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", diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index 5e92f700..28c484fd 100644 --- a/tabby-core/src/api/index.ts +++ b/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' diff --git a/tabby-core/src/api/platform.ts b/tabby-core/src/api/platform.ts index 2cb33810..be87aa17 100644 --- a/tabby-core/src/api/platform.ts +++ b/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 @@ -66,7 +65,14 @@ export abstract class FileDownload extends FileTransfer { abstract write (buffer: Uint8Array): Promise } +export abstract class DirectoryDownload extends FileTransfer { + abstract createDirectory (relativePath: string): Promise + abstract createFile (relativePath: string, mode: number, size: number): Promise +} + export abstract class FileUpload extends FileTransfer { + abstract getMode (): number + abstract read (): Promise async readAll (): Promise { @@ -127,6 +133,7 @@ export abstract class PlatformService { abstract saveConfig (content: string): Promise abstract startDownload (name: string, mode: number, size: number): Promise + abstract startDownloadDirectory (name: string, estimatedSize?: number): Promise abstract startUpload (options?: FileUploadOptions): Promise abstract startUploadDirectory (paths?: string[]): Promise @@ -237,7 +244,7 @@ export abstract class PlatformService { abstract setErrorHandler (handler: (_: any) => void): void abstract popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void abstract showMessageBox (options: MessageBoxOptions): Promise - abstract pickDirectory (): Promise + abstract pickDirectory (): Promise abstract quit (): void } diff --git a/tabby-electron/src/services/platform.service.ts b/tabby-electron/src/services/platform.service.ts index e3d5d55e..b447cb29 100644 --- a/tabby-electron/src/services/platform.service.ts +++ b/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,47 @@ export class ElectronPlatformService extends PlatformService { return transfer } + async startDownloadDirectory (name: string, estimatedSize?: number): Promise { + const selectedFolder = await this.pickDirectory(this.translate.instant('Select destination folder for {name}', { name })) + 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, this) + 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 { - return (await this.electron.dialog.showOpenDialog( + async pickDirectory (title?: string): Promise { + const result = await this.electron.dialog.showOpenDialog( this.hostWindow.getWindow(), { + title, properties: ['openDirectory', 'showHiddenFiles'], }, - )).filePaths[0] + ) + if (result.canceled || !result.filePaths.length) { + return null + } + return result.filePaths[0] } getTheme (): PlatformTheme { @@ -362,10 +389,6 @@ class ElectronFileDownload extends FileDownload { return path.basename(this.filePath) } - getMode (): number { - return this.mode - } - getSize (): number { return this.size } @@ -384,3 +407,50 @@ class ElectronFileDownload extends FileDownload { this.file.close() } } + +class ElectronDirectoryDownload extends DirectoryDownload { + private powerSaveBlocker = 0 + + constructor ( + private basePath: string, + private name: string, + private estimatedSize: number, + private electron: ElectronService, + private zone: NgZone, + private platformService: ElectronPlatformService, + ) { + super() + this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension') + } + + async open (): Promise { + await fs.mkdir(this.basePath, { recursive: true }) + } + + getName (): string { + return this.name + } + + getSize (): number { + return this.estimatedSize + } + + async createDirectory (relativePath: string): Promise { + const fullPath = path.join(this.basePath, relativePath) + await fs.mkdir(fullPath, { recursive: true }) + } + + async createFile (relativePath: string, mode: number, size: number): Promise { + 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()) + this.platformService._registerFileTransfer(fileDownload) + return fileDownload + } + + close (): void { + this.electron.powerSaveBlocker.stop(this.powerSaveBlocker) + } +} diff --git a/tabby-local/src/components/localProfileSettings.component.ts b/tabby-local/src/components/localProfileSettings.component.ts index 45200e3d..091a4212 100644 --- a/tabby-local/src/components/localProfileSettings.component.ts +++ b/tabby-local/src/components/localProfileSettings.component.ts @@ -28,6 +28,10 @@ export class LocalProfileSettingsComponent implements ProfileSettingsComponent { + try { + const estimatedSize = await this.calculateFolderSize(folder) + + const transfer = await this.platform.startDownloadDirectory(folder.name, estimatedSize) + if (!transfer) { + return + } + + try { + await this.downloadFolderRecursive(folder, transfer, '') + } catch (error) { + transfer.cancel() + throw error + } finally { + transfer.close() + } + } catch (error) { + this.notifications.error(`Failed to download folder: ${error.message}`) + throw error + } + } + + private async calculateFolderSize (folder: SFTPFile): Promise { + let totalSize = 0 + const items = await this.sftp.readdir(folder.fullPath) + for (const item of items) { + if (item.isDirectory) { + totalSize += await this.calculateFolderSize(item) + } else { + totalSize += item.size + } + } + return totalSize + } + + private async downloadFolderRecursive (folder: SFTPFile, transfer: DirectoryDownload, relativePath: string): Promise { + 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 + + if (item.isDirectory) { + await transfer.createDirectory(itemRelativePath) + await this.downloadFolderRecursive(item, transfer, itemRelativePath) + } else { + // Create file download for this individual file + 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 = ' ---------' diff --git a/tabby-ssh/src/sftpContextMenu.ts b/tabby-ssh/src/sftpContextMenu.ts index ef452c5b..ab1092f2 100644 --- a/tabby-ssh/src/sftpContextMenu.ts +++ b/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 { - 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 folder'), + }) + } + + 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 { diff --git a/tabby-web/src/platform.ts b/tabby-web/src/platform.ts index 54a7c6db..f1592382 100644 --- a/tabby-web/src/platform.ts +++ b/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 { + throw new Error('Unsupported') + } + startUpload (options?: FileUploadOptions): Promise { return new Promise(resolve => { this.fileSelector.onchange = () => { diff --git a/yarn.lock b/yarn.lock index 1cc91a65..b800c850 100644 --- a/yarn.lock +++ b/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"