diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index cc467f96..b55f7e76 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 } from './platform' +export { PlatformService, ClipboardContent, MessageBoxResult, MessageBoxOptions, FileDownload, FileUpload, FileTransfer, HTMLFileUpload, FileUploadOptions, DirectoryUpload } 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 9a87dc18..6f63154f 100644 --- a/tabby-core/src/api/platform.ts +++ b/tabby-core/src/api/platform.ts @@ -86,6 +86,26 @@ export interface FileUploadOptions { multiple: boolean } +export class DirectoryUpload { + private childrens: (FileUpload|DirectoryUpload)[] = [] + + constructor (private name = '') { + // Just set name for now. + } + + getName (): string { + return this.name + } + + getChildrens (): (FileUpload|DirectoryUpload)[] { + return this.childrens + } + + pushChildren (item: FileUpload|DirectoryUpload): void { + this.childrens.push(item) + } +} + export type PlatformTheme = 'light'|'dark' export abstract class PlatformService { @@ -106,23 +126,54 @@ export abstract class PlatformService { abstract startDownload (name: string, mode: number, size: number): Promise abstract startUpload (options?: FileUploadOptions): Promise + abstract startUploadDirectory (paths?: string[]): Promise + + async startUploadFromDragEvent (event: DragEvent, multiple = false): Promise { + const result = new DirectoryUpload() - startUploadFromDragEvent (event: DragEvent, multiple = false): FileUpload[] { - const result: FileUpload[] = [] if (!event.dataTransfer) { - return [] + return Promise.resolve(result) } + + const traverseFileTree = (item: any, root: DirectoryUpload = result): Promise => { + return new Promise((resolve) => { + if (item.isFile) { + item.file((file: File) => { + const transfer = new HTMLFileUpload(file) + this.fileTransferStarted.next(transfer) + root.pushChildren(transfer) + resolve() + }) + } else if (item.isDirectory) { + const dirReader = item.createReader() + const childrenFolder = new DirectoryUpload(item.name) + dirReader.readEntries(async (entries: any[]) => { + for (const entry of entries) { + await traverseFileTree(entry, childrenFolder) + } + resolve() + }) + root.pushChildren(childrenFolder) + } else { + resolve() + } + }) + } + + const promises: Promise[] = [] + + const items = event.dataTransfer.items // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < event.dataTransfer.files.length; i++) { - const file = event.dataTransfer.files[i] - const transfer = new HTMLFileUpload(file) - this.fileTransferStarted.next(transfer) - result.push(transfer) - if (!multiple) { - break + for (let i = 0; i < items.length; i++) { + const item = items[i].webkitGetAsEntry() + if (item) { + promises.push(traverseFileTree(item)) + if (!multiple) { + break + } } } - return result + return Promise.all(promises).then(() => result) } getConfigPath (): string|null { diff --git a/tabby-core/src/directives/dropZone.directive.ts b/tabby-core/src/directives/dropZone.directive.ts index 1da823eb..64632fbc 100644 --- a/tabby-core/src/directives/dropZone.directive.ts +++ b/tabby-core/src/directives/dropZone.directive.ts @@ -1,5 +1,5 @@ import { Directive, Output, ElementRef, EventEmitter, AfterViewInit } from '@angular/core' -import { FileUpload, PlatformService } from '../api/platform' +import { DirectoryUpload, PlatformService } from '../api/platform' import './dropZone.directive.scss' /** @hidden */ @@ -7,7 +7,7 @@ import './dropZone.directive.scss' selector: '[dropZone]', }) export class DropZoneDirective implements AfterViewInit { - @Output() transfer = new EventEmitter() + @Output() transfer = new EventEmitter() private dropHint?: HTMLElement constructor ( @@ -27,11 +27,9 @@ export class DropZoneDirective implements AfterViewInit { }) } }) - this.el.nativeElement.addEventListener('drop', (event: DragEvent) => { + this.el.nativeElement.addEventListener('drop', async (event: DragEvent) => { this.removeHint() - for (const transfer of this.platform.startUploadFromDragEvent(event, true)) { - this.transfer.emit(transfer) - } + this.transfer.emit(await this.platform.startUploadFromDragEvent(event, true)) }) this.el.nativeElement.addEventListener('dragleave', () => { this.removeHint() diff --git a/tabby-electron/src/services/platform.service.ts b/tabby-electron/src/services/platform.service.ts index 85ff48f6..52fcf9fc 100644 --- a/tabby-electron/src/services/platform.service.ts +++ b/tabby-electron/src/services/platform.service.ts @@ -5,7 +5,7 @@ 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, FileUpload, FileDownload, FileUploadOptions, wrapPromise, TranslateService } from 'tabby-core' +import { PlatformService, ClipboardContent, Platform, MenuItemOptions, MessageBoxOptions, MessageBoxResult, DirectoryUpload, FileUpload, FileDownload, FileUploadOptions, wrapPromise, TranslateService } from 'tabby-core' import { ElectronService } from '../services/electron.service' import { ElectronHostWindow } from './hostWindow.service' import { ShellIntegrationService } from './shellIntegration.service' @@ -48,6 +48,21 @@ export class ElectronPlatformService extends PlatformService { }) } + async getAllFiles (dir: string, root: DirectoryUpload): Promise { + const items = await fs.readdir(dir, { withFileTypes: true }) + for (const item of items) { + if (item.isDirectory()) { + root.pushChildren(await this.getAllFiles(path.join(dir, item.name), new DirectoryUpload(item.name))) + } else { + const file = new ElectronFileUpload(path.join(dir, item.name), this.electron) + root.pushChildren(file) + await wrapPromise(this.zone, file.open()) + this.fileTransferStarted.next(file) + } + } + return root + } + readClipboard (): string { return this.electron.clipboard.readText() } @@ -216,6 +231,28 @@ export class ElectronPlatformService extends PlatformService { })) } + async startUploadDirectory (paths?: string[]): Promise { + const properties: any[] = ['openFile', 'treatPackageAsDirectory', 'openDirectory'] + + if (!paths) { + const result = await this.electron.dialog.showOpenDialog( + this.hostWindow.getWindow(), + { + buttonLabel: this.translate.instant('Select'), + properties, + }, + ) + if (result.canceled) { + return new DirectoryUpload() + } + paths = result.filePaths + } + + const root = new DirectoryUpload() + root.pushChildren(await this.getAllFiles(paths[0].split(path.sep).join(path.posix.sep), new DirectoryUpload(path.basename(paths[0])))) + return root + } + async startDownload (name: string, mode: number, size: number, filePath?: string): Promise { if (!filePath) { const result = await this.electron.dialog.showSaveDialog( diff --git a/tabby-ssh/src/components/sftpPanel.component.pug b/tabby-ssh/src/components/sftpPanel.component.pug index 57e96941..67e4f202 100644 --- a/tabby-ssh/src/components/sftpPanel.component.pug +++ b/tabby-ssh/src/components/sftpPanel.component.pug @@ -23,11 +23,15 @@ button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='upload()') i.fas.fa-upload.me-1 - div(translate) Upload + div(translate) Upload files + + button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='uploadFolder()') + i.fas.fa-upload.me-1 + div(translate) Upload folder button.btn.btn-link.text-decoration-none((click)='close()') !{require('../../../tabby-core/src/icons/times.svg')} -.body(dropZone, (transfer)='uploadOne($event)') +.body(dropZone, (transfer)='uploadOneFolder($event)') a.alert.alert-info.d-flex.align-items-center( *ngIf='shouldShowCWDTip && !cwdDetectionAvailable', (click)='platform.openExternal("https://tabby.sh/go/cwd-detection")' diff --git a/tabby-ssh/src/components/sftpPanel.component.ts b/tabby-ssh/src/components/sftpPanel.component.ts index e9662443..38625c0f 100644 --- a/tabby-ssh/src/components/sftpPanel.component.ts +++ b/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, MenuItemOptions, NotificationsService, PlatformService } from 'tabby-core' +import { FileUpload, DirectoryUpload, MenuItemOptions, NotificationsService, PlatformService } from 'tabby-core' import { SFTPSession, SFTPFile } from '../session/sftp' import { SSHSession } from '../session/ssh' import { SFTPContextMenuItemProvider } from '../api' @@ -180,6 +180,30 @@ export class SFTPPanelComponent { await Promise.all(transfers.map(t => this.uploadOne(t))) } + async uploadFolder (): Promise { + const transfer = await this.platform.startUploadDirectory() + await this.uploadOneFolder(transfer) + } + + async uploadOneFolder (transfer: DirectoryUpload, accumPath = ''): Promise { + const savedPath = this.path + for(const t of transfer.getChildrens()) { + if (t instanceof DirectoryUpload) { + try { + await this.sftp.mkdir(path.posix.join(this.path, accumPath, t.getName())) + } catch { + // Intentionally ignoring errors from making duplicate dirs. + } + await this.uploadOneFolder(t, path.posix.join(accumPath, t.getName())) + } else { + await this.sftp.upload(path.posix.join(this.path, accumPath, t.getName()), t) + } + } + if (this.path === savedPath) { + await this.navigate(this.path) + } + } + async uploadOne (transfer: FileUpload): Promise { const savedPath = this.path await this.sftp.upload(path.join(this.path, transfer.getName()), transfer) diff --git a/tabby-web/src/platform.ts b/tabby-web/src/platform.ts index 035e2b6b..50958dae 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 } from 'tabby-core' +import { PlatformService, ClipboardContent, MenuItemOptions, MessageBoxOptions, MessageBoxResult, FileUpload, FileUploadOptions, FileDownload, HTMLFileUpload, DirectoryUpload } from 'tabby-core' // eslint-disable-next-line no-duplicate-imports import type { ContextMenuElement, ContextMenuItem } from '@vaadin/vaadin-context-menu' @@ -135,6 +135,10 @@ export class WebPlatformService extends PlatformService { }) } + async startUploadDirectory (_paths?: string[]): Promise { + return new DirectoryUpload() + } + setErrorHandler (handler: (_: any) => void): void { window.addEventListener('error', handler) }