diff --git a/app/src/global.scss b/app/src/global.scss index e1b54a13..30094c6b 100644 --- a/app/src/global.scss +++ b/app/src/global.scss @@ -152,3 +152,9 @@ ngb-typeahead-window { } } } + +.no-wrap { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} diff --git a/terminus-core/src/components/base.component.ts b/terminus-core/src/components/base.component.ts index cc93a556..8167c014 100644 --- a/terminus-core/src/components/base.component.ts +++ b/terminus-core/src/components/base.component.ts @@ -1,4 +1,4 @@ -import { Observable, Subscription } from 'rxjs' +import { Observable, Subscription, Subject } from 'rxjs' interface CancellableEvent { element: HTMLElement @@ -38,17 +38,21 @@ export class SubscriptionContainer { } export class BaseComponent { - private subscriptionContainer = new SubscriptionContainer() + protected get destroyed$ (): Observable { return this._destroyed } + private _destroyed = new Subject() + private _subscriptionContainer = new SubscriptionContainer() addEventListenerUntilDestroyed (element: HTMLElement, event: string, handler: EventListenerOrEventListenerObject, options?: boolean|AddEventListenerOptions): void { - this.subscriptionContainer.addEventListener(element, event, handler, options) + this._subscriptionContainer.addEventListener(element, event, handler, options) } subscribeUntilDestroyed (observable: Observable, handler: (v: T) => void): void { - this.subscriptionContainer.subscribe(observable, handler) + this._subscriptionContainer.subscribe(observable, handler) } ngOnDestroy (): void { - this.subscriptionContainer.cancelAll() + this._destroyed.next() + this._destroyed.complete() + this._subscriptionContainer.cancelAll() } } diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index bffffeaa..88566934 100644 --- a/terminus-ssh/src/api.ts +++ b/terminus-ssh/src/api.ts @@ -1,6 +1,9 @@ import * as fs from 'mz/fs' import * as crypto from 'crypto' import * as path from 'path' +import * as C from 'constants' +// eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports +import { posix as posixPath } from 'path' import * as sshpk from 'sshpk' import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' @@ -138,6 +141,15 @@ interface AuthMethod { path?: string } +export interface SFTPFile { + name: string + fullPath: string + isDirectory: boolean + isSymlink: boolean + mode: number + size: number +} + export class SFTPFileHandle { position = 0 @@ -191,22 +203,52 @@ export class SFTPFileHandle { export class SFTPSession { constructor (private sftp: SFTPWrapper, private zone: NgZone) { } - readdir (p: string): Promise { - return wrapPromise(this.zone, promisify(f => this.sftp.readdir(p, f))()) + async readdir (p: string): Promise { + const entries = await wrapPromise(this.zone, promisify(f => this.sftp.readdir(p, f))()) + return entries.map(entry => this._makeFile( + posixPath.join(p, entry.filename), entry, + )) } readlink (p: string): Promise { return wrapPromise(this.zone, promisify(f => this.sftp.readlink(p, f))()) } - stat (p: string): Promise { - return wrapPromise(this.zone, promisify(f => this.sftp.stat(p, f))()) + async stat (p: string): Promise { + const stats = await wrapPromise(this.zone, promisify(f => this.sftp.stat(p, f))()) + return { + name: posixPath.basename(p), + fullPath: p, + isDirectory: stats.isDirectory(), + isSymlink: stats.isSymbolicLink(), + mode: stats.mode, + size: stats.size, + } } async open (p: string, mode: string): Promise { const handle = await wrapPromise(this.zone, promisify(f => this.sftp.open(p, mode, f))()) return new SFTPFileHandle(this.sftp, handle, this.zone) } + + async rmdir (p: string): Promise { + await promisify((f: any) => this.sftp.rmdir(p, f))() + } + + async unlink (p: string): Promise { + await promisify((f: any) => this.sftp.unlink(p, f))() + } + + private _makeFile (p: string, entry: FileEntry): SFTPFile { + return { + fullPath: p, + name: posixPath.basename(p), + isDirectory: (entry.attrs.mode & C.S_IFDIR) === C.S_IFDIR, + isSymlink: (entry.attrs.mode & C.S_IFLNK) === C.S_IFLNK, + mode: entry.attrs.mode, + size: entry.attrs.size, + } + } } export class SSHSession extends BaseSession { diff --git a/terminus-ssh/src/components/sftpDeleteModal.component.pug b/terminus-ssh/src/components/sftpDeleteModal.component.pug new file mode 100644 index 00000000..55c539a5 --- /dev/null +++ b/terminus-ssh/src/components/sftpDeleteModal.component.pug @@ -0,0 +1,6 @@ +.modal-body + label Deleting + .no-wrap {{progressMessage}} + +.modal-footer + button.btn.btn-outline-danger((click)='cancel()') Cancel diff --git a/terminus-ssh/src/components/sftpDeleteModal.component.ts b/terminus-ssh/src/components/sftpDeleteModal.component.ts new file mode 100644 index 00000000..1ae3f03c --- /dev/null +++ b/terminus-ssh/src/components/sftpDeleteModal.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { BaseComponent } from 'terminus-core' +import { SFTPFile, SFTPSession } from '../api' + +/** @hidden */ +@Component({ + template: require('./sftpDeleteModal.component.pug'), +}) +export class SFTPDeleteModalComponent extends BaseComponent { + sftp: SFTPSession + item: SFTPFile + progressMessage = '' + cancelled = false + + constructor ( + private modalInstance: NgbActiveModal, + ) { + super() + } + + ngOnInit (): void { + this.destroyed$.subscribe(() => this.cancel()) + this.run(this.item).then(() => { + this.modalInstance.close() + }) + } + + cancel (): void { + this.cancelled = true + this.modalInstance.close() + } + + async run (file: SFTPFile): Promise { + this.progressMessage = file.fullPath + + if (file.isDirectory) { + for (const child of await this.sftp.readdir(file.fullPath)) { + await this.run(child) + if (this.cancelled) { + break + } + } + await this.sftp.rmdir(file.fullPath) + } else { + this.sftp.unlink(file.fullPath) + } + } +} diff --git a/terminus-ssh/src/components/sftpPanel.component.pug b/terminus-ssh/src/components/sftpPanel.component.pug index f540ef4b..f0d96771 100644 --- a/terminus-ssh/src/components/sftpPanel.component.pug +++ b/terminus-ssh/src/components/sftpPanel.component.pug @@ -25,9 +25,10 @@ div Go up .list-group-item.list-group-item-action.d-flex.align-items-center( *ngFor='let item of fileList', + (contextmenu)='showContextMenu(item, $event)', (click)='open(item)' ) i.fa-fw([class]='getIcon(item)') - div {{item.filename}} + div {{item.name}} .mr-auto .mode {{getModeString(item)}} diff --git a/terminus-ssh/src/components/sftpPanel.component.scss b/terminus-ssh/src/components/sftpPanel.component.scss index 7b8d4bf9..bc5462aa 100644 --- a/terminus-ssh/src/components/sftpPanel.component.scss +++ b/terminus-ssh/src/components/sftpPanel.component.scss @@ -24,6 +24,10 @@ margin: 0; } + .breadcrumb-item { + cursor: pointer; + } + .breadcrumb-item:first-child { font-weight: bold; } diff --git a/terminus-ssh/src/components/sftpPanel.component.ts b/terminus-ssh/src/components/sftpPanel.component.ts index 5a418d24..6c5ede86 100644 --- a/terminus-ssh/src/components/sftpPanel.component.ts +++ b/terminus-ssh/src/components/sftpPanel.component.ts @@ -1,9 +1,10 @@ import { Component, Input, Output, EventEmitter } from '@angular/core' -import type { FileEntry } from 'ssh2-streams' -import { SSHSession, SFTPSession } from '../api' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { SSHSession, SFTPSession, SFTPFile } from '../api' import { posix as path } from 'path' import * as C from 'constants' import { FileUpload, PlatformService } from 'terminus-core' +import { SFTPDeleteModalComponent } from './sftpDeleteModal.component' interface PathSegment { name: string @@ -20,21 +21,24 @@ export class SFTPPanelComponent { @Input() session: SSHSession @Output() closed = new EventEmitter() sftp: SFTPSession - fileList: FileEntry[]|null = null - path = '/' + fileList: SFTPFile[]|null = null + @Input() path = '/' + @Output() pathChange = new EventEmitter() pathSegments: PathSegment[] = [] constructor ( private platform: PlatformService, + private ngbModal: NgbModal, ) { } async ngOnInit (): Promise { this.sftp = await this.session.openSFTP() - this.navigate('/') + this.navigate(this.path) } async navigate (newPath: string): Promise { this.path = newPath + this.pathChange.next(this.path) let p = newPath this.pathSegments = [] @@ -49,17 +53,17 @@ export class SFTPPanelComponent { this.fileList = null this.fileList = await this.sftp.readdir(this.path) - const dirKey = a => (a.attrs.mode & C.S_IFDIR) === C.S_IFDIR ? 1 : 0 + const dirKey = a => a.isDirectory ? 1 : 0 this.fileList.sort((a, b) => dirKey(b) - dirKey(a) || - a.filename.localeCompare(b.filename)) + a.name.localeCompare(b.name)) } - getIcon (item: FileEntry): string { - if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) { + getIcon (item: SFTPFile): string { + if (item.isDirectory) { return 'fas fa-folder text-info' } - if ((item.attrs.mode & C.S_IFLNK) === C.S_IFLNK) { + if (item.isSymlink) { return 'fas fa-link text-warning' } return 'fas fa-file' @@ -69,20 +73,19 @@ export class SFTPPanelComponent { this.navigate(path.dirname(this.path)) } - async open (item: FileEntry): Promise { - const itemPath = path.join(this.path, item.filename) - if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) { - this.navigate(path.join(this.path, item.filename)) - } else if ((item.attrs.mode & C.S_IFLNK) === C.S_IFLNK) { - const target = await this.sftp.readlink(itemPath) + async open (item: SFTPFile): Promise { + if (item.isDirectory) { + this.navigate(item.fullPath) + } else if (item.isSymlink) { + const target = await this.sftp.readlink(item.fullPath) const stat = await this.sftp.stat(target) - if (stat.isDirectory()) { - this.navigate(itemPath) + if (stat.isDirectory) { + this.navigate(item.fullPath) } else { - this.download(itemPath, stat.size) + this.download(item.fullPath, stat.size) } } else { - this.download(itemPath, item.attrs.size) + this.download(item.fullPath, item.size) } } @@ -139,7 +142,7 @@ export class SFTPPanelComponent { } } - getModeString (item: FileEntry): string { + getModeString (item: SFTPFile): string { const s = 'SGdrwxrwxrwx' const e = ' ---------' const c = [ @@ -150,11 +153,38 @@ export class SFTPPanelComponent { ] let result = '' for (let i = 0; i < c.length; i++) { - result += item.attrs.mode & c[i] ? s[i] : e[i] + result += item.mode & c[i] ? s[i] : e[i] } return result } + showContextMenu (item: SFTPFile, event: MouseEvent): void { + event.preventDefault() + this.platform.popupContextMenu([ + { + click: async () => { + if ((await this.platform.showMessageBox({ + type: 'warning', + message: `Delete ${item.fullPath}?`, + defaultId: 0, + buttons: ['Delete', 'Cancel'], + })).response === 0) { + await this.deleteItem(item) + this.navigate(this.path) + } + }, + label: 'Delete', + }, + ], event) + } + + async deleteItem (item: SFTPFile): Promise { + const modal = this.ngbModal.open(SFTPDeleteModalComponent) + modal.componentInstance.item = item + modal.componentInstance.sftp = this.sftp + await modal.result + } + close (): void { this.closed.emit() } diff --git a/terminus-ssh/src/components/sshTab.component.pug b/terminus-ssh/src/components/sshTab.component.pug index 71e79be6..97fd70f1 100644 --- a/terminus-ssh/src/components/sshTab.component.pug +++ b/terminus-ssh/src/components/sshTab.component.pug @@ -11,12 +11,17 @@ button.btn.btn-secondary.mr-2((click)='openSFTP()', *ngIf='session && session.open') span SFTP + span.badge.badge-info.ml-2 + i.fas.fa-flask + span Experimental button.btn.btn-secondary((click)='showPortForwarding()', *ngIf='session && session.open') i.fas.fa-plug span Ports sftp-panel.bg-dark( + @panelSlide, + [(path)]='sftpPath', *ngIf='sftpPanelVisible', (click)='$event.stopPropagation()', [session]='session', diff --git a/terminus-ssh/src/components/sshTab.component.ts b/terminus-ssh/src/components/sshTab.component.ts index 6bec6875..87175a85 100644 --- a/terminus-ssh/src/components/sshTab.component.ts +++ b/terminus-ssh/src/components/sshTab.component.ts @@ -21,6 +21,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent { connection?: SSHConnection session: SSHSession|null = null sftpPanelVisible = false + sftpPath = '/' private sessionStack: SSHSession[] = [] private recentInputs = '' private reconnectOffered = false diff --git a/terminus-ssh/src/index.ts b/terminus-ssh/src/index.ts index 5c7a04bc..85edbdc9 100644 --- a/terminus-ssh/src/index.ts +++ b/terminus-ssh/src/index.ts @@ -14,13 +14,14 @@ import { PromptModalComponent } from './components/promptModal.component' import { SSHSettingsTabComponent } from './components/sshSettingsTab.component' import { SSHTabComponent } from './components/sshTab.component' import { SFTPPanelComponent } from './components/sftpPanel.component' +import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component' import { ButtonProvider } from './buttonProvider' import { SSHConfigProvider } from './config' import { SSHSettingsTabProvider } from './settings' import { RecoveryProvider } from './recoveryProvider' import { SSHHotkeyProvider } from './hotkeys' -import { WinSCPContextMenu } from './tabContextMenu' +import { SFTPContextMenu } from './tabContextMenu' import { SSHCLIHandler } from './cli' /** @hidden */ @@ -39,12 +40,13 @@ import { SSHCLIHandler } from './cli' { provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true }, { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true }, { provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true }, - { provide: TabContextMenuItemProvider, useClass: WinSCPContextMenu, multi: true }, + { provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true }, { provide: CLIHandler, useClass: SSHCLIHandler, multi: true }, ], entryComponents: [ EditConnectionModalComponent, PromptModalComponent, + SFTPDeleteModalComponent, SSHPortForwardingModalComponent, SSHSettingsTabComponent, SSHTabComponent, @@ -52,6 +54,7 @@ import { SSHCLIHandler } from './cli' declarations: [ EditConnectionModalComponent, PromptModalComponent, + SFTPDeleteModalComponent, SSHPortForwardingModalComponent, SSHPortForwardingConfigComponent, SSHSettingsTabComponent, diff --git a/terminus-ssh/src/tabContextMenu.ts b/terminus-ssh/src/tabContextMenu.ts index 1cbaac5b..91a8dde6 100644 --- a/terminus-ssh/src/tabContextMenu.ts +++ b/terminus-ssh/src/tabContextMenu.ts @@ -6,7 +6,7 @@ import { SSHService } from './services/ssh.service' /** @hidden */ @Injectable() -export class WinSCPContextMenu extends TabContextMenuItemProvider { +export class SFTPContextMenu extends TabContextMenuItemProvider { weight = 10 constructor ( diff --git a/terminus-terminal/src/api/baseTerminalTab.component.ts b/terminus-terminal/src/api/baseTerminalTab.component.ts index 5298bcf6..aeb7070a 100644 --- a/terminus-terminal/src/api/baseTerminalTab.component.ts +++ b/terminus-terminal/src/api/baseTerminalTab.component.ts @@ -19,24 +19,44 @@ import { TerminalDecorator } from './decorator' export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit, OnDestroy { static template: string = require('../components/baseTerminalTab.component.pug') static styles: string[] = [require('../components/baseTerminalTab.component.scss')] - static animations: AnimationTriggerMetadata[] = [trigger('slideInOut', [ - transition(':enter', [ - style({ - transform: 'translateY(-25%)', - opacity: '0', - }), - animate('100ms ease-out', style({ - transform: 'translateY(0%)', - opacity: '1', - })), + static animations: AnimationTriggerMetadata[] = [ + trigger('toolbarSlide', [ + transition(':enter', [ + style({ + transform: 'translateY(-25%)', + opacity: '0', + }), + animate('100ms ease-out', style({ + transform: 'translateY(0%)', + opacity: '1', + })), + ]), + transition(':leave', [ + animate('100ms ease-out', style({ + transform: 'translateY(-25%)', + opacity: '0', + })), + ]), ]), - transition(':leave', [ - animate('100ms ease-out', style({ - transform: 'translateY(-25%)', - opacity: '0', - })), + trigger('panelSlide', [ + transition(':enter', [ + style({ + transform: 'translateY(25%)', + opacity: '0', + }), + animate('100ms ease-out', style({ + transform: 'translateY(0%)', + opacity: '1', + })), + ]), + transition(':leave', [ + animate('100ms ease-out', style({ + transform: 'translateY(25%)', + opacity: '0', + })), + ]), ]), - ])] + ] session: BaseSession|null = null savedState?: any diff --git a/terminus-terminal/src/components/baseTerminalTab.component.pug b/terminus-terminal/src/components/baseTerminalTab.component.pug index 4161600a..e00976db 100644 --- a/terminus-terminal/src/components/baseTerminalTab.component.pug +++ b/terminus-terminal/src/components/baseTerminalTab.component.pug @@ -1,7 +1,7 @@ .content(#content, [style.opacity]='frontendIsReady ? 1 : 0') search-panel( - *ngIf='showSearchPanel', - @slideInOut, + *ngIf='showSearchPanel', + @toolbarSlide, [frontend]='frontend', (close)='showSearchPanel = false' )