1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-12-23 10:32:29 +03:00

sftp improvements, fixes #3992

This commit is contained in:
Eugene Pankov 2021-06-17 20:38:47 +02:00
parent 96ce42461d
commit c946decbca
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
14 changed files with 224 additions and 53 deletions

View File

@ -152,3 +152,9 @@ ngb-typeahead-window {
}
}
}
.no-wrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}

View File

@ -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<void> { return this._destroyed }
private _destroyed = new Subject<void>()
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 <T> (observable: Observable<T>, 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()
}
}

View File

@ -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<FileEntry[]> {
return wrapPromise(this.zone, promisify<FileEntry[]>(f => this.sftp.readdir(p, f))())
async readdir (p: string): Promise<SFTPFile[]> {
const entries = await wrapPromise(this.zone, promisify<FileEntry[]>(f => this.sftp.readdir(p, f))())
return entries.map(entry => this._makeFile(
posixPath.join(p, entry.filename), entry,
))
}
readlink (p: string): Promise<string> {
return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
}
stat (p: string): Promise<Stats> {
return wrapPromise(this.zone, promisify<Stats>(f => this.sftp.stat(p, f))())
async stat (p: string): Promise<SFTPFile> {
const stats = await wrapPromise(this.zone, promisify<Stats>(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<SFTPFileHandle> {
const handle = await wrapPromise(this.zone, promisify<Buffer>(f => this.sftp.open(p, mode, f))())
return new SFTPFileHandle(this.sftp, handle, this.zone)
}
async rmdir (p: string): Promise<void> {
await promisify((f: any) => this.sftp.rmdir(p, f))()
}
async unlink (p: string): Promise<void> {
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 {

View File

@ -0,0 +1,6 @@
.modal-body
label Deleting
.no-wrap {{progressMessage}}
.modal-footer
button.btn.btn-outline-danger((click)='cancel()') Cancel

View File

@ -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<void> {
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)
}
}
}

View File

@ -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)}}

View File

@ -24,6 +24,10 @@
margin: 0;
}
.breadcrumb-item {
cursor: pointer;
}
.breadcrumb-item:first-child {
font-weight: bold;
}

View File

@ -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<void>()
sftp: SFTPSession
fileList: FileEntry[]|null = null
path = '/'
fileList: SFTPFile[]|null = null
@Input() path = '/'
@Output() pathChange = new EventEmitter<string>()
pathSegments: PathSegment[] = []
constructor (
private platform: PlatformService,
private ngbModal: NgbModal,
) { }
async ngOnInit (): Promise<void> {
this.sftp = await this.session.openSFTP()
this.navigate('/')
this.navigate(this.path)
}
async navigate (newPath: string): Promise<void> {
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<void> {
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<void> {
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<void> {
const modal = this.ngbModal.open(SFTPDeleteModalComponent)
modal.componentInstance.item = item
modal.componentInstance.sftp = this.sftp
await modal.result
}
close (): void {
this.closed.emit()
}

View File

@ -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',

View File

@ -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

View File

@ -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,

View File

@ -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 (

View File

@ -19,7 +19,8 @@ import { TerminalDecorator } from './decorator'
export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit, OnDestroy {
static template: string = require<string>('../components/baseTerminalTab.component.pug')
static styles: string[] = [require<string>('../components/baseTerminalTab.component.scss')]
static animations: AnimationTriggerMetadata[] = [trigger('slideInOut', [
static animations: AnimationTriggerMetadata[] = [
trigger('toolbarSlide', [
transition(':enter', [
style({
transform: 'translateY(-25%)',
@ -36,7 +37,26 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
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

View File

@ -1,7 +1,7 @@
.content(#content, [style.opacity]='frontendIsReady ? 1 : 0')
search-panel(
*ngIf='showSearchPanel',
@slideInOut,
@toolbarSlide,
[frontend]='frontend',
(close)='showSearchPanel = false'
)