diff --git a/.eslintrc.yml b/.eslintrc.yml index a32a28f6..adb170d0 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -65,9 +65,6 @@ rules: eqeqeq: - error - smart - linebreak-style: - - error - - unix max-depth: - 1 - 5 diff --git a/terminus-core/src/api/index.ts b/terminus-core/src/api/index.ts index e2dbad90..fedafd2f 100644 --- a/terminus-core/src/api/index.ts +++ b/terminus-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 } from './platform' +export { PlatformService, ClipboardContent, MessageBoxResult, MessageBoxOptions, FileDownload, FileUpload, FileTransfer } from './platform' export { MenuItemOptions } from './menu' export { BootstrapData, BOOTSTRAP_DATA } from './mainProcess' export { HostWindowService } from './hostWindow' diff --git a/terminus-core/src/api/platform.ts b/terminus-core/src/api/platform.ts index 22673c96..54aa648c 100644 --- a/terminus-core/src/api/platform.ts +++ b/terminus-core/src/api/platform.ts @@ -18,6 +18,44 @@ export interface MessageBoxResult { response: number } +export abstract class FileTransfer { + abstract getName (): string + abstract getSize (): number + abstract close (): void + + getCompletedBytes (): number { + return this.completedBytes + } + + isComplete (): boolean { + return this.completedBytes >= this.getSize() + } + + isCancelled (): boolean { + return this.cancelled + } + + cancel (): void { + this.cancelled = true + this.close() + } + + protected increaseProgress (bytes: number): void { + this.completedBytes += bytes + } + + private completedBytes = 0 + private cancelled = false +} + +export abstract class FileDownload extends FileTransfer { + abstract write (buffer: Buffer): Promise +} + +export abstract class FileUpload extends FileTransfer { + abstract read (): Promise +} + export abstract class PlatformService { supportsWindowControls = false @@ -26,6 +64,9 @@ export abstract class PlatformService { abstract loadConfig (): Promise abstract saveConfig (content: string): Promise + abstract startDownload (name: string, size: number): Promise + abstract startUpload (): Promise + getConfigPath (): string|null { return null } diff --git a/terminus-core/src/components/appRoot.component.pug b/terminus-core/src/components/appRoot.component.pug index 11bdf211..f8c51828 100644 --- a/terminus-core/src/components/appRoot.component.pug +++ b/terminus-core/src/components/appRoot.component.pug @@ -83,12 +83,27 @@ title-bar( ) div([class.ml-3]='hasIcons(button.submenuItems)') {{item.title}} + .d-flex(ngbDropdown) + button.btn.btn-secondary.btn-tab-bar( + title='File transfers', + ngbDropdownToggle + ) !{require('../icons/download.svg')} + .transfers-dropdown-menu(ngbDropdownMenu) + .dropdown-header File transfers + .dropdown-item.transfer + .mr-3 !{require('../icons/download.svg')} + .main + label file.bin + .progress + .progress-bar.w-25 + small 25% + button.btn.btn-link !{require('../icons/times.svg')} + button.btn.btn-secondary.btn-tab-bar.btn-update( *ngIf='updatesAvailable', title='Update available - Click to install', - (click)='updater.update()', - [fastHtmlBind]='updateIcon' - ) + (click)='updater.update()' + ) !{require('../icons/gift.svg')} window-controls.background( *ngIf='config.store.appearance.frame == "thin" \ diff --git a/terminus-core/src/components/appRoot.component.scss b/terminus-core/src/components/appRoot.component.scss index f29fb77c..73885b4e 100644 --- a/terminus-core/src/components/appRoot.component.scss +++ b/terminus-core/src/components/appRoot.component.scss @@ -178,3 +178,25 @@ hotkey-hint { ::ng-deep .btn-update svg { fill: cyan; } + +.transfers-dropdown-menu { + min-width: 300px; + + .transfer { + display: flex; + align-items: center; + padding: 5px 0 5px 25px; + + .main { + margin-right: auto; + + label { + margin-bottom: 5px; + } + } + + > i { + margin-right: 10px; + } + } +} diff --git a/terminus-core/src/components/appRoot.component.ts b/terminus-core/src/components/appRoot.component.ts index 16bde733..c51e0f44 100644 --- a/terminus-core/src/components/appRoot.component.ts +++ b/terminus-core/src/components/appRoot.component.ts @@ -59,7 +59,6 @@ export class AppRootComponent { @HostBinding('class.no-tabs') noTabs = true tabsDragging = false unsortedTabs: BaseTabComponent[] = [] - updateIcon: string updatesAvailable = false private logger: Logger @@ -79,8 +78,6 @@ export class AppRootComponent { this.logger = log.create('main') this.logger.info('v', platform.getAppVersion()) - this.updateIcon = require('../icons/gift.svg') - this.hotkeys.matchedHotkey.subscribe((hotkey: string) => { if (hotkey.startsWith('tab-')) { const index = parseInt(hotkey.split('-')[1]) diff --git a/terminus-core/src/icons/download.svg b/terminus-core/src/icons/download.svg new file mode 100644 index 00000000..00a19b78 --- /dev/null +++ b/terminus-core/src/icons/download.svg @@ -0,0 +1 @@ + diff --git a/terminus-core/src/icons/times.svg b/terminus-core/src/icons/times.svg new file mode 100644 index 00000000..a984649c --- /dev/null +++ b/terminus-core/src/icons/times.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/terminus-core/src/theme.vars.scss b/terminus-core/src/theme.vars.scss index e24c27ea..bb53b7ed 100644 --- a/terminus-core/src/theme.vars.scss +++ b/terminus-core/src/theme.vars.scss @@ -190,3 +190,6 @@ $modal-header-border-width: 0; $modal-footer-border-color: #222; $modal-footer-border-width: 1px; $modal-content-border-width: 0; + +$progress-bar-bg: $table-bg; +$progress-height: 3px; diff --git a/terminus-electron/src/services/platform.service.ts b/terminus-electron/src/services/platform.service.ts index 631bb87d..b44d5de7 100644 --- a/terminus-electron/src/services/platform.service.ts +++ b/terminus-electron/src/services/platform.service.ts @@ -1,10 +1,11 @@ import * as path from 'path' -import * as fs from 'mz/fs' +import * as fs from 'fs/promises' +import * as fsSync from 'fs' import * as os from 'os' import promiseIpc from 'electron-promise-ipc' import { execFile } from 'mz/child_process' import { Injectable } from '@angular/core' -import { PlatformService, ClipboardContent, HostAppService, Platform, ElectronService, MenuItemOptions, MessageBoxOptions, MessageBoxResult } from 'terminus-core' +import { PlatformService, ClipboardContent, HostAppService, Platform, ElectronService, MenuItemOptions, MessageBoxOptions, MessageBoxResult, FileUpload } from 'terminus-core' const fontManager = require('fontmanager-redux') // eslint-disable-line /* eslint-disable block-scoped-var */ @@ -89,7 +90,7 @@ export class ElectronPlatformService extends PlatformService { } async loadConfig (): Promise { - if (await fs.exists(this.configPath)) { + if (fsSync.existsSync(this.configPath)) { return fs.readFile(this.configPath, 'utf8') } else { return '' @@ -157,4 +158,58 @@ export class ElectronPlatformService extends PlatformService { quit (): void { this.electron.app.exit(0) } + + async startUpload (): Promise { + const result = await this.electron.dialog.showOpenDialog( + this.hostApp.getWindow(), + { + buttonLabel: 'Select', + properties: ['multiSelections', 'openFile', 'treatPackageAsDirectory'], + }, + ) + if (result.canceled) { + return [] + } + + return Promise.all(result.filePaths.map(async path => { + const t = new ElectronFileUpload(path) + await t.open() + return t + })) + } +} + +class ElectronFileUpload extends FileUpload { + private size: number + private file: fs.FileHandle + private buffer: Buffer + + constructor (private filePath: string) { + super() + this.buffer = Buffer.alloc(256 * 1024) + } + + async open (): Promise { + this.size = (await fs.stat(this.filePath)).size + this.file = await fs.open(this.filePath, 'r') + } + + getName (): string { + return path.basename(this.filePath) + } + + getSize (): number { + return this.size + } + + async read (): Promise { + const result = await this.file.read(this.buffer, 0, this.buffer.length, null) + this.increaseProgress(result.bytesRead) + console.log(result) + return this.buffer.slice(0, result.bytesRead) + } + + close (): void { + this.file.close() + } } diff --git a/terminus-terminal/src/features/zmodem.ts b/terminus-terminal/src/features/zmodem.ts index c855fa64..71ab368b 100644 --- a/terminus-terminal/src/features/zmodem.ts +++ b/terminus-terminal/src/features/zmodem.ts @@ -1,13 +1,12 @@ import colors from 'ansi-colors' import * as ZModem from 'zmodem.js' import * as fs from 'fs' -import * as path from 'path' import { Observable } from 'rxjs' import { filter } from 'rxjs/operators' import { Injectable } from '@angular/core' import { TerminalDecorator } from '../api/decorator' import { BaseTerminalTabComponent } from '../api/baseTerminalTab.component' -import { LogService, Logger, ElectronService, HostAppService, HotkeysService } from 'terminus-core' +import { LogService, Logger, ElectronService, HostAppService, HotkeysService, PlatformService, FileUpload } from 'terminus-core' const SPACER = ' ' @@ -23,6 +22,7 @@ export class ZModemDecorator extends TerminalDecorator { hotkeys: HotkeysService, private electron: ElectronService, private hostApp: HostAppService, + private platform: PlatformService, ) { super() this.logger = log.create('zmodem') @@ -87,22 +87,13 @@ export class ZModemDecorator extends TerminalDecorator { this.logger.info('new session', zsession) if (zsession.type === 'send') { - const result = await this.electron.dialog.showOpenDialog( - this.hostApp.getWindow(), - { - buttonLabel: 'Send', - properties: ['multiSelections', 'openFile', 'treatPackageAsDirectory'], - }, - ) - if (result.canceled) { - zsession.close() - return - } - - let filesRemaining = result.filePaths.length - for (const filePath of result.filePaths) { - await this.sendFile(terminal, zsession, filePath, filesRemaining) + const transfers = await this.platform.startUpload() + let filesRemaining = transfers.length + let sizeRemaining = transfers.reduce((a, b) => a + b.getSize(), 0) + for (const transfer of transfers) { + await this.sendFile(terminal, zsession, transfer, filesRemaining, sizeRemaining) filesRemaining-- + sizeRemaining -= transfer.getSize() } this.activeSession = null await zsession.close() @@ -178,44 +169,41 @@ export class ZModemDecorator extends TerminalDecorator { stream.end() } - private async sendFile (terminal, zsession, filePath, filesRemaining) { - const stat = fs.statSync(filePath) + private async sendFile (terminal, zsession, transfer: FileUpload, filesRemaining, sizeRemaining) { const offer = { - name: path.basename(filePath), - size: stat.size, - mode: stat.mode, - mtime: Math.floor(stat.mtimeMs / 1000), + name: transfer.getName(), + size: transfer.getSize(), + mode: 0o755, files_remaining: filesRemaining, - bytes_remaining: stat.size, + bytes_remaining: sizeRemaining, } this.logger.info('offering', offer) this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + offer.name, true) const xfer = await zsession.send_offer(offer) if (xfer) { - let bytesSent = 0 let canceled = false - const stream = fs.createReadStream(filePath) const cancelSubscription = this.cancelEvent.subscribe(() => { if (terminal.hasFocus) { canceled = true } }) - stream.on('data', chunk => { - if (canceled) { - stream.close() - return + while (true) { + const chunk = await transfer.read() + if (canceled || !chunk.length) { + break } - xfer.send(chunk) - bytesSent += chunk.length - this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * bytesSent / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true) - }) - await Promise.race([ - new Promise(resolve => stream.on('end', resolve)), - this.cancelEvent.toPromise(), - ]) + await xfer.send(chunk) + this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true) + } + + if (canceled) { + transfer.cancel() + } else { + transfer.close() + } await xfer.end() @@ -226,7 +214,6 @@ export class ZModemDecorator extends TerminalDecorator { this.showMessage(terminal, colors.bgGreen.black(' Sent ') + ' ' + offer.name) } - stream.close() cancelSubscription.unsubscribe() } else { this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + offer.name)