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 { interface CancellableEvent {
element: HTMLElement element: HTMLElement
@ -38,17 +38,21 @@ export class SubscriptionContainer {
} }
export class BaseComponent { 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 { 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 { subscribeUntilDestroyed <T> (observable: Observable<T>, handler: (v: T) => void): void {
this.subscriptionContainer.subscribe(observable, handler) this._subscriptionContainer.subscribe(observable, handler)
} }
ngOnDestroy (): void { 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 fs from 'mz/fs'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import * as path from 'path' 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 * as sshpk from 'sshpk'
import colors from 'ansi-colors' import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
@ -138,6 +141,15 @@ interface AuthMethod {
path?: string path?: string
} }
export interface SFTPFile {
name: string
fullPath: string
isDirectory: boolean
isSymlink: boolean
mode: number
size: number
}
export class SFTPFileHandle { export class SFTPFileHandle {
position = 0 position = 0
@ -191,22 +203,52 @@ export class SFTPFileHandle {
export class SFTPSession { export class SFTPSession {
constructor (private sftp: SFTPWrapper, private zone: NgZone) { } constructor (private sftp: SFTPWrapper, private zone: NgZone) { }
readdir (p: string): Promise<FileEntry[]> { async readdir (p: string): Promise<SFTPFile[]> {
return wrapPromise(this.zone, promisify<FileEntry[]>(f => this.sftp.readdir(p, f))()) 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> { readlink (p: string): Promise<string> {
return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))()) return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
} }
stat (p: string): Promise<Stats> { async stat (p: string): Promise<SFTPFile> {
return wrapPromise(this.zone, promisify<Stats>(f => this.sftp.stat(p, f))()) 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> { async open (p: string, mode: string): Promise<SFTPFileHandle> {
const handle = await wrapPromise(this.zone, promisify<Buffer>(f => this.sftp.open(p, mode, f))()) const handle = await wrapPromise(this.zone, promisify<Buffer>(f => this.sftp.open(p, mode, f))())
return new SFTPFileHandle(this.sftp, handle, this.zone) 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 { 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 div Go up
.list-group-item.list-group-item-action.d-flex.align-items-center( .list-group-item.list-group-item-action.d-flex.align-items-center(
*ngFor='let item of fileList', *ngFor='let item of fileList',
(contextmenu)='showContextMenu(item, $event)',
(click)='open(item)' (click)='open(item)'
) )
i.fa-fw([class]='getIcon(item)') i.fa-fw([class]='getIcon(item)')
div {{item.filename}} div {{item.name}}
.mr-auto .mr-auto
.mode {{getModeString(item)}} .mode {{getModeString(item)}}

View File

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

View File

@ -1,9 +1,10 @@
import { Component, Input, Output, EventEmitter } from '@angular/core' import { Component, Input, Output, EventEmitter } from '@angular/core'
import type { FileEntry } from 'ssh2-streams' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { SSHSession, SFTPSession } from '../api' import { SSHSession, SFTPSession, SFTPFile } from '../api'
import { posix as path } from 'path' import { posix as path } from 'path'
import * as C from 'constants' import * as C from 'constants'
import { FileUpload, PlatformService } from 'terminus-core' import { FileUpload, PlatformService } from 'terminus-core'
import { SFTPDeleteModalComponent } from './sftpDeleteModal.component'
interface PathSegment { interface PathSegment {
name: string name: string
@ -20,21 +21,24 @@ export class SFTPPanelComponent {
@Input() session: SSHSession @Input() session: SSHSession
@Output() closed = new EventEmitter<void>() @Output() closed = new EventEmitter<void>()
sftp: SFTPSession sftp: SFTPSession
fileList: FileEntry[]|null = null fileList: SFTPFile[]|null = null
path = '/' @Input() path = '/'
@Output() pathChange = new EventEmitter<string>()
pathSegments: PathSegment[] = [] pathSegments: PathSegment[] = []
constructor ( constructor (
private platform: PlatformService, private platform: PlatformService,
private ngbModal: NgbModal,
) { } ) { }
async ngOnInit (): Promise<void> { async ngOnInit (): Promise<void> {
this.sftp = await this.session.openSFTP() this.sftp = await this.session.openSFTP()
this.navigate('/') this.navigate(this.path)
} }
async navigate (newPath: string): Promise<void> { async navigate (newPath: string): Promise<void> {
this.path = newPath this.path = newPath
this.pathChange.next(this.path)
let p = newPath let p = newPath
this.pathSegments = [] this.pathSegments = []
@ -49,17 +53,17 @@ export class SFTPPanelComponent {
this.fileList = null this.fileList = null
this.fileList = await this.sftp.readdir(this.path) 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) => this.fileList.sort((a, b) =>
dirKey(b) - dirKey(a) || dirKey(b) - dirKey(a) ||
a.filename.localeCompare(b.filename)) a.name.localeCompare(b.name))
} }
getIcon (item: FileEntry): string { getIcon (item: SFTPFile): string {
if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) { if (item.isDirectory) {
return 'fas fa-folder text-info' 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-link text-warning'
} }
return 'fas fa-file' return 'fas fa-file'
@ -69,20 +73,19 @@ export class SFTPPanelComponent {
this.navigate(path.dirname(this.path)) this.navigate(path.dirname(this.path))
} }
async open (item: FileEntry): Promise<void> { async open (item: SFTPFile): Promise<void> {
const itemPath = path.join(this.path, item.filename) if (item.isDirectory) {
if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) { this.navigate(item.fullPath)
this.navigate(path.join(this.path, item.filename)) } else if (item.isSymlink) {
} else if ((item.attrs.mode & C.S_IFLNK) === C.S_IFLNK) { const target = await this.sftp.readlink(item.fullPath)
const target = await this.sftp.readlink(itemPath)
const stat = await this.sftp.stat(target) const stat = await this.sftp.stat(target)
if (stat.isDirectory()) { if (stat.isDirectory) {
this.navigate(itemPath) this.navigate(item.fullPath)
} else { } else {
this.download(itemPath, stat.size) this.download(item.fullPath, stat.size)
} }
} else { } 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 s = 'SGdrwxrwxrwx'
const e = ' ---------' const e = ' ---------'
const c = [ const c = [
@ -150,11 +153,38 @@ export class SFTPPanelComponent {
] ]
let result = '' let result = ''
for (let i = 0; i < c.length; i++) { 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 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 { close (): void {
this.closed.emit() this.closed.emit()
} }

View File

@ -11,12 +11,17 @@
button.btn.btn-secondary.mr-2((click)='openSFTP()', *ngIf='session && session.open') button.btn.btn-secondary.mr-2((click)='openSFTP()', *ngIf='session && session.open')
span SFTP span SFTP
span.badge.badge-info.ml-2
i.fas.fa-flask
span Experimental
button.btn.btn-secondary((click)='showPortForwarding()', *ngIf='session && session.open') button.btn.btn-secondary((click)='showPortForwarding()', *ngIf='session && session.open')
i.fas.fa-plug i.fas.fa-plug
span Ports span Ports
sftp-panel.bg-dark( sftp-panel.bg-dark(
@panelSlide,
[(path)]='sftpPath',
*ngIf='sftpPanelVisible', *ngIf='sftpPanelVisible',
(click)='$event.stopPropagation()', (click)='$event.stopPropagation()',
[session]='session', [session]='session',

View File

@ -21,6 +21,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
connection?: SSHConnection connection?: SSHConnection
session: SSHSession|null = null session: SSHSession|null = null
sftpPanelVisible = false sftpPanelVisible = false
sftpPath = '/'
private sessionStack: SSHSession[] = [] private sessionStack: SSHSession[] = []
private recentInputs = '' private recentInputs = ''
private reconnectOffered = false private reconnectOffered = false

View File

@ -14,13 +14,14 @@ import { PromptModalComponent } from './components/promptModal.component'
import { SSHSettingsTabComponent } from './components/sshSettingsTab.component' import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
import { SSHTabComponent } from './components/sshTab.component' import { SSHTabComponent } from './components/sshTab.component'
import { SFTPPanelComponent } from './components/sftpPanel.component' import { SFTPPanelComponent } from './components/sftpPanel.component'
import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
import { ButtonProvider } from './buttonProvider' import { ButtonProvider } from './buttonProvider'
import { SSHConfigProvider } from './config' import { SSHConfigProvider } from './config'
import { SSHSettingsTabProvider } from './settings' import { SSHSettingsTabProvider } from './settings'
import { RecoveryProvider } from './recoveryProvider' import { RecoveryProvider } from './recoveryProvider'
import { SSHHotkeyProvider } from './hotkeys' import { SSHHotkeyProvider } from './hotkeys'
import { WinSCPContextMenu } from './tabContextMenu' import { SFTPContextMenu } from './tabContextMenu'
import { SSHCLIHandler } from './cli' import { SSHCLIHandler } from './cli'
/** @hidden */ /** @hidden */
@ -39,12 +40,13 @@ import { SSHCLIHandler } from './cli'
{ provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true }, { provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true }, { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
{ provide: HotkeyProvider, useClass: SSHHotkeyProvider, 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 }, { provide: CLIHandler, useClass: SSHCLIHandler, multi: true },
], ],
entryComponents: [ entryComponents: [
EditConnectionModalComponent, EditConnectionModalComponent,
PromptModalComponent, PromptModalComponent,
SFTPDeleteModalComponent,
SSHPortForwardingModalComponent, SSHPortForwardingModalComponent,
SSHSettingsTabComponent, SSHSettingsTabComponent,
SSHTabComponent, SSHTabComponent,
@ -52,6 +54,7 @@ import { SSHCLIHandler } from './cli'
declarations: [ declarations: [
EditConnectionModalComponent, EditConnectionModalComponent,
PromptModalComponent, PromptModalComponent,
SFTPDeleteModalComponent,
SSHPortForwardingModalComponent, SSHPortForwardingModalComponent,
SSHPortForwardingConfigComponent, SSHPortForwardingConfigComponent,
SSHSettingsTabComponent, SSHSettingsTabComponent,

View File

@ -6,7 +6,7 @@ import { SSHService } from './services/ssh.service'
/** @hidden */ /** @hidden */
@Injectable() @Injectable()
export class WinSCPContextMenu extends TabContextMenuItemProvider { export class SFTPContextMenu extends TabContextMenuItemProvider {
weight = 10 weight = 10
constructor ( constructor (

View File

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

View File

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