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:
parent
96ce42461d
commit
c946decbca
@ -152,3 +152,9 @@ ngb-typeahead-window {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-wrap {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -0,0 +1,6 @@
|
||||
.modal-body
|
||||
label Deleting
|
||||
.no-wrap {{progressMessage}}
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-danger((click)='cancel()') Cancel
|
49
terminus-ssh/src/components/sftpDeleteModal.component.ts
Normal file
49
terminus-ssh/src/components/sftpDeleteModal.component.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)}}
|
||||
|
@ -24,6 +24,10 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-item:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 (
|
||||
|
@ -19,24 +19,44 @@ 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', [
|
||||
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
|
||||
|
@ -1,7 +1,7 @@
|
||||
.content(#content, [style.opacity]='frontendIsReady ? 1 : 0')
|
||||
search-panel(
|
||||
*ngIf='showSearchPanel',
|
||||
@slideInOut,
|
||||
@toolbarSlide,
|
||||
[frontend]='frontend',
|
||||
(close)='showSearchPanel = false'
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user