sftp: allow editing remote files locally - fixes #4311

This commit is contained in:
Eugene Pankov
2021-08-01 16:13:11 +02:00
parent 923b559857
commit 3b09dfa145
21 changed files with 480 additions and 225 deletions

View File

@@ -22,6 +22,7 @@
min-width: 0; min-width: 0;
margin-right: auto; margin-right: auto;
margin-bottom: 3px; margin-bottom: 3px;
width: 100%;
label { label {
margin: 0; margin: 0;

View File

@@ -20,7 +20,8 @@
"@angular/core": "^9.1.9" "@angular/core": "^9.1.9"
}, },
"devDependencies": { "devDependencies": {
"winston": "^3.3.3", "electron-promise-ipc": "^2.2.4",
"electron-promise-ipc": "^2.2.4" "tmp-promise": "^3.0.2",
"winston": "^3.3.3"
} }
} }

View File

@@ -1,6 +1,7 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { PlatformService, LogService, UpdaterService, DockingService, HostAppService, ThemesService, Platform, AppService, ConfigService, WIN_BUILD_FLUENT_BG_SUPPORTED, isWindowsBuild, HostWindowService, HotkeyProvider, ConfigProvider, FileProvider } from 'tabby-core' import { PlatformService, LogService, UpdaterService, DockingService, HostAppService, ThemesService, Platform, AppService, ConfigService, WIN_BUILD_FLUENT_BG_SUPPORTED, isWindowsBuild, HostWindowService, HotkeyProvider, ConfigProvider, FileProvider } from 'tabby-core'
import { TerminalColorSchemeProvider } from 'tabby-terminal' import { TerminalColorSchemeProvider } from 'tabby-terminal'
import { SFTPContextMenuItemProvider } from 'tabby-ssh'
import { HyperColorSchemes } from './colorSchemes' import { HyperColorSchemes } from './colorSchemes'
import { ElectronPlatformService } from './services/platform.service' import { ElectronPlatformService } from './services/platform.service'
@@ -14,11 +15,12 @@ import { ElectronHostAppService } from './services/hostApp.service'
import { ElectronService } from './services/electron.service' import { ElectronService } from './services/electron.service'
import { ElectronHotkeyProvider } from './hotkeys' import { ElectronHotkeyProvider } from './hotkeys'
import { ElectronConfigProvider } from './config' import { ElectronConfigProvider } from './config'
import { EditSFTPContextMenu } from './sftpContextMenu'
@NgModule({ @NgModule({
providers: [ providers: [
{ provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true }, { provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true },
{ provide: PlatformService, useClass: ElectronPlatformService }, { provide: PlatformService, useExisting: ElectronPlatformService },
{ provide: HostWindowService, useExisting: ElectronHostWindow }, { provide: HostWindowService, useExisting: ElectronHostWindow },
{ provide: HostAppService, useExisting: ElectronHostAppService }, { provide: HostAppService, useExisting: ElectronHostAppService },
{ provide: LogService, useClass: ElectronLogService }, { provide: LogService, useClass: ElectronLogService },
@@ -27,6 +29,7 @@ import { ElectronConfigProvider } from './config'
{ provide: HotkeyProvider, useClass: ElectronHotkeyProvider, multi: true }, { provide: HotkeyProvider, useClass: ElectronHotkeyProvider, multi: true },
{ provide: ConfigProvider, useClass: ElectronConfigProvider, multi: true }, { provide: ConfigProvider, useClass: ElectronConfigProvider, multi: true },
{ provide: FileProvider, useClass: ElectronFileProvider, multi: true }, { provide: FileProvider, useClass: ElectronFileProvider, multi: true },
{ provide: SFTPContextMenuItemProvider, useClass: EditSFTPContextMenu, multi: true },
], ],
}) })
export default class ElectronModule { export default class ElectronModule {

View File

@@ -20,7 +20,7 @@ try {
var wnr = require('windows-native-registry') var wnr = require('windows-native-registry')
} catch { } } catch { }
@Injectable() @Injectable({ providedIn: 'root' })
export class ElectronPlatformService extends PlatformService { export class ElectronPlatformService extends PlatformService {
supportsWindowControls = true supportsWindowControls = true
private configPath: string private configPath: string
@@ -189,7 +189,7 @@ export class ElectronPlatformService extends PlatformService {
this.electron.app.exit(0) this.electron.app.exit(0)
} }
async startUpload (options?: FileUploadOptions): Promise<FileUpload[]> { async startUpload (options?: FileUploadOptions, paths?: string[]): Promise<FileUpload[]> {
options ??= { multiple: false } options ??= { multiple: false }
const properties: any[] = ['openFile', 'treatPackageAsDirectory'] const properties: any[] = ['openFile', 'treatPackageAsDirectory']
@@ -197,18 +197,21 @@ export class ElectronPlatformService extends PlatformService {
properties.push('multiSelections') properties.push('multiSelections')
} }
const result = await this.electron.dialog.showOpenDialog( if (!paths) {
this.hostWindow.getWindow(), const result = await this.electron.dialog.showOpenDialog(
{ this.hostWindow.getWindow(),
buttonLabel: 'Select', {
properties, buttonLabel: 'Select',
}, properties,
) },
if (result.canceled) { )
return [] if (result.canceled) {
return []
}
paths = result.filePaths
} }
return Promise.all(result.filePaths.map(async p => { return Promise.all(paths.map(async p => {
const transfer = new ElectronFileUpload(p, this.electron) const transfer = new ElectronFileUpload(p, this.electron)
await wrapPromise(this.zone, transfer.open()) await wrapPromise(this.zone, transfer.open())
this.fileTransferStarted.next(transfer) this.fileTransferStarted.next(transfer)
@@ -216,17 +219,20 @@ export class ElectronPlatformService extends PlatformService {
})) }))
} }
async startDownload (name: string, mode: number, size: number): Promise<FileDownload|null> { async startDownload (name: string, mode: number, size: number, filePath?: string): Promise<FileDownload|null> {
const result = await this.electron.dialog.showSaveDialog( if (!filePath) {
this.hostWindow.getWindow(), const result = await this.electron.dialog.showSaveDialog(
{ this.hostWindow.getWindow(),
defaultPath: name, {
}, defaultPath: name,
) },
if (!result.filePath) { )
return null if (!result.filePath) {
return null
}
filePath = result.filePath
} }
const transfer = new ElectronFileDownload(result.filePath, mode, size, this.electron) const transfer = new ElectronFileDownload(filePath, mode, size, this.electron)
await wrapPromise(this.zone, transfer.open()) await wrapPromise(this.zone, transfer.open())
this.fileTransferStarted.next(transfer) this.fileTransferStarted.next(transfer)
return transfer return transfer

View File

@@ -0,0 +1,59 @@
import * as tmp from 'tmp-promise'
import * as path from 'path'
import * as fs from 'fs'
import { Subject, debounceTime, debounce } from 'rxjs'
import { Injectable } from '@angular/core'
import { MenuItemOptions } from 'tabby-core'
import { SFTPFile, SFTPPanelComponent, SFTPContextMenuItemProvider, SFTPSession } from 'tabby-ssh'
import { ElectronPlatformService } from './services/platform.service'
/** @hidden */
@Injectable()
export class EditSFTPContextMenu extends SFTPContextMenuItemProvider {
weight = 0
constructor (
private platform: ElectronPlatformService,
) {
super()
}
async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise<MenuItemOptions[]> {
if (item.isDirectory) {
return []
}
return [
{
click: () => this.edit(item, panel.sftp),
label: 'Edit locally',
},
]
}
private async edit (item: SFTPFile, sftp: SFTPSession) {
const tempDir = (await tmp.dir({ unsafeCleanup: true })).path
const tempPath = path.join(tempDir, item.name)
const transfer = await this.platform.startDownload(item.name, item.mode, item.size, tempPath)
if (!transfer) {
return
}
await sftp.download(item.fullPath, transfer)
this.platform.openPath(tempPath)
const events = new Subject<string>()
const watcher = fs.watch(tempPath, event => events.next(event))
events.pipe(debounceTime(1000), debounce(async event => {
if (event === 'rename') {
watcher.close()
}
const upload = await this.platform.startUpload({ multiple: false }, [tempPath])
if (!upload.length) {
return
}
sftp.upload(item.fullPath, upload[0])
})).subscribe()
watcher.on('close', () => events.complete())
sftp.closed$.subscribe(() => watcher.close())
}
}

View File

@@ -16,6 +16,19 @@ async@^3.1.0:
resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
call-bind@^1.0.0, call-bind@^1.0.2: call-bind@^1.0.0, call-bind@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
@@ -70,6 +83,11 @@ colorspace@1.1.x:
color "3.0.x" color "3.0.x"
text-hex "1.0.x" text-hex "1.0.x"
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
core-util-is@~1.0.0: core-util-is@~1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -143,6 +161,11 @@ fn.name@1.x.x:
resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
function-bind@^1.1.1: function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -157,6 +180,18 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1:
has "^1.0.3" has "^1.0.3"
has-symbols "^1.0.1" has-symbols "^1.0.1"
glob@^7.1.3:
version "7.1.7"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
has-bigints@^1.0.1: has-bigints@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
@@ -174,7 +209,15 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
inherits@^2.0.3, inherits@~2.0.3: inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -267,6 +310,13 @@ logform@^2.2.0:
ms "^2.1.1" ms "^2.1.1"
triple-beam "^1.3.0" triple-beam "^1.3.0"
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
dependencies:
brace-expansion "^1.1.7"
ms@^2.1.1: ms@^2.1.1:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -302,6 +352,13 @@ object.entries@^1.1.3:
es-abstract "^1.18.0-next.1" es-abstract "^1.18.0-next.1"
has "^1.0.3" has "^1.0.3"
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
dependencies:
wrappy "1"
one-time@^1.0.0: one-time@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
@@ -309,6 +366,11 @@ one-time@^1.0.0:
dependencies: dependencies:
fn.name "1.x.x" fn.name "1.x.x"
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
process-nextick-args@~2.0.0: process-nextick-args@~2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -336,6 +398,13 @@ readable-stream@^3.4.0:
string_decoder "^1.1.1" string_decoder "^1.1.1"
util-deprecate "^1.0.1" util-deprecate "^1.0.1"
rimraf@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
dependencies:
glob "^7.1.3"
safe-buffer@~5.1.0, safe-buffer@~5.1.1: safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2" version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -400,6 +469,20 @@ text-hex@1.0.x:
resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
tmp-promise@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.2.tgz#6e933782abff8b00c3119d63589ca1fb9caaa62a"
integrity sha512-OyCLAKU1HzBjL6Ev3gxUeraJNlbNingmi8IrHHEsYH8LTmEuhvYfqvhn2F/je+mjf4N58UmZ96OMEy1JanSCpA==
dependencies:
tmp "^0.2.0"
tmp@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==
dependencies:
rimraf "^3.0.0"
triple-beam@^1.2.0, triple-beam@^1.3.0: triple-beam@^1.2.0, triple-beam@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
@@ -463,3 +546,8 @@ winston@^3.3.3:
stack-trace "0.0.x" stack-trace "0.0.x"
triple-beam "^1.3.0" triple-beam "^1.3.0"
winston-transport "^4.4.0" winston-transport "^4.4.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=

View File

@@ -1,5 +1,5 @@
import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api'
import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api'
export const supportedAlgorithms: Record<string, string> = {} export const supportedAlgorithms: Record<string, string> = {}

View File

@@ -0,0 +1,12 @@
import { MenuItemOptions } from 'tabby-core'
import { SFTPFile } from '../session/sftp'
import { SFTPPanelComponent } from '../components/sftpPanel.component'
/**
* Extend to add items to the SFTPPanel context menu
*/
export abstract class SFTPContextMenuItemProvider {
weight = 0
abstract getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise<MenuItemOptions[]>
}

View File

@@ -0,0 +1,2 @@
export * from './contextMenu'
export * from './interfaces'

View File

@@ -0,0 +1,53 @@
import { Profile } from 'tabby-core'
import { LoginScriptsOptions } from 'tabby-terminal'
export enum SSHAlgorithmType {
HMAC = 'hmac',
KEX = 'kex',
CIPHER = 'cipher',
HOSTKEY = 'serverHostKey',
}
export interface SSHProfile extends Profile {
options: SSHProfileOptions
}
export interface SSHProfileOptions extends LoginScriptsOptions {
host: string
port?: number
user: string
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
password?: string
privateKeys?: string[]
keepaliveInterval?: number
keepaliveCountMax?: number
readyTimeout?: number
x11?: boolean
skipBanner?: boolean
jumpHost?: string
agentForward?: boolean
warnOnClose?: boolean
algorithms?: Record<string, string[]>
proxyCommand?: string
forwardedPorts?: ForwardedPortConfig[]
}
export enum PortForwardType {
Local = 'Local',
Remote = 'Remote',
Dynamic = 'Dynamic',
}
export interface ForwardedPortConfig {
type: PortForwardType
host: string
port: number
targetAddress: string
targetPort: number
}
export const ALGORITHM_BLACKLIST = [
// cause native crashes in node crypto, use EC instead
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
]

View File

@@ -1,18 +1,16 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { SSHSession } from '../api'
import { SFTPSession, SFTPFile } from '../session/sftp'
import { posix as path } from 'path'
import * as C from 'constants' import * as C from 'constants'
import { FileUpload, PlatformService } from 'tabby-core' import { posix as path } from 'path'
import { SFTPDeleteModalComponent } from './sftpDeleteModal.component' import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core'
import { FileUpload, MenuItemOptions, PlatformService } from 'tabby-core'
import { SFTPSession, SFTPFile } from '../session/sftp'
import { SSHSession } from '../session/ssh'
import { SFTPContextMenuItemProvider } from '../api'
interface PathSegment { interface PathSegment {
name: string name: string
path: string path: string
} }
/** @hidden */
@Component({ @Component({
selector: 'sftp-panel', selector: 'sftp-panel',
template: require('./sftpPanel.component.pug'), template: require('./sftpPanel.component.pug'),
@@ -29,8 +27,10 @@ export class SFTPPanelComponent {
constructor ( constructor (
private platform: PlatformService, private platform: PlatformService,
private ngbModal: NgbModal, @Optional() @Inject(SFTPContextMenuItemProvider) protected contextMenuProviders: SFTPContextMenuItemProvider[],
) { } ) {
this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
}
async ngOnInit (): Promise<void> { async ngOnInit (): Promise<void> {
this.sftp = await this.session.openSFTP() this.sftp = await this.session.openSFTP()
@@ -96,28 +96,10 @@ export class SFTPPanelComponent {
} }
async uploadOne (transfer: FileUpload): Promise<void> { async uploadOne (transfer: FileUpload): Promise<void> {
const itemPath = path.join(this.path, transfer.getName()) this.sftp.upload(path.join(this.path, transfer.getName()), transfer)
const tempPath = itemPath + '.tabby-upload'
const savedPath = this.path const savedPath = this.path
try { if (this.path === savedPath) {
const handle = await this.sftp.open(tempPath, 'w') await this.navigate(this.path)
while (true) {
const chunk = await transfer.read()
if (!chunk.length) {
break
}
await handle.write(chunk)
}
handle.close()
await this.sftp.rename(tempPath, itemPath)
transfer.close()
if (this.path === savedPath) {
await this.navigate(this.path)
}
} catch (e) {
transfer.cancel()
this.sftp.unlink(tempPath)
throw e
} }
} }
@@ -126,21 +108,7 @@ export class SFTPPanelComponent {
if (!transfer) { if (!transfer) {
return return
} }
try { this.sftp.download(itemPath, transfer)
const handle = await this.sftp.open(itemPath, 'r')
while (true) {
const chunk = await handle.read()
if (!chunk.length) {
break
}
await transfer.write(chunk)
}
transfer.close()
handle.close()
} catch (e) {
transfer.cancel()
throw e
}
} }
getModeString (item: SFTPFile): string { getModeString (item: SFTPFile): string {
@@ -159,31 +127,18 @@ export class SFTPPanelComponent {
return result return result
} }
showContextMenu (item: SFTPFile, event: MouseEvent): void { async buildContextMenu (item: SFTPFile): Promise<MenuItemOptions[]> {
event.preventDefault() let items: MenuItemOptions[] = []
this.platform.popupContextMenu([ for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(item, this)))) {
{ items.push({ type: 'separator' })
click: async () => { items = items.concat(section)
if ((await this.platform.showMessageBox({ }
type: 'warning', return items.slice(1)
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> { async showContextMenu (item: SFTPFile, event: MouseEvent): Promise<void> {
const modal = this.ngbModal.open(SFTPDeleteModalComponent) event.preventDefault()
modal.componentInstance.item = item this.platform.popupContextMenu(await this.buildContextMenu(item), event)
modal.componentInstance.sftp = this.sftp
await modal.result
} }
close (): void { close (): void {

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { ForwardedPort, ForwardedPortConfig, SSHSession } from '../api' import { ForwardedPort } from '../session/forwards'
import { SSHSession } from '../session/ssh'
import { ForwardedPortConfig } from '../api'
/** @hidden */ /** @hidden */
@Component({ @Component({

View File

@@ -5,8 +5,9 @@ import { first } from 'rxjs'
import { Platform, RecoveryToken } from 'tabby-core' import { Platform, RecoveryToken } from 'tabby-core'
import { BaseTerminalTabComponent } from 'tabby-terminal' import { BaseTerminalTabComponent } from 'tabby-terminal'
import { SSHService } from '../services/ssh.service' import { SSHService } from '../services/ssh.service'
import { SSHProfile, SSHSession } from '../api' import { SSHSession } from '../session/ssh'
import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component' import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
import { SSHProfile } from '../api'
/** @hidden */ /** @hidden */

View File

@@ -22,6 +22,8 @@ import { RecoveryProvider } from './recoveryProvider'
import { SSHHotkeyProvider } from './hotkeys' import { SSHHotkeyProvider } from './hotkeys'
import { SFTPContextMenu } from './tabContextMenu' import { SFTPContextMenu } from './tabContextMenu'
import { SSHProfilesService } from './profiles' import { SSHProfilesService } from './profiles'
import { SFTPContextMenuItemProvider } from './api/contextMenu'
import { CommonSFTPContextMenu } from './sftpContextMenu'
/** @hidden */ /** @hidden */
@NgModule({ @NgModule({
@@ -41,6 +43,7 @@ import { SSHProfilesService } from './profiles'
{ provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true }, { provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true },
{ provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true }, { provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true },
{ provide: ProfileProvider, useExisting: SSHProfilesService, multi: true }, { provide: ProfileProvider, useExisting: SSHProfilesService, multi: true },
{ provide: SFTPContextMenuItemProvider, useClass: CommonSFTPContextMenu, multi: true },
], ],
entryComponents: [ entryComponents: [
SSHProfileSettingsComponent, SSHProfileSettingsComponent,
@@ -105,3 +108,7 @@ export default class SSHModule {
await this.selector.show('Select an SSH profile', options) await this.selector.show('Select an SSH profile', options)
} }
} }
export * from './api'
export { SFTPFile, SFTPSession } from './session/sftp'
export { SFTPPanelComponent }

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ProfileProvider, NewTabParameters, PartialProfile } from 'tabby-core' import { ProfileProvider, NewTabParameters, PartialProfile } from 'tabby-core'
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
import { SSHTabComponent } from './components/sshTab.component' import { SSHTabComponent } from './components/sshTab.component'
import { PasswordStorageService } from './services/passwordStorage.service' import { PasswordStorageService } from './services/passwordStorage.service'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api' import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api'
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -1,7 +1,7 @@
import * as keytar from 'keytar' import * as keytar from 'keytar'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { SSHProfile } from '../api'
import { VaultService } from 'tabby-core' import { VaultService } from 'tabby-core'
import { SSHProfile } from '../api'
export const VAULT_SECRET_TYPE_PASSWORD = 'ssh:password' export const VAULT_SECRET_TYPE_PASSWORD = 'ssh:password'
export const VAULT_SECRET_TYPE_PASSPHRASE = 'ssh:key-passphrase' export const VAULT_SECRET_TYPE_PASSPHRASE = 'ssh:key-passphrase'

View File

@@ -4,11 +4,13 @@ import { Injectable, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Client } from 'ssh2' import { Client } from 'ssh2'
import { exec } from 'child_process' import { exec } from 'child_process'
import { ChildProcess } from 'node:child_process'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core' import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core'
import { ALGORITHM_BLACKLIST, ForwardedPort, SSHAlgorithmType, SSHProfile, SSHSession } from '../api' import { SSHSession } from '../session/ssh'
import { ForwardedPort } from '../session/forwards'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from '../api'
import { PasswordStorageService } from './passwordStorage.service' import { PasswordStorageService } from './passwordStorage.service'
import { ChildProcess } from 'node:child_process'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SSHService { export class SSHService {

View File

@@ -0,0 +1,64 @@
import socksv5 from 'socksv5'
import { Server, Socket, createServer } from 'net'
import { ForwardedPortConfig, PortForwardType } from '../api'
export class ForwardedPort implements ForwardedPortConfig {
type: PortForwardType
host = '127.0.0.1'
port: number
targetAddress: string
targetPort: number
private listener: Server|null = null
async startLocalListener (callback: (accept: () => Socket, reject: () => void, sourceAddress: string|null, sourcePort: number|null, targetAddress: string, targetPort: number) => void): Promise<void> {
if (this.type === PortForwardType.Local) {
const listener = this.listener = createServer(s => callback(
() => s,
() => s.destroy(),
s.remoteAddress ?? null,
s.remotePort ?? null,
this.targetAddress,
this.targetPort,
))
return new Promise((resolve, reject) => {
listener.listen(this.port, this.host)
listener.on('error', reject)
listener.on('listening', resolve)
})
} else if (this.type === PortForwardType.Dynamic) {
return new Promise((resolve, reject) => {
this.listener = socksv5.createServer((info, acceptConnection, rejectConnection) => {
callback(
() => acceptConnection(true),
() => rejectConnection(),
null,
null,
info.dstAddr,
info.dstPort,
)
}) as Server
this.listener.on('error', reject)
this.listener.listen(this.port, this.host, resolve)
this.listener['useAuth'](socksv5.auth.None())
})
} else {
throw new Error('Invalid forward type for a local listener')
}
}
stopLocalListener (): void {
this.listener?.close()
}
toString (): string {
if (this.type === PortForwardType.Local) {
return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}`
} if (this.type === PortForwardType.Remote) {
return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}`
} else {
return `(dynamic) ${this.host}:${this.port}`
}
}
}

View File

@@ -1,8 +1,9 @@
import * as C from 'constants' import * as C from 'constants'
// eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports // eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
import { Subject, Observable } from 'rxjs'
import { posix as posixPath } from 'path' import { posix as posixPath } from 'path'
import { NgZone } from '@angular/core' import { Injector, NgZone } from '@angular/core'
import { wrapPromise } from 'tabby-core' import { FileDownload, FileUpload, Logger, LogService, wrapPromise } from 'tabby-core'
import { SFTPWrapper } from 'ssh2' import { SFTPWrapper } from 'ssh2'
import { promisify } from 'util' import { promisify } from 'util'
@@ -70,9 +71,22 @@ export class SFTPFileHandle {
} }
export class SFTPSession { export class SFTPSession {
constructor (private sftp: SFTPWrapper, private zone: NgZone) { } get closed$ (): Observable<void> { return this.closed }
private closed = new Subject<void>()
private zone: NgZone
private logger: Logger
constructor (private sftp: SFTPWrapper, injector: Injector) {
this.zone = injector.get(NgZone)
this.logger = injector.get(LogService).create('sftp')
sftp.on('close', () => {
this.closed.next()
this.closed.complete()
})
}
async readdir (p: string): Promise<SFTPFile[]> { async readdir (p: string): Promise<SFTPFile[]> {
this.logger.debug('readdir', p)
const entries = await 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( return entries.map(entry => this._makeFile(
posixPath.join(p, entry.filename), entry, posixPath.join(p, entry.filename), entry,
@@ -80,10 +94,12 @@ export class SFTPSession {
} }
readlink (p: string): Promise<string> { readlink (p: string): Promise<string> {
this.logger.debug('readlink', p)
return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))()) return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
} }
async stat (p: string): Promise<SFTPFile> { async stat (p: string): Promise<SFTPFile> {
this.logger.debug('stat', p)
const stats = await 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 { return {
name: posixPath.basename(p), name: posixPath.basename(p),
@@ -97,22 +113,68 @@ export class SFTPSession {
} }
async open (p: string, mode: string): Promise<SFTPFileHandle> { async open (p: string, mode: string): Promise<SFTPFileHandle> {
this.logger.debug('open', p)
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> { async rmdir (p: string): Promise<void> {
this.logger.debug('rmdir', p)
await promisify((f: any) => this.sftp.rmdir(p, f))() await promisify((f: any) => this.sftp.rmdir(p, f))()
} }
async rename (oldPath: string, newPath: string): Promise<void> { async rename (oldPath: string, newPath: string): Promise<void> {
this.logger.debug('rename', oldPath, newPath)
await promisify((f: any) => this.sftp.rename(oldPath, newPath, f))() await promisify((f: any) => this.sftp.rename(oldPath, newPath, f))()
} }
async unlink (p: string): Promise<void> { async unlink (p: string): Promise<void> {
this.logger.debug('unlink', p)
await promisify((f: any) => this.sftp.unlink(p, f))() await promisify((f: any) => this.sftp.unlink(p, f))()
} }
async upload (path: string, transfer: FileUpload): Promise<void> {
this.logger.info('Uploading into', path)
const tempPath = path + '.tabby-upload'
try {
const handle = await this.open(tempPath, 'w')
while (true) {
const chunk = await transfer.read()
if (!chunk.length) {
break
}
await handle.write(chunk)
}
handle.close()
await this.unlink(path)
await this.rename(tempPath, path)
transfer.close()
} catch (e) {
transfer.cancel()
this.unlink(tempPath)
throw e
}
}
async download (path: string, transfer: FileDownload): Promise<void> {
this.logger.info('Downloading', path)
try {
const handle = await this.open(path, 'r')
while (true) {
const chunk = await handle.read()
if (!chunk.length) {
break
}
await transfer.write(chunk)
}
transfer.close()
handle.close()
} catch (e) {
transfer.cancel()
throw e
}
}
private _makeFile (p: string, entry: FileEntry): SFTPFile { private _makeFile (p: string, entry: FileEntry): SFTPFile {
return { return {
fullPath: p, fullPath: p,

View File

@@ -5,126 +5,22 @@ import * as path 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'
import socksv5 from 'socksv5'
import { Injector, NgZone } from '@angular/core' import { Injector, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, Profile, LogService } from 'tabby-core' import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService } from 'tabby-core'
import { BaseSession, LoginScriptsOptions } from 'tabby-terminal' import { BaseSession } from 'tabby-terminal'
import { Server, Socket, createServer, createConnection } from 'net' import { Socket, createConnection } from 'net'
import { Client, ClientChannel, SFTPWrapper } 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 { promisify } from 'util' import { promisify } from 'util'
import { SFTPSession } from './session/sftp' import { SFTPSession } from './sftp'
import { PortForwardType, SSHProfile } from '../api/interfaces'
import { ForwardedPort } from './forwards'
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
export enum SSHAlgorithmType {
HMAC = 'hmac',
KEX = 'kex',
CIPHER = 'cipher',
HOSTKEY = 'serverHostKey',
}
export interface SSHProfile extends Profile {
options: SSHProfileOptions
}
export interface SSHProfileOptions extends LoginScriptsOptions {
host: string
port?: number
user: string
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
password?: string
privateKeys?: string[]
keepaliveInterval?: number
keepaliveCountMax?: number
readyTimeout?: number
x11?: boolean
skipBanner?: boolean
jumpHost?: string
agentForward?: boolean
warnOnClose?: boolean
algorithms?: Record<string, string[]>
proxyCommand?: string
forwardedPorts?: ForwardedPortConfig[]
}
export enum PortForwardType {
Local = 'Local',
Remote = 'Remote',
Dynamic = 'Dynamic',
}
export interface ForwardedPortConfig {
type: PortForwardType
host: string
port: number
targetAddress: string
targetPort: number
}
export class ForwardedPort implements ForwardedPortConfig {
type: PortForwardType
host = '127.0.0.1'
port: number
targetAddress: string
targetPort: number
private listener: Server|null = null
async startLocalListener (callback: (accept: () => Socket, reject: () => void, sourceAddress: string|null, sourcePort: number|null, targetAddress: string, targetPort: number) => void): Promise<void> {
if (this.type === PortForwardType.Local) {
const listener = this.listener = createServer(s => callback(
() => s,
() => s.destroy(),
s.remoteAddress ?? null,
s.remotePort ?? null,
this.targetAddress,
this.targetPort,
))
return new Promise((resolve, reject) => {
listener.listen(this.port, this.host)
listener.on('error', reject)
listener.on('listening', resolve)
})
} else if (this.type === PortForwardType.Dynamic) {
return new Promise((resolve, reject) => {
this.listener = socksv5.createServer((info, acceptConnection, rejectConnection) => {
callback(
() => acceptConnection(true),
() => rejectConnection(),
null,
null,
info.dstAddr,
info.dstPort,
)
}) as Server
this.listener.on('error', reject)
this.listener.listen(this.port, this.host, resolve)
this.listener['useAuth'](socksv5.auth.None())
})
} else {
throw new Error('Invalid forward type for a local listener')
}
}
stopLocalListener (): void {
this.listener?.close()
}
toString (): string {
if (this.type === PortForwardType.Local) {
return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}`
} if (this.type === PortForwardType.Remote) {
return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}`
} else {
return `(dynamic) ${this.host}:${this.port}`
}
}
}
interface AuthMethod { interface AuthMethod {
type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased' type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
name?: string name?: string
@@ -158,7 +54,7 @@ export class SSHSession extends BaseSession {
private config: ConfigService private config: ConfigService
constructor ( constructor (
injector: Injector, private injector: Injector,
public profile: SSHProfile, public profile: SSHProfile,
) { ) {
super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`)) super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`))
@@ -241,7 +137,7 @@ export class SSHSession extends BaseSession {
if (!this.sftp) { if (!this.sftp) {
this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))()) this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))())
} }
return new SFTPSession(this.sftp, this.zone) return new SFTPSession(this.sftp, this.injector)
} }
async start (): Promise<void> { async start (): Promise<void> {
@@ -613,9 +509,3 @@ export class SSHSession extends BaseSession {
} }
} }
} }
export const ALGORITHM_BLACKLIST = [
// cause native crashes in node crypto, use EC instead
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
]

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { MenuItemOptions, PlatformService } from 'tabby-core'
import { SFTPSession, SFTPFile } from './session/sftp'
import { SFTPContextMenuItemProvider } from './api'
import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
import { SFTPPanelComponent } from './components/sftpPanel.component'
/** @hidden */
@Injectable()
export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider {
weight = 10
constructor (
private platform: PlatformService,
private ngbModal: NgbModal,
) {
super()
}
async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise<MenuItemOptions[]> {
return [
{
click: async () => {
if ((await this.platform.showMessageBox({
type: 'warning',
message: `Delete ${item.fullPath}?`,
defaultId: 0,
buttons: ['Delete', 'Cancel'],
})).response === 0) {
await this.deleteItem(item, panel.sftp)
panel.navigate(panel.path)
}
},
label: 'Delete',
},
]
}
async deleteItem (item: SFTPFile, session: SFTPSession): Promise<void> {
const modal = this.ngbModal.open(SFTPDeleteModalComponent)
modal.componentInstance.item = item
modal.componentInstance.sftp = session
await modal.result
}
}