Przeglądaj źródła

sftp: allow editing remote files locally - fixes #4311

Eugene Pankov 4 lat temu
rodzic
commit
3b09dfa145

+ 1 - 0
tabby-core/src/components/transfersMenu.component.scss

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

+ 3 - 2
tabby-electron/package.json

@@ -20,7 +20,8 @@
     "@angular/core": "^9.1.9"
   },
   "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"
   }
 }

+ 4 - 1
tabby-electron/src/index.ts

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

+ 28 - 22
tabby-electron/src/services/platform.service.ts

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

+ 59 - 0
tabby-electron/src/sftpContextMenu.ts

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

+ 89 - 1
tabby-electron/yarn.lock

@@ -16,6 +16,19 @@ async@^3.1.0:
   resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
   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:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
@@ -70,6 +83,11 @@ [email protected]:
     color "3.0.x"
     text-hex "1.0.x"
 
[email protected]:
+  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:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -143,6 +161,11 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
   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:
   version "1.1.1"
   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-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:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
@@ -174,7 +209,15 @@ has@^1.0.3:
   dependencies:
     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"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -267,6 +310,13 @@ logform@^2.2.0:
     ms "^2.1.1"
     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:
   version "2.1.3"
   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"
     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:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
@@ -309,6 +366,11 @@ one-time@^1.0.0:
   dependencies:
     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:
   version "2.0.1"
   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"
     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:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -400,6 +469,20 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
   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:
   version "1.3.0"
   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"
     triple-beam "^1.3.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=

+ 1 - 1
tabby-ssh/src/algorithms.ts

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

+ 12 - 0
tabby-ssh/src/api/contextMenu.ts

@@ -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[]>
+}

+ 2 - 0
tabby-ssh/src/api/index.ts

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

+ 53 - 0
tabby-ssh/src/api/interfaces.ts

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

+ 24 - 69
tabby-ssh/src/components/sftpPanel.component.ts

@@ -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 { FileUpload, PlatformService } from 'tabby-core'
-import { SFTPDeleteModalComponent } from './sftpDeleteModal.component'
+import { posix as path } from 'path'
+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 {
     name: string
     path: string
 }
 
-/** @hidden */
 @Component({
     selector: 'sftp-panel',
     template: require('./sftpPanel.component.pug'),
@@ -29,8 +27,10 @@ export class SFTPPanelComponent {
 
     constructor (
         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> {
         this.sftp = await this.session.openSFTP()
@@ -96,28 +96,10 @@ export class SFTPPanelComponent {
     }
 
     async uploadOne (transfer: FileUpload): Promise<void> {
-        const itemPath = path.join(this.path, transfer.getName())
-        const tempPath = itemPath + '.tabby-upload'
+        this.sftp.upload(path.join(this.path, transfer.getName()), transfer)
         const savedPath = this.path
-        try {
-            const handle = await this.sftp.open(tempPath, 'w')
-            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
+        if (this.path === savedPath) {
+            await this.navigate(this.path)
         }
     }
 
@@ -126,21 +108,7 @@ export class SFTPPanelComponent {
         if (!transfer) {
             return
         }
-        try {
-            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
-        }
+        this.sftp.download(itemPath, transfer)
     }
 
     getModeString (item: SFTPFile): string {
@@ -159,31 +127,18 @@ export class SFTPPanelComponent {
         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 buildContextMenu (item: SFTPFile): Promise<MenuItemOptions[]> {
+        let items: MenuItemOptions[] = []
+        for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(item, this)))) {
+            items.push({ type: 'separator' })
+            items = items.concat(section)
+        }
+        return items.slice(1)
     }
 
-    async deleteItem (item: SFTPFile): Promise<void> {
-        const modal = this.ngbModal.open(SFTPDeleteModalComponent)
-        modal.componentInstance.item = item
-        modal.componentInstance.sftp = this.sftp
-        await modal.result
+    async showContextMenu (item: SFTPFile, event: MouseEvent): Promise<void> {
+        event.preventDefault()
+        this.platform.popupContextMenu(await this.buildContextMenu(item), event)
     }
 
     close (): void {

+ 3 - 1
tabby-ssh/src/components/sshPortForwardingModal.component.ts

@@ -1,6 +1,8 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 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 */
 @Component({

+ 2 - 1
tabby-ssh/src/components/sshTab.component.ts

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

+ 7 - 0
tabby-ssh/src/index.ts

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

+ 1 - 1
tabby-ssh/src/profiles.ts

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

+ 1 - 1
tabby-ssh/src/services/passwordStorage.service.ts

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

+ 4 - 2
tabby-ssh/src/services/ssh.service.ts

@@ -4,11 +4,13 @@ import { Injectable, NgZone } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { Client } from 'ssh2'
 import { exec } from 'child_process'
+import { ChildProcess } from 'node:child_process'
 import { Subject, Observable } from 'rxjs'
 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 { ChildProcess } from 'node:child_process'
 
 @Injectable({ providedIn: 'root' })
 export class SSHService {

+ 64 - 0
tabby-ssh/src/session/forwards.ts

@@ -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}`
+        }
+    }
+}

+ 65 - 3
tabby-ssh/src/session/sftp.ts

@@ -1,8 +1,9 @@
 import * as C from 'constants'
 // eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
+import { Subject, Observable } from 'rxjs'
 import { posix as posixPath } from 'path'
-import { NgZone } from '@angular/core'
-import { wrapPromise } from 'tabby-core'
+import { Injector, NgZone } from '@angular/core'
+import { FileDownload, FileUpload, Logger, LogService, wrapPromise } from 'tabby-core'
 import { SFTPWrapper } from 'ssh2'
 import { promisify } from 'util'
 
@@ -70,9 +71,22 @@ export class SFTPFileHandle {
 }
 
 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[]> {
+        this.logger.debug('readdir', p)
         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,
@@ -80,10 +94,12 @@ export class SFTPSession {
     }
 
     readlink (p: string): Promise<string> {
+        this.logger.debug('readlink', p)
         return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
     }
 
     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))())
         return {
             name: posixPath.basename(p),
@@ -97,22 +113,68 @@ export class SFTPSession {
     }
 
     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))())
         return new SFTPFileHandle(this.sftp, handle, this.zone)
     }
 
     async rmdir (p: string): Promise<void> {
+        this.logger.debug('rmdir', p)
         await promisify((f: any) => this.sftp.rmdir(p, f))()
     }
 
     async rename (oldPath: string, newPath: string): Promise<void> {
+        this.logger.debug('rename', oldPath, newPath)
         await promisify((f: any) => this.sftp.rename(oldPath, newPath, f))()
     }
 
     async unlink (p: string): Promise<void> {
+        this.logger.debug('unlink', p)
         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 {
         return {
             fullPath: p,

+ 10 - 120
tabby-ssh/src/api.ts → tabby-ssh/src/session/ssh.ts

@@ -5,126 +5,22 @@ import * as path from 'path'
 import * as sshpk from 'sshpk'
 import colors from 'ansi-colors'
 import stripAnsi from 'strip-ansi'
-import socksv5 from 'socksv5'
 import { Injector, NgZone } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, Profile, LogService } from 'tabby-core'
-import { BaseSession, LoginScriptsOptions } from 'tabby-terminal'
-import { Server, Socket, createServer, createConnection } from 'net'
+import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService } from 'tabby-core'
+import { BaseSession } from 'tabby-terminal'
+import { Socket, createConnection } from 'net'
 import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
 import { Subject, Observable } from 'rxjs'
-import { ProxyCommandStream } from './services/ssh.service'
-import { PasswordStorageService } from './services/passwordStorage.service'
+import { ProxyCommandStream } from '../services/ssh.service'
+import { PasswordStorageService } from '../services/passwordStorage.service'
 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'
 
-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 {
     type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
     name?: string
@@ -158,7 +54,7 @@ export class SSHSession extends BaseSession {
     private config: ConfigService
 
     constructor (
-        injector: Injector,
+        private injector: Injector,
         public profile: SSHProfile,
     ) {
         super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`))
@@ -241,7 +137,7 @@ export class SSHSession extends BaseSession {
         if (!this.sftp) {
             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> {
@@ -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',
-]

+ 47 - 0
tabby-ssh/src/sftpContextMenu.ts

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