mirror of
https://github.com/Eugeny/tabby
synced 2024-11-15 01:17:14 +00:00
added experimental SFTP implementation - fixes #296
This commit is contained in:
parent
019cba06d4
commit
a397884d3c
16 changed files with 462 additions and 6 deletions
|
@ -88,6 +88,21 @@ export abstract class PlatformService {
|
||||||
abstract startDownload (name: string, size: number): Promise<FileDownload|null>
|
abstract startDownload (name: string, size: number): Promise<FileDownload|null>
|
||||||
abstract startUpload (options?: FileUploadOptions): Promise<FileUpload[]>
|
abstract startUpload (options?: FileUploadOptions): Promise<FileUpload[]>
|
||||||
|
|
||||||
|
startUploadFromDragEvent (event: DragEvent): FileUpload[] {
|
||||||
|
const result: FileUpload[] = []
|
||||||
|
if (!event.dataTransfer) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||||
|
for (let i = 0; i < event.dataTransfer.files.length; i++) {
|
||||||
|
const file = event.dataTransfer.files[i]
|
||||||
|
const transfer = new DropUpload(file)
|
||||||
|
this.fileTransferStarted.next(transfer)
|
||||||
|
result.push(transfer)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
getConfigPath (): string|null {
|
getConfigPath (): string|null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -144,3 +159,36 @@ export abstract class PlatformService {
|
||||||
abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
|
abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
|
||||||
abstract quit (): void
|
abstract quit (): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DropUpload extends FileUpload {
|
||||||
|
private stream: ReadableStream
|
||||||
|
private reader: ReadableStreamDefaultReader
|
||||||
|
|
||||||
|
constructor (private file: File) {
|
||||||
|
super()
|
||||||
|
this.stream = this.file.stream()
|
||||||
|
this.reader = this.stream.getReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
getName (): string {
|
||||||
|
return this.file.name
|
||||||
|
}
|
||||||
|
|
||||||
|
getSize (): number {
|
||||||
|
return this.file.size
|
||||||
|
}
|
||||||
|
|
||||||
|
async read (): Promise<Buffer> {
|
||||||
|
const result: any = await this.reader.read()
|
||||||
|
if (result.done || !result.value) {
|
||||||
|
return Buffer.from('')
|
||||||
|
}
|
||||||
|
const chunk = Buffer.from(result.value)
|
||||||
|
this.increaseProgress(chunk.length)
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
close (): void { }
|
||||||
|
}
|
||||||
|
|
1
terminus-core/src/directives/dropZone.directive.pug
Normal file
1
terminus-core/src/directives/dropZone.directive.pug
Normal file
|
@ -0,0 +1 @@
|
||||||
|
i.fas.fa-upload
|
24
terminus-core/src/directives/dropZone.directive.scss
Normal file
24
terminus-core/src/directives/dropZone.directive.scss
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
.drop-zone-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, .5);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
transition: .25s opacity ease-out;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 48px;
|
||||||
|
align-self: center;
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
49
terminus-core/src/directives/dropZone.directive.ts
Normal file
49
terminus-core/src/directives/dropZone.directive.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { Directive, Output, ElementRef, EventEmitter, AfterViewInit } from '@angular/core'
|
||||||
|
import { FileUpload, PlatformService } from '../api/platform'
|
||||||
|
import './dropZone.directive.scss'
|
||||||
|
|
||||||
|
/** @hidden */
|
||||||
|
@Directive({
|
||||||
|
selector: '[dropZone]',
|
||||||
|
})
|
||||||
|
export class DropZoneDirective implements AfterViewInit {
|
||||||
|
@Output() transfer = new EventEmitter<FileUpload>()
|
||||||
|
private dropHint?: HTMLElement
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private el: ElementRef,
|
||||||
|
private platform: PlatformService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngAfterViewInit (): void {
|
||||||
|
this.el.nativeElement.addEventListener('dragover', () => {
|
||||||
|
if (!this.dropHint) {
|
||||||
|
this.dropHint = document.createElement('div')
|
||||||
|
this.dropHint.className = 'drop-zone-hint'
|
||||||
|
this.dropHint.innerHTML = require('./dropZone.directive.pug')
|
||||||
|
this.el.nativeElement.appendChild(this.dropHint)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dropHint!.classList.add('visible')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.el.nativeElement.addEventListener('drop', (event: DragEvent) => {
|
||||||
|
this.removeHint()
|
||||||
|
for (const transfer of this.platform.startUploadFromDragEvent(event)) {
|
||||||
|
this.transfer.emit(transfer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.el.nativeElement.addEventListener('dragleave', () => {
|
||||||
|
this.removeHint()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeHint () {
|
||||||
|
const element = this.dropHint
|
||||||
|
delete this.dropHint
|
||||||
|
element?.classList.remove('visible')
|
||||||
|
setTimeout(() => {
|
||||||
|
element?.remove()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ import { TransfersMenuComponent } from './components/transfersMenu.component'
|
||||||
|
|
||||||
import { AutofocusDirective } from './directives/autofocus.directive'
|
import { AutofocusDirective } from './directives/autofocus.directive'
|
||||||
import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
|
import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
|
||||||
|
import { DropZoneDirective } from './directives/dropZone.directive'
|
||||||
|
|
||||||
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider } from './api'
|
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider } from './api'
|
||||||
|
|
||||||
|
@ -83,6 +84,7 @@ const PROVIDERS = [
|
||||||
UnlockVaultModalComponent,
|
UnlockVaultModalComponent,
|
||||||
WelcomeTabComponent,
|
WelcomeTabComponent,
|
||||||
TransfersMenuComponent,
|
TransfersMenuComponent,
|
||||||
|
DropZoneDirective,
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
RenameTabModalComponent,
|
RenameTabModalComponent,
|
||||||
|
@ -96,6 +98,7 @@ const PROVIDERS = [
|
||||||
CheckboxComponent,
|
CheckboxComponent,
|
||||||
ToggleComponent,
|
ToggleComponent,
|
||||||
AutofocusDirective,
|
AutofocusDirective,
|
||||||
|
DropZoneDirective,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
|
export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
|
||||||
|
|
|
@ -38,7 +38,7 @@ $theme-colors: (
|
||||||
warning: $orange,
|
warning: $orange,
|
||||||
danger: $red,
|
danger: $red,
|
||||||
light: $gray-300,
|
light: $gray-300,
|
||||||
dark: $gray-800,
|
dark: #0e151d,
|
||||||
rare: $purple
|
rare: $purple
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -150,7 +150,7 @@ $navbar-padding-x: 0;
|
||||||
$dropdown-bg: $content-bg-solid;
|
$dropdown-bg: $content-bg-solid;
|
||||||
$dropdown-color: $body-color;
|
$dropdown-color: $body-color;
|
||||||
$dropdown-border-width: 1px;
|
$dropdown-border-width: 1px;
|
||||||
$dropdown-box-shadow: 0 .5rem 1rem rgba($black,.175);
|
$dropdown-box-shadow: 0 0 1rem rgba($black, .25), 0 1px 1px rgba($black, .12);
|
||||||
$dropdown-header-color: $gray-500;
|
$dropdown-header-color: $gray-500;
|
||||||
|
|
||||||
$dropdown-link-color: $body-color;
|
$dropdown-link-color: $body-color;
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "14.14.31",
|
"@types/node": "14.14.31",
|
||||||
|
"@types/ssh2": "^0.5.46",
|
||||||
"ansi-colors": "^4.1.1",
|
"ansi-colors": "^4.1.1",
|
||||||
"cli-spinner": "^0.2.10",
|
"cli-spinner": "^0.2.10",
|
||||||
"clone-deep": "^4.0.1",
|
"clone-deep": "^4.0.1",
|
||||||
|
|
|
@ -10,11 +10,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { HostAppService, Logger, NotificationsService, Platform, PlatformService } from 'terminus-core'
|
import { HostAppService, Logger, NotificationsService, Platform, PlatformService } from 'terminus-core'
|
||||||
import { BaseSession } from 'terminus-terminal'
|
import { BaseSession } from 'terminus-terminal'
|
||||||
import { Server, Socket, createServer, createConnection } from 'net'
|
import { Server, Socket, createServer, createConnection } from 'net'
|
||||||
import { Client, ClientChannel } from 'ssh2'
|
import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
|
||||||
import { Subject, Observable } from 'rxjs'
|
import { Subject, Observable } from 'rxjs'
|
||||||
import { ProxyCommandStream } from './services/ssh.service'
|
import { ProxyCommandStream } from './services/ssh.service'
|
||||||
import { PasswordStorageService } from './services/passwordStorage.service'
|
import { PasswordStorageService } from './services/passwordStorage.service'
|
||||||
import { PromptModalComponent } from './components/promptModal.component'
|
import { PromptModalComponent } from './components/promptModal.component'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
|
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
|
||||||
|
|
||||||
|
@ -140,6 +141,7 @@ export class SSHSession extends BaseSession {
|
||||||
scripts?: LoginScript[]
|
scripts?: LoginScript[]
|
||||||
shell?: ClientChannel
|
shell?: ClientChannel
|
||||||
ssh: Client
|
ssh: Client
|
||||||
|
sftp?: SFTPWrapper
|
||||||
forwardedPorts: ForwardedPort[] = []
|
forwardedPorts: ForwardedPort[] = []
|
||||||
logger: Logger
|
logger: Logger
|
||||||
jumpStream: any
|
jumpStream: any
|
||||||
|
@ -221,6 +223,13 @@ export class SSHSession extends BaseSession {
|
||||||
this.remainingAuthMethods.push({ type: 'hostbased' })
|
this.remainingAuthMethods.push({ type: 'hostbased' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openSFTP (): Promise<SFTPWrapper> {
|
||||||
|
if (!this.sftp) {
|
||||||
|
this.sftp = await promisify<SFTPWrapper>(f => this.ssh.sftp(f))()
|
||||||
|
}
|
||||||
|
return this.sftp
|
||||||
|
}
|
||||||
|
|
||||||
async start (): Promise<void> {
|
async start (): Promise<void> {
|
||||||
this.open = true
|
this.open = true
|
||||||
|
|
||||||
|
@ -273,7 +282,7 @@ export class SSHSession extends BaseSession {
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
this.logger.info('Executing script: "' + cmd + '"')
|
this.logger.info('Executing script: "' + cmd + '"')
|
||||||
this.shell.write(cmd + '\n')
|
this.shell?.write(cmd + '\n')
|
||||||
this.scripts = this.scripts.filter(x => x !== script)
|
this.scripts = this.scripts.filter(x => x !== script)
|
||||||
} else {
|
} else {
|
||||||
if (script.optional) {
|
if (script.optional) {
|
||||||
|
@ -569,7 +578,7 @@ export class SSHSession extends BaseSession {
|
||||||
for (const script of this.scripts) {
|
for (const script of this.scripts) {
|
||||||
if (!script.expect) {
|
if (!script.expect) {
|
||||||
console.log('Executing script:', script.send)
|
console.log('Executing script:', script.send)
|
||||||
this.shell.write(script.send + '\n')
|
this.shell?.write(script.send + '\n')
|
||||||
this.scripts = this.scripts.filter(x => x !== script)
|
this.scripts = this.scripts.filter(x => x !== script)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
|
|
33
terminus-ssh/src/components/sftpPanel.component.pug
Normal file
33
terminus-ssh/src/components/sftpPanel.component.pug
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
.header
|
||||||
|
.breadcrumb.mr-auto
|
||||||
|
a.breadcrumb-item((click)='navigate("/")') SFTP
|
||||||
|
a.breadcrumb-item(
|
||||||
|
*ngFor='let segment of pathSegments',
|
||||||
|
(click)='navigate(segment.path)'
|
||||||
|
) {{segment.name}}
|
||||||
|
|
||||||
|
button.btn.btn-link.btn-sm.d-flex((click)='upload()')
|
||||||
|
i.fas.fa-upload.mr-1
|
||||||
|
div Upload
|
||||||
|
|
||||||
|
button.btn.btn-link.btn-close((click)='close()') !{require('../../../terminus-core/src/icons/times.svg')}
|
||||||
|
|
||||||
|
.body(dropZone, (transfer)='uploadOne($event)')
|
||||||
|
div(*ngIf='!sftp') Connecting
|
||||||
|
div(*ngIf='sftp')
|
||||||
|
div(*ngIf='fileList === null') Loading
|
||||||
|
.list-group.list-group-light(*ngIf='fileList !== null')
|
||||||
|
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||||
|
*ngIf='path !== "/"',
|
||||||
|
(click)='goUp()'
|
||||||
|
)
|
||||||
|
i.fas.fa-fw.fa-level-up-alt
|
||||||
|
div Go up
|
||||||
|
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||||
|
*ngFor='let item of fileList',
|
||||||
|
(click)='open(item)'
|
||||||
|
)
|
||||||
|
i.fa-fw([class]='getIcon(item)')
|
||||||
|
div {{item.filename}}
|
||||||
|
.mr-auto
|
||||||
|
.mode {{getModeString(item)}}
|
40
terminus-ssh/src/components/sftpPanel.component.scss
Normal file
40
terminus-ssh/src/components/sftpPanel.component.scss
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
}
|
||||||
|
|
||||||
|
> .header {
|
||||||
|
padding: 5px 15px 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .body {
|
||||||
|
padding: 10px 20px;
|
||||||
|
flex: 1 1 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item:first-child {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode {
|
||||||
|
font-family: monospace;
|
||||||
|
opacity: .5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close svg {
|
||||||
|
width: 12px;
|
||||||
|
}
|
196
terminus-ssh/src/components/sftpPanel.component.ts
Normal file
196
terminus-ssh/src/components/sftpPanel.component.ts
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
import { Component, Input, Output, EventEmitter } from '@angular/core'
|
||||||
|
import { SFTPWrapper } from 'ssh2'
|
||||||
|
import type { FileEntry, Stats } from 'ssh2-streams'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { SSHSession } from '../api'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as C from 'constants'
|
||||||
|
import { FileUpload, PlatformService } from 'terminus-core'
|
||||||
|
|
||||||
|
interface PathSegment {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @hidden */
|
||||||
|
@Component({
|
||||||
|
selector: 'sftp-panel',
|
||||||
|
template: require('./sftpPanel.component.pug'),
|
||||||
|
styles: [require('./sftpPanel.component.scss')],
|
||||||
|
})
|
||||||
|
export class SFTPPanelComponent {
|
||||||
|
@Input() session: SSHSession
|
||||||
|
@Output() closed = new EventEmitter<void>()
|
||||||
|
sftp: SFTPWrapper
|
||||||
|
fileList: FileEntry[]|null = null
|
||||||
|
path = '/'
|
||||||
|
pathSegments: PathSegment[] = []
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private platform: PlatformService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async ngOnInit (): Promise<void> {
|
||||||
|
this.sftp = await this.session.openSFTP()
|
||||||
|
this.navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate (newPath: string): Promise<void> {
|
||||||
|
this.path = newPath
|
||||||
|
|
||||||
|
let p = newPath
|
||||||
|
this.pathSegments = []
|
||||||
|
while (p !== '/') {
|
||||||
|
this.pathSegments.unshift({
|
||||||
|
name: path.basename(p),
|
||||||
|
path: p,
|
||||||
|
})
|
||||||
|
p = path.dirname(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fileList = null
|
||||||
|
this.fileList = await promisify<FileEntry[]>(f => this.sftp.readdir(this.path, f))()
|
||||||
|
console.log(this.fileList)
|
||||||
|
|
||||||
|
const dirKey = a => (a.attrs.mode & C.S_IFDIR) === C.S_IFDIR ? 1 : 0
|
||||||
|
this.fileList.sort((a, b) =>
|
||||||
|
dirKey(b) - dirKey(a) ||
|
||||||
|
a.filename.localeCompare(b.filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon (item: FileEntry): string {
|
||||||
|
if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) {
|
||||||
|
return 'fas fa-folder text-info'
|
||||||
|
}
|
||||||
|
if ((item.attrs.mode & C.S_IFLNK) === C.S_IFLNK) {
|
||||||
|
return 'fas fa-link text-warning'
|
||||||
|
}
|
||||||
|
return 'fas fa-file'
|
||||||
|
}
|
||||||
|
|
||||||
|
goUp (): void {
|
||||||
|
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 promisify<string>(f => this.sftp.readlink(itemPath, f))()
|
||||||
|
const stat = await promisify<Stats>(f => this.sftp.stat(target, f))()
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
this.navigate(itemPath)
|
||||||
|
} else {
|
||||||
|
this.download(itemPath, stat.size)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.download(itemPath, item.attrs.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload (): Promise<void> {
|
||||||
|
const transfers = await this.platform.startUpload({ multiple: true })
|
||||||
|
const savedPath = this.path
|
||||||
|
for (const transfer of transfers) {
|
||||||
|
this.uploadOne(transfer).then(() => {
|
||||||
|
if (this.path === savedPath) {
|
||||||
|
this.navigate(this.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadOne (transfer: FileUpload): Promise<void> {
|
||||||
|
const itemPath = path.join(this.path, transfer.getName())
|
||||||
|
try {
|
||||||
|
const handle = await promisify<Buffer>(f => this.sftp.open(itemPath, 'w', f))()
|
||||||
|
let position = 0
|
||||||
|
while (true) {
|
||||||
|
const chunk = await transfer.read()
|
||||||
|
if (!chunk.length) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const p = position
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
while (true) {
|
||||||
|
const wait = this.sftp.write(handle, chunk, 0, chunk.length, p, err => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
if (!wait) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
position += chunk.length
|
||||||
|
}
|
||||||
|
this.sftp.close(handle, () => null)
|
||||||
|
transfer.close()
|
||||||
|
} catch (e) {
|
||||||
|
transfer.cancel()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async download (itemPath: string, size: number): Promise<void> {
|
||||||
|
const transfer = await this.platform.startDownload(path.basename(itemPath), size)
|
||||||
|
if (!transfer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const handle = await promisify<Buffer>(f => this.sftp.open(itemPath, 'r', f))()
|
||||||
|
const buffer = Buffer.alloc(256 * 1024)
|
||||||
|
let position = 0
|
||||||
|
while (true) {
|
||||||
|
const p = position
|
||||||
|
const chunk: Buffer = await new Promise((resolve, reject) => {
|
||||||
|
while (true) {
|
||||||
|
const wait = this.sftp.read(handle, buffer, 0, buffer.length, p, (err, read) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(buffer.slice(0, read))
|
||||||
|
})
|
||||||
|
if (!wait) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!chunk.length) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await transfer.write(chunk)
|
||||||
|
position += chunk.length
|
||||||
|
}
|
||||||
|
transfer.close()
|
||||||
|
this.sftp.close(handle, () => null)
|
||||||
|
} catch (e) {
|
||||||
|
transfer.cancel()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getModeString (item: FileEntry): string {
|
||||||
|
const s = 'SGdrwxrwxrwx'
|
||||||
|
const e = ' ---------'
|
||||||
|
const c = [
|
||||||
|
0o4000, 0o2000, C.S_IFDIR,
|
||||||
|
C.S_IRUSR, C.S_IWUSR, C.S_IXUSR,
|
||||||
|
C.S_IRGRP, C.S_IWGRP, C.S_IXGRP,
|
||||||
|
C.S_IROTH, C.S_IWOTH, C.S_IXOTH,
|
||||||
|
]
|
||||||
|
let result = ''
|
||||||
|
for (let i = 0; i < c.length; i++) {
|
||||||
|
result += item.attrs.mode & c[i] ? s[i] : e[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
close (): void {
|
||||||
|
this.closed.emit()
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,16 @@
|
||||||
button.btn.btn-secondary.mr-2((click)='reconnect()', [class.btn-info]='!session || !session.open')
|
button.btn.btn-secondary.mr-2((click)='reconnect()', [class.btn-info]='!session || !session.open')
|
||||||
span Reconnect
|
span Reconnect
|
||||||
|
|
||||||
|
button.btn.btn-secondary.mr-2((click)='openSFTP()', *ngIf='session && session.open')
|
||||||
|
span SFTP
|
||||||
|
|
||||||
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(
|
||||||
|
*ngIf='sftpPanelVisible',
|
||||||
|
(click)='$event.stopPropagation()',
|
||||||
|
[session]='session',
|
||||||
|
(closed)='sftpPanelVisible = false'
|
||||||
|
)
|
||||||
|
|
|
@ -70,3 +70,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sftp-panel {
|
||||||
|
position: absolute;
|
||||||
|
height: 80%;
|
||||||
|
width: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import colors from 'ansi-colors'
|
import colors from 'ansi-colors'
|
||||||
import { Spinner } from 'cli-spinner'
|
import { Spinner } from 'cli-spinner'
|
||||||
import { Component, Injector } from '@angular/core'
|
import { Component, Injector, HostListener } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { RecoveryToken } from 'terminus-core'
|
import { RecoveryToken } from 'terminus-core'
|
||||||
|
@ -20,6 +20,7 @@ import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.compon
|
||||||
export class SSHTabComponent extends BaseTerminalTabComponent {
|
export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||||
connection?: SSHConnection
|
connection?: SSHConnection
|
||||||
session: SSHSession|null = null
|
session: SSHSession|null = null
|
||||||
|
sftpPanelVisible = false
|
||||||
private sessionStack: SSHSession[] = []
|
private sessionStack: SSHSession[] = []
|
||||||
private recentInputs = ''
|
private recentInputs = ''
|
||||||
private reconnectOffered = false
|
private reconnectOffered = false
|
||||||
|
@ -225,6 +226,17 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||||
)).response === 1
|
)).response === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openSFTP (): void {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.sftpPanelVisible = true
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('click')
|
||||||
|
onClick (): void {
|
||||||
|
this.sftpPanelVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
private startSpinner () {
|
private startSpinner () {
|
||||||
this.spinner.setSpinnerString(6)
|
this.spinner.setSpinnerString(6)
|
||||||
this.spinner.start()
|
this.spinner.start()
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { SSHPortForwardingConfigComponent } from './components/sshPortForwarding
|
||||||
import { PromptModalComponent } from './components/promptModal.component'
|
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 { ButtonProvider } from './buttonProvider'
|
import { ButtonProvider } from './buttonProvider'
|
||||||
import { SSHConfigProvider } from './config'
|
import { SSHConfigProvider } from './config'
|
||||||
|
@ -55,6 +56,7 @@ import { SSHCLIHandler } from './cli'
|
||||||
SSHPortForwardingConfigComponent,
|
SSHPortForwardingConfigComponent,
|
||||||
SSHSettingsTabComponent,
|
SSHSettingsTabComponent,
|
||||||
SSHTabComponent,
|
SSHTabComponent,
|
||||||
|
SFTPPanelComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class SSHModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class
|
export default class SSHModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class
|
||||||
|
|
|
@ -2,11 +2,31 @@
|
||||||
# yarn lockfile v1
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@types/node@*":
|
||||||
|
version "15.12.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.2.tgz#1f2b42c4be7156ff4a6f914b2fb03d05fa84e38d"
|
||||||
|
integrity sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==
|
||||||
|
|
||||||
"@types/node@14.14.31":
|
"@types/node@14.14.31":
|
||||||
version "14.14.31"
|
version "14.14.31"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055"
|
||||||
integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==
|
integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==
|
||||||
|
|
||||||
|
"@types/ssh2-streams@*":
|
||||||
|
version "0.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.8.tgz#142af404dae059931aea7fcd1511b5478964feb6"
|
||||||
|
integrity sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/ssh2@^0.5.46":
|
||||||
|
version "0.5.46"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.46.tgz#e12341a242aea0e98ac2dec89e039bf421fd3584"
|
||||||
|
integrity sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
"@types/ssh2-streams" "*"
|
||||||
|
|
||||||
ansi-colors@^4.1.1:
|
ansi-colors@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
|
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
|
||||||
|
|
Loading…
Reference in a new issue