SFTP folder downloads (#10586)

This commit is contained in:
Eugene
2025-07-23 08:38:25 +02:00
committed by GitHub
parent bbf3b785fc
commit 7e1905c32c
10 changed files with 234 additions and 42 deletions

View File

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

View File

@@ -10,7 +10,7 @@ export { Theme } from './theme'
export { TabContextMenuItemProvider } from './tabContextMenuProvider' export { TabContextMenuItemProvider } from './tabContextMenuProvider'
export { SelectorOption } from './selector' export { SelectorOption } from './selector'
export { CLIHandler, CLIEvent } from './cli' 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 { MenuItemOptions } from './menu'
export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess' export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
export { HostWindowService } from './hostWindow' export { HostWindowService } from './hostWindow'

View File

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

View File

@@ -5,7 +5,9 @@
.icon(*ngIf='isDownload(transfer)') !{require('../icons/download.svg')} .icon(*ngIf='isDownload(transfer)') !{require('../icons/download.svg')}
.icon(*ngIf='!isDownload(transfer)') !{require('../icons/upload.svg')} .icon(*ngIf='!isDownload(transfer)') !{require('../icons/upload.svg')}
.main .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)') ngb-progressbar([type]='transfer.isComplete() ? "success" : transfer.isCancelled() ? "danger" : "info"', [value]='getProgress(transfer)')
.metadata .metadata
.size {{transfer.getSize()|filesize}} .size {{transfer.getSize()|filesize}}

View File

@@ -5,12 +5,11 @@ import * as os from 'os'
import promiseIpc, { RendererProcessType } from 'electron-promise-ipc' import promiseIpc, { RendererProcessType } from 'electron-promise-ipc'
import { execFile } from 'mz/child_process' import { execFile } from 'mz/child_process'
import { Injectable, NgZone } from '@angular/core' 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 { ElectronService } from '../services/electron.service'
import { ElectronHostWindow } from './hostWindow.service' import { ElectronHostWindow } from './hostWindow.service'
import { ShellIntegrationService } from './shellIntegration.service' import { ShellIntegrationService } from './shellIntegration.service'
import { ElectronHostAppService } from './hostApp.service' import { ElectronHostAppService } from './hostApp.service'
import { PlatformTheme } from '../../../tabby-core/src/api/platform'
import { configPath } from '../../../app/lib/config' import { configPath } from '../../../app/lib/config'
const fontManager = require('fontmanager-redux') // eslint-disable-line const fontManager = require('fontmanager-redux') // eslint-disable-line
@@ -272,19 +271,48 @@ export class ElectronPlatformService extends PlatformService {
return transfer 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 { setErrorHandler (handler: (_: any) => void): void {
this.electron.ipcRenderer.on('uncaughtException', (_$event, err) => { this.electron.ipcRenderer.on('uncaughtException', (_$event, err) => {
handler(err) handler(err)
}) })
} }
async pickDirectory (): Promise<string> { async pickDirectory (title?: string, buttonLabel?: string): Promise<string | null> {
return (await this.electron.dialog.showOpenDialog( const result = await this.electron.dialog.showOpenDialog(
this.hostWindow.getWindow(), this.hostWindow.getWindow(),
{ {
title,
buttonLabel,
properties: ['openDirectory', 'showHiddenFiles'], properties: ['openDirectory', 'showHiddenFiles'],
}, },
)).filePaths[0] )
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
} }
getTheme (): PlatformTheme { getTheme (): PlatformTheme {
@@ -313,6 +341,7 @@ class ElectronFileUpload extends FileUpload {
const stat = await fs.stat(this.filePath) const stat = await fs.stat(this.filePath)
this.size = stat.size this.size = stat.size
this.mode = stat.mode this.mode = stat.mode
this.setTotalSize(this.size)
this.file = await fs.open(this.filePath, 'r') this.file = await fs.open(this.filePath, 'r')
} }
@@ -331,6 +360,9 @@ class ElectronFileUpload extends FileUpload {
async read (): Promise<Uint8Array> { async read (): Promise<Uint8Array> {
const result = await this.file.read(this.buffer, 0, this.buffer.length, null) const result = await this.file.read(this.buffer, 0, this.buffer.length, null)
this.increaseProgress(result.bytesRead) this.increaseProgress(result.bytesRead)
if (this.getCompletedBytes() >= this.getSize()) {
this.setCompleted(true)
}
return this.buffer.slice(0, result.bytesRead) return this.buffer.slice(0, result.bytesRead)
} }
@@ -352,6 +384,7 @@ class ElectronFileDownload extends FileDownload {
) { ) {
super() super()
this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension') this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension')
this.setTotalSize(size)
} }
async open (): Promise<void> { async open (): Promise<void> {
@@ -362,10 +395,6 @@ class ElectronFileDownload extends FileDownload {
return path.basename(this.filePath) return path.basename(this.filePath)
} }
getMode (): number {
return this.mode
}
getSize (): number { getSize (): number {
return this.size return this.size
} }
@@ -377,6 +406,9 @@ class ElectronFileDownload extends FileDownload {
this.increaseProgress(result.bytesWritten) this.increaseProgress(result.bytesWritten)
pos += result.bytesWritten pos += result.bytesWritten
} }
if (this.getCompletedBytes() >= this.getSize()) {
this.setCompleted(true)
}
} }
close (): void { close (): void {
@@ -384,3 +416,49 @@ class ElectronFileDownload extends FileDownload {
this.file.close() 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)
}
}

View File

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

View File

@@ -1,7 +1,7 @@
import * as C from 'constants' import * as C from 'constants'
import { posix as path } from 'path' import { posix as path } from 'path'
import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core' 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 { SFTPSession, SFTPFile } from '../session/sftp'
import { SSHSession } from '../session/ssh' import { SSHSession } from '../session/ssh'
import { SFTPContextMenuItemProvider } from '../api' import { SFTPContextMenuItemProvider } from '../api'
@@ -220,6 +220,68 @@ export class SFTPPanelComponent {
this.sftp.download(itemPath, transfer) 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 { getModeString (item: SFTPFile): string {
const s = 'SGdrwxrwxrwx' const s = 'SGdrwxrwxrwx'
const e = ' ---------' const e = ' ---------'

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 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 { SFTPSession, SFTPFile } from './session/sftp'
import { SFTPContextMenuItemProvider } from './api' import { SFTPContextMenuItemProvider } from './api'
import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component' import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
@@ -16,19 +16,30 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider {
private platform: PlatformService, private platform: PlatformService,
private ngbModal: NgbModal, private ngbModal: NgbModal,
private translate: TranslateService, private translate: TranslateService,
private hostApp: HostAppService,
) { ) {
super() super()
} }
async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise<MenuItemOptions[]> { async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise<MenuItemOptions[]> {
return [ const items: MenuItemOptions[] = [
{ {
click: async () => { click: async () => {
await panel.openCreateDirectoryModal() await panel.openCreateDirectoryModal()
}, },
label: this.translate.instant('Create directory'), label: this.translate.instant('Create directory'),
}, },
{ ]
// 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 () => { click: async () => {
if ((await this.platform.showMessageBox({ if ((await this.platform.showMessageBox({
type: 'warning', type: 'warning',
@@ -45,8 +56,9 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider {
} }
}, },
label: this.translate.instant('Delete'), label: this.translate.instant('Delete'),
}, })
]
return items
} }
async deleteItem (item: SFTPFile, session: SFTPSession): Promise<void> { async deleteItem (item: SFTPFile, session: SFTPSession): Promise<void> {

View File

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

View File

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