├── src ├── core │ ├── upath.ts │ ├── localFs.ts │ ├── remote-client │ │ ├── index.ts │ │ ├── remoteClient.ts │ │ └── ftpClient.ts │ ├── fs │ │ ├── index.ts │ │ ├── fileSystem.ts │ │ ├── remoteFileSystem.ts │ │ └── localFileSystem.ts │ ├── customError.ts │ ├── ignore.ts │ ├── index.ts │ ├── fileBaseOperations.ts │ ├── remoteFs.ts │ ├── uResource.ts │ ├── transferTask.ts │ └── scheduler.ts ├── fileHandlers │ ├── option.ts │ ├── index.ts │ ├── rename.ts │ ├── shared.ts │ ├── diff.ts │ ├── remove.ts │ ├── createFileHandler.ts │ └── transfer │ │ └── index.ts ├── helper │ ├── index.ts │ ├── error.ts │ ├── file.ts │ ├── paths.ts │ └── select.ts ├── modules │ ├── remoteExplorer │ │ ├── index.ts │ │ ├── explorer.ts │ │ └── treeDataProvider.ts │ ├── ext.ts │ ├── git │ │ ├── index.ts │ │ └── git.d.ts │ ├── appState.ts │ ├── fileActivityMonitor.ts │ ├── fileWatcher.ts │ ├── config.ts │ └── serviceManager │ │ ├── index.ts │ │ └── trie.ts ├── commands │ ├── commandToggleOutputPanel.ts │ ├── fileCommandDiffActiveFile.ts │ ├── fileCommandDiff.ts │ ├── fileCommandUploadProject.ts │ ├── fileCommandUpload.ts │ ├── commandCancelAllTransfer.ts │ ├── fileCommandDownload.ts │ ├── fileCommandUploadActiveFolder.ts │ ├── fileCommandDownloadActiveFolder.ts │ ├── fileCommandUploadFolder.ts │ ├── fileCommandDownloadFolder.ts │ ├── fileCommandDownloadProject.ts │ ├── fileCommandUploadActiveFile.ts │ ├── fileCommandDownloadActiveFile.ts │ ├── fileCommandUploadForce.ts │ ├── fileCommandUploadFile.ts │ ├── fileCommandDownloadForce.ts │ ├── fileCommandDownloadFile.ts │ ├── fileCommandSyncRemoteToLocal.ts │ ├── fileCommandSyncLocalToRemote.ts │ ├── fileCommandRevealInExplorer.ts │ ├── fileCommandSyncBothDirections.ts │ ├── fileCommandEditInLocal.ts │ ├── fileCommandRevealInRemoteExplorer.ts │ ├── fileCommandList.ts │ ├── fileCommandListAll.ts │ ├── fileCommandDeleteRemote.ts │ ├── abstract │ │ ├── command.ts │ │ └── createCommand.ts │ ├── commandSetProfile.ts │ ├── commandListActiveFolder.ts │ ├── commandConfig.ts │ ├── commandOpenSshConnection.ts │ ├── commandUploadChangedFiles.ts │ └── shared.ts ├── utils.ts ├── app.ts ├── ui │ ├── output.ts │ └── statusBarItem.ts ├── logger.ts ├── extension.ts ├── initCommands.ts ├── constants.ts └── host.ts ├── assets ├── alipay.png ├── wechat.png └── showcase │ └── remote-explorer.png ├── resources ├── icon.png ├── light │ ├── upload.svg │ └── refresh.svg ├── dark │ ├── refresh.svg │ └── upload.svg └── remote-explorer.svg ├── __mocks__ ├── fs.js └── vscode.js ├── schema ├── simple-config.schema.json ├── config.schema.json ├── ftp.schema.json └── sftp.schema.json ├── .vscodeignore ├── .gitignore ├── test ├── preprocessor.js ├── index.ts ├── helper │ └── localRemoteFs.ts ├── trie.spec.js ├── config.spec.js └── core │ └── scheduler.spec.js ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── config.yml ├── webpack.config.js ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── FAQ.md └── tslint.json /src/core/upath.ts: -------------------------------------------------------------------------------- 1 | import * as upath from 'upath'; 2 | 3 | export default upath; 4 | -------------------------------------------------------------------------------- /assets/alipay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/vscode-sftp/HEAD/assets/alipay.png -------------------------------------------------------------------------------- /assets/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/vscode-sftp/HEAD/assets/wechat.png -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/vscode-sftp/HEAD/resources/icon.png -------------------------------------------------------------------------------- /__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | const { fs } = require('memfs'); 2 | 3 | fs.__mock__ = true; 4 | module.exports = fs; 5 | -------------------------------------------------------------------------------- /schema/simple-config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "oneOf": [{ "$ref": "sftp.schema.json" }, { "$ref": "ftp.schema.json" }] 3 | } 4 | -------------------------------------------------------------------------------- /assets/showcase/remote-explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/vscode-sftp/HEAD/assets/showcase/remote-explorer.png -------------------------------------------------------------------------------- /src/fileHandlers/option.ts: -------------------------------------------------------------------------------- 1 | export interface FileHandleOption { 2 | ignore?: ((filepath: string) => boolean) | null; 3 | } 4 | -------------------------------------------------------------------------------- /src/helper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './paths'; 2 | export * from './file'; 3 | export * from './error'; 4 | export * from './select'; 5 | -------------------------------------------------------------------------------- /src/modules/remoteExplorer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './explorer'; 2 | export { ExplorerItem, ExplorerRoot } from './treeDataProvider'; 3 | -------------------------------------------------------------------------------- /src/core/localFs.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { LocalFileSystem } from './fs'; 3 | 4 | const fs = new LocalFileSystem(path); 5 | 6 | export default fs; 7 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | * 2 | */** 3 | **/.DS_Store 4 | !node_modules/**/* 5 | !dist/extension.js 6 | !package.json 7 | !README.md 8 | !CHANGELOG.md 9 | !LICENSE 10 | !resources/**/* 11 | !schema/**/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | out 3 | dist 4 | node_modules 5 | *.log 6 | test/test.js 7 | _debug 8 | .vscode/** 9 | !.vscode/tasks.json 10 | !.vscode/launch.json 11 | /TODO.md 12 | /INSTALL_DEBUG.md 13 | -------------------------------------------------------------------------------- /src/modules/ext.ts: -------------------------------------------------------------------------------- 1 | import { getUserSetting } from '../host'; 2 | import { EXTENSION_NAME } from '../constants'; 3 | 4 | export function getExtensionSetting() { 5 | return getUserSetting(EXTENSION_NAME); 6 | } 7 | -------------------------------------------------------------------------------- /src/fileHandlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transfer'; 2 | export * from './remove'; 3 | export * from './diff'; 4 | export * from './rename'; 5 | export { handleCtxFromUri, FileHandlerContext } from './createFileHandler'; 6 | -------------------------------------------------------------------------------- /schema/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "oneOf": [ 3 | { 4 | "type": "array", 5 | "items": { 6 | "$ref": "simple-config.schema.json" 7 | } 8 | }, 9 | { 10 | "$ref": "simple-config.schema.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/preprocessor.js: -------------------------------------------------------------------------------- 1 | const tsc = require('typescript'); 2 | const tsConfig = require('../tsconfig.json'); 3 | 4 | module.exports = { 5 | process(src, path) { 6 | if (path.endsWith('.ts')) { 7 | return tsc.transpile(src, tsConfig.compilerOptions, path, []); 8 | } 9 | return src; 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /__mocks__/vscode.js: -------------------------------------------------------------------------------- 1 | 2 | const Nothing = (() => { 3 | const fn = () => Nothing 4 | fn.toString = fn.toLocaleString = fn[Symbol.toPrimitive] = () => '' 5 | fn.valueOf = () => false 6 | 7 | return new Proxy(fn, { 8 | get: (o, key) => o.hasOwnProperty(key) ? o[key] : Nothing 9 | }) 10 | })() 11 | 12 | module.exports = Nothing; 13 | -------------------------------------------------------------------------------- /src/commands/commandToggleOutputPanel.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_TOGGLE_OUTPUT } from '../constants'; 2 | import * as output from '../ui/output'; 3 | import { checkCommand } from './abstract/createCommand'; 4 | 5 | export default checkCommand({ 6 | id: COMMAND_TOGGLE_OUTPUT, 7 | 8 | handleCommand() { 9 | output.toggle(); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/core/remote-client/index.ts: -------------------------------------------------------------------------------- 1 | import RemoteClient, { ErrorCode, ConnectOption, Config } from './remoteClient'; 2 | import SSHClient from './sshClient'; 3 | import FTPClient from './ftpClient'; 4 | 5 | export { 6 | RemoteClient, 7 | Config as RemoteClientConfig, 8 | ErrorCode, 9 | ConnectOption, 10 | SSHClient, 11 | FTPClient, 12 | }; 13 | -------------------------------------------------------------------------------- /src/commands/fileCommandDiffActiveFile.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DIFF_ACTIVEFILE } from '../constants'; 2 | import { diff } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { getActiveDocumentUri } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DIFF_ACTIVEFILE, 8 | getFileTarget: getActiveDocumentUri, 9 | handleFile: diff, 10 | }); 11 | -------------------------------------------------------------------------------- /src/commands/fileCommandDiff.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DIFF } from '../constants'; 2 | import { diff } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DIFF, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | handleFile: diff, 10 | }); 11 | -------------------------------------------------------------------------------- /src/commands/fileCommandUploadProject.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_UPLOAD_PROJECT } from '../constants'; 2 | import { uploadFolder } from '../fileHandlers'; 3 | import { selectContext } from './shared'; 4 | import { checkFileCommand } from './abstract/createCommand'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_UPLOAD_PROJECT, 8 | getFileTarget: selectContext, 9 | 10 | handleFile: uploadFolder, 11 | }); 12 | -------------------------------------------------------------------------------- /src/modules/git/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { GitExtension, API, Status, Change, Repository } from './git'; 3 | 4 | let git: API; 5 | 6 | export { API as GitAPI, Repository, Status, Change }; 7 | 8 | export function getGitService(): API { 9 | const gitExtension = vscode.extensions.getExtension('vscode.git')!.exports; 10 | 11 | git = gitExtension.getAPI(1); 12 | return git; 13 | } 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandUpload.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_UPLOAD } from '../constants'; 2 | import { upload } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromfspath } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_UPLOAD, 8 | getFileTarget: uriFromfspath, 9 | 10 | async handleFile(ctx) { 11 | await upload(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function flatten(items) { 2 | const accumulater = (result, item) => result.concat(item); 3 | return items.reduce(accumulater, []); 4 | } 5 | 6 | export function interpolate(str: string, props: { [x: string]: string }) { 7 | return str.replace(/\${([^{}]*)}/g, (match, expr) => { 8 | const value = props[expr]; 9 | return typeof value === 'string' || typeof value === 'number' ? value : match; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/commandCancelAllTransfer.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_CANCEL_ALL_TRANSFER } from '../constants'; 2 | import { checkCommand } from './abstract/createCommand'; 3 | import { findAllFileService } from '../modules/serviceManager'; 4 | 5 | export default checkCommand({ 6 | id: COMMAND_CANCEL_ALL_TRANSFER, 7 | 8 | async handleCommand() { 9 | findAllFileService(f => f.isTransferring()).forEach(f => f.cancelTransferTasks()); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownload.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DOWNLOAD } from '../constants'; 2 | import { download } from '../fileHandlers'; 3 | import { uriFromfspath } from './shared'; 4 | import { checkFileCommand } from './abstract/createCommand'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DOWNLOAD, 8 | getFileTarget: uriFromfspath, 9 | 10 | async handleFile(ctx) { 11 | await download(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandUploadActiveFolder.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_UPLOAD_ACTIVEFOLDER } from '../constants'; 2 | import { checkFileCommand } from './abstract/createCommand'; 3 | import { getActiveFolder } from './shared'; 4 | import fileCommandUploadFolder from './fileCommandUploadFolder'; 5 | 6 | export default checkFileCommand({ 7 | ...fileCommandUploadFolder, 8 | id: COMMAND_UPLOAD_ACTIVEFOLDER, 9 | getFileTarget: getActiveFolder, 10 | }); 11 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownloadActiveFolder.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DOWNLOAD_ACTIVEFOLDER } from '../constants'; 2 | import { checkFileCommand } from './abstract/createCommand'; 3 | import { getActiveFolder } from './shared'; 4 | import fileCommandDownloadFolder from './fileCommandDownloadFolder'; 5 | 6 | export default checkFileCommand({ 7 | ...fileCommandDownloadFolder, 8 | id: COMMAND_DOWNLOAD_ACTIVEFOLDER, 9 | getFileTarget: getActiveFolder, 10 | }); 11 | -------------------------------------------------------------------------------- /src/commands/fileCommandUploadFolder.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_UPLOAD_FOLDER } from '../constants'; 2 | import { uploadFolder } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_UPLOAD_FOLDER, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | handleFile: uploadFolder, 11 | }); 12 | -------------------------------------------------------------------------------- /src/core/fs/index.ts: -------------------------------------------------------------------------------- 1 | import FileSystem, { FileEntry, FileType } from './fileSystem'; 2 | import LocalFileSystem from './localFileSystem'; 3 | import RemoteFileSystem from './remoteFileSystem'; 4 | import FTPFileSystem from './ftpFileSystem'; 5 | import SFTPFileSystem from './sftpFileSystem'; 6 | 7 | export { 8 | FileSystem, 9 | FileEntry, 10 | FileType, 11 | LocalFileSystem, 12 | RemoteFileSystem, 13 | FTPFileSystem, 14 | SFTPFileSystem, 15 | }; 16 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownloadFolder.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DOWNLOAD_FOLDER } from '../constants'; 2 | import { downloadFolder } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DOWNLOAD_FOLDER, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | handleFile: downloadFolder, 11 | }); 12 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownloadProject.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DOWNLOAD_PROJECT } from '../constants'; 2 | import { downloadFolder } from '../fileHandlers'; 3 | import { selectContext } from './shared'; 4 | import { checkFileCommand } from './abstract/createCommand'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DOWNLOAD_PROJECT, 8 | getFileTarget: selectContext, 9 | 10 | async handleFile(ctx) { 11 | await downloadFolder(ctx); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/fileHandlers/rename.ts: -------------------------------------------------------------------------------- 1 | import { fileOperations } from '../core'; 2 | import createFileHandler from './createFileHandler'; 3 | 4 | export const renameRemote = createFileHandler<{ originPath: string }>({ 5 | name: 'rename', 6 | async handle({ originPath }) { 7 | const remoteFs = await this.fileService.getRemoteFileSystem(this.config); 8 | const { localFsPath } = this.target; 9 | await fileOperations.rename(originPath, localFsPath, remoteFs); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/commands/fileCommandUploadActiveFile.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_UPLOAD_ACTIVEFILE } from '../constants'; 2 | import { uploadFile } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { getActiveDocumentUri } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_UPLOAD_ACTIVEFILE, 8 | getFileTarget: getActiveDocumentUri, 9 | 10 | async handleFile(ctx) { 11 | await uploadFile(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownloadActiveFile.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DOWNLOAD_ACTIVEFILE } from '../constants'; 2 | import { downloadFile } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { getActiveDocumentUri } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DOWNLOAD_ACTIVEFILE, 8 | getFileTarget: getActiveDocumentUri, 9 | 10 | async handleFile(ctx) { 11 | await downloadFile(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandUploadForce.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_FORCE_UPLOAD } from '../constants'; 2 | import { upload } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_FORCE_UPLOAD, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | async handleFile(ctx) { 11 | await upload(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandUploadFile.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_UPLOAD_FILE } from '../constants'; 2 | import { uploadFile } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_UPLOAD_FILE, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | async handleFile(ctx) { 11 | await uploadFile(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownloadForce.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_FORCE_DOWNLOAD } from '../constants'; 2 | import { download } from '../fileHandlers'; 3 | import { uriFromExplorerContextOrEditorContext } from './shared'; 4 | import { checkFileCommand } from './abstract/createCommand'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_FORCE_DOWNLOAD, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | async handleFile(ctx) { 11 | await download(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandDownloadFile.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DOWNLOAD_FILE } from '../constants'; 2 | import { downloadFile } from '../fileHandlers'; 3 | import { uriFromExplorerContextOrEditorContext } from './shared'; 4 | import { checkFileCommand } from './abstract/createCommand'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_DOWNLOAD_FILE, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | async handleFile(ctx) { 11 | await downloadFile(ctx, { ignore: null }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/fileCommandSyncRemoteToLocal.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_SYNC_REMOTE_TO_LOCAL } from '../constants'; 2 | import { sync2Local } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { selectFolderFallbackToConfigContext, uriFromfspath, applySelector } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_SYNC_REMOTE_TO_LOCAL, 8 | getFileTarget: applySelector(uriFromfspath, selectFolderFallbackToConfigContext), 9 | 10 | handleFile: sync2Local, 11 | }); 12 | -------------------------------------------------------------------------------- /src/commands/fileCommandSyncLocalToRemote.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_SYNC_LOCAL_TO_REMOTE } from '../constants'; 2 | import { sync2Remote } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { selectFolderFallbackToConfigContext, uriFromfspath, applySelector } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_SYNC_LOCAL_TO_REMOTE, 8 | getFileTarget: applySelector(uriFromfspath, selectFolderFallbackToConfigContext), 9 | 10 | handleFile: sync2Remote, 11 | }); 12 | -------------------------------------------------------------------------------- /src/commands/fileCommandRevealInExplorer.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_REVEAL_IN_EXPLORER } from '../constants'; 2 | import { executeCommand } from '../host'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_REVEAL_IN_EXPLORER, 8 | getFileTarget: uriFromExplorerContextOrEditorContext, 9 | 10 | async handleFile({ target }) { 11 | await executeCommand('revealInExplorer', target.localUri); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/core/customError.ts: -------------------------------------------------------------------------------- 1 | class CustomError extends Error { 2 | code: string; 3 | 4 | constructor(code, message) { 5 | // Pass remaining arguments (including vendor specific ones) to parent constructor 6 | super(message); 7 | 8 | // Maintains proper stack trace for where our error was thrown (only available on V8) 9 | if (Error.captureStackTrace) { 10 | Error.captureStackTrace(this, CustomError); 11 | } 12 | 13 | // Custom error properties 14 | this.code = code; 15 | } 16 | } 17 | 18 | export default CustomError; 19 | -------------------------------------------------------------------------------- /src/core/ignore.ts: -------------------------------------------------------------------------------- 1 | import GitIgnore from 'ignore'; 2 | 3 | export default class Ignore { 4 | static from(pattern) { 5 | return new Ignore(pattern); 6 | } 7 | 8 | pattern: string[] | string; 9 | private ignore: any; 10 | 11 | constructor(pattern) { 12 | this.ignore = GitIgnore(); 13 | this.pattern = pattern; 14 | this.ignore.add(pattern); 15 | } 16 | 17 | ignores(pathname): boolean { 18 | if (!GitIgnore.isPathValid(pathname)) { 19 | return false; 20 | } 21 | 22 | return this.ignore.ignores(pathname); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/fileCommandSyncBothDirections.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_SYNC_BOTH_DIRECTIONS } from '../constants'; 2 | import { sync2Remote } from '../fileHandlers'; 3 | import { checkFileCommand } from './abstract/createCommand'; 4 | import { selectFolderFallbackToConfigContext, uriFromfspath, applySelector } from './shared'; 5 | 6 | export default checkFileCommand({ 7 | id: COMMAND_SYNC_BOTH_DIRECTIONS, 8 | getFileTarget: applySelector(uriFromfspath, selectFolderFallbackToConfigContext), 9 | 10 | handleFile(ctx) { 11 | return sync2Remote(ctx, { bothDiretions: true }); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/helper/error.ts: -------------------------------------------------------------------------------- 1 | import * as output from '../ui/output'; 2 | import logger from '../logger'; 3 | import { showErrorMessage } from '../host'; 4 | 5 | export function reportError(err: Error | string, ctx?: string) { 6 | let errorString: string; 7 | if (err instanceof Error) { 8 | errorString = err.message; 9 | logger.error(`${err.stack}`, ctx); 10 | } else { 11 | errorString = err; 12 | logger.error(errorString, ctx); 13 | } 14 | 15 | showErrorMessage(errorString, 'Detail').then(result => { 16 | if (result === 'Detail') { 17 | output.show(); 18 | } 19 | }); 20 | return; 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/appState.ts: -------------------------------------------------------------------------------- 1 | class AppState { 2 | private _profile: string | null = null; 3 | private _observer: (x: any) => void; 4 | 5 | get profile(): string | null { 6 | return this._profile; 7 | } 8 | 9 | set profile(newProfile: string | null) { 10 | if (this._profile === newProfile) { 11 | return; 12 | } 13 | 14 | this._profile = newProfile; 15 | this._observer(this.getStateSnapshot()); 16 | } 17 | 18 | getStateSnapshot() { 19 | return { 20 | profile: this._profile, 21 | }; 22 | } 23 | 24 | subscribe(observer) { 25 | this._observer = observer; 26 | } 27 | } 28 | 29 | export default AppState; 30 | -------------------------------------------------------------------------------- /src/commands/fileCommandEditInLocal.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_REMOTEEXPLORER_EDITINLOCAL } from '../constants'; 2 | import { downloadFile } from '../fileHandlers'; 3 | import { showTextDocument } from '../host'; 4 | import { uriFromExplorerContextOrEditorContext } from './shared'; 5 | import { checkFileCommand } from './abstract/createCommand'; 6 | 7 | export default checkFileCommand({ 8 | id: COMMAND_REMOTEEXPLORER_EDITINLOCAL, 9 | getFileTarget: uriFromExplorerContextOrEditorContext, 10 | 11 | async handleFile(ctx) { 12 | await downloadFile(ctx, { ignore: null }); 13 | await showTextDocument(ctx.target.localUri, { preview: true }); 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | import * as fileOperations from './fileBaseOperations'; 2 | import upath from './upath'; 3 | import FileService, { WatcherService, FileServiceConfig, ServiceConfig } from './fileService'; 4 | import UResource, { Resource } from './uResource'; 5 | import Scheduler from './scheduler'; 6 | import TransferTask from './transferTask'; 7 | import Ignore from './ignore'; 8 | export * from './transferTask'; 9 | export * from './fs'; 10 | 11 | export { 12 | fileOperations, 13 | upath, 14 | TransferTask, 15 | FileService, 16 | WatcherService, 17 | FileServiceConfig, 18 | ServiceConfig, 19 | UResource, 20 | Resource, 21 | Scheduler, 22 | Ignore, 23 | }; 24 | -------------------------------------------------------------------------------- /src/commands/fileCommandRevealInRemoteExplorer.ts: -------------------------------------------------------------------------------- 1 | import { UResource } from '../core'; 2 | import app from '../app'; 3 | import { COMMAND_REVEAL_IN_REMOTE_EXPLORER } from '../constants'; 4 | import { checkFileCommand } from './abstract/createCommand'; 5 | import { uriFromExplorerContextOrEditorContext } from './shared'; 6 | 7 | export default checkFileCommand({ 8 | id: COMMAND_REVEAL_IN_REMOTE_EXPLORER, 9 | getFileTarget: uriFromExplorerContextOrEditorContext, 10 | 11 | async handleFile({ target }) { 12 | // todo: make this to a method of remoteExplorer 13 | await app.remoteExplorer.reveal({ 14 | resource: UResource.makeResource(target.remoteUri), 15 | isDirectory: false, 16 | }); 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": ".", 11 | "noUnusedLocals": true, 12 | "strictNullChecks": true, 13 | "plugins": [ 14 | { 15 | "name": "typescript-tslint-plugin", 16 | "alwaysShowRuleFailuresAsWarnings": false, 17 | "ignoreDefinitionFiles": true, 18 | "suppressWhileTypeErrorsPresent": false, 19 | "mockTypeScriptVersion": false, 20 | "configFile": "./tslint.json" 21 | } 22 | ] 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | ".vscode-test", 27 | "test" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is this a similar or duplicate feature request?** 8 | - [ ] I don't know. I will go check it. 9 | - [ ] No. 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | **Does this project help you?** 21 | - [x] Yes. SFTP IS AWESOME! 22 | - [ ] Yes. [I want to support you.](https://github.com/liximomo/vscode-sftp#donation) 23 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import * as LRU from 'lru-cache'; 2 | import StatusBarItem from './ui/statusBarItem'; 3 | import { COMMAND_TOGGLE_OUTPUT } from './constants'; 4 | import AppState from './modules/appState'; 5 | import RemoteExplorer from './modules/remoteExplorer'; 6 | 7 | interface App { 8 | fsCache: LRU.Cache; 9 | state: AppState; 10 | sftpBarItem: StatusBarItem; 11 | remoteExplorer: RemoteExplorer; 12 | } 13 | 14 | const app: App = Object.create(null); 15 | 16 | app.state = new AppState(); 17 | app.sftpBarItem = new StatusBarItem( 18 | () => { 19 | if (app.state.profile) { 20 | return `SFTP: ${app.state.profile}`; 21 | } else { 22 | return 'SFTP'; 23 | } 24 | }, 25 | 'SFTP@liximomo', 26 | COMMAND_TOGGLE_OUTPUT 27 | ); 28 | app.fsCache = LRU({ max: 6 }); 29 | 30 | export default app; 31 | -------------------------------------------------------------------------------- /src/helper/file.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as tmp from 'tmp'; 3 | import * as vscode from 'vscode'; 4 | import { CONGIF_FILENAME } from '../constants'; 5 | import { upath } from '../core'; 6 | 7 | export function isValidFile(uri: vscode.Uri) { 8 | return uri.scheme === 'file'; 9 | } 10 | 11 | export function isConfigFile(uri: vscode.Uri) { 12 | const filename = path.basename(uri.fsPath); 13 | return filename === CONGIF_FILENAME; 14 | } 15 | 16 | export function fileDepth(file: string) { 17 | return upath.normalize(file).split('/').length; 18 | } 19 | 20 | export function makeTmpFile(option): Promise { 21 | return new Promise((resolve, reject) => { 22 | tmp.file({ ...option, discardDescriptor: true }, (err, tmpPath) => { 23 | if (err) reject(err); 24 | 25 | resolve(tmpPath); 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/fileHandlers/shared.ts: -------------------------------------------------------------------------------- 1 | import { UResource, FileService, FileType } from '../core'; 2 | import app from '../app'; 3 | 4 | // NEED_VSCODE_UPDATE: detect explorer view visible 5 | // refresh will open explorer view which cause a problem https://github.com/liximomo/vscode-sftp/issues/286 6 | // export function refreshLocalExplorer(localUri: Uri) { 7 | // // do nothing 8 | // } 9 | 10 | export async function refreshRemoteExplorer(target: UResource, isDirectory: FileService | boolean) { 11 | if (isDirectory instanceof FileService) { 12 | const fileService = isDirectory; 13 | const localFs = fileService.getLocalFileSystem(); 14 | const fileEntry = await localFs.lstat(target.localFsPath); 15 | isDirectory = fileEntry.type === FileType.Directory; 16 | } 17 | 18 | app.remoteExplorer.refresh({ 19 | resource: UResource.makeResource(target.remoteUri), 20 | isDirectory, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | /**@type {import('webpack').Configuration}*/ 8 | const config = { 9 | target: 'node', 10 | 11 | entry: './src/extension.ts', 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: 'extension.js', 15 | libraryTarget: 'commonjs2', 16 | devtoolModuleFilenameTemplate: '../[resource-path]', 17 | }, 18 | devtool: 'source-map', 19 | externals: { 20 | vscode: 'commonjs vscode', 21 | ssh2: 'commonjs ssh2', 22 | }, 23 | resolve: { 24 | extensions: ['.ts', '.js'], 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.ts$/, 30 | exclude: /node_modules/, 31 | use: [ 32 | { 33 | loader: 'ts-loader', 34 | }, 35 | ], 36 | }, 37 | ], 38 | }, 39 | }; 40 | 41 | module.exports = config; 42 | -------------------------------------------------------------------------------- /src/commands/fileCommandList.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_LIST } from '../constants'; 2 | import { showTextDocument } from '../host'; 3 | import { FileType } from '../core'; 4 | import { downloadFile, downloadFolder } from '../fileHandlers'; 5 | import { checkFileCommand } from './abstract/createCommand'; 6 | import { selectFile } from './shared'; 7 | 8 | export default checkFileCommand({ 9 | id: COMMAND_LIST, 10 | getFileTarget: selectFile, 11 | 12 | async handleFile(ctx) { 13 | const remotefs = await ctx.fileService.getRemoteFileSystem(ctx.config); 14 | const fileEntry = await remotefs.lstat(ctx.target.remoteFsPath); 15 | if (fileEntry.type !== FileType.Directory) { 16 | await downloadFile(ctx); 17 | try { 18 | await showTextDocument(ctx.target.localUri); 19 | } catch (error) { 20 | // ignore 21 | } 22 | } else { 23 | await downloadFolder(ctx); 24 | } 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /resources/light/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/dark/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/light/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commands/fileCommandListAll.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_LIST_ALL } from '../constants'; 2 | import { showTextDocument } from '../host'; 3 | import { FileType } from '../core'; 4 | import { downloadFile, downloadFolder } from '../fileHandlers'; 5 | import { checkFileCommand } from './abstract/createCommand'; 6 | import { selectFileFromAll } from './shared'; 7 | 8 | export default checkFileCommand({ 9 | id: COMMAND_LIST_ALL, 10 | getFileTarget: selectFileFromAll, 11 | 12 | async handleFile(ctx) { 13 | const remotefs = await ctx.fileService.getRemoteFileSystem(ctx.config); 14 | const fileEntry = await remotefs.lstat(ctx.target.remoteFsPath); 15 | if (fileEntry.type !== FileType.Directory) { 16 | await downloadFile(ctx, { ignore: null }); 17 | try { 18 | await showTextDocument(ctx.target.localUri); 19 | } catch (error) { 20 | // ignore 21 | } 22 | } else { 23 | await downloadFolder(ctx, { ignore: null }); 24 | } 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/fileHandlers/diff.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { diffFiles } from '../host'; 3 | import { EXTENSION_NAME } from '../constants'; 4 | import { fileOperations } from '../core'; 5 | import { makeTmpFile } from '../helper'; 6 | import createFileHandler from './createFileHandler'; 7 | 8 | export const diff = createFileHandler({ 9 | name: 'diff', 10 | async handle() { 11 | const remoteFs = await this.fileService.getRemoteFileSystem(this.config); 12 | const localFs = this.fileService.getLocalFileSystem(); 13 | const { localFsPath, remoteFsPath } = this.target; 14 | const tmpPath = await makeTmpFile({ 15 | prefix: `${EXTENSION_NAME}-`, 16 | postfix: path.extname(localFsPath), 17 | }); 18 | 19 | await fileOperations.transferFile(remoteFsPath, tmpPath, remoteFs, localFs); 20 | await diffFiles( 21 | tmpPath, 22 | localFsPath, 23 | `${path.basename(localFsPath)} (${this.fileService.name || 'remote'} ↔ local)` 24 | ); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/commands/fileCommandDeleteRemote.ts: -------------------------------------------------------------------------------- 1 | import { COMMAND_DELETE_REMOTE } from '../constants'; 2 | import { upath } from '../core'; 3 | import { removeRemote } from '../fileHandlers'; 4 | import { showConfirmMessage } from '../host'; 5 | import { checkFileCommand } from './abstract/createCommand'; 6 | import { uriFromExplorerContextOrEditorContext } from './shared'; 7 | 8 | export default checkFileCommand({ 9 | id: COMMAND_DELETE_REMOTE, 10 | async getFileTarget(item, items) { 11 | const targets = await uriFromExplorerContextOrEditorContext(item, items); 12 | 13 | if (!targets) { 14 | return; 15 | } 16 | 17 | const filename = Array.isArray(targets) 18 | ? targets.map(t => upath.basename(t.fsPath)).join(',') 19 | : upath.basename(targets.fsPath); 20 | const result = await showConfirmMessage( 21 | `Are you sure you want to delete '${filename}'?`, 22 | 'Delete', 23 | 'Cancel' 24 | ); 25 | 26 | return result ? targets : undefined; 27 | }, 28 | 29 | handleFile: removeRemote, 30 | }); 31 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING 3 | // 4 | // This file is providing the test runner to use when running extension tests. 5 | // By default the test runner in use is Mocha based. 6 | // 7 | // You can provide your own test runner if you want to override it by exporting 8 | // a function run(testRoot: string, clb: (error:Error) => void) that the extension 9 | // host can call to run the tests. The test runner is expected to use console.log 10 | // to report the results back to the caller. When the tests are finished, return 11 | // a possible error to the callback or null if none. 12 | 13 | var testRunner = require('vscode/lib/testrunner'); 14 | 15 | // You can directly control Mocha options by uncommenting the following lines 16 | // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info 17 | testRunner.configure({ 18 | ui: 'tdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) 19 | useColors: true // colored output from test results 20 | }); 21 | 22 | module.exports = testRunner; -------------------------------------------------------------------------------- /src/ui/output.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import app from '../app'; 3 | import { EXTENSION_NAME } from '../constants'; 4 | import StatusBarItem from './statusBarItem'; 5 | 6 | let isShow = false; 7 | const outputChannel = vscode.window.createOutputChannel(EXTENSION_NAME); 8 | 9 | export function show() { 10 | app.sftpBarItem.updateStatus(StatusBarItem.Status.ok); 11 | outputChannel.show(); 12 | isShow = true; 13 | } 14 | 15 | export function hide() { 16 | outputChannel.hide(); 17 | isShow = false; 18 | } 19 | 20 | export function toggle() { 21 | if (isShow) { 22 | hide(); 23 | } else { 24 | show(); 25 | } 26 | } 27 | 28 | export function print(...args) { 29 | const msg = args 30 | .map(arg => { 31 | if (!arg) { 32 | return arg; 33 | } 34 | 35 | if (arg instanceof Error) { 36 | return arg.stack; 37 | } else if (!arg.toString || arg.toString() === '[object Object]') { 38 | return JSON.stringify(arg); 39 | } 40 | 41 | return arg; 42 | }) 43 | .join(' '); 44 | 45 | outputChannel.appendLine(msg); 46 | } 47 | -------------------------------------------------------------------------------- /schema/ftp.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { 4 | "$ref": "definitions.json#/rootOption" 5 | }, 6 | { 7 | "$ref": "definitions.json#/option" 8 | }, 9 | { 10 | "$ref": "definitions.json#/host" 11 | }, 12 | { 13 | "$ref": "definitions.json#/sftp" 14 | }, 15 | { 16 | "type": "object", 17 | "properties": { 18 | "protocol": { 19 | "enum": ["ftp"], 20 | "description": "ftp connection protocol." 21 | }, 22 | 23 | "profiles": { 24 | "patternProperties": { 25 | "^.*$": { 26 | "allOf": [ 27 | { 28 | "$ref": "definitions.json#/option" 29 | }, 30 | { 31 | "$ref": "definitions.json#/host" 32 | }, 33 | { 34 | "$ref": "definitions.json#/sftp" 35 | } 36 | ] 37 | } 38 | }, 39 | "default": { 40 | "profileName": {} 41 | } 42 | } 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], 11 | "stopOnEntry": false, 12 | "sourceMaps": true, 13 | "outFiles": [ "${workspaceRoot}/dist/**/*.js" ], 14 | "preLaunchTask": "npm" 15 | }, 16 | { 17 | "name": "Launch Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "runtimeExecutable": "${execPath}", 21 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 22 | "stopOnEntry": false, 23 | "sourceMaps": true, 24 | "outFiles": [ "${workspaceRoot}/out/test/**/*.js" ], 25 | "preLaunchTask": "npm" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present, liximomo(X.L) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Do you read the FAQ?** 8 | - [ ] Yes. 9 | - [ ] [I am going to read now.](https://github.com/liximomo/vscode-sftp/blob/master/FAQ.md) 10 | 11 | **Describe the bug** 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | Steps to reproduce the behavior: 16 | 1. Go to '...' 17 | 2. Run command '....' 18 | 3. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Mac, Win, Linux] 28 | - VSCode Version [e.g. 1.27.0] 29 | - Extension Version [e.g. 1.7.0] 30 | 31 | **Extension Logs from Startup** - *required* 32 | 1. Open User Settings. 33 | 34 | * On Windows/Linux - File > Preferences > Settings 35 | * On macOS - Code > Preferences > Settings 36 | 2. Set `sftp.debug` to `true` and reload vscode. 37 | 3. Reproduce the problem, get the logs from View > Output > sftp. 38 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | ## Error: Failure 2 | 3 | The failure error message comes from the remote side and is more or less the default/generic error message that SSH' sftp server sends when a syscall fails or similar. To know what exactly is going wrong you could try to enable debug output for the sftp server and then execute your transfers again and see what (if anything) shows up in the logs there. 4 | 5 | ### Solution One 6 | Change `remotePath` to the actual path if it's a symlink. 7 | 8 | ### Solution Two 9 | The problem would be that your server runs out of file descriptors. You should try to increase the file descriptors limit. If you don't have the permission to do this, set [limitOpenFilesOnRemote](https://github.com/liximomo/vscode-sftp/wiki/Config#limitopenfilesonremote) option in your config. 10 | 11 | ## ENFILE: file table overflow ... 12 | 13 | MacOS have a harsh limit on number of open files. 14 | 15 | ### Solution 16 | 17 | Run those command 18 | 19 | ``` 20 | echo kern.maxfiles=65536 | sudo tee -a /etc/sysctl.conf 21 | echo kern.maxfilesperproc=65536 | sudo tee -a /etc/sysctl.conf 22 | sudo sysctl -w kern.maxfiles=65536 23 | sudo sysctl -w kern.maxfilesperproc=65536 24 | ulimit -n 65536 25 | ``` 26 | -------------------------------------------------------------------------------- /src/commands/abstract/command.ts: -------------------------------------------------------------------------------- 1 | import { reportError } from '../../helper'; 2 | import logger from '../../logger'; 3 | 4 | export interface ITarget { 5 | fsPath: string; 6 | } 7 | 8 | export interface CommandOption { 9 | [x: string]: any; 10 | } 11 | 12 | export default abstract class Command { 13 | id: string; 14 | name!: string; 15 | private _commandDoneListeners: Array<(...args: any[]) => void>; 16 | 17 | constructor() { 18 | this._commandDoneListeners = []; 19 | } 20 | 21 | onCommandDone(listener) { 22 | this._commandDoneListeners.push(listener); 23 | 24 | return () => { 25 | const index = this._commandDoneListeners.indexOf(listener); 26 | if (index > -1) this._commandDoneListeners.splice(index, 1); 27 | }; 28 | } 29 | 30 | protected abstract async doCommandRun(...args: any[]); 31 | 32 | async run(...args) { 33 | logger.trace(`run command '${this.name}'`); 34 | try { 35 | await this.doCommandRun(...args); 36 | } catch (error) { 37 | reportError(error); 38 | } finally { 39 | this.commitCommandDone(...args); 40 | } 41 | } 42 | 43 | private commitCommandDone(...args: any[]) { 44 | this._commandDoneListeners.forEach(listener => listener(...args)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/fileHandlers/remove.ts: -------------------------------------------------------------------------------- 1 | import { fileOperations, FileType } from '../core'; 2 | import createFileHandler from './createFileHandler'; 3 | import { FileHandleOption } from './option'; 4 | import logger from '../logger'; 5 | 6 | export const removeRemote = createFileHandler({ 7 | name: 'removeRemote', 8 | async handle(option) { 9 | const remoteFs = await this.fileService.getRemoteFileSystem(this.config); 10 | const { remoteFsPath } = this.target; 11 | const stat = await remoteFs.lstat(remoteFsPath); 12 | let promise; 13 | switch (stat.type) { 14 | case FileType.Directory: 15 | if (option.skipDir) { 16 | return; 17 | } 18 | 19 | promise = fileOperations.removeDir(remoteFsPath, remoteFs, {}); 20 | break; 21 | case FileType.File: 22 | case FileType.SymbolicLink: 23 | promise = fileOperations.removeFile(remoteFsPath, remoteFs, {}); 24 | break; 25 | default: 26 | logger.warn(`Unsupported file type (type = ${stat.type}). File ${remoteFsPath}`); 27 | } 28 | await promise; 29 | }, 30 | transformOption() { 31 | const config = this.config; 32 | return { 33 | ignore: config.ignore, 34 | }; 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/core/fileBaseOperations.ts: -------------------------------------------------------------------------------- 1 | import { FileSystem } from './fs'; 2 | 3 | interface FileOption { 4 | mode?: number; 5 | } 6 | 7 | export async function transferFile( 8 | src: string, 9 | des: string, 10 | srcFs: FileSystem, 11 | desFs: FileSystem, 12 | option?: FileOption 13 | ): Promise { 14 | const inputStream = await srcFs.get(src, option); 15 | await desFs.put(inputStream, des, option); 16 | } 17 | 18 | export function transferSymlink( 19 | src: string, 20 | des: string, 21 | srcFs: FileSystem, 22 | desFs: FileSystem, 23 | option: FileOption 24 | ): Promise { 25 | return srcFs.readlink(src).then(targetPath => { 26 | return desFs.symlink(targetPath, des).catch(err => { 27 | // ignore file already exist 28 | if (err.code === 4 || err.code === 'EEXIST') { 29 | return; 30 | } 31 | throw err; 32 | }); 33 | }); 34 | } 35 | 36 | export function removeFile(path: string, fs: FileSystem, option): Promise { 37 | return fs.unlink(path); 38 | } 39 | 40 | export function removeDir(path: string, fs: FileSystem, option): Promise { 41 | return fs.rmdir(path, true); 42 | } 43 | 44 | export function rename(srcPath: string, destPath: string, fs: FileSystem): Promise { 45 | return fs.rename(srcPath, destPath); 46 | } 47 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "object-literal-key-quotes": false, 5 | "no-angle-bracket-type-assertion": false, 6 | "max-classes-per-file": false, 7 | "member-ordering": false, 8 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 9 | "array-type": false, 10 | "interface-name": false, 11 | "no-unused-expression": true, 12 | "no-duplicate-variable": true, 13 | "curly": [true, "ignore-same-line"], 14 | "class-name": true, 15 | "semicolon": true, 16 | "triple-equals": true, 17 | "quotemark": [true, "single"], 18 | "arrow-parens": [true, "ban-single-arg-parens"], 19 | "ordered-imports": [false], 20 | "object-literal-sort-keys": false, 21 | "trailing-comma": [ 22 | true, 23 | { 24 | "multiline": { 25 | "objects": "always", 26 | "arrays": "always", 27 | "functions": "never", 28 | "typeLiterals": "ignore" 29 | }, 30 | "singleline": { 31 | "objects": "never", 32 | "arrays": "never", 33 | "functions": "never", 34 | "typeLiterals": "ignore" 35 | } 36 | } 37 | ], 38 | "member-access": [true, "no-public"], 39 | "unified-signatures": false 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | # Configuration for request-info - https://github.com/behaviorbot/request-info 2 | 3 | # *OPTIONAL* Comment to reply with 4 | # Can be either a string : 5 | requestInfoReplyComment: > 6 | We would appreciate it if you could provide us with more info about this issue! 7 | 8 | # *OPTIONAL* default titles to check against for lack of descriptiveness 9 | # MUST BE ALL LOWERCASE 10 | requestInfoDefaultTitles: 11 | - update readme.md 12 | - updates 13 | 14 | # *OPTIONAL* Label to be added to Issues and Pull Requests with insufficient information given 15 | requestInfoLabelToAdd: needs-more-info 16 | 17 | # *OPTIONAL* Require Issues to contain more information than what is provided in the issue templates 18 | # Will fail if the issue's body is equal to a provided template 19 | checkIssueTemplate: true 20 | 21 | # *OPTIONAL* Require Pull Requests to contain more information than what is provided in the PR template 22 | # Will fail if the pull request's body is equal to the provided template 23 | checkPullRequestTemplate: true 24 | 25 | # *OPTIONAL* Only warn about insufficient information on these events type 26 | # Keys must be lowercase. Valid values are 'issue' and 'pullRequest' 27 | requestInfoOn: 28 | pullRequest: true 29 | issue: true 30 | 31 | # *OPTIONAL* Add a list of people whose Issues/PRs will not be commented on 32 | # keys must be GitHub usernames 33 | requestInfoUserstoExclude: 34 | - liximomo 35 | -------------------------------------------------------------------------------- /test/helper/localRemoteFs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as fse from 'fs-extra'; 3 | import FileSystem, { FileStats } from '../../src/core/fs/fileSystem'; 4 | import localfs from '../../src/core/localFs'; 5 | import RemoteFileSystem from '../../src/core/fs/remoteFileSystem'; 6 | 7 | // @ts-ignore 8 | export default class LocalRemoteFileSystem extends RemoteFileSystem { 9 | _createClient() { 10 | return {}; 11 | } 12 | 13 | toFileStat(stat: fs.Stats): FileStats { 14 | return { 15 | type: FileSystem.getFileTypecharacter(stat), 16 | size: stat.size, 17 | mode: stat.mode & parseInt('777', 8), // tslint:disable-line:no-bitwise 18 | mtime: this.toLocalTime(stat.mtime.getTime()), 19 | atime: this.toLocalTime(stat.atime.getTime()), 20 | }; 21 | } 22 | 23 | futimes(fd: number, atime: number, mtime: number): Promise { 24 | return fse.futimes( 25 | fd, 26 | this.toRemoteTimeInSecnonds(atime), 27 | this.toRemoteTimeInSecnonds(mtime) 28 | ); 29 | } 30 | } 31 | 32 | [ 33 | 'toFileEntry', 34 | 'readFile', 35 | 'open', 36 | 'close', 37 | 'fstat', 38 | 'get', 39 | 'put', 40 | 'mkdir', 41 | 'ensureDir', 42 | 'list', 43 | 'lstat', 44 | 'readlink', 45 | 'symlink', 46 | 'unlink', 47 | 'rmdir', 48 | 'rename', 49 | ].forEach(method => { 50 | Object.defineProperty(LocalRemoteFileSystem.prototype, method, { 51 | enumerable: false, 52 | value(...args) { 53 | const fn = localfs[method]; 54 | return fn.call(this, ...args); 55 | }, 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "2.0.0", 12 | 13 | // we want to run npm 14 | "command": "npm", 15 | 16 | "type": "shell", 17 | 18 | "presentation": { 19 | "reveal": "silent" 20 | }, 21 | 22 | // we run the custom script "dev" as defined in package.json 23 | "args": ["run", "dev", "--", "--display", "minimal"], 24 | 25 | // The dev is started in watching mode 26 | "isBackground": true, 27 | 28 | // use the standard tsc in watch mode problem matcher to find compile problems in the output. 29 | "problemMatcher": { 30 | "owner": "webpack", 31 | "fileLocation": "relative", 32 | "severity": "error", 33 | "pattern": [ 34 | { 35 | "regexp": "^(.*)\\s+in\\s+(.*)\\s(\\d+):(\\d+)-(\\d+)$", 36 | "severity": 1, 37 | "file": 2, 38 | "line": 3, 39 | "column": 4, 40 | "endColumn": 5 41 | }, 42 | { 43 | "regexp": "^(?!Hash|Time|Version|Built at).+:\\s+(.*)$", 44 | "message": 0 45 | } 46 | ], 47 | "background": { 48 | "activeOnStart": true, 49 | "beginsPattern": "^$", 50 | "endsPattern": "^$" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/helper/paths.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as path from 'path'; 3 | import { upath } from '../core'; 4 | import { pathRelativeToWorkspace, getWorkspaceFolders } from '../host'; 5 | 6 | export function simplifyPath(absolutePath: string) { 7 | return pathRelativeToWorkspace(absolutePath); 8 | } 9 | 10 | // FIXME: use fs.pathResolver instead of upath 11 | export function toRemotePath(localPath: string, localContext: string, remoteContext: string) { 12 | return upath.join(remoteContext, path.relative(localContext, localPath)); 13 | } 14 | 15 | // FIXME: use fs.pathResolver instead of upath 16 | export function toLocalPath(remotePath: string, remoteContext: string, localContext: string) { 17 | return path.join(localContext, upath.relative(remoteContext, remotePath)); 18 | } 19 | 20 | export function isSubpathOf(possiableParentPath: string, pathname: string) { 21 | return path.normalize(pathname).indexOf(path.normalize(possiableParentPath)) === 0; 22 | } 23 | 24 | export function replaceHomePath(pathname: string) { 25 | return pathname.substr(0, 2) === '~/' ? path.join(os.homedir(), pathname.slice(2)) : pathname; 26 | } 27 | 28 | export function resolvePath(from: string, to: string) { 29 | return path.resolve(from, replaceHomePath(to)); 30 | } 31 | 32 | export function isInWorksapce(filepath: string) { 33 | const workspaceFolders = getWorkspaceFolders(); 34 | return ( 35 | workspaceFolders && 36 | workspaceFolders.some( 37 | // vscode can't keep filepath's stable, covert them to toLowerCase before check 38 | folder => filepath.toLowerCase().indexOf(folder.uri.fsPath.toLowerCase()) === 0 39 | ) 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /resources/dark/upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commands/commandSetProfile.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { COMMAND_SET_PROFILE } from '../constants'; 3 | import { showInformationMessage } from '../host'; 4 | import app from '../app'; 5 | import logger from '../logger'; 6 | import { getAllFileService } from '../modules/serviceManager'; 7 | import { checkCommand } from './abstract/createCommand'; 8 | 9 | export default checkCommand({ 10 | id: COMMAND_SET_PROFILE, 11 | 12 | async handleCommand(definedProfile) { 13 | const profiles = getAllFileService().reduce< 14 | Array 15 | >( 16 | (acc, service) => { 17 | if (service.getAvaliableProfiles().length <= 0) { 18 | return acc; 19 | } 20 | 21 | service.getAvaliableProfiles().forEach(profile => { 22 | acc.push({ 23 | value: profile, 24 | label: app.state.profile === profile ? `${profile} (active)` : profile, 25 | }); 26 | }); 27 | return acc; 28 | }, 29 | [ 30 | { 31 | value: null, 32 | label: 'UNSET', 33 | }, 34 | ] 35 | ); 36 | 37 | if (profiles.length <= 1) { 38 | showInformationMessage('No Avaliable Profile.'); 39 | return; 40 | } 41 | 42 | if (definedProfile !== undefined) { 43 | const index = profiles.findIndex(a => a.value === definedProfile); 44 | if (index !== -1) { 45 | app.state.profile = definedProfile; 46 | } else { 47 | app.state.profile = null; 48 | logger.warn(`try to set a unknown profile "${definedProfile}"`); 49 | } 50 | return; 51 | } 52 | 53 | const item = await vscode.window.showQuickPick(profiles, { placeHolder: 'select a profile' }); 54 | if (item === undefined) return; 55 | app.state.profile = item.value; 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /src/commands/commandListActiveFolder.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { Uri } from 'vscode'; 3 | import { COMMAND_LIST_ACTIVEFOLDER } from '../constants'; 4 | import { showTextDocument } from '../host'; 5 | import { FileType } from '../core'; 6 | import { downloadFile, downloadFolder } from '../fileHandlers'; 7 | import { checkCommand } from './abstract/createCommand'; 8 | import { getActiveFolder } from './shared'; 9 | import { handleCtxFromUri } from '../fileHandlers'; 10 | import { listFiles, toLocalPath } from '../helper'; 11 | 12 | export default checkCommand({ 13 | id: COMMAND_LIST_ACTIVEFOLDER, 14 | 15 | async handleCommand() { 16 | const folderUri = getActiveFolder(); 17 | if (!folderUri) { 18 | return; 19 | } 20 | 21 | const ctx = handleCtxFromUri(folderUri); 22 | const config = ctx.config; 23 | const remotefs = await ctx.fileService.getRemoteFileSystem(config); 24 | const fileEntry = await remotefs.list(ctx.target.remoteFsPath); 25 | const filter = config.ignore ? file => !config.ignore!(file.fsPath) : undefined; 26 | 27 | const listItems = fileEntry.map(file => ({ 28 | name: path.basename(file.fspath) + (file.type === FileType.Directory ? '/' : ''), 29 | fsPath: file.fspath, 30 | type: file.type, 31 | description: '', 32 | getFs: remotefs, 33 | filter, 34 | })); 35 | const selected = await listFiles(listItems); 36 | if (!selected) { 37 | return; 38 | } 39 | 40 | const localUri = Uri.file( 41 | toLocalPath(selected.fsPath, config.remotePath, ctx.fileService.baseDir) 42 | ); 43 | if (selected.type !== FileType.Directory) { 44 | await downloadFile(localUri); 45 | try { 46 | await showTextDocument(localUri); 47 | } catch (error) { 48 | // ignore 49 | } 50 | } else { 51 | await downloadFolder(localUri); 52 | } 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as output from './ui/output'; 2 | import { getExtensionSetting } from './modules/ext'; 3 | 4 | const extSetting = getExtensionSetting(); 5 | const debug = extSetting.debug || extSetting.printDebugLog; 6 | 7 | const paddingTime = time => ('00' + time).slice(-2); 8 | 9 | export interface Logger { 10 | trace(message: string, ...args: any[]): void; 11 | debug(message: string, ...args: any[]): void; 12 | info(message: string, ...args: any[]): void; 13 | warn(message: string, ...args: any[]): void; 14 | error(message: string | Error, ...args: any[]): void; 15 | critical(message: string | Error, ...args: any[]): void; 16 | } 17 | 18 | class VSCodeLogger implements Logger { 19 | log(message: string, ...args: any[]) { 20 | const now = new Date(); 21 | const month = paddingTime(now.getMonth() + 1); 22 | const date = paddingTime(now.getDate()); 23 | const h = paddingTime(now.getHours()); 24 | const m = paddingTime(now.getMinutes()); 25 | const s = paddingTime(now.getSeconds()); 26 | output.print(`[${month}-${date} ${h}:${m}:${s}]`, message, ...args); 27 | } 28 | 29 | trace(message: string, ...args: any[]) { 30 | if (debug) { 31 | this.log('[trace]', message, ...args); 32 | } 33 | } 34 | 35 | debug(message: string, ...args: any[]) { 36 | if (debug) { 37 | this.log('[debug]', message, ...args); 38 | } 39 | } 40 | 41 | info(message: string, ...args: any[]) { 42 | this.log('[info]', message, ...args); 43 | } 44 | 45 | warn(message: string, ...args: any[]) { 46 | this.log('[warn]', message, ...args); 47 | } 48 | 49 | error(message: string | Error, ...args: any[]) { 50 | this.log('[error]', message, ...args); 51 | } 52 | 53 | critical(message: string | Error, ...args: any[]) { 54 | this.log('[critical]', message, ...args); 55 | } 56 | } 57 | 58 | const logger = new VSCodeLogger(); 59 | 60 | export default logger; 61 | -------------------------------------------------------------------------------- /schema/sftp.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { 4 | "$ref": "definitions.json#/rootOption" 5 | }, 6 | { 7 | "$ref": "definitions.json#/option" 8 | }, 9 | { 10 | "$ref": "definitions.json#/host" 11 | }, 12 | { 13 | "$ref": "definitions.json#/sftp" 14 | }, 15 | { 16 | "type": "object", 17 | "properties": { 18 | "protocol": { 19 | "enum": ["sftp"], 20 | "description": "sftp connection protocol." 21 | }, 22 | "hop": { 23 | "oneOf": [ 24 | { 25 | "$ref": "#/definitions/hop" 26 | }, 27 | { 28 | "type": "array", 29 | "items": { 30 | "$ref": "#/definitions/hop" 31 | } 32 | } 33 | ], 34 | "default": { 35 | "host": "bridgeHost", 36 | "port": "bridgePort" 37 | } 38 | }, 39 | "profiles": { 40 | "description": "A collection of profiles. Using key as name. The profile will be merge to top level config.", 41 | "patternProperties": { 42 | "^.*$": { 43 | "allOf": [ 44 | { 45 | "$ref": "definitions.json#/option" 46 | }, 47 | { 48 | "$ref": "definitions.json#/host" 49 | }, 50 | { 51 | "$ref": "definitions.json#/sftp" 52 | } 53 | ] 54 | } 55 | }, 56 | "default": { 57 | "profileName": {} 58 | } 59 | } 60 | } 61 | } 62 | ], 63 | "definitions": { 64 | "hop": { 65 | "allOf": [ 66 | { 67 | "$ref": "definitions.json#/host" 68 | }, 69 | { 70 | "$ref": "definitions.json#/sftp" 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/commands/commandConfig.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { COMMAND_CONFIG } from '../constants'; 3 | import { newConfig } from '../modules/config'; 4 | import { 5 | getWorkspaceFolders, 6 | showConfirmMessage, 7 | showOpenDialog, 8 | openFolder, 9 | addWorkspaceFolder, 10 | } from '../host'; 11 | import { checkCommand } from './abstract/createCommand'; 12 | 13 | export default checkCommand({ 14 | id: COMMAND_CONFIG, 15 | 16 | async handleCommand() { 17 | const workspaceFolders = getWorkspaceFolders(); 18 | if (!workspaceFolders) { 19 | const result = await showConfirmMessage( 20 | 'SFTP expects to work at a folder.', 21 | 'Open Folder', 22 | 'Ok' 23 | ); 24 | 25 | if (!result) { 26 | return; 27 | } 28 | 29 | return openFolder(); 30 | } 31 | 32 | if (workspaceFolders.length <= 0) { 33 | const result = await showConfirmMessage( 34 | 'There are no available folders in current workspace.', 35 | 'Add Folder to Workspace', 36 | 'Ok' 37 | ); 38 | 39 | if (!result) { 40 | return; 41 | } 42 | 43 | const resources = await showOpenDialog({ 44 | canSelectFiles: false, 45 | canSelectFolders: true, 46 | canSelectMany: true, 47 | }); 48 | 49 | if (!resources) { 50 | return; 51 | } 52 | 53 | addWorkspaceFolder(...resources.map(uri => ({ uri }))); 54 | return; 55 | } 56 | 57 | if (workspaceFolders.length === 1) { 58 | newConfig(workspaceFolders[0].uri.fsPath); 59 | return; 60 | } 61 | 62 | const initDirs = workspaceFolders.map(folder => ({ 63 | value: folder.uri.fsPath, 64 | label: folder.name, 65 | description: folder.uri.fsPath, 66 | })); 67 | 68 | vscode.window 69 | .showQuickPick(initDirs, { 70 | placeHolder: 'Select a folder...', 71 | }) 72 | .then(item => { 73 | if (item === undefined) { 74 | return; 75 | } 76 | 77 | newConfig(item.value); 78 | }); 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /resources/remote-explorer.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/core/remote-client/remoteClient.ts: -------------------------------------------------------------------------------- 1 | import CustomError from '../customError'; 2 | 3 | export interface ConnectOption { 4 | // common 5 | host: string; 6 | port: number; 7 | username?: string; 8 | password?: string; 9 | connectTimeout?: number; 10 | debug(x: string): void; 11 | 12 | // ssh-only 13 | privateKeyPath?: string; 14 | privateKey?: string; 15 | passphrase?: string | boolean; 16 | interactiveAuth?: boolean; 17 | agent?: string; 18 | sock?: any; 19 | hop?: ConnectOption | ConnectOption[]; 20 | limitOpenFilesOnRemote?: boolean | number; 21 | 22 | // ftp-only 23 | secure?: any; 24 | secureOptions?: object; 25 | passive?: boolean; 26 | } 27 | 28 | export enum ErrorCode { 29 | CONNECT_CANCELLED, 30 | } 31 | 32 | export interface Config { 33 | askForPasswd(msg: string): Promise; 34 | } 35 | 36 | export default abstract class RemoteClient { 37 | protected _client: any; 38 | protected _option: ConnectOption; 39 | 40 | constructor(option: ConnectOption) { 41 | this._option = option; 42 | this._client = this._initClient(); 43 | } 44 | 45 | abstract end(): void; 46 | abstract getFsClient(): any; 47 | protected abstract _doConnect(connectOption: ConnectOption, config: Config): Promise; 48 | protected abstract _hasProvideAuth(connectOption: ConnectOption): boolean; 49 | protected abstract _initClient(): any; 50 | 51 | async connect(connectOption: ConnectOption, config: Config) { 52 | if (this._hasProvideAuth(connectOption)) { 53 | return this._doConnect(connectOption, config); 54 | } 55 | 56 | const password = await config.askForPasswd(`[${connectOption.host}]: Enter your password`); 57 | 58 | // cancel connect 59 | if (password === undefined) { 60 | throw new CustomError(ErrorCode.CONNECT_CANCELLED, 'cancelled'); 61 | } 62 | 63 | return this._doConnect({ ...connectOption, password }, config); 64 | } 65 | 66 | onDisconnected(cb) { 67 | this._client 68 | .on('end', () => { 69 | cb('end'); 70 | }) 71 | .on('close', () => { 72 | cb('close'); 73 | }) 74 | .on('error', err => { 75 | cb('error'); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // The module 'vscode' contains the VS Code extensibility API 3 | // Import the module and reference it with the alias vscode in your code below 4 | import * as vscode from 'vscode'; 5 | import app from './app'; 6 | import initCommands from './initCommands'; 7 | import { reportError } from './helper'; 8 | import fileActivityMonitor from './modules/fileActivityMonitor'; 9 | import { tryLoadConfigs } from './modules/config'; 10 | import { getAllFileService, createFileService, disposeFileService } from './modules/serviceManager'; 11 | import { getWorkspaceFolders, setContextValue } from './host'; 12 | import RemoteExplorer from './modules/remoteExplorer'; 13 | 14 | async function setupWorkspaceFolder(dir) { 15 | const configs = await tryLoadConfigs(dir); 16 | configs.forEach(config => { 17 | createFileService(config, dir); 18 | }); 19 | } 20 | 21 | function setup(workspaceFolders: vscode.WorkspaceFolder[]) { 22 | fileActivityMonitor.init(); 23 | const pendingInits = workspaceFolders.map(folder => setupWorkspaceFolder(folder.uri.fsPath)); 24 | 25 | return Promise.all(pendingInits); 26 | } 27 | 28 | // this method is called when your extension is activated 29 | // your extension is activated the very first time the command is executed 30 | export async function activate(context: vscode.ExtensionContext) { 31 | try { 32 | initCommands(context); 33 | } catch (error) { 34 | reportError(error, 'initCommands'); 35 | } 36 | 37 | const workspaceFolders = getWorkspaceFolders(); 38 | if (!workspaceFolders) { 39 | return; 40 | } 41 | 42 | setContextValue('enabled', true); 43 | app.sftpBarItem.show(); 44 | app.state.subscribe(_ => { 45 | const currentText = app.sftpBarItem.getText(); 46 | // current is showing profile 47 | if (currentText.startsWith('SFTP')) { 48 | app.sftpBarItem.reset(); 49 | } 50 | if (app.remoteExplorer) { 51 | app.remoteExplorer.refresh(); 52 | } 53 | }); 54 | try { 55 | await setup(workspaceFolders); 56 | app.remoteExplorer = new RemoteExplorer(context); 57 | } catch (error) { 58 | reportError(error); 59 | } 60 | } 61 | 62 | export function deactivate() { 63 | fileActivityMonitor.destory(); 64 | getAllFileService().forEach(disposeFileService); 65 | } 66 | -------------------------------------------------------------------------------- /src/commands/abstract/createCommand.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode'; 2 | import logger from '../../logger'; 3 | import { reportError } from '../../helper'; 4 | import { handleCtxFromUri, FileHandlerContext } from '../../fileHandlers'; 5 | import Command from './command'; 6 | 7 | interface BaseCommandOption { 8 | id: string; 9 | name?: string; 10 | } 11 | 12 | interface CommandOption extends BaseCommandOption { 13 | handleCommand: (this: Command, ...args: any[]) => unknown | Promise; 14 | } 15 | 16 | interface FileCommandOption extends BaseCommandOption { 17 | handleFile: (ctx: FileHandlerContext) => Promise; 18 | getFileTarget: (...args: any[]) => undefined | Uri | Uri[] | Promise; 19 | } 20 | 21 | function checkType() { 22 | return (a: T) => a; 23 | } 24 | 25 | export const checkCommand = checkType(); 26 | export const checkFileCommand = checkType(); 27 | 28 | export function createCommand(commandOption: CommandOption & { name: string }) { 29 | return class NormalCommand extends Command { 30 | constructor() { 31 | super(); 32 | this.id = commandOption.id; 33 | this.name = commandOption.name; 34 | } 35 | 36 | doCommandRun(...args) { 37 | commandOption.handleCommand.apply(this, args); 38 | } 39 | }; 40 | } 41 | 42 | export function createFileCommand(commandOption: FileCommandOption & { name: string }) { 43 | return class FileCommand extends Command { 44 | constructor() { 45 | super(); 46 | this.id = commandOption.id; 47 | this.name = commandOption.name; 48 | } 49 | 50 | protected async doCommandRun(...args) { 51 | const target = await commandOption.getFileTarget(...args); 52 | if (!target) { 53 | logger.warn(`The "${this.name}" command get canceled because of missing targets.`); 54 | return; 55 | } 56 | 57 | const targetList: Uri[] = Array.isArray(target) ? target : [target]; 58 | const pendingTasks = targetList.map(async uri => { 59 | try { 60 | await commandOption.handleFile(handleCtxFromUri(uri)); 61 | } catch (error) { 62 | reportError(error); 63 | } 64 | }); 65 | 66 | await Promise.all(pendingTasks); 67 | } 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /src/initCommands.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from 'vscode'; 2 | import logger from './logger'; 3 | import { registerCommand } from './host'; 4 | import Command from './commands/abstract/command'; 5 | import { createCommand, createFileCommand } from './commands/abstract/createCommand'; 6 | 7 | export default function init(context: ExtensionContext) { 8 | loadCommands( 9 | require.context( 10 | // Look for files in the commands directory 11 | './commands', 12 | // Do not look in subdirectories 13 | false, 14 | // Only include "_base-" prefixed .vue files 15 | /command.*.ts$/ 16 | ), 17 | /command(.*)/, 18 | createCommand, 19 | context 20 | ); 21 | loadCommands( 22 | require.context( 23 | // Look for files in the current directory 24 | './commands', 25 | // Do not look in subdirectories 26 | false, 27 | // Only include "_base-" prefixed .vue files 28 | /fileCommand.*.ts$/ 29 | ), 30 | /fileCommand(.*)/, 31 | createFileCommand, 32 | context 33 | ); 34 | } 35 | 36 | function nomalizeCommandName(rawName) { 37 | const firstLetter = rawName[0].toUpperCase(); 38 | return firstLetter + rawName.slice(1).replace(/[A-Z]/g, token => ` ${token[0]}`); 39 | } 40 | 41 | async function loadCommands(requireContext, nameRegex, commandCreator, context: ExtensionContext) { 42 | requireContext.keys().forEach(fileName => { 43 | const clearName = fileName 44 | // Remove the "./" from the beginning 45 | .replace(/^\.\//, '') 46 | // Remove the file extension from the end 47 | .replace(/\.\w+$/, ''); 48 | 49 | const match = nameRegex.exec(clearName); 50 | if (!match || !match[1]) { 51 | logger.warn(`Command name not found from ${fileName}`); 52 | return; 53 | } 54 | 55 | const commandOption = requireContext(fileName).default; 56 | commandOption.name = nomalizeCommandName(match[1]); 57 | 58 | try { 59 | // tslint:disable-next-line variable-name 60 | const Cmd = commandCreator(commandOption); 61 | const cmdInstance: Command = new Cmd(); 62 | logger.debug(`register command "${commandOption.name}" from "${fileName}"`); 63 | registerCommand(context, commandOption.id, cmdInstance.run, cmdInstance); 64 | } catch (error) { 65 | logger.error(error, `load command "${fileName}"`); 66 | } 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/remoteExplorer/explorer.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { registerCommand } from '../../host'; 3 | import { 4 | COMMAND_REMOTEEXPLORER_REFRESH, 5 | COMMAND_REMOTEEXPLORER_VIEW_CONTENT, 6 | } from '../../constants'; 7 | import { UResource } from '../../core'; 8 | import { toRemotePath } from '../../helper'; 9 | import { REMOTE_SCHEME } from '../../constants'; 10 | import { getFileService } from '../serviceManager'; 11 | import RemoteTreeDataProvider, { ExplorerItem } from './treeDataProvider'; 12 | 13 | export default class RemoteExplorer { 14 | private _explorerView: vscode.TreeView; 15 | private _treeDataProvider: RemoteTreeDataProvider; 16 | 17 | constructor(context: vscode.ExtensionContext) { 18 | this._treeDataProvider = new RemoteTreeDataProvider(); 19 | context.subscriptions.push( 20 | vscode.workspace.registerTextDocumentContentProvider(REMOTE_SCHEME, this._treeDataProvider) 21 | ); 22 | 23 | this._explorerView = vscode.window.createTreeView('remoteExplorer', { 24 | showCollapseAll: true, 25 | treeDataProvider: this._treeDataProvider, 26 | }); 27 | 28 | registerCommand(context, COMMAND_REMOTEEXPLORER_REFRESH, () => this._refreshSelection()); 29 | registerCommand(context, COMMAND_REMOTEEXPLORER_VIEW_CONTENT, (item: ExplorerItem) => 30 | this._treeDataProvider.showItem(item) 31 | ); 32 | } 33 | 34 | refresh(item?: ExplorerItem) { 35 | if (item && !UResource.isRemote(item.resource.uri)) { 36 | const uri = item.resource.uri; 37 | const fileService = getFileService(uri); 38 | if (!fileService) { 39 | throw new Error(`Config Not Found. (${uri.toString(true)}) `); 40 | } 41 | const config = fileService.getConfig(); 42 | const localPath = item.resource.fsPath; 43 | const remotePath = toRemotePath(localPath, config.context, config.remotePath); 44 | item.resource = UResource.makeResource({ 45 | remote: { 46 | host: config.host, 47 | port: config.port, 48 | }, 49 | fsPath: remotePath, 50 | remoteId: fileService.id, 51 | }); 52 | } 53 | 54 | this._treeDataProvider.refresh(item); 55 | } 56 | 57 | reveal(item: ExplorerItem): Thenable { 58 | return item ? this._explorerView.reveal(item) : Promise.resolve(); 59 | } 60 | 61 | findRoot(remoteUri: vscode.Uri) { 62 | return this._treeDataProvider.findRoot(remoteUri); 63 | } 64 | 65 | private _refreshSelection() { 66 | if (this._explorerView.selection.length) { 67 | this._explorerView.selection.forEach(item => this.refresh(item)); 68 | } else { 69 | this.refresh(); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | const VENDOR_FOLDER = '.vscode'; 4 | 5 | export const EXTENSION_NAME = 'sftp'; 6 | export const SETTING_KEY_REMOTE = 'remotefs.remote'; 7 | 8 | export const REMOTE_SCHEME = 'remote'; 9 | 10 | export const CONGIF_FILENAME = 'sftp.json'; 11 | export const CONFIG_PATH = path.join(VENDOR_FOLDER, CONGIF_FILENAME); 12 | 13 | // command not in package.json 14 | export const COMMAND_TOGGLE_OUTPUT = 'sftp.toggleOutput'; 15 | 16 | // commands in package.json 17 | export const COMMAND_CONFIG = 'sftp.config'; 18 | export const COMMAND_SET_PROFILE = 'sftp.setProfile'; 19 | export const COMMAND_CANCEL_ALL_TRANSFER = 'sftp.cancelAllTransfer'; 20 | export const COMMAND_OPEN_CONNECTION_IN_TERMINAL = 'sftp.openConnectInTerminal'; 21 | 22 | export const COMMAND_FORCE_UPLOAD = 'sftp.forceUpload'; 23 | export const COMMAND_UPLOAD = 'sftp.upload'; 24 | export const COMMAND_UPLOAD_FILE = 'sftp.upload.file'; 25 | export const COMMAND_UPLOAD_CHANGEDFILES = 'sftp.upload.changedFiles'; 26 | export const COMMAND_UPLOAD_ACTIVEFILE = 'sftp.upload.activeFile'; 27 | export const COMMAND_UPLOAD_FOLDER = 'sftp.upload.folder'; 28 | export const COMMAND_UPLOAD_ACTIVEFOLDER = 'sftp.upload.activeFolder'; 29 | export const COMMAND_UPLOAD_PROJECT = 'sftp.upload.project'; 30 | export const COMMAND_FORCE_DOWNLOAD = 'sftp.forceDownload'; 31 | export const COMMAND_DOWNLOAD = 'sftp.download'; 32 | export const COMMAND_DOWNLOAD_FILE = 'sftp.download.file'; 33 | export const COMMAND_DOWNLOAD_ACTIVEFILE = 'sftp.download.activeFile'; 34 | export const COMMAND_DOWNLOAD_FOLDER = 'sftp.download.folder'; 35 | export const COMMAND_DOWNLOAD_ACTIVEFOLDER = 'sftp.download.activeFolder'; 36 | export const COMMAND_DOWNLOAD_PROJECT = 'sftp.download.project'; 37 | 38 | export const COMMAND_SYNC_LOCAL_TO_REMOTE = 'sftp.sync.localToRemote'; 39 | export const COMMAND_SYNC_REMOTE_TO_LOCAL = 'sftp.sync.remoteToLocal'; 40 | export const COMMAND_SYNC_BOTH_DIRECTIONS = 'sftp.sync.bothDirections'; 41 | 42 | export const COMMAND_DIFF = 'sftp.diff'; 43 | export const COMMAND_DIFF_ACTIVEFILE = 'sftp.diff.activeFile'; 44 | export const COMMAND_LIST = 'sftp.list'; 45 | export const COMMAND_LIST_ACTIVEFOLDER = 'sftp.listActiveFolder'; 46 | export const COMMAND_LIST_ALL = 'sftp.listAll'; 47 | export const COMMAND_DELETE_REMOTE = 'sftp.delete.remote'; 48 | export const COMMAND_REVEAL_IN_EXPLORER = 'sftp.revealInExplorer'; 49 | export const COMMAND_REVEAL_IN_REMOTE_EXPLORER = 'sftp.revealInRemoteExplorer'; 50 | 51 | export const COMMAND_REMOTEEXPLORER_REFRESH = 'sftp.remoteExplorer.refresh'; 52 | export const COMMAND_REMOTEEXPLORER_EDITINLOCAL = 'sftp.remoteExplorer.editInLocal'; 53 | export const COMMAND_REMOTEEXPLORER_VIEW_CONTENT = 'sftp.viewContent'; 54 | -------------------------------------------------------------------------------- /src/fileHandlers/createFileHandler.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode'; 2 | import app from '../app'; 3 | import { UResource, FileService, ServiceConfig } from '../core'; 4 | import logger from '../logger'; 5 | import { getFileService } from '../modules/serviceManager'; 6 | 7 | interface FileHandlerConfig { 8 | _?: boolean; 9 | } 10 | 11 | export interface FileHandlerContext { 12 | target: UResource; 13 | fileService: FileService; 14 | config: ServiceConfig; 15 | } 16 | 17 | type FileHandlerContextMethod = (this: FileHandlerContext) => R; 18 | type FileHandlerContextMethodArg1 = (this: FileHandlerContext, a: A) => R; 19 | 20 | interface FileHandlerOption { 21 | name: string; 22 | handle: FileHandlerContextMethodArg1>; 23 | afterHandle?: FileHandlerContextMethod; 24 | config?: FileHandlerConfig; 25 | transformOption?: FileHandlerContextMethod; 26 | } 27 | 28 | export function handleCtxFromUri(uri: Uri): FileHandlerContext { 29 | const fileService = getFileService(uri); 30 | if (!fileService) { 31 | throw new Error(`Config Not Found. (${uri.toString(true)}) `); 32 | } 33 | const config = fileService.getConfig(); 34 | const target = UResource.from(uri, { 35 | localBasePath: fileService.baseDir, 36 | remoteBasePath: config.remotePath, 37 | remoteId: fileService.id, 38 | remote: { 39 | host: config.host, 40 | port: config.port, 41 | }, 42 | }); 43 | 44 | return { 45 | fileService, 46 | config, 47 | target, 48 | }; 49 | } 50 | 51 | export default function createFileHandler( 52 | handlerOption: FileHandlerOption 53 | ): (ctx: FileHandlerContext | Uri, option?: Partial) => Promise { 54 | async function fileHandle(ctx: Uri | FileHandlerContext, option?: T) { 55 | const handleCtx = ctx instanceof Uri ? handleCtxFromUri(ctx) : ctx; 56 | const { target } = handleCtx; 57 | 58 | const invokeOption = handlerOption.transformOption 59 | ? handlerOption.transformOption.call(handleCtx) 60 | : {}; 61 | if (option) { 62 | Object.assign(invokeOption, option); 63 | } 64 | 65 | if (invokeOption.ignore && invokeOption.ignore(target.localFsPath)) { 66 | return; 67 | } 68 | 69 | logger.trace(`handle ${handlerOption.name} for`, target.localFsPath); 70 | 71 | app.sftpBarItem.startSpinner(); 72 | try { 73 | await handlerOption.handle.call(handleCtx, invokeOption); 74 | // } catch (error) { 75 | // reportError(error, `when ${handlerOption.name} ${target.localFsPath}`); 76 | // Object.defineProperty(error, 'reported', { 77 | // configurable: false, 78 | // enumerable: false, 79 | // value: true, 80 | // }); 81 | // throw error; 82 | } finally { 83 | app.sftpBarItem.stopSpinner(); 84 | } 85 | if (handlerOption.afterHandle) { 86 | handlerOption.afterHandle.call(handleCtx); 87 | } 88 | } 89 | 90 | return fileHandle; 91 | } 92 | -------------------------------------------------------------------------------- /src/core/remote-client/ftpClient.ts: -------------------------------------------------------------------------------- 1 | import * as Client from 'ftp'; 2 | import RemoteClient, { ConnectOption } from './remoteClient'; 3 | 4 | // tslint:disable 5 | Client.prototype._send = function(cmd: string, cb: (err: Error) => void, promote: boolean) { 6 | clearTimeout(this._keepalive); 7 | if (cmd !== undefined) { 8 | if (promote) this._queue.unshift({ cmd: cmd, cb: cb }); 9 | else this._queue.push({ cmd: cmd, cb: cb }); 10 | 11 | if (cmd === 'ABOR') { 12 | if (this._pasvSocket) this._pasvSocket.aborting = true; 13 | this._debug && this._debug('[connection] > ' + cmd); 14 | this._socket.write(cmd + '\r\n'); 15 | return; 16 | } 17 | } 18 | var queueLen = this._queue.length; 19 | if (!this._curReq && queueLen && this._socket && this._socket.readable) { 20 | this._curReq = this._queue.shift(); 21 | if (this._curReq.cmd !== 'ABOR') { 22 | this._debug && this._debug('[connection] > ' + this._curReq.cmd); 23 | this._socket.write(this._curReq.cmd + '\r\n'); 24 | } 25 | } else if (!this._curReq && !queueLen && this._ending) this._reset(); 26 | }; 27 | // tslint:enable 28 | 29 | Client.prototype.setLastMod = function(path: string, date: Date, cb) { 30 | const dateStr = 31 | date.getUTCFullYear() + 32 | ('00' + (date.getUTCMonth() + 1)).slice(-2) + 33 | ('00' + date.getUTCDate()).slice(-2) + 34 | ('00' + date.getUTCHours()).slice(-2) + 35 | ('00' + date.getUTCMinutes()).slice(-2) + 36 | ('00' + date.getUTCSeconds()).slice(-2); 37 | 38 | this._send('MFMT ' + dateStr + ' ' + path, cb); 39 | }; 40 | 41 | export default class FTPClient extends RemoteClient { 42 | private connected: boolean = false; 43 | 44 | _initClient() { 45 | return new Client(); 46 | } 47 | 48 | _hasProvideAuth(connectOption: ConnectOption) { 49 | // tslint:disable-next-line triple-equals 50 | return connectOption.password != undefined; 51 | } 52 | 53 | _doConnect(connectOption: ConnectOption): Promise { 54 | this.onDisconnected(() => { 55 | this.connected = false; 56 | }); 57 | 58 | const { username, connectTimeout = 3 * 1000, ...option } = connectOption; 59 | return new Promise((resolve, reject) => { 60 | setTimeout(() => { 61 | if (!this.connected) { 62 | this.end(); 63 | reject(new Error('Timeout while connecting to server')); 64 | } 65 | }, connectTimeout); 66 | 67 | this._client 68 | .on('ready', () => { 69 | this.connected = true; 70 | if (option.passive) { 71 | this._client._pasv(resolve); 72 | } else { 73 | resolve(); 74 | } 75 | }) 76 | .on('error', err => { 77 | reject(err); 78 | }) 79 | .connect({ 80 | keepalive: 1000 * 10, 81 | pasvTimeout: connectTimeout, 82 | ...option, 83 | connTimeout: connectTimeout, 84 | user: username, 85 | }); 86 | }); 87 | } 88 | 89 | end() { 90 | return this._client.end(); 91 | } 92 | 93 | getFsClient() { 94 | return this._client; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/core/fs/fileSystem.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | import * as fs from 'fs'; 3 | 4 | interface FileSystemError extends Error { 5 | code: string; 6 | } 7 | 8 | export const ERROR_MSG_STREAM_INTERRUPT = 'sftp.stream.interrupt'; 9 | 10 | export type FileHandle = unknown; 11 | 12 | export enum FileType { 13 | Directory = 1, 14 | File, 15 | SymbolicLink, 16 | Unknown, 17 | } 18 | 19 | export interface FileOption { 20 | flags?: string; 21 | encoding?: string; 22 | mode?: number; 23 | autoClose?: boolean; 24 | fd?: FileHandle; 25 | } 26 | 27 | export interface FileStats { 28 | type: FileType; 29 | mode: number; 30 | size: number; 31 | mtime: number; 32 | atime: number; 33 | // symbol link target 34 | target?: string; 35 | } 36 | 37 | export type FileEntry = FileStats & { 38 | fspath: string; 39 | name: string; 40 | }; 41 | 42 | export default abstract class FileSystem { 43 | static getFileTypecharacter(stat: fs.Stats): FileType { 44 | if (stat.isDirectory()) { 45 | return FileType.Directory; 46 | } else if (stat.isFile()) { 47 | return FileType.File; 48 | } else if (stat.isSymbolicLink()) { 49 | return FileType.SymbolicLink; 50 | } else { 51 | return FileType.Unknown; 52 | } 53 | } 54 | 55 | pathResolver: any; 56 | 57 | constructor(pathResolver: any) { 58 | this.pathResolver = pathResolver; 59 | } 60 | 61 | abstract readFile(path: string, option?: FileOption): Promise; 62 | abstract open(path: string, flags: string, mode?: number): Promise; 63 | abstract close(fd: FileHandle): Promise; 64 | abstract fstat(fd: FileHandle): Promise; 65 | /** 66 | * Change the file system timestamps of the object referenced by the supplied file descriptor. 67 | * 68 | * @abstract 69 | * @param {FileHandle} fd 70 | * @param {number} atime time in seconds 71 | * @param {number} mtime time in seconds 72 | * @returns {Promise} 73 | * @memberof FileSystem 74 | */ 75 | abstract futimes(fd: FileHandle, atime: number, mtime: number): Promise; 76 | abstract get(path: string, option?: FileOption): Promise; 77 | abstract put(input: Readable, path, option?: FileOption): Promise; 78 | abstract mkdir(dir: string): Promise; 79 | abstract ensureDir(dir: string): Promise; 80 | abstract list(dir: string, option?): Promise; 81 | abstract lstat(path: string): Promise; 82 | abstract readlink(path: string): Promise; 83 | abstract symlink(targetPath: string, path: string): Promise; 84 | abstract unlink(path: string): Promise; 85 | abstract rmdir(path: string, recursive: boolean): Promise; 86 | abstract rename(srcPath: string, destPath: string): Promise; 87 | 88 | static abortReadableStream(stream: Readable) { 89 | const err = new Error('Transfer Aborted') as FileSystemError; 90 | err.code = ERROR_MSG_STREAM_INTERRUPT; 91 | 92 | // don't do `stream.destroy(err)`! `sftp.ReadaStream` do not support `err` parameter in `destory` method. 93 | stream.emit('error', err); 94 | stream.destroy(); 95 | } 96 | 97 | static isAbortedError(err: FileSystemError) { 98 | return err.code === ERROR_MSG_STREAM_INTERRUPT; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/core/fs/remoteFileSystem.ts: -------------------------------------------------------------------------------- 1 | import FileSystem, { FileOption } from './fileSystem'; 2 | import { RemoteClient, ConnectOption, RemoteClientConfig } from '../remote-client'; 3 | 4 | interface RFSOptionDefaults { 5 | remoteTimeOffsetInHours: number; 6 | } 7 | 8 | type RFSOption = Partial & { 9 | client?: RemoteClient; 10 | clientOption?: ConnectOption; 11 | }; 12 | 13 | const SECONDS_PER_HOUR = 60 * 60; 14 | const MILLISECONDS_PER_HOUR = SECONDS_PER_HOUR * 1000; 15 | 16 | const defaultOption: RFSOptionDefaults = { 17 | remoteTimeOffsetInHours: 0, 18 | }; 19 | 20 | export default abstract class RemoteFileSystem extends FileSystem { 21 | protected client: RemoteClient; 22 | private _remoteTimeOffsetInMilliseconds: number = 0; 23 | private _remoteTimeOffsetInSeconds: number = 0; 24 | 25 | constructor(pathResolver, option: RFSOption) { 26 | super(pathResolver); 27 | 28 | const _option = { 29 | ...defaultOption, 30 | ...option, 31 | }; 32 | const { client, clientOption, remoteTimeOffsetInHours } = _option; 33 | if (client) { 34 | this.client = client; 35 | } else if (clientOption) { 36 | this.client = this._createClient(clientOption); 37 | } else { 38 | throw new Error('No client or clientOption is provided'); 39 | } 40 | 41 | this.setRemoteTimeOffsetInHours(remoteTimeOffsetInHours); 42 | } 43 | 44 | setRemoteTimeOffsetInHours(offset: number) { 45 | this._remoteTimeOffsetInSeconds = offset * SECONDS_PER_HOUR; 46 | this._remoteTimeOffsetInMilliseconds = offset * MILLISECONDS_PER_HOUR; 47 | } 48 | 49 | getClient() { 50 | if (!this.client) { 51 | throw new Error('client not found!'); 52 | } 53 | return this.client; 54 | } 55 | 56 | connect(connectOpetion: ConnectOption, config: RemoteClientConfig): Promise { 57 | return this.client.connect( 58 | connectOpetion, 59 | config 60 | ); 61 | } 62 | 63 | onDisconnected(cb) { 64 | this.client.onDisconnected(cb); 65 | } 66 | 67 | end() { 68 | this.client.end(); 69 | } 70 | 71 | toLocalTime(remoteTimeMilliseconds: number): number { 72 | return remoteTimeMilliseconds - this._remoteTimeOffsetInMilliseconds; 73 | } 74 | 75 | toRemoteTimeInSecnonds(localtime: number): number { 76 | return localtime + this._remoteTimeOffsetInSeconds; 77 | } 78 | 79 | async readFile(path: string, option?: FileOption): Promise { 80 | return new Promise(async (resolve, reject) => { 81 | let stream; 82 | try { 83 | stream = await this.get(path, option); 84 | } catch (error) { 85 | return reject(error); 86 | } 87 | 88 | const arr: Buffer[] = []; 89 | const onData = chunk => { 90 | arr.push(chunk); 91 | }; 92 | const onEnd = err => { 93 | if (err) { 94 | return reject(err); 95 | } 96 | 97 | const buffer = Buffer.concat(arr); 98 | resolve(option && option.encoding ? buffer.toString(option.encoding) : buffer); 99 | }; 100 | 101 | stream.on('data', onData); 102 | stream.on('error', onEnd); 103 | stream.on('end', onEnd); 104 | }); 105 | } 106 | 107 | protected abstract _createClient(option: ConnectOption): any; 108 | } 109 | -------------------------------------------------------------------------------- /src/ui/statusBarItem.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | const spinners = { 4 | dots: { 5 | interval: 80, 6 | frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], 7 | }, 8 | }; 9 | 10 | enum Status { 11 | ok = 1, 12 | warn, 13 | error, 14 | } 15 | 16 | export default class StatusBarItem { 17 | static Status = Status; 18 | 19 | private _name: () => string | string; 20 | private tooltip: string; 21 | private statusBarItem: vscode.StatusBarItem; 22 | private spinnerTimer: any = null; 23 | private resetTimer: any = null; 24 | private curFrameOfSpinner: number = 0; 25 | private text: string; 26 | private status: Status = Status.ok; 27 | private spinner: { 28 | interval: number; 29 | frames: string[]; 30 | }; 31 | 32 | constructor(name, tooltip, command) { 33 | this._name = name; 34 | this.tooltip = tooltip; 35 | this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); 36 | this.statusBarItem.command = command; 37 | this.spinner = spinners.dots; 38 | this.reset = this.reset.bind(this); 39 | this.reset(); 40 | } 41 | 42 | private get name() { 43 | return typeof this._name === 'function' ? this._name() : this._name; 44 | } 45 | 46 | updateStatus(status: Status) { 47 | this.status = status; 48 | this._render(); 49 | } 50 | 51 | getText() { 52 | return this.statusBarItem.text; 53 | } 54 | 55 | show() { 56 | this.statusBarItem.show(); 57 | } 58 | 59 | isSpinning() { 60 | return this.spinnerTimer !== null; 61 | } 62 | 63 | startSpinner() { 64 | if (this.spinnerTimer) { 65 | return; 66 | } 67 | 68 | const totalFrame = this.spinner.frames.length; 69 | this.spinnerTimer = setInterval(() => { 70 | this.curFrameOfSpinner = (this.curFrameOfSpinner + 1) % totalFrame; 71 | this._render(); 72 | }, this.spinner.interval); 73 | this._render(); 74 | } 75 | 76 | stopSpinner() { 77 | clearInterval(this.spinnerTimer); 78 | this.spinnerTimer = null; 79 | this.curFrameOfSpinner = 0; 80 | this._render(); 81 | } 82 | 83 | showMsg(text: string, hideAfterTimeout?: number); 84 | showMsg(text: string, tooltip: string, hideAfterTimeout?: number); 85 | showMsg(text: string, tooltip?: string | number, hideAfterTimeout?: number) { 86 | if (typeof tooltip === 'number') { 87 | hideAfterTimeout = tooltip; 88 | tooltip = text; 89 | } 90 | 91 | if (this.resetTimer) { 92 | clearTimeout(this.resetTimer); 93 | this.resetTimer = null; 94 | } 95 | 96 | this.text = text; 97 | this.statusBarItem.tooltip = tooltip; 98 | this._render(); 99 | if (hideAfterTimeout) { 100 | this.resetTimer = setTimeout(this.reset, hideAfterTimeout); 101 | } 102 | } 103 | 104 | private _render() { 105 | if (this.isSpinning()) { 106 | this.statusBarItem.text = this.spinner.frames[this.curFrameOfSpinner] + ' ' + this.text; 107 | } else if (this.name === this.text) { 108 | switch (this.status) { 109 | case Status.ok: 110 | this.statusBarItem.text = this.text; 111 | break; 112 | case Status.warn: 113 | this.statusBarItem.text = `$(alert) ${this.text}`; 114 | break; 115 | case Status.error: 116 | this.statusBarItem.text = `$(issue-opened) ${this.text}`; 117 | break; 118 | default: 119 | this.statusBarItem.text = this.text; 120 | } 121 | } else { 122 | this.statusBarItem.text = this.text; 123 | } 124 | } 125 | 126 | reset() { 127 | this.text = this.name; 128 | this.statusBarItem.tooltip = this.tooltip; 129 | this._render(); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/commands/commandOpenSshConnection.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { COMMAND_OPEN_CONNECTION_IN_TERMINAL } from '../constants'; 3 | import { getAllFileService } from '../modules/serviceManager'; 4 | import { ExplorerRoot } from '../modules/remoteExplorer'; 5 | import { getUserSetting } from '../host'; 6 | import { interpolate } from '../utils'; 7 | import { checkCommand } from './abstract/createCommand'; 8 | 9 | const isWindows = process.platform === 'win32'; 10 | 11 | function shouldUseAgent(config) { 12 | return typeof config.agent === 'string' && config.agent.length > 0; 13 | } 14 | 15 | function shouldUseKey(config) { 16 | return typeof config.privateKeyPath === 'string' && config.privateKeyPath.length > 0; 17 | } 18 | 19 | function adaptPath(filepath) { 20 | // convert to unix style 21 | const safeUnixPath = filepath.replace(/\\\\/g, '/').replace(/\\/g, '/'); 22 | if (!isWindows) { 23 | return safeUnixPath; 24 | } 25 | 26 | const setting = getUserSetting('terminal.integrated.shell'); 27 | const shell = setting.get('windows', ''); 28 | 29 | if (!shell.endsWith('wsl.exe')) { 30 | return safeUnixPath; 31 | } 32 | 33 | // append with /mnt and convert c: to c 34 | return '/mnt/' + safeUnixPath.replace(/^([a-zA-Z]):/, '$1'); 35 | } 36 | 37 | // function shouldUsePass(config) { 38 | // return typeof config.password === 'string' && config.password.length > 0; 39 | // } 40 | 41 | function getSshCommand( 42 | config: { host: string; port: number; username: string }, 43 | extraOpiton?: string 44 | ) { 45 | let sshStr = `ssh -t ${config.username}@${config.host} -p ${config.port}`; 46 | if (extraOpiton) { 47 | sshStr += ` ${extraOpiton}`; 48 | } 49 | // sshStr += ` "cd \\"${config.workingDir}\\"; exec \\$SHELL -l"`; 50 | return sshStr; 51 | } 52 | 53 | export default checkCommand({ 54 | id: COMMAND_OPEN_CONNECTION_IN_TERMINAL, 55 | 56 | async handleCommand(exploreItem?: ExplorerRoot) { 57 | let remoteConfig; 58 | if (exploreItem && exploreItem.explorerContext) { 59 | remoteConfig = exploreItem.explorerContext.config; 60 | if (remoteConfig.protocol !== 'sftp') { 61 | return; 62 | } 63 | } else { 64 | const remoteItems = getAllFileService().reduce< 65 | { label: string; description: string; config: any }[] 66 | >((result, fileService) => { 67 | const config = fileService.getConfig(); 68 | if (config.protocol === 'sftp') { 69 | result.push({ 70 | label: config.name || config.remotePath, 71 | description: config.host, 72 | config, 73 | }); 74 | } 75 | return result; 76 | }, []); 77 | if (remoteItems.length <= 0) { 78 | return; 79 | } 80 | 81 | const item = await vscode.window.showQuickPick(remoteItems, { 82 | placeHolder: 'Select a folder...', 83 | }); 84 | if (item === undefined) { 85 | return; 86 | } 87 | 88 | remoteConfig = item.config; 89 | } 90 | 91 | const sshConfig = { 92 | host: remoteConfig.host, 93 | port: remoteConfig.port, 94 | username: remoteConfig.username, 95 | }; 96 | const terminal = vscode.window.createTerminal(remoteConfig.name); 97 | let sshCommand; 98 | if (shouldUseAgent(remoteConfig)) { 99 | sshCommand = getSshCommand(sshConfig); 100 | } else if (shouldUseKey(remoteConfig)) { 101 | sshCommand = getSshCommand(sshConfig, `-i "${adaptPath(remoteConfig.privateKeyPath)}"`); 102 | } else { 103 | sshCommand = getSshCommand(sshConfig); 104 | } 105 | 106 | if (remoteConfig.sshCustomParams) { 107 | sshCommand = 108 | sshCommand + 109 | ' ' + 110 | interpolate(remoteConfig.sshCustomParams, { 111 | remotePath: remoteConfig.remotePath, 112 | }); 113 | } 114 | 115 | terminal.sendText(sshCommand); 116 | terminal.show(); 117 | }, 118 | }); 119 | -------------------------------------------------------------------------------- /src/modules/fileActivityMonitor.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import logger from '../logger'; 3 | import app from '../app'; 4 | import StatusBarItem from '../ui/statusBarItem'; 5 | import { onDidOpenTextDocument, onDidSaveTextDocument, showConfirmMessage } from '../host'; 6 | import { readConfigsFromFile } from './config'; 7 | import { 8 | createFileService, 9 | getFileService, 10 | findAllFileService, 11 | disposeFileService, 12 | } from './serviceManager'; 13 | import { reportError, isValidFile, isConfigFile, isInWorksapce } from '../helper'; 14 | import { downloadFile, uploadFile } from '../fileHandlers'; 15 | 16 | let workspaceWatcher: vscode.Disposable; 17 | 18 | async function handleConfigSave(uri: vscode.Uri) { 19 | const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); 20 | if (!workspaceFolder) { 21 | return; 22 | } 23 | 24 | const workspacePath = workspaceFolder.uri.fsPath; 25 | 26 | // dispose old service 27 | findAllFileService(service => service.workspace === workspacePath).forEach(disposeFileService); 28 | 29 | // create new service 30 | try { 31 | const configs = await readConfigsFromFile(uri.fsPath); 32 | configs.forEach(config => createFileService(config, workspacePath)); 33 | } catch (error) { 34 | reportError(error); 35 | } finally { 36 | app.remoteExplorer.refresh(); 37 | } 38 | } 39 | 40 | async function handleFileSave(uri: vscode.Uri) { 41 | const fileService = getFileService(uri); 42 | if (!fileService) { 43 | return; 44 | } 45 | 46 | const config = fileService.getConfig(); 47 | if (config.uploadOnSave) { 48 | const fspath = uri.fsPath; 49 | logger.info(`[file-save] ${fspath}`); 50 | try { 51 | await uploadFile(uri); 52 | } catch (error) { 53 | logger.error(error, `download ${fspath}`); 54 | app.sftpBarItem.updateStatus(StatusBarItem.Status.error); 55 | } 56 | } 57 | } 58 | 59 | async function downloadOnOpen(uri: vscode.Uri) { 60 | const fileService = getFileService(uri); 61 | if (!fileService) { 62 | return; 63 | } 64 | 65 | const config = fileService.getConfig(); 66 | if (config.downloadOnOpen) { 67 | if (config.downloadOnOpen === 'confirm') { 68 | const isConfirm = await showConfirmMessage('Do you want SFTP to download this file?'); 69 | if (!isConfirm) return; 70 | } 71 | 72 | const fspath = uri.fsPath; 73 | logger.info(`[file-open] ${fspath}`); 74 | try { 75 | await downloadFile(uri); 76 | } catch (error) { 77 | logger.error(error, `download ${fspath}`); 78 | app.sftpBarItem.updateStatus(StatusBarItem.Status.error); 79 | } 80 | } 81 | } 82 | 83 | function watchWorkspace({ 84 | onDidSaveFile, 85 | onDidSaveSftpConfig, 86 | }: { 87 | onDidSaveFile: (uri: vscode.Uri) => void; 88 | onDidSaveSftpConfig: (uri: vscode.Uri) => void; 89 | }) { 90 | if (workspaceWatcher) { 91 | workspaceWatcher.dispose(); 92 | } 93 | 94 | workspaceWatcher = onDidSaveTextDocument((doc: vscode.TextDocument) => { 95 | const uri = doc.uri; 96 | if (!isValidFile(uri) || !isInWorksapce(uri.fsPath)) { 97 | return; 98 | } 99 | 100 | // remove staled cache 101 | if (app.fsCache.has(uri.fsPath)) { 102 | app.fsCache.del(uri.fsPath); 103 | } 104 | 105 | if (isConfigFile(uri)) { 106 | onDidSaveSftpConfig(uri); 107 | return; 108 | } 109 | 110 | onDidSaveFile(uri); 111 | }); 112 | } 113 | 114 | function init() { 115 | onDidOpenTextDocument((doc: vscode.TextDocument) => { 116 | if (!isValidFile(doc.uri) || !isInWorksapce(doc.uri.fsPath)) { 117 | return; 118 | } 119 | 120 | downloadOnOpen(doc.uri); 121 | }); 122 | 123 | watchWorkspace({ 124 | onDidSaveFile: handleFileSave, 125 | onDidSaveSftpConfig: handleConfigSave, 126 | }); 127 | } 128 | 129 | function destory() { 130 | if (workspaceWatcher) { 131 | workspaceWatcher.dispose(); 132 | } 133 | } 134 | 135 | export default { 136 | init, 137 | destory, 138 | }; 139 | -------------------------------------------------------------------------------- /src/core/remoteFs.ts: -------------------------------------------------------------------------------- 1 | import upath from './upath'; 2 | import { promptForPassword } from '../host'; 3 | import logger from '../logger'; 4 | import app from '../app'; 5 | import { ConnectOption } from './remote-client/remoteClient'; 6 | import { 7 | FileSystem, 8 | RemoteFileSystem, 9 | SFTPFileSystem, 10 | FTPFileSystem, 11 | } from './fs'; 12 | import localFs from './localFs'; 13 | 14 | function hashOption(opiton) { 15 | return Object.keys(opiton) 16 | .map(key => opiton[key]) 17 | .join(''); 18 | } 19 | 20 | class KeepAliveRemoteFs { 21 | private isValid: boolean = false; 22 | 23 | private pendingPromise: Promise | null; 24 | 25 | private fs: RemoteFileSystem; 26 | 27 | async getFs( 28 | option: ConnectOption & { 29 | protocol: string; 30 | remoteTimeOffsetInHours: number; 31 | } 32 | ): Promise { 33 | if (this.isValid) { 34 | this.pendingPromise = null; 35 | return Promise.resolve(this.fs); 36 | } 37 | 38 | if (this.pendingPromise) { 39 | return this.pendingPromise; 40 | } 41 | 42 | const connectOption = Object.assign({}, option); 43 | // tslint:disable variable-name 44 | let FsConstructor: typeof SFTPFileSystem | typeof FTPFileSystem; 45 | if (option.protocol === 'sftp') { 46 | connectOption.debug = function debug(str) { 47 | const log = str.match(/^DEBUG(?:\[SFTP\])?: (.*?): (.*?)$/); 48 | 49 | if (log) { 50 | if (log[1] === 'Parser') return; 51 | logger.debug(`${log[1]}: ${log[2]}`); 52 | } else { 53 | logger.debug(str); 54 | } 55 | }; 56 | FsConstructor = SFTPFileSystem; 57 | } else if (option.protocol === 'ftp') { 58 | connectOption.debug = function debug(str) { 59 | const log = str.match(/^\[connection\] (>|<) (.*?)(\\r\\n)?$/); 60 | 61 | if (!log) return; 62 | 63 | if (log[2].match(/200 NOOP/)) return; 64 | 65 | if (log[2].match(/^PASS /)) log[2] = 'PASS ******'; 66 | 67 | logger.debug(`${log[1]} ${log[2]}`); 68 | }; 69 | FsConstructor = FTPFileSystem; 70 | } else { 71 | throw new Error(`unsupported protocol ${option.protocol}`); 72 | } 73 | 74 | this.fs = new FsConstructor(upath, { 75 | clientOption: connectOption, 76 | remoteTimeOffsetInHours: option.remoteTimeOffsetInHours, 77 | }); 78 | this.fs.onDisconnected(this.invalid.bind(this)); 79 | 80 | app.sftpBarItem.showMsg('connecting...', connectOption.connectTimeout); 81 | this.pendingPromise = this.fs 82 | .connect(connectOption, { 83 | askForPasswd: promptForPassword, 84 | }) 85 | .then( 86 | () => { 87 | app.sftpBarItem.reset(); 88 | this.isValid = true; 89 | return this.fs; 90 | }, 91 | err => { 92 | this.fs.end(); 93 | this.invalid('error'); 94 | throw err; 95 | } 96 | ); 97 | 98 | return this.pendingPromise; 99 | } 100 | 101 | invalid(reason: string) { 102 | this.pendingPromise = null; 103 | this.fs.end(); 104 | this.isValid = false; 105 | } 106 | 107 | end() { 108 | this.fs.end(); 109 | } 110 | } 111 | 112 | function getLocalFs() { 113 | return Promise.resolve(localFs); 114 | } 115 | 116 | const fsTable: { 117 | [x: string]: KeepAliveRemoteFs; 118 | } = {}; 119 | 120 | export function createRemoteIfNoneExist(option): Promise { 121 | if (option.protocol === 'local') { 122 | return getLocalFs(); 123 | } 124 | 125 | const identity = hashOption(option); 126 | const fs = fsTable[identity]; 127 | if (fs !== undefined) { 128 | return fs.getFs(option); 129 | } 130 | 131 | const fsInstance = new KeepAliveRemoteFs(); 132 | fsTable[identity] = fsInstance; 133 | return fsInstance.getFs(option); 134 | } 135 | 136 | export function removeRemoteFs(option) { 137 | const identity = hashOption(option); 138 | const fs = fsTable[identity]; 139 | if (fs !== undefined) { 140 | fs.end(); 141 | delete fsTable[identity]; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/modules/fileWatcher.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as debounce from 'lodash.debounce'; 3 | import logger from '../logger'; 4 | import { isValidFile, fileDepth } from '../helper'; 5 | import { upload, removeRemote } from '../fileHandlers'; 6 | import { WatcherService, TransferDirection } from '../core'; 7 | import app from '../app'; 8 | import StatusBarItem from '../ui/statusBarItem'; 9 | import { getRunningTransformTasks } from './serviceManager'; 10 | 11 | const watchers: { 12 | [x: string]: vscode.FileSystemWatcher; 13 | } = {}; 14 | 15 | const uploadQueue = new Set(); 16 | const deleteQueue = new Set(); 17 | 18 | // less than 550 will not work 19 | const ACTION_INTEVAL = 550; 20 | 21 | function doUpload() { 22 | const files = Array.from(uploadQueue).sort((a, b) => fileDepth(b.fsPath) - fileDepth(a.fsPath)); 23 | uploadQueue.clear(); 24 | 25 | const currentDownloadTasks = getRunningTransformTasks().filter( 26 | task => task.transferType === TransferDirection.REMOTE_TO_LOCAL 27 | ); 28 | 29 | files.forEach(async uri => { 30 | // current target is still in downloading, so don't upload it. 31 | if (currentDownloadTasks.find(task => task.localFsPath === uri.fsPath)) { 32 | return; 33 | } 34 | 35 | const fspath = uri.fsPath; 36 | logger.info(`[watcher/updated] ${fspath}`); 37 | try { 38 | await upload(uri); 39 | } catch (error) { 40 | logger.error(error, `upload ${fspath}`); 41 | app.sftpBarItem.updateStatus(StatusBarItem.Status.error); 42 | } 43 | }); 44 | } 45 | 46 | function doDelete() { 47 | const files = Array.from(deleteQueue).sort((a, b) => fileDepth(b.fsPath) - fileDepth(a.fsPath)); 48 | deleteQueue.clear(); 49 | files.forEach(async uri => { 50 | const fspath = uri.fsPath; 51 | logger.info(`[watcher/removed] ${fspath}`); 52 | try { 53 | await removeRemote(uri); 54 | } catch (error) { 55 | logger.error(error, `remove ${fspath}`); 56 | app.sftpBarItem.updateStatus(StatusBarItem.Status.error); 57 | } 58 | }); 59 | } 60 | 61 | const debouncedUpload = debounce(doUpload, ACTION_INTEVAL, { leading: true, trailing: true }); 62 | const debouncedDelete = debounce(doDelete, ACTION_INTEVAL, { leading: true, trailing: true }); 63 | 64 | function uploadHandler(uri: vscode.Uri) { 65 | if (!isValidFile(uri)) { 66 | return; 67 | } 68 | 69 | uploadQueue.add(uri); 70 | debouncedUpload(); 71 | } 72 | 73 | function addWatcher(id, watcher) { 74 | watchers[id] = watcher; 75 | } 76 | 77 | function getWatcher(id) { 78 | return watchers[id]; 79 | } 80 | 81 | function createWatcher( 82 | watcherBase: string, 83 | watcherConfig: { files: false | string; autoUpload: boolean; autoDelete: boolean } 84 | ) { 85 | let watcher = getWatcher(watcherBase); 86 | if (watcher) { 87 | // clear old watcher 88 | watcher.dispose(); 89 | } 90 | 91 | if (!watcherConfig) { 92 | return; 93 | } 94 | 95 | const shouldAddListenser = watcherConfig.autoUpload || watcherConfig.autoDelete; 96 | // tslint:disable-next-line triple-equals 97 | if (watcherConfig.files == false || !shouldAddListenser) { 98 | return; 99 | } 100 | 101 | watcher = vscode.workspace.createFileSystemWatcher( 102 | new vscode.RelativePattern(watcherBase, watcherConfig.files), 103 | false, 104 | false, 105 | false 106 | ); 107 | addWatcher(watcherBase, watcher); 108 | 109 | if (watcherConfig.autoUpload) { 110 | watcher.onDidCreate(uploadHandler); 111 | watcher.onDidChange(uploadHandler); 112 | } 113 | 114 | if (watcherConfig.autoDelete) { 115 | watcher.onDidDelete(uri => { 116 | if (!isValidFile(uri)) { 117 | return; 118 | } 119 | 120 | deleteQueue.add(uri); 121 | debouncedDelete(); 122 | }); 123 | } 124 | } 125 | 126 | function removeWatcher(watcherBase: string) { 127 | const watcher = getWatcher(watcherBase); 128 | if (watcher) { 129 | watcher.dispose(); 130 | delete watchers[watcherBase]; 131 | } 132 | } 133 | 134 | const watcherService: WatcherService = { 135 | create: createWatcher, 136 | dispose: removeWatcher, 137 | }; 138 | 139 | export default watcherService; 140 | -------------------------------------------------------------------------------- /src/host.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { EXTENSION_NAME } from './constants'; 3 | 4 | export function getOpenTextDocuments(): vscode.TextDocument[] { 5 | return vscode.workspace.textDocuments; 6 | } 7 | 8 | export function getUserSetting(section: string, resource?: vscode.Uri | null | undefined) { 9 | return vscode.workspace.getConfiguration(section, resource); 10 | } 11 | 12 | export function executeCommand(command: string, ...rest: any[]): Thenable { 13 | return vscode.commands.executeCommand(command, ...rest); 14 | } 15 | 16 | export function onWillSaveTextDocument( 17 | listener: (e: vscode.TextDocumentWillSaveEvent) => any, 18 | thisArgs?: any 19 | ) { 20 | return vscode.workspace.onWillSaveTextDocument(listener, thisArgs); 21 | } 22 | 23 | export function onDidSaveTextDocument(listener: (e: vscode.TextDocument) => any, thisArgs?: any) { 24 | return vscode.workspace.onDidSaveTextDocument(listener, thisArgs); 25 | } 26 | 27 | export function onDidOpenTextDocument(listener: (e: vscode.TextDocument) => any, thisArgs?: any) { 28 | return vscode.workspace.onDidOpenTextDocument(listener, thisArgs); 29 | } 30 | 31 | export function pathRelativeToWorkspace(localPath) { 32 | return vscode.workspace.asRelativePath(localPath); 33 | } 34 | 35 | export function getActiveTextEditor() { 36 | return vscode.window.activeTextEditor; 37 | } 38 | 39 | export function getWorkspaceFolders() { 40 | return vscode.workspace.workspaceFolders; 41 | } 42 | 43 | export function refreshExplorer() { 44 | return executeCommand('workbench.files.action.refreshFilesExplorer'); 45 | } 46 | 47 | export function focusOpenEditors() { 48 | return executeCommand('workbench.files.action.focusOpenEditorsView'); 49 | } 50 | 51 | export function showTextDocument(uri: vscode.Uri, option?: vscode.TextDocumentShowOptions) { 52 | return vscode.window.showTextDocument(uri, option); 53 | } 54 | 55 | export function diffFiles(leftFsPath, rightFsPath, title, option?) { 56 | const leftUri = vscode.Uri.file(leftFsPath); 57 | const rightUri = vscode.Uri.file(rightFsPath); 58 | 59 | return executeCommand('vscode.diff', leftUri, rightUri, title, option); 60 | } 61 | 62 | export function promptForPassword(prompt: string): Promise { 63 | return vscode.window.showInputBox({ 64 | ignoreFocusOut: true, 65 | password: true, 66 | prompt, 67 | }) as Promise; 68 | } 69 | 70 | export function setContextValue(key: string, value: any) { 71 | executeCommand('setContext', EXTENSION_NAME + '.' + key, value); 72 | } 73 | 74 | export function showErrorMessage(message: string, ...items: string[]) { 75 | return vscode.window.showErrorMessage(message, ...items); 76 | } 77 | 78 | export function showInformationMessage(message: string, ...items: string[]) { 79 | return vscode.window.showInformationMessage(message, ...items); 80 | } 81 | 82 | export function showWarningMessage(message: string, ...items: string[]) { 83 | return vscode.window.showWarningMessage(message, ...items); 84 | } 85 | 86 | export async function showConfirmMessage( 87 | message: string, 88 | confirmLabel: string = 'Yes', 89 | cancelLabel: string = 'No' 90 | ) { 91 | const result = await vscode.window.showInformationMessage( 92 | message, 93 | { title: confirmLabel }, 94 | { title: cancelLabel } 95 | ); 96 | 97 | return Boolean(result && result.title === confirmLabel); 98 | } 99 | 100 | export function showOpenDialog(options: vscode.OpenDialogOptions) { 101 | return vscode.window.showOpenDialog(options); 102 | } 103 | 104 | export function openFolder(uri?: vscode.Uri, newWindow?: boolean) { 105 | return executeCommand('vscode.openFolder', uri, newWindow); 106 | } 107 | 108 | export function registerCommand( 109 | context: vscode.ExtensionContext, 110 | name: string, 111 | callback: (...args: any[]) => any, 112 | thisArg?: any 113 | ) { 114 | const disposable = vscode.commands.registerCommand(name, callback, thisArg); 115 | context.subscriptions.push(disposable); 116 | } 117 | 118 | export function addWorkspaceFolder(...workspaceFoldersToAdd: { uri: vscode.Uri; name?: string }[]) { 119 | return vscode.workspace.updateWorkspaceFolders(0, 0, ...workspaceFoldersToAdd); 120 | } 121 | -------------------------------------------------------------------------------- /src/commands/commandUploadChangedFiles.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { COMMAND_UPLOAD_CHANGEDFILES } from '../constants'; 4 | import { getFileService } from '../modules/serviceManager'; 5 | import { uploadFile, renameRemote } from '../fileHandlers'; 6 | import { getGitService, GitAPI, Repository, Status, Change } from '../modules/git'; 7 | import { checkCommand } from './abstract/createCommand'; 8 | import logger from '../logger'; 9 | import { simplifyPath } from '../helper'; 10 | 11 | export default checkCommand({ 12 | id: COMMAND_UPLOAD_CHANGEDFILES, 13 | 14 | async handleCommand(hint: any) { 15 | return handleCommand(hint); 16 | 17 | // resourceGroup.resourceStates.forEach(resourceState => { 18 | // resourceState. 19 | // console.log(resourceState.decorations); 20 | // }); 21 | 22 | // try { 23 | // await uploadFile(ctx, { ignore: null }); 24 | // } catch (error) { 25 | // // ignore error when try to upload a deleted file 26 | // if (error.code !== 'ENOENT') { 27 | // throw error; 28 | // } 29 | // } 30 | }, 31 | }); 32 | 33 | function isRepository(object: any): object is Repository { 34 | return 'rootUri' in object; 35 | } 36 | 37 | function isSourceControlResourceGroup(object: any): object is vscode.SourceControlResourceGroup { 38 | return 'id' in object && 'resourceStates' in object; 39 | } 40 | 41 | async function handleCommand(hint: any) { 42 | let repository: Repository | undefined; 43 | let filterGroupId; 44 | const git = getGitService(); 45 | 46 | if (!hint) { 47 | repository = await getRepository(git); 48 | } else if (isSourceControlResourceGroup(hint)) { 49 | repository = git.repositories.find(repo => repo.ui.selected); 50 | filterGroupId = hint.id; 51 | } else if (isRepository(hint)) { 52 | repository = git.repositories.find(repo => repo.ui.selected); 53 | } 54 | 55 | if (!repository) { 56 | return; 57 | } 58 | 59 | let changes: Change[]; 60 | if (filterGroupId === 'index') { 61 | changes = repository.state.indexChanges; 62 | } else if (filterGroupId === 'workingTree') { 63 | changes = repository.state.workingTreeChanges; 64 | } else { 65 | changes = repository.state.indexChanges.concat(repository.state.workingTreeChanges); 66 | } 67 | 68 | const creates: Change[] = []; 69 | const uploads: Change[] = []; 70 | const renames: Change[] = []; 71 | for (const change of changes) { 72 | if (!getFileService(change.uri)) { 73 | continue; 74 | } 75 | 76 | switch (change.status) { 77 | case Status.INDEX_MODIFIED: 78 | case Status.MODIFIED: 79 | uploads.push(change); 80 | break; 81 | case Status.INDEX_ADDED: 82 | case Status.UNTRACKED: 83 | creates.push(change); 84 | case Status.INDEX_RENAMED: 85 | renames.push(change); 86 | default: 87 | break; 88 | } 89 | } 90 | 91 | await Promise.all(creates.concat(uploads).map(change => uploadFile(change.uri))); 92 | await Promise.all( 93 | renames.map(change => renameRemote(change.originalUri, { originPath: change.renameUri!.fsPath })) 94 | ); 95 | 96 | logger.log(''); 97 | logger.log('------ Upload Changed Files Result ------'); 98 | outputGroup('create', creates, c => simplifyPath(c.uri.fsPath)); 99 | outputGroup('upload', uploads, c => simplifyPath(c.uri.fsPath)); 100 | outputGroup( 101 | 'renamed', 102 | renames, 103 | c => `${simplifyPath(c.originalUri.fsPath)} ➞ ${simplifyPath(c.renameUri!.fsPath)}` 104 | ); 105 | } 106 | 107 | function outputGroup(label: string, items: T[], formatItem: (x: T) => string) { 108 | if (items.length <= 0) { 109 | return; 110 | } 111 | 112 | logger.log(`${label.toUpperCase()}:`); 113 | logger.log(items.map(i => formatItem(i)).join('\n')); 114 | logger.log(''); 115 | } 116 | 117 | async function getRepository(git: GitAPI): Promise { 118 | if (git.repositories.length === 1) { 119 | return git.repositories[0]; 120 | } 121 | 122 | if (git.repositories.length === 0) { 123 | throw new Error('There are no available repositories'); 124 | } 125 | 126 | const picks = git.repositories.map(repo => { 127 | const label = path.basename(repo.rootUri.fsPath); 128 | const description = repo.state.HEAD ? repo.state.HEAD.name : ''; 129 | 130 | return { 131 | label, 132 | description, 133 | repository: repo, 134 | }; 135 | }); 136 | 137 | const pick = await vscode.window.showQuickPick(picks, { placeHolder: 'Choose a repository' }); 138 | 139 | return pick && pick.repository; 140 | } 141 | -------------------------------------------------------------------------------- /src/helper/select.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { FileSystem, FileType } from '../core'; 3 | import * as path from 'path'; 4 | 5 | const ROOT = '@root'; 6 | 7 | interface IFileLookUp { 8 | [x: string]: T[]; 9 | } 10 | 11 | interface IFilePickerOption { 12 | type?: FileType; 13 | } 14 | 15 | interface FileListChildItem extends FileListItem { 16 | parentFsPath: string; 17 | } 18 | 19 | interface FileListItem { 20 | name: string; 21 | fsPath: string; 22 | type: FileType; 23 | 24 | description: string; 25 | 26 | getFs: (() => Promise) | FileSystem; 27 | filter?: (x: string) => boolean; 28 | } 29 | 30 | async function showFiles( 31 | fileLookUp: IFileLookUp, 32 | parent: T | null, 33 | files: T[], 34 | option: IFilePickerOption = {} 35 | ): Promise { 36 | let avalibleFiles = files; 37 | let filter; 38 | if (option.type === FileType.Directory) { 39 | filter = file => file.type === FileType.Directory; 40 | } else { 41 | // don't show SymbolicLink 42 | filter = file => file.type !== FileType.SymbolicLink; 43 | } 44 | if (parent && parent.filter) { 45 | const parentFilter = parent.filter; 46 | const oldFilter = filter; 47 | filter = file => { 48 | return oldFilter(file) && parentFilter(file); 49 | }; 50 | } 51 | avalibleFiles = avalibleFiles.filter(file => { 52 | if (file.parentFsPath === ROOT) { 53 | return true; 54 | } 55 | 56 | if (file.name === '.' || file.name === '..') { 57 | return true; 58 | } 59 | 60 | return filter(file); 61 | }); 62 | 63 | const items = avalibleFiles 64 | .map(file => ({ 65 | value: file, 66 | label: file.name, 67 | description: file.description, 68 | })) 69 | .sort((l, r) => { 70 | if (l.value.type === r.value.type) { 71 | return l.label.localeCompare(r.label); 72 | } else if (l.value.type === FileType.Directory) { 73 | // dir goes to first 74 | return -1; 75 | } else { 76 | return 1; 77 | } 78 | }); 79 | 80 | const result = await vscode.window.showQuickPick(items, { 81 | placeHolder: 'Select a target...', 82 | }); 83 | 84 | if (result === undefined) { 85 | return; 86 | } 87 | 88 | // no limit or limit to dir, so we can choose current folder 89 | const allowChooseFolder = option.type === undefined || option.type === FileType.Directory; 90 | 91 | if (allowChooseFolder) { 92 | if (result.label === '.') { 93 | return result.value; 94 | } 95 | } 96 | 97 | // select a file 98 | if (result.value.type === FileType.File) { 99 | return result.value; 100 | } 101 | 102 | const selectedValue = result.value; 103 | const selectedPath = selectedValue.fsPath; 104 | const fileSystem = 105 | typeof selectedValue.getFs === 'function' ? await selectedValue.getFs() : selectedValue.getFs; 106 | 107 | const nextItems = fileLookUp[selectedPath]; 108 | if (nextItems !== undefined) { 109 | return showFiles(fileLookUp, selectedValue, nextItems, option); 110 | } 111 | 112 | return fileSystem.list(selectedPath).then(subFiles => { 113 | const subItems = subFiles.map(file => 114 | Object.assign({}, selectedValue, { 115 | name: path.basename(file.fspath) + (file.type === FileType.Directory ? '/' : ''), 116 | fsPath: file.fspath, 117 | parentFsPath: selectedPath, 118 | type: file.type, 119 | description: '', 120 | }) 121 | ); 122 | 123 | subItems.unshift( 124 | Object.assign({}, selectedValue, { 125 | name: '..', 126 | fsPath: selectedValue.parentFsPath, 127 | parentFsPath: '#will never reach here, cause the dir has alreay be cached#', 128 | type: FileType.Directory, 129 | description: 'go back', 130 | }) 131 | ); 132 | 133 | if (allowChooseFolder) { 134 | subItems.unshift( 135 | Object.assign({}, selectedValue, { 136 | name: '.', 137 | fsPath: selectedPath, 138 | parentFsPath: selectedValue.parentFsPath, 139 | type: FileType.Directory, 140 | description: 'choose current folder', 141 | }) 142 | ); 143 | } 144 | 145 | fileLookUp[selectedPath] = subItems; 146 | return showFiles(fileLookUp, selectedValue, subItems, option); 147 | }); 148 | } 149 | 150 | export function listFiles( 151 | items: T[], 152 | option?: IFilePickerOption 153 | ): Promise { 154 | const baseItems = items.map(item => Object.assign({}, item, { parentFsPath: ROOT })); 155 | const fileLookUp = { 156 | [ROOT]: baseItems, 157 | }; 158 | return showFiles(fileLookUp, null, baseItems, option); 159 | } 160 | -------------------------------------------------------------------------------- /src/core/uResource.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable max-classes-per-file */ 2 | import * as querystring from 'querystring'; 3 | import { Uri } from 'vscode'; 4 | import { toLocalPath, toRemotePath } from '../helper'; 5 | import { REMOTE_SCHEME } from '../constants'; 6 | 7 | function createUriString(authority: string, filepath: string, query: { [x: string]: any }) { 8 | // remove leading slash 9 | const normalizedPath = encodeURIComponent(filepath.replace(/^\/+/, '')); 10 | 11 | // vscode.Uri will call decodeURIComponent for query, so we must encode it first. 12 | const queryStr = encodeURIComponent(querystring.stringify(query)); 13 | return `${REMOTE_SCHEME}://${authority}/${normalizedPath}?${queryStr}`; 14 | } 15 | 16 | // tslint:disable-next-line class-name 17 | class _Resource { 18 | private readonly _uri: Uri; 19 | private readonly _fsPath: string; 20 | private readonly _remoteId: number; 21 | 22 | constructor(uri: Uri) { 23 | this._uri = uri; 24 | if (UResource.isRemote(uri)) { 25 | const query = querystring.parse<{ [x: string]: string }>(this._uri.query); 26 | this._remoteId = parseInt(query.remoteId, 10); 27 | 28 | if (query.fsPath === undefined) { 29 | throw new Error(`fsPath is missing in remote uri ${this._uri}.`); 30 | } 31 | 32 | this._fsPath = query.fsPath; 33 | } else { 34 | this._fsPath = this._uri.fsPath; 35 | } 36 | } 37 | 38 | get remoteId(): number { 39 | return this._remoteId; 40 | } 41 | 42 | get uri(): Uri { 43 | return this._uri; 44 | } 45 | 46 | get fsPath() { 47 | return this._fsPath; 48 | } 49 | } 50 | 51 | interface RemoteResourceConfig { 52 | remote: { 53 | host: string; 54 | port: number; 55 | }; 56 | remoteId: number; 57 | } 58 | 59 | type ResourceConfig = RemoteResourceConfig & { 60 | localBasePath: string; 61 | remoteBasePath: string; 62 | }; 63 | 64 | export type Resource = InstanceType; 65 | 66 | // Universal resource 67 | export default class UResource { 68 | private readonly _localResouce: Resource; 69 | private readonly _remoteResouce: Resource; 70 | 71 | static isRemote(uri: Uri) { 72 | return uri.scheme === REMOTE_SCHEME; 73 | } 74 | 75 | static makeResource(config: RemoteResourceConfig & { fsPath: string } | Uri): Resource { 76 | if (config instanceof Uri) { 77 | return new _Resource(config); 78 | } 79 | 80 | if (!config.remote) { 81 | return new _Resource(Uri.file(config.fsPath)); 82 | } 83 | 84 | const { 85 | remote: { host, port }, 86 | remoteId, 87 | fsPath, 88 | } = config; 89 | const remote = `${host}${port ? `:${port}` : ''}`; 90 | const query = { 91 | remoteId, 92 | fsPath, 93 | }; 94 | 95 | // uri.fsPath will always be current platform specific. 96 | // We need to store valid fsPath for remote platform. 97 | return new _Resource(Uri.parse(createUriString(remote, fsPath, query))); 98 | } 99 | 100 | static updateResource(resource: Resource, delta: { remotePath: string }): Resource { 101 | const uri = resource.uri; 102 | const { remotePath } = delta; 103 | const query = querystring.parse(resource.uri.query); 104 | query.fsPath = delta.remotePath; 105 | 106 | return new _Resource(Uri.parse(createUriString(uri.authority, remotePath, query))); 107 | } 108 | 109 | static from(uri: Uri, root: Resource | ResourceConfig): UResource { 110 | if ((root as Resource).fsPath) { 111 | return new UResource(new _Resource(uri), root as Resource); 112 | } 113 | 114 | const { localBasePath, remoteBasePath, remote, remoteId } = root as ResourceConfig; 115 | 116 | let localResouce: Resource; 117 | let remoteResouce: Resource; 118 | if (uri.scheme === REMOTE_SCHEME) { 119 | const localFsPath = toLocalPath( 120 | UResource.makeResource(uri).fsPath, 121 | remoteBasePath, 122 | localBasePath 123 | ); 124 | localResouce = new _Resource(Uri.file(localFsPath)); 125 | remoteResouce = new _Resource(uri); 126 | } else { 127 | const remoteFsPath = toRemotePath(uri.fsPath, localBasePath, remoteBasePath); 128 | remoteResouce = UResource.makeResource({ 129 | remote, 130 | fsPath: remoteFsPath, 131 | remoteId, 132 | }); 133 | localResouce = new _Resource(uri); 134 | } 135 | 136 | return new UResource(localResouce, remoteResouce); 137 | } 138 | 139 | private constructor(localResouce: Resource, remoteResouce: Resource) { 140 | this._localResouce = localResouce; 141 | this._remoteResouce = remoteResouce; 142 | } 143 | 144 | get localFsPath(): string { 145 | return this._localResouce.fsPath; 146 | } 147 | 148 | get remoteFsPath(): string { 149 | return this._remoteResouce.fsPath; 150 | } 151 | 152 | get localUri(): Uri { 153 | return this._localResouce.uri; 154 | } 155 | 156 | get remoteUri(): Uri { 157 | return this._remoteResouce.uri; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/core/transferTask.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | import * as fileOperations from './fileBaseOperations'; 3 | import { FileSystem, FileType } from './fs'; 4 | import { Task } from './scheduler'; 5 | import logger from '../logger'; 6 | 7 | let hasWarnedModifedTimePermission = false; 8 | 9 | export enum TransferDirection { 10 | LOCAL_TO_REMOTE = 'local ➞ remote', 11 | REMOTE_TO_LOCAL = 'remote ➞ local', 12 | } 13 | 14 | interface FileHandle { 15 | fsPath: string; 16 | fileSystem: FileSystem; 17 | } 18 | 19 | export interface TransferOption { 20 | atime: number; 21 | mtime: number; 22 | mode?: number; 23 | fallbackMode?: number; 24 | perserveTargetMode: boolean; 25 | } 26 | 27 | export default class TransferTask implements Task { 28 | readonly fileType: FileType; 29 | private readonly _srcFsPath: string; 30 | private readonly _targetFsPath: string; 31 | private readonly _srcFs: FileSystem; 32 | private readonly _targetFs: FileSystem; 33 | private readonly _transferDirection: TransferDirection; 34 | private readonly _TransferOption: TransferOption; 35 | private _handle: Readable; 36 | private _cancelled: boolean; 37 | // private _fileStatus: FileStatus; 38 | 39 | constructor( 40 | src: FileHandle, 41 | target: FileHandle, 42 | option: { 43 | fileType: FileType; 44 | transferDirection: TransferDirection; 45 | transferOption: TransferOption; 46 | } 47 | ) { 48 | this._srcFsPath = src.fsPath; 49 | this._targetFsPath = target.fsPath; 50 | this._srcFs = src.fileSystem; 51 | this._targetFs = target.fileSystem; 52 | this._TransferOption = option.transferOption; 53 | this._transferDirection = option.transferDirection; 54 | this.fileType = option.fileType; 55 | } 56 | 57 | get localFsPath() { 58 | if (this._transferDirection === TransferDirection.REMOTE_TO_LOCAL) { 59 | return this._targetFsPath; 60 | } else { 61 | return this._srcFsPath; 62 | } 63 | } 64 | 65 | get srcFsPath() { 66 | return this._srcFsPath; 67 | } 68 | 69 | get targetFsPath() { 70 | return this._targetFsPath; 71 | } 72 | 73 | get transferType() { 74 | return this._transferDirection; 75 | } 76 | 77 | async run() { 78 | const src = this._srcFsPath; 79 | const target = this._targetFsPath; 80 | const srcFs = this._srcFs; 81 | const targetFs = this._targetFs; 82 | switch (this.fileType) { 83 | case FileType.File: 84 | await this._transferFile(); 85 | break; 86 | case FileType.SymbolicLink: 87 | await fileOperations.transferSymlink( 88 | src, 89 | target, 90 | srcFs, 91 | targetFs, 92 | this._TransferOption 93 | ); 94 | break; 95 | default: 96 | logger.warn(`Unsupported file type (type = ${this.fileType}). File ${src}`); 97 | } 98 | } 99 | 100 | cancel() { 101 | if (this._handle && !this._cancelled) { 102 | this._cancelled = true; 103 | FileSystem.abortReadableStream(this._handle); 104 | } 105 | } 106 | 107 | isCancelled(): boolean { 108 | return this._cancelled; 109 | } 110 | 111 | private async _transferFile() { 112 | const src = this._srcFsPath; 113 | const target = this._targetFsPath; 114 | const srcFs = this._srcFs; 115 | const targetFs = this._targetFs; 116 | const { 117 | perserveTargetMode, 118 | fallbackMode, 119 | atime, 120 | mtime, 121 | } = this._TransferOption; 122 | let { mode } = this._TransferOption; 123 | let targetFd; 124 | // Use mode first. 125 | // Then check perserveTargetMode and fallback to fallbackMode if fail to get mode of target 126 | if (mode === undefined && perserveTargetMode) { 127 | targetFd = await targetFs.open(target, 'w'); 128 | [this._handle, mode] = await Promise.all([ 129 | srcFs.get(src), 130 | targetFs 131 | .fstat(targetFd) 132 | .then(stat => stat.mode) 133 | .catch(() => fallbackMode), 134 | ]); 135 | } else { 136 | [this._handle, targetFd] = await Promise.all([ 137 | srcFs.get(src), 138 | targetFs.open(target, 'w'), 139 | ]); 140 | } 141 | 142 | try { 143 | await targetFs.put(this._handle, target, { 144 | mode, 145 | fd: targetFd, 146 | autoClose: false, 147 | }); 148 | if (atime && mtime) { 149 | try { 150 | await targetFs.futimes( 151 | targetFd, 152 | Math.floor(atime / 1000), 153 | Math.floor(mtime / 1000) 154 | ); 155 | } catch (error) { 156 | if (!hasWarnedModifedTimePermission) { 157 | hasWarnedModifedTimePermission = true; 158 | logger.warn( 159 | `Can't set modified time to the file because ${error.message}` 160 | ); 161 | } 162 | } 163 | } 164 | } finally { 165 | await targetFs.close(targetFd); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/modules/config.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as fse from 'fs-extra'; 3 | import * as path from 'path'; 4 | import * as Joi from 'joi'; 5 | import { CONFIG_PATH } from '../constants'; 6 | import { reportError } from '../helper'; 7 | import { showTextDocument } from '../host'; 8 | 9 | const nullable = schema => schema.optional().allow(null); 10 | 11 | const configScheme = { 12 | name: Joi.string(), 13 | 14 | context: Joi.string(), 15 | protocol: Joi.any().valid('sftp', 'ftp', 'local'), 16 | 17 | host: Joi.string().required(), 18 | port: Joi.number().integer(), 19 | connectTimeout: Joi.number().integer(), 20 | username: Joi.string().required(), 21 | password: nullable(Joi.string()), 22 | 23 | agent: nullable(Joi.string()), 24 | privateKeyPath: nullable(Joi.string()), 25 | passphrase: nullable(Joi.string().allow(true)), 26 | interactiveAuth: Joi.boolean(), 27 | algorithms: Joi.any(), 28 | sshConfigPath: Joi.string(), 29 | sshCustomParams: Joi.string(), 30 | 31 | secure: Joi.any().valid(true, false, 'control', 'implicit'), 32 | secureOptions: nullable(Joi.object()), 33 | passive: Joi.boolean(), 34 | 35 | remotePath: Joi.string().required(), 36 | uploadOnSave: Joi.boolean(), 37 | downloadOnOpen: Joi.boolean().allow('confirm'), 38 | 39 | ignore: Joi.array() 40 | .min(0) 41 | .items(Joi.string()), 42 | ignoreFile: Joi.string(), 43 | watcher: { 44 | files: Joi.string().allow(false, null), 45 | autoUpload: Joi.boolean(), 46 | autoDelete: Joi.boolean(), 47 | }, 48 | concurrency: Joi.number().integer(), 49 | 50 | syncOption: { 51 | delete: Joi.boolean(), 52 | skipCreate: Joi.boolean(), 53 | ignoreExisting: Joi.boolean(), 54 | update: Joi.boolean(), 55 | }, 56 | remoteTimeOffsetInHours: Joi.number(), 57 | 58 | remoteExplorer: { 59 | filesExclude: Joi.array() 60 | .min(0) 61 | .items(Joi.string()), 62 | }, 63 | }; 64 | 65 | const defaultConfig = { 66 | // common 67 | // name: undefined, 68 | remotePath: './', 69 | uploadOnSave: false, 70 | downloadOnOpen: false, 71 | ignore: [], 72 | // ignoreFile: undefined, 73 | // watcher: { 74 | // files: false, 75 | // autoUpload: false, 76 | // autoDelete: false, 77 | // }, 78 | concurrency: 4, 79 | // limitOpenFilesOnRemote: false 80 | 81 | protocol: 'sftp', 82 | 83 | // server common 84 | // host, 85 | // port, 86 | // username, 87 | // password, 88 | connectTimeout: 10 * 1000, 89 | 90 | // sftp 91 | // agent, 92 | // privateKeyPath, 93 | // passphrase, 94 | interactiveAuth: false, 95 | // algorithms, 96 | 97 | // ftp 98 | secure: false, 99 | // secureOptions, 100 | // passive: false, 101 | remoteTimeOffsetInHours: 0, 102 | }; 103 | 104 | function mergedDefault(config) { 105 | return { 106 | ...defaultConfig, 107 | ...config, 108 | }; 109 | } 110 | 111 | function getConfigPath(basePath) { 112 | return path.join(basePath, CONFIG_PATH); 113 | } 114 | 115 | export function validateConfig(config) { 116 | const { error } = Joi.validate(config, configScheme, { 117 | allowUnknown: true, 118 | convert: false, 119 | language: { 120 | object: { 121 | child: '!!prop "{{!child}}" fails because {{reason}}', 122 | }, 123 | }, 124 | }); 125 | return error; 126 | } 127 | 128 | export function readConfigsFromFile(configPath): Promise { 129 | return fse.readJson(configPath).then(config => { 130 | const configs = Array.isArray(config) ? config : [config]; 131 | return configs.map(mergedDefault); 132 | }); 133 | } 134 | 135 | export function tryLoadConfigs(workspace): Promise { 136 | const configPath = getConfigPath(workspace); 137 | return fse.pathExists(configPath).then( 138 | exist => { 139 | if (exist) { 140 | return readConfigsFromFile(configPath); 141 | } 142 | return []; 143 | }, 144 | _ => [] 145 | ); 146 | } 147 | 148 | // export function getConfig(activityPath: string) { 149 | // const config = configTrie.findPrefix(normalizePath(activityPath)); 150 | // if (!config) { 151 | // throw new Error(`(${activityPath}) config file not found`); 152 | // } 153 | 154 | // return normalizeConfig(config); 155 | // } 156 | 157 | export function newConfig(basePath) { 158 | const configPath = getConfigPath(basePath); 159 | 160 | return fse 161 | .pathExists(configPath) 162 | .then(exist => { 163 | if (exist) { 164 | return showTextDocument(vscode.Uri.file(configPath)); 165 | } 166 | 167 | return fse 168 | .outputJson( 169 | configPath, 170 | { 171 | name: 'My Server', 172 | host: 'localhost', 173 | protocol: 'sftp', 174 | port: 22, 175 | username: 'username', 176 | remotePath: '/', 177 | uploadOnSave: true, 178 | }, 179 | { spaces: 4 } 180 | ) 181 | .then(() => showTextDocument(vscode.Uri.file(configPath))); 182 | }) 183 | .catch(reportError); 184 | } 185 | -------------------------------------------------------------------------------- /src/commands/shared.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { Uri, window } from 'vscode'; 3 | import { FileType } from '../core'; 4 | import { getAllFileService } from '../modules/serviceManager'; 5 | import { ExplorerItem } from '../modules/remoteExplorer'; 6 | import { getActiveTextEditor } from '../host'; 7 | import { listFiles, toLocalPath, simplifyPath } from '../helper'; 8 | 9 | function configIngoreFilterCreator(config) { 10 | if (!config || !config.ignore) { 11 | return; 12 | } 13 | 14 | return file => !config.ignore(file.fsPath); 15 | } 16 | 17 | function createFileSelector(filterCreator?) { 18 | return async (): Promise => { 19 | const remoteItems = getAllFileService().map((fileService, index) => { 20 | const config = fileService.getConfig(); 21 | return { 22 | name: config.name || config.remotePath, 23 | description: config.host, 24 | fsPath: config.remotePath, 25 | type: FileType.Directory, 26 | filter: filterCreator ? filterCreator(config) : undefined, 27 | getFs: () => fileService.getRemoteFileSystem(config), 28 | index, 29 | remoteBaseDir: config.remotePath, 30 | baseDir: fileService.baseDir, 31 | }; 32 | }); 33 | 34 | const selected = await listFiles(remoteItems); 35 | 36 | if (!selected) { 37 | return; 38 | } 39 | 40 | const rootItem = remoteItems[selected.index]; 41 | const localTarget = toLocalPath(selected.fsPath, rootItem.remoteBaseDir, rootItem.baseDir); 42 | 43 | return Uri.file(localTarget); 44 | }; 45 | } 46 | 47 | export function selectContext(): Promise { 48 | return new Promise((resolve, reject) => { 49 | const sercives = getAllFileService(); 50 | const projectsList = sercives 51 | .map(service => ({ 52 | value: service.baseDir, 53 | label: service.name || simplifyPath(service.baseDir), 54 | description: '', 55 | detail: service.baseDir, 56 | })) 57 | .sort((l, r) => l.label.localeCompare(r.label)); 58 | 59 | // if (projectsList.length === 1) { 60 | // return resolve(projectsList[0].value); 61 | // } 62 | 63 | window 64 | .showQuickPick(projectsList, { 65 | placeHolder: 'Select a folder...', 66 | }) 67 | .then(selection => { 68 | if (selection) { 69 | return resolve(Uri.file(selection.value)); 70 | } 71 | 72 | // cancel selection 73 | resolve(); 74 | }, reject); 75 | }); 76 | } 77 | 78 | export function applySelector(...selectors: ((...args: any[]) => T | Promise)[]) { 79 | return function combinedSelector(...args: any[]): T | Promise { 80 | let result; 81 | for (const selector of selectors) { 82 | result = selector.apply(this, args); 83 | if (result) { 84 | break; 85 | } 86 | } 87 | 88 | return result; 89 | }; 90 | } 91 | 92 | export function uriFromfspath(fileList: string[]): Uri[] | undefined { 93 | if (!Array.isArray(fileList) || typeof fileList[0] !== 'string') { 94 | return; 95 | } 96 | 97 | return fileList.map(file => Uri.file(file)); 98 | } 99 | 100 | export function getActiveDocumentUri() { 101 | const active = getActiveTextEditor(); 102 | if (!active || !active.document) { 103 | return; 104 | } 105 | 106 | return active.document.uri; 107 | } 108 | 109 | export function getActiveFolder() { 110 | const uri = getActiveDocumentUri(); 111 | if (!uri) { 112 | return; 113 | } 114 | 115 | return Uri.file(path.dirname(uri.fsPath)); 116 | } 117 | 118 | // selected file or activeTarget or configContext 119 | export function uriFromExplorerContextOrEditorContext(item, items): undefined | Uri | Uri[] { 120 | // from explorer or editor context 121 | if (item instanceof Uri) { 122 | if (Array.isArray(items) && items[0] instanceof Uri) { 123 | // multi-select in explorer 124 | return items; 125 | } else { 126 | return item; 127 | } 128 | } else if ((item as ExplorerItem).resource) { 129 | // from remote explorer 130 | return item.resource.uri; 131 | } 132 | 133 | return; 134 | } 135 | 136 | // selected folder or configContext 137 | export function selectFolderFallbackToConfigContext(item, items): Promise { 138 | // from explorer or editor context 139 | if (item) { 140 | if (item instanceof Uri) { 141 | if (Array.isArray(items) && items[0] instanceof Uri) { 142 | // multi-select in explorer 143 | return Promise.resolve(items); 144 | } else { 145 | return Promise.resolve(item); 146 | } 147 | } else if ((item as ExplorerItem).resource) { 148 | // from remote explorer 149 | return Promise.resolve(item.resource.uri); 150 | } 151 | } 152 | 153 | return selectContext(); 154 | } 155 | 156 | // selected file from all remote files 157 | export const selectFileFromAll = createFileSelector(); 158 | 159 | // selected file from remote files expect ignored 160 | export const selectFile = createFileSelector(configIngoreFilterCreator); 161 | -------------------------------------------------------------------------------- /src/core/scheduler.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line 2 | // modified from https://raw.githubusercontent.com/sindresorhus/p-queue/a202b25d3e2f8d0472f85d501f7f558a7fa89b56/index.js 3 | 4 | import { EventEmitter } from 'events'; 5 | 6 | // Port of lower_bound from http://en.cppreference.com/w/cpp/algorithm/lower_bound 7 | // Used to compute insertion index to keep queue sorted after insertion 8 | function lowerBound(array: T[], value: T, comp: (a: T, b: T) => number) { 9 | let first = 0; 10 | let count = array.length; 11 | 12 | while (count > 0) { 13 | // tslint:disable-next-line no-bitwise 14 | const step = (count / 2) | 0; 15 | let it = first + step; 16 | 17 | if (comp(array[it], value) <= 0) { 18 | first = ++it; 19 | count -= step + 1; 20 | } else { 21 | count = step; 22 | } 23 | } 24 | 25 | return first; 26 | } 27 | 28 | export interface Task { 29 | run(): unknown | Promise; 30 | } 31 | 32 | type taskFunc = Task['run']; 33 | 34 | interface Queue { 35 | enqueue(r: T): void; 36 | dequeue(): T; 37 | size: number; 38 | } 39 | 40 | class PriorityQueue implements Queue { 41 | constructor(private _queue: { priority: number; item: T }[] = []) {} 42 | 43 | enqueue(item: T, opts?) { 44 | opts = Object.assign( 45 | { 46 | priority: 0, 47 | }, 48 | opts 49 | ); 50 | 51 | const element = { priority: opts.priority, item }; 52 | if (this.size && this._queue[this.size - 1].priority >= opts.priority) { 53 | this._queue.push(element); 54 | return; 55 | } 56 | 57 | const index = lowerBound(this._queue, element, (a, b) => b.priority - a.priority); 58 | this._queue.splice(index, 0, element); 59 | } 60 | 61 | dequeue(): T { 62 | return this._queue.shift()!.item; 63 | } 64 | 65 | clear() { 66 | this._queue.length = 0; 67 | } 68 | 69 | get size(): number { 70 | return this._queue.length; 71 | } 72 | } 73 | 74 | const EVENT_TASK_START = 'task.start'; 75 | const EVENT_TASK_DONE = 'task.done'; 76 | const EVENT_IDLE = 'idle'; 77 | 78 | class Scheduler { 79 | private _queue: PriorityQueue = new PriorityQueue(); 80 | private _pendingCount: number = 0; 81 | private _eventEmitter: EventEmitter = new EventEmitter(); 82 | private _concurrency: number; 83 | private _isPaused: boolean; 84 | 85 | constructor(opts: { concurrency?: number; autoStart?: boolean } = {}) { 86 | opts = Object.assign( 87 | { 88 | concurrency: Infinity, 89 | autoStart: true, 90 | }, 91 | opts 92 | ); 93 | 94 | if (!(typeof opts.concurrency === 'number' && opts.concurrency >= 1)) { 95 | throw new TypeError( 96 | `Expected \`concurrency\` to be a number from 1 and up, got \`${ 97 | opts.concurrency 98 | }\` (${typeof opts.concurrency})` 99 | ); 100 | } 101 | 102 | this._concurrency = opts.concurrency; 103 | this._isPaused = opts.autoStart === false; 104 | } 105 | 106 | setConcurrency(concurrency: number) { 107 | this._concurrency = concurrency; 108 | } 109 | 110 | add(task: Task | taskFunc, opt?: { priority: number }) { 111 | if (typeof task === 'function') { 112 | task = { 113 | run: task, 114 | }; 115 | } 116 | 117 | if (!this._isPaused && this._pendingCount < this._concurrency) { 118 | this._runTask(task); 119 | } else { 120 | this._queue.enqueue(task, opt); 121 | } 122 | } 123 | 124 | addAll(tasks: (Task | taskFunc)[]) { 125 | tasks.forEach(t => this.add(t)); 126 | } 127 | 128 | start() { 129 | if (!this._isPaused) { 130 | return; 131 | } 132 | 133 | this._isPaused = false; 134 | while (this.size > 0 && this._pendingCount < this._concurrency) { 135 | this._runTask(this._queue.dequeue()); 136 | } 137 | } 138 | 139 | pause() { 140 | this._isPaused = true; 141 | } 142 | 143 | empty() { 144 | this._queue.clear(); 145 | } 146 | 147 | onTaskStart(listener: (task: Task) => void) { 148 | this._eventEmitter.on(EVENT_TASK_START, listener); 149 | } 150 | 151 | onTaskDone(listener: (err: Error | null, task: Task) => void) { 152 | this._eventEmitter.on(EVENT_TASK_DONE, listener); 153 | } 154 | 155 | onIdle(listener: () => void) { 156 | this._eventEmitter.on(EVENT_IDLE, listener); 157 | } 158 | 159 | get isRunning() { 160 | return !this._isPaused; 161 | } 162 | 163 | get size() { 164 | return this._queue.size; 165 | } 166 | 167 | get pendingCount() { 168 | return this._pendingCount; 169 | } 170 | 171 | private _next() { 172 | if (this.size > 0) { 173 | if (!this._isPaused) { 174 | this._runTask(this._queue.dequeue()); 175 | } 176 | } else if (this._pendingCount <= 0) { 177 | this._eventEmitter.emit(EVENT_IDLE); 178 | } 179 | } 180 | 181 | private async _runTask(task: Task) { 182 | this._pendingCount += 1; 183 | this._eventEmitter.emit(EVENT_TASK_START, task); 184 | 185 | let error = null; 186 | try { 187 | await task.run(); 188 | } catch (err) { 189 | error = err; 190 | } finally { 191 | this._pendingCount -= 1; 192 | this._eventEmitter.emit(EVENT_TASK_DONE, error, task); 193 | this._next(); 194 | } 195 | } 196 | } 197 | 198 | export default Scheduler; 199 | -------------------------------------------------------------------------------- /src/core/fs/localFileSystem.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as fse from 'fs-extra'; 3 | import FileSystem, { FileEntry, FileStats, FileOption } from './fileSystem'; 4 | 5 | export default class LocalFileSystem extends FileSystem { 6 | constructor(pathResolver: any) { 7 | super(pathResolver); 8 | } 9 | 10 | toFileStat(stat: fs.Stats): FileStats { 11 | return { 12 | type: FileSystem.getFileTypecharacter(stat), 13 | size: stat.size, 14 | mode: stat.mode & parseInt('777', 8), // tslint:disable-line:no-bitwise 15 | mtime: stat.mtime.getTime(), 16 | atime: stat.atime.getTime(), 17 | }; 18 | } 19 | 20 | lstat(path: string): Promise { 21 | return new Promise((resolve, reject) => { 22 | fs.lstat(path, (err, stat: fs.Stats) => { 23 | if (err) { 24 | reject(err); 25 | return; 26 | } 27 | 28 | resolve(this.toFileStat(stat)); 29 | }); 30 | }); 31 | } 32 | 33 | readFile(path, option?): Promise { 34 | return new Promise((resolve, reject) => { 35 | fs.readFile(path, option, (err, data) => { 36 | if (err) { 37 | return reject(err); 38 | } 39 | 40 | resolve(data); 41 | }); 42 | }); 43 | } 44 | 45 | open(path: string, flags: string, mode?: number): Promise { 46 | return fse.open(path, flags, mode); 47 | } 48 | 49 | close(fd: number): Promise { 50 | return fse.close(fd); 51 | } 52 | 53 | fstat(fd: number): Promise { 54 | return fse.fstat(fd).then(stat => this.toFileStat(stat)); 55 | } 56 | 57 | futimes(fd: number, atime: number, mtime: number): Promise { 58 | return fse.futimes(fd, atime, mtime); 59 | } 60 | 61 | get(path, option?): Promise { 62 | return new Promise((resolve, reject) => { 63 | try { 64 | const stream = fs.createReadStream(path, option); 65 | stream.once('error', reject); 66 | resolve(stream); 67 | } catch (err) { 68 | reject(err); 69 | } 70 | }); 71 | } 72 | 73 | put(input: fs.ReadStream, path, option?: FileOption): Promise { 74 | return new Promise((resolve, reject) => { 75 | if (option && option.fd && typeof option.fd !== 'number') { 76 | return reject(new Error('fd is not a number')); 77 | } 78 | 79 | const writer = fs.createWriteStream(path, option as any); 80 | writer.once('error', reject).once('finish', resolve); // transffered 81 | 82 | input.once('error', err => { 83 | reject(err); 84 | writer.end(); 85 | }); 86 | input.pipe(writer); 87 | }); 88 | } 89 | 90 | readlink(path: string): Promise { 91 | return new Promise((resolve, reject) => { 92 | fs.readlink(path, (err, linkString) => { 93 | if (err) { 94 | reject(err); 95 | return; 96 | } 97 | 98 | resolve(linkString); 99 | }); 100 | }); 101 | } 102 | 103 | symlink(targetPath: string, path: string): Promise { 104 | return new Promise((resolve, reject) => { 105 | fs.symlink(targetPath, path, null, err => { 106 | if (err) { 107 | reject(err); 108 | return; 109 | } 110 | resolve(); 111 | }); 112 | }); 113 | } 114 | 115 | mkdir(dir: string): Promise { 116 | return new Promise((resolve, reject) => { 117 | fs.mkdir(dir, err => { 118 | if (err) { 119 | reject(err); 120 | return; 121 | } 122 | resolve(); 123 | }); 124 | }); 125 | } 126 | 127 | ensureDir(dir: string): Promise { 128 | return fse.ensureDir(dir); 129 | } 130 | 131 | toFileEntry(fullPath: string, stat: FileStats): FileEntry { 132 | return { 133 | fspath: fullPath, 134 | name: this.pathResolver.basename(fullPath), 135 | ...stat, 136 | }; 137 | } 138 | 139 | list(dir: string): Promise { 140 | return new Promise((resolve, reject) => { 141 | fs.readdir(dir, (err, files) => { 142 | if (err) { 143 | reject(err); 144 | return; 145 | } 146 | 147 | const fileStatus = files.map(file => { 148 | const fspath = this.pathResolver.join(dir, file); 149 | return this.lstat(fspath).then(stat => 150 | this.toFileEntry(fspath, stat) 151 | ); 152 | }); 153 | 154 | resolve(Promise.all(fileStatus)); 155 | }); 156 | }); 157 | } 158 | 159 | unlink(path: string): Promise { 160 | return new Promise((resolve, reject) => { 161 | fs.unlink(path, err => { 162 | if (err) { 163 | reject(err); 164 | return; 165 | } 166 | 167 | resolve(); 168 | }); 169 | }); 170 | } 171 | 172 | rmdir(path: string, recursive: boolean): Promise { 173 | if (recursive) { 174 | return fse.remove(path); 175 | } 176 | 177 | return new Promise((resolve, reject) => { 178 | fs.rmdir(path, err => { 179 | if (err) { 180 | reject(err); 181 | return; 182 | } 183 | 184 | resolve(); 185 | }); 186 | }); 187 | } 188 | 189 | rename(srcPath: string, destPath: string): Promise { 190 | return fse.rename(srcPath, destPath); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/modules/serviceManager/index.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode'; 2 | import * as path from 'path'; 3 | import app from '../../app'; 4 | import logger from '../../logger'; 5 | import { simplifyPath, reportError } from '../../helper'; 6 | import { UResource, FileService, TransferTask } from '../../core'; 7 | import { validateConfig } from '../config'; 8 | import watcherService from '../fileWatcher'; 9 | import Trie from './trie'; 10 | 11 | const WIN_DRIVE_REGEX = /^([a-zA-Z]):/; 12 | const isWindows = process.platform === 'win32'; 13 | 14 | const serviceManager = new Trie( 15 | {}, 16 | { 17 | delimiter: path.sep, 18 | } 19 | ); 20 | 21 | function maskConfig(config) { 22 | const copy = {}; 23 | const privated = ['username', 'password', 'passphrase']; 24 | Object.keys(config).forEach(key => { 25 | const configValue = config[key]; 26 | // tslint:disable-next-line triple-equals 27 | if (privated.indexOf(key) !== -1 && configValue != undefined) { 28 | copy[key] = '******'; 29 | } else { 30 | copy[key] = configValue; 31 | } 32 | }); 33 | return copy; 34 | } 35 | 36 | function normalizePathForTrie(pathname) { 37 | if (isWindows) { 38 | const device = pathname.substr(0, 2); 39 | if (device.charAt(1) === ':') { 40 | // lowercase drive letter 41 | pathname = pathname[0].toLowerCase() + pathname.substr(1); 42 | } 43 | } 44 | 45 | return path.normalize(pathname); 46 | } 47 | 48 | export function getBasePath(context: string, workspace: string) { 49 | let dirpath; 50 | if (context) { 51 | if (path.isAbsolute(context)) { 52 | dirpath = context; 53 | if (isWindows) { 54 | const contextBeginWithDrive = context.match(WIN_DRIVE_REGEX); 55 | // if a windows user omit drive, we complete it with a drive letter same with the workspace one 56 | if (!contextBeginWithDrive) { 57 | const workspaceDrive = workspace.match(WIN_DRIVE_REGEX); 58 | if (workspaceDrive) { 59 | const drive = workspaceDrive[1]; 60 | dirpath = path.join(`${drive}:`, context); 61 | } 62 | } 63 | } 64 | } else { 65 | // Don't use path.resolve bacause it may change the root dir of workspace! 66 | // Example: On window path.resove('\\a\\b\\c') will result to ':\\a\\b\\c' 67 | // We know workspace must be a absolute path and context is a relative path to workspace, 68 | // so path.join will suit our requirements. 69 | dirpath = path.join(workspace, context); 70 | } 71 | } else { 72 | dirpath = workspace; 73 | } 74 | 75 | return normalizePathForTrie(dirpath); 76 | } 77 | 78 | export function createFileService(config: any, workspace: string) { 79 | if (config.defaultProfile) { 80 | app.state.profile = config.defaultProfile; 81 | } 82 | 83 | const normalizedBasePath = getBasePath(config.context, workspace); 84 | const service = new FileService(normalizedBasePath, workspace, config); 85 | 86 | logger.info(`config at ${normalizedBasePath}`, maskConfig(config)); 87 | 88 | serviceManager.add(normalizedBasePath, service); 89 | service.name = config.name; 90 | service.setConfigValidator(validateConfig); 91 | service.setWatcherService(watcherService); 92 | service.beforeTransfer(task => { 93 | const { localFsPath, transferType } = task; 94 | app.sftpBarItem.showMsg( 95 | `${transferType} ${path.basename(localFsPath)}`, 96 | simplifyPath(localFsPath) 97 | ); 98 | }); 99 | service.afterTransfer((error, task) => { 100 | const { localFsPath, transferType } = task; 101 | const filename = path.basename(localFsPath); 102 | const filepath = simplifyPath(localFsPath); 103 | if (task.isCancelled()) { 104 | logger.info(`cancel transfer ${localFsPath}`); 105 | app.sftpBarItem.showMsg(`cancelled ${filename}`, filepath, 2000 * 2); 106 | } else if (error) { 107 | // if ((error as any).reported !== true) { 108 | reportError(error, `when ${transferType} ${localFsPath}`); 109 | // } 110 | app.sftpBarItem.showMsg(`failed ${filename}`, filepath, 2000 * 2); 111 | } else { 112 | logger.info(`${transferType} ${localFsPath}`); 113 | app.sftpBarItem.showMsg(`done ${filename}`, filepath, 2000 * 2); 114 | } 115 | }); 116 | 117 | return service; 118 | } 119 | 120 | export function getFileService(uri: Uri): FileService { 121 | let fileService; 122 | if (UResource.isRemote(uri)) { 123 | const remoteRoot = app.remoteExplorer.findRoot(uri); 124 | if (remoteRoot) { 125 | fileService = remoteRoot.explorerContext.fileService; 126 | } 127 | } else { 128 | fileService = serviceManager.findPrefix(normalizePathForTrie(uri.fsPath)); 129 | } 130 | 131 | return fileService; 132 | } 133 | 134 | export function disposeFileService(fileService: FileService) { 135 | serviceManager.remove(fileService.baseDir); 136 | fileService.dispose(); 137 | } 138 | 139 | export function findAllFileService(predictor: (x: FileService) => boolean): FileService[] { 140 | if (serviceManager === undefined) { 141 | return []; 142 | } 143 | 144 | return getAllFileService().filter(predictor); 145 | } 146 | 147 | export function getAllFileService(): FileService[] { 148 | if (serviceManager === undefined) { 149 | return []; 150 | } 151 | 152 | return serviceManager.getAllValues(); 153 | } 154 | 155 | export function getRunningTransformTasks(): TransferTask[] { 156 | return getAllFileService().reduce((acc, fileService) => { 157 | return acc.concat(fileService.getPendingTransferTasks()); 158 | }, []); 159 | } 160 | -------------------------------------------------------------------------------- /src/modules/serviceManager/trie.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:max-classes-per-file ... */ 2 | const defaultOption = { 3 | delimiter: '/', 4 | }; 5 | 6 | interface ITrieNodeChildren { 7 | [key: string]: TrieNode; 8 | } 9 | 10 | class TrieNode { 11 | token: string; 12 | 13 | private value: T | null; 14 | private children: ITrieNodeChildren; 15 | private parent: TrieNode | null; 16 | 17 | constructor(token: string, value: T | null = null) { 18 | this.token = token; 19 | this.value = value; 20 | this.children = {}; 21 | this.parent = null; 22 | } 23 | 24 | // is store value 25 | isLoaded() { 26 | return this.value !== null; 27 | } 28 | 29 | setValue(value: T): TrieNode { 30 | this.value = value; 31 | return this; 32 | } 33 | 34 | getValue(): T | null { 35 | return this.value; 36 | } 37 | 38 | clearValue(): TrieNode { 39 | this.value = null; 40 | return this; 41 | } 42 | 43 | getParent(): TrieNode | null { 44 | return this.parent; 45 | } 46 | 47 | getChildren(): TrieNode[] { 48 | return Object.keys(this.children).map(key => this.children[key]); 49 | } 50 | 51 | addChild(token: string, childNode: TrieNode): TrieNode { 52 | this.children[token] = childNode; 53 | childNode.parent = this; 54 | return this; 55 | } 56 | 57 | getChild(token: string): TrieNode { 58 | return this.children[token]; 59 | } 60 | 61 | removeChild(node: TrieNode) { 62 | return delete this.children[node.token]; 63 | } 64 | 65 | getChildrenNum(): number { 66 | return Object.keys(this.children).length; 67 | } 68 | 69 | hasChild(token): boolean { 70 | return this.children[token] !== undefined ? true : false; 71 | } 72 | } 73 | 74 | export default class Trie { 75 | private option: any; 76 | private root: TrieNode; 77 | 78 | constructor(dict: any, option?: any) { 79 | this.option = Object.assign({}, defaultOption, option); 80 | this.root = new TrieNode('@root'); 81 | Object.keys(dict).forEach(key => this.add(key, dict[key])); 82 | } 83 | 84 | empty() { 85 | this.root = new TrieNode('@root'); 86 | } 87 | 88 | isEmpty() { 89 | return this.root.getChildrenNum() <= 0; 90 | } 91 | 92 | add(path: string | string[], value: T): void { 93 | const tokens = Array.isArray(path) ? path : this.splitPath(path); 94 | const bottomNode = tokens.reduce((parent, token) => { 95 | let node = parent.getChild(token); 96 | if (node === undefined) { 97 | node = new TrieNode(token); 98 | parent.addChild(token, node); 99 | } 100 | return node; 101 | }, this.root); 102 | bottomNode.setValue(value); 103 | } 104 | 105 | remove(path: string | string[]): boolean { 106 | const tokens = Array.isArray(path) ? path : this.splitPath(path); 107 | const node = this.findNode(this.root, tokens); 108 | if (!node) { 109 | return false; 110 | } 111 | 112 | if (node.getChildrenNum() > 0) { 113 | node.clearValue(); 114 | return true; 115 | } 116 | 117 | node.clearValue(); 118 | let current = node; 119 | let parent; 120 | // tslint:disable-next-line no-conditional-assignment 121 | while (!current.isLoaded() && current.getChildrenNum() <= 0 && (parent = current.getParent())) { 122 | parent.removeChild(current); 123 | current = parent; 124 | } 125 | return true; 126 | } 127 | 128 | findPrefix(path: string | string[]): T | null { 129 | const tokens = Array.isArray(path) ? path : this.splitPath(path); 130 | const node = this.findPrefixNode(this.root, tokens); 131 | if (node) { 132 | return node.getValue(); 133 | } 134 | 135 | return null; 136 | } 137 | 138 | clearPrefix(path: string | string[]) { 139 | const tokens = Array.isArray(path) ? path : this.splitPath(path); 140 | const node = this.findPrefixNode(this.root, tokens); 141 | if (node) { 142 | node.clearValue(); 143 | } 144 | } 145 | 146 | findPrefixNode(parent: TrieNode, tokens: string[]): TrieNode | null { 147 | let result = parent; 148 | 149 | const tokensQueue = tokens.slice().reverse(); 150 | 151 | let curentNode = this.root; 152 | do { 153 | curentNode = curentNode.getChild(tokensQueue.pop()!); 154 | if (curentNode === undefined) { 155 | break; 156 | } 157 | 158 | if (curentNode.isLoaded()) { 159 | result = curentNode; 160 | } 161 | } while (tokensQueue.length > 0); 162 | 163 | return result; 164 | } 165 | 166 | findNode(parent: TrieNode, tokens: string[]): TrieNode | null { 167 | const [top, ...rest] = tokens; 168 | if (top === undefined) { 169 | return parent; 170 | } 171 | 172 | const childNode = parent.getChild(top); 173 | if (childNode !== undefined) { 174 | return this.findNode(childNode, rest); 175 | } 176 | return null; 177 | } 178 | 179 | getAllValues(): T[] { 180 | const nodeQueue = [this.root]; 181 | const result: T[] = []; 182 | 183 | do { 184 | const curentNode = nodeQueue.shift()!; 185 | if (curentNode.isLoaded()) { 186 | result.push(curentNode.getValue()!); 187 | } 188 | 189 | const childrenNodes = curentNode.getChildren(); 190 | nodeQueue.push(...childrenNodes); 191 | } while (nodeQueue.length > 0); 192 | 193 | return result; 194 | } 195 | 196 | findValuesWithShortestBranch(): T[] { 197 | const nodeQueue = [this.root]; 198 | const result: T[] = []; 199 | 200 | do { 201 | const curentNode = nodeQueue.shift()!; 202 | if (curentNode.isLoaded()) { 203 | result.push(curentNode.getValue()!); 204 | } else { 205 | const childrenNodes = curentNode.getChildren(); 206 | nodeQueue.push(...childrenNodes); 207 | } 208 | } while (nodeQueue.length > 0); 209 | 210 | return result; 211 | } 212 | 213 | splitPath(path: string): string[] { 214 | let normalizePath = path; 215 | if (normalizePath[0] === this.option.delimiter) { 216 | normalizePath = normalizePath.substr(1); 217 | } 218 | if (normalizePath[normalizePath.length - 1] === this.option.delimiter) { 219 | normalizePath = normalizePath.substr(0, normalizePath.length - 1); 220 | } 221 | return normalizePath.split(this.option.delimiter); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /test/trie.spec.js: -------------------------------------------------------------------------------- 1 | const Trie = require('../src/modules/serviceManager/trie').default; 2 | 3 | describe('Trie Tests', () => { 4 | describe('find all values', () => { 5 | test('multiple branch', () => { 6 | const trie = new Trie({ 7 | 'a/b/c': 1, 8 | 'a/b/c/d': 2, 9 | 'a/b/c/e': 3, 10 | 'a/f': 4, 11 | 'a/g/c': 5, 12 | }); 13 | 14 | const result = trie.getAllValues(); 15 | const expected = [1, 2, 3, 4, 5]; 16 | expect(result).toEqual(expect.arrayContaining(expected)); 17 | expect(result.length).toEqual(expected.length); 18 | }); 19 | 20 | test('deep branch', () => { 21 | const trie = new Trie({ 22 | 'a/b/c': 1, 23 | 'a/b/c/d': 2, 24 | 'a/b/c/e/f/g': 3, 25 | 'a/f': 4, 26 | 'a/g/b': 5, 27 | 'a/h/b': 6, 28 | }); 29 | 30 | const result = trie.getAllValues(); 31 | const expected = [1, 2, 3, 4, 5, 6]; 32 | expect(result).toEqual(expect.arrayContaining(expected)); 33 | expect(result.length).toEqual(expected.length); 34 | }); 35 | 36 | test('multiple root branch', () => { 37 | const trie = new Trie({ 38 | 'a/b/c': 1, 39 | 'a/b/c/d': 2, 40 | 'a/f': 3, 41 | 'b/c/d': 4, 42 | 'b/c/e': 5, 43 | 'b/g': 6, 44 | }); 45 | 46 | const result = trie.getAllValues(); 47 | const expected = [1, 2, 3, 4, 5, 6]; 48 | expect(result).toEqual(expect.arrayContaining(expected)); 49 | expect(result.length).toEqual(expected.length); 50 | }); 51 | }); 52 | 53 | describe('find values with shortest branch', () => { 54 | test('multiple branch', () => { 55 | const trie = new Trie({ 56 | 'a/b/c': 1, 57 | 'a/b/c/d': 2, 58 | 'a/b/c/e': 3, 59 | 'a/f': 4, 60 | 'a/g/c': 5, 61 | }); 62 | 63 | const result = trie.findValuesWithShortestBranch(); 64 | const expected = [1, 4, 5]; 65 | expect(result).toEqual(expect.arrayContaining(expected)); 66 | expect(result.length).toEqual(expected.length); 67 | }); 68 | 69 | test('deep branch', () => { 70 | const trie = new Trie({ 71 | 'a/b/c': 1, 72 | 'a/b/c/d': 2, 73 | 'a/b/c/e/f/g': 3, 74 | 'a/f': 4, 75 | 'a/g/b': 5, 76 | 'a/h/b': 6, 77 | }); 78 | 79 | const result = trie.findValuesWithShortestBranch(); 80 | const expected = [1, 4, 5, 6]; 81 | expect(result).toEqual(expect.arrayContaining(expected)); 82 | expect(result.length).toEqual(expected.length); 83 | }); 84 | 85 | test('multiple root branch', () => { 86 | const trie = new Trie({ 87 | 'a/b/c': 1, 88 | 'a/b/c/d': 2, 89 | 'a/f': 3, 90 | 'b/c/d': 4, 91 | 'b/c/e': 5, 92 | 'b/g': 6, 93 | }); 94 | 95 | const result = trie.findValuesWithShortestBranch(); 96 | const expected = [1, 3, 4, 5, 6]; 97 | expect(result).toEqual(expect.arrayContaining(expected)); 98 | expect(result.length).toEqual(expected.length); 99 | }); 100 | }); 101 | 102 | describe('find value with shortest branch', () => { 103 | test('single branch', () => { 104 | const trie = new Trie({ 105 | 'a/b/c/d': 1, 106 | }); 107 | 108 | let result = trie.findPrefix('a/test.js'); 109 | expect(result).toEqual(null); 110 | 111 | result = trie.findPrefix('a/b/test.js'); 112 | expect(result).toEqual(null); 113 | 114 | result = trie.findPrefix('a/b/c/test.js'); 115 | expect(result).toEqual(null); 116 | 117 | result = trie.findPrefix('a/b/c/d/test.js'); 118 | expect(result).toEqual(1); 119 | 120 | result = trie.findPrefix('a/b/c/d/'); 121 | expect(result).toEqual(1); 122 | 123 | result = trie.findPrefix('a/b/c/d'); 124 | expect(result).toEqual(1); 125 | 126 | result = trie.findPrefix('a/b/c/d/e/test.js'); 127 | expect(result).toEqual(1); 128 | }); 129 | 130 | test('deep branch', () => { 131 | const trie = new Trie({ 132 | a: 1, 133 | 'a/b/c': 2, 134 | }); 135 | 136 | const result = trie.findPrefix('a/b/test.js'); 137 | const expected = 1; 138 | expect(result).toEqual(expected); 139 | }); 140 | 141 | test('multiple branch', () => { 142 | const trie = new Trie({ 143 | a: 1, 144 | b: 2, 145 | c: 3, 146 | }); 147 | 148 | let result = trie.findPrefix('a/test.js'); 149 | expect(result).toEqual(1); 150 | 151 | result = trie.findPrefix('b/test.js'); 152 | expect(result).toEqual(2); 153 | 154 | result = trie.findPrefix('c/test.js'); 155 | expect(result).toEqual(3); 156 | }); 157 | 158 | test('multiple deep branch', () => { 159 | const trie = new Trie({ 160 | a: 1, 161 | b: 2, 162 | c: 3, 163 | 'd/e': 4, 164 | 'd/f': 5, 165 | 'd/g': 6, 166 | 'h/i/l': 7, 167 | 'h/j/m': 8, 168 | 'h/k/n': 9, 169 | }); 170 | 171 | let result = trie.findPrefix('a/test.js'); 172 | expect(result).toEqual(1); 173 | 174 | result = trie.findPrefix('d/f/test.js'); 175 | expect(result).toEqual(5); 176 | 177 | result = trie.findPrefix('h/k/n/test.js'); 178 | expect(result).toEqual(9); 179 | }); 180 | }); 181 | 182 | describe('remove should work', () => { 183 | test('single branch', () => { 184 | const trie = new Trie({ 185 | 'a/b/c/d': 1, 186 | }); 187 | 188 | trie.remove('a/b/c/d'); 189 | 190 | expect(trie.root.getChildren().length).toEqual(0); 191 | }); 192 | 193 | test('multiple branch', () => { 194 | const trie = new Trie({ 195 | 'a/b/c/d': 1, 196 | 'a/b/c/e': 2, 197 | 'a/b/c/f': 3, 198 | }); 199 | 200 | trie.remove('a/b/c/d'); 201 | const node = trie.findNode(trie.root, trie.splitPath('a/b/c')); 202 | 203 | expect(node).toBeTruthy(); 204 | expect(node.getChildren().map(n => n.value).sort()).toEqual([2, 3]); 205 | }); 206 | 207 | test('nested branch', () => { 208 | const trie = new Trie({ 209 | 'a/b/c/d': 1, 210 | 'a/b': 2, 211 | }); 212 | 213 | trie.remove('a/b/c/d'); 214 | 215 | expect(trie.findNode(trie.root, trie.splitPath('a/b'))).toBeTruthy(); 216 | expect(trie.findNode(trie.root, trie.splitPath('a/b/c'))).toBeFalsy(); 217 | }); 218 | 219 | test('nested branch -- top', () => { 220 | const trie = new Trie({ 221 | 'a/b/c/d': 1, 222 | 'a/b': 2, 223 | }); 224 | 225 | trie.remove('a/b'); 226 | 227 | expect(trie.findPrefix('a/b/c/d')).toEqual(1); 228 | expect(trie.findPrefix('a/b')).toEqual(null); 229 | }); 230 | 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /src/fileHandlers/transfer/index.ts: -------------------------------------------------------------------------------- 1 | import { refreshRemoteExplorer } from '../shared'; 2 | import createFileHandler, { FileHandlerContext } from '../createFileHandler'; 3 | import { transfer, sync, TransferOption, SyncOption, TransferDirection } from './transfer'; 4 | 5 | function createTransferHandle(direction: TransferDirection) { 6 | return async function handle(this: FileHandlerContext, option) { 7 | const remoteFs = await this.fileService.getRemoteFileSystem(this.config); 8 | const localFs = this.fileService.getLocalFileSystem(); 9 | const { localFsPath, remoteFsPath } = this.target; 10 | const scheduler = this.fileService.createTransferScheduler(this.config.concurrency); 11 | let transferConfig; 12 | 13 | if (direction === TransferDirection.REMOTE_TO_LOCAL) { 14 | transferConfig = { 15 | srcFsPath: remoteFsPath, 16 | srcFs: remoteFs, 17 | targetFsPath: localFsPath, 18 | targetFs: localFs, 19 | transferOption: option, 20 | transferDirection: TransferDirection.REMOTE_TO_LOCAL, 21 | }; 22 | } else { 23 | transferConfig = { 24 | srcFsPath: localFsPath, 25 | srcFs: localFs, 26 | targetFsPath: remoteFsPath, 27 | targetFs: remoteFs, 28 | transferOption: option, 29 | transferDirection: TransferDirection.LOCAL_TO_REMOTE, 30 | }; 31 | } 32 | // todo: abort at here. we should stop collect task 33 | await transfer(transferConfig, t => scheduler.add(t)); 34 | await scheduler.run(); 35 | }; 36 | } 37 | 38 | const uploadHandle = createTransferHandle(TransferDirection.LOCAL_TO_REMOTE); 39 | const downloadHandle = createTransferHandle(TransferDirection.REMOTE_TO_LOCAL); 40 | 41 | export const sync2Remote = createFileHandler({ 42 | name: 'sync local ➞ remote', 43 | async handle(option) { 44 | const remoteFs = await this.fileService.getRemoteFileSystem(this.config); 45 | const localFs = this.fileService.getLocalFileSystem(); 46 | const { localFsPath, remoteFsPath } = this.target; 47 | const scheduler = this.fileService.createTransferScheduler(this.config.concurrency); 48 | await sync( 49 | { 50 | srcFsPath: localFsPath, 51 | srcFs: localFs, 52 | targetFsPath: remoteFsPath, 53 | targetFs: remoteFs, 54 | transferOption: option, 55 | transferDirection: TransferDirection.LOCAL_TO_REMOTE, 56 | }, 57 | t => scheduler.add(t) 58 | ); 59 | await scheduler.run(); 60 | }, 61 | transformOption() { 62 | const config = this.config; 63 | const syncOption = config.syncOption || {}; 64 | return { 65 | perserveTargetMode: config.protocol === 'sftp', 66 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 67 | ignore: config.ignore, 68 | delete: syncOption.delete, 69 | skipCreate: syncOption.skipCreate, 70 | ignoreExisting: syncOption.ignoreExisting, 71 | update: syncOption.update, 72 | }; 73 | }, 74 | afterHandle() { 75 | refreshRemoteExplorer(this.target, true); 76 | }, 77 | }); 78 | 79 | export const sync2Local = createFileHandler({ 80 | name: 'sync remote ➞ local', 81 | async handle(option) { 82 | const remoteFs = await this.fileService.getRemoteFileSystem(this.config); 83 | const localFs = this.fileService.getLocalFileSystem(); 84 | const { localFsPath, remoteFsPath } = this.target; 85 | const scheduler = this.fileService.createTransferScheduler(this.config.concurrency); 86 | await sync( 87 | { 88 | srcFsPath: remoteFsPath, 89 | srcFs: remoteFs, 90 | targetFsPath: localFsPath, 91 | targetFs: localFs, 92 | transferOption: option, 93 | transferDirection: TransferDirection.REMOTE_TO_LOCAL, 94 | }, 95 | t => scheduler.add(t) 96 | ); 97 | await scheduler.run(); 98 | }, 99 | transformOption() { 100 | const config = this.config; 101 | const syncOption = config.syncOption || {}; 102 | return { 103 | perserveTargetMode: false, 104 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 105 | ignore: config.ignore, 106 | delete: syncOption.delete, 107 | skipCreate: syncOption.skipCreate, 108 | ignoreExisting: syncOption.ignoreExisting, 109 | update: syncOption.update, 110 | }; 111 | }, 112 | }); 113 | 114 | export const upload = createFileHandler({ 115 | name: 'upload', 116 | handle: uploadHandle, 117 | transformOption() { 118 | const config = this.config; 119 | return { 120 | perserveTargetMode: config.protocol === 'sftp', 121 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 122 | ignore: config.ignore, 123 | }; 124 | }, 125 | afterHandle() { 126 | refreshRemoteExplorer(this.target, this.fileService); 127 | }, 128 | }); 129 | 130 | export const uploadFile = createFileHandler({ 131 | name: 'upload file', 132 | handle: uploadHandle, 133 | transformOption() { 134 | const config = this.config; 135 | return { 136 | perserveTargetMode: config.protocol === 'sftp', 137 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 138 | ignore: config.ignore, 139 | }; 140 | }, 141 | afterHandle() { 142 | refreshRemoteExplorer(this.target, false); 143 | }, 144 | }); 145 | 146 | export const uploadFolder = createFileHandler({ 147 | name: 'upload folder', 148 | handle: uploadHandle, 149 | transformOption() { 150 | const config = this.config; 151 | return { 152 | perserveTargetMode: config.protocol === 'sftp', 153 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 154 | ignore: config.ignore, 155 | }; 156 | }, 157 | afterHandle() { 158 | refreshRemoteExplorer(this.target, true); 159 | }, 160 | }); 161 | 162 | export const download = createFileHandler({ 163 | name: 'download', 164 | handle: downloadHandle, 165 | transformOption() { 166 | const config = this.config; 167 | return { 168 | perserveTargetMode: false, 169 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 170 | ignore: config.ignore, 171 | }; 172 | }, 173 | }); 174 | 175 | export const downloadFile = createFileHandler({ 176 | name: 'download file', 177 | handle: downloadHandle, 178 | transformOption() { 179 | const config = this.config; 180 | return { 181 | perserveTargetMode: false, 182 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 183 | ignore: config.ignore, 184 | }; 185 | }, 186 | }); 187 | 188 | export const downloadFolder = createFileHandler({ 189 | name: 'download folder', 190 | handle: downloadHandle, 191 | transformOption() { 192 | const config = this.config; 193 | return { 194 | perserveTargetMode: false, 195 | // remoteTimeOffsetInHours: config.remoteTimeOffsetInHours, 196 | ignore: config.ignore, 197 | }; 198 | }, 199 | }); 200 | -------------------------------------------------------------------------------- /src/modules/git/git.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { Uri, SourceControlInputBox, Event, CancellationToken } from 'vscode'; 7 | 8 | export interface Git { 9 | readonly path: string; 10 | } 11 | 12 | export interface InputBox { 13 | value: string; 14 | } 15 | 16 | export const enum RefType { 17 | Head, 18 | RemoteHead, 19 | Tag 20 | } 21 | 22 | export interface Ref { 23 | readonly type: RefType; 24 | readonly name?: string; 25 | readonly commit?: string; 26 | readonly remote?: string; 27 | } 28 | 29 | export interface UpstreamRef { 30 | readonly remote: string; 31 | readonly name: string; 32 | } 33 | 34 | export interface Branch extends Ref { 35 | readonly upstream?: UpstreamRef; 36 | readonly ahead?: number; 37 | readonly behind?: number; 38 | } 39 | 40 | export interface Commit { 41 | readonly hash: string; 42 | readonly message: string; 43 | readonly parents: string[]; 44 | } 45 | 46 | export interface Submodule { 47 | readonly name: string; 48 | readonly path: string; 49 | readonly url: string; 50 | } 51 | 52 | export interface Remote { 53 | readonly name: string; 54 | readonly fetchUrl?: string; 55 | readonly pushUrl?: string; 56 | readonly isReadOnly: boolean; 57 | } 58 | 59 | export const enum Status { 60 | INDEX_MODIFIED, 61 | INDEX_ADDED, 62 | INDEX_DELETED, 63 | INDEX_RENAMED, 64 | INDEX_COPIED, 65 | 66 | MODIFIED, 67 | DELETED, 68 | UNTRACKED, 69 | IGNORED, 70 | 71 | ADDED_BY_US, 72 | ADDED_BY_THEM, 73 | DELETED_BY_US, 74 | DELETED_BY_THEM, 75 | BOTH_ADDED, 76 | BOTH_DELETED, 77 | BOTH_MODIFIED 78 | } 79 | 80 | export interface Change { 81 | 82 | /** 83 | * Returns either `originalUri` or `renameUri`, depending 84 | * on whether this change is a rename change. When 85 | * in doubt always use `uri` over the other two alternatives. 86 | */ 87 | readonly uri: Uri; 88 | readonly originalUri: Uri; 89 | readonly renameUri: Uri | undefined; 90 | readonly status: Status; 91 | } 92 | 93 | export interface RepositoryState { 94 | readonly HEAD: Branch | undefined; 95 | readonly refs: Ref[]; 96 | readonly remotes: Remote[]; 97 | readonly submodules: Submodule[]; 98 | readonly rebaseCommit: Commit | undefined; 99 | 100 | readonly mergeChanges: Change[]; 101 | readonly indexChanges: Change[]; 102 | readonly workingTreeChanges: Change[]; 103 | 104 | readonly onDidChange: Event; 105 | } 106 | 107 | export interface RepositoryUIState { 108 | readonly selected: boolean; 109 | readonly onDidChange: Event; 110 | } 111 | 112 | export interface Repository { 113 | 114 | readonly rootUri: Uri; 115 | readonly inputBox: InputBox; 116 | readonly state: RepositoryState; 117 | readonly ui: RepositoryUIState; 118 | 119 | getConfigs(): Promise<{ key: string; value: string; }[]>; 120 | getConfig(key: string): Promise; 121 | setConfig(key: string, value: string): Promise; 122 | 123 | getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; 124 | detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; 125 | buffer(ref: string, path: string): Promise; 126 | show(ref: string, path: string): Promise; 127 | getCommit(ref: string): Promise; 128 | 129 | clean(paths: string[]): Promise; 130 | 131 | apply(patch: string, reverse?: boolean): Promise; 132 | diff(cached?: boolean): Promise; 133 | diffWithHEAD(path: string): Promise; 134 | diffWith(ref: string, path: string): Promise; 135 | diffIndexWithHEAD(path: string): Promise; 136 | diffIndexWith(ref: string, path: string): Promise; 137 | diffBlobs(object1: string, object2: string): Promise; 138 | diffBetween(ref1: string, ref2: string, path: string): Promise; 139 | 140 | hashObject(data: string): Promise; 141 | 142 | createBranch(name: string, checkout: boolean, ref?: string): Promise; 143 | deleteBranch(name: string, force?: boolean): Promise; 144 | getBranch(name: string): Promise; 145 | setBranchUpstream(name: string, upstream: string): Promise; 146 | 147 | getMergeBase(ref1: string, ref2: string): Promise; 148 | 149 | status(): Promise; 150 | checkout(treeish: string): Promise; 151 | 152 | addRemote(name: string, url: string): Promise; 153 | removeRemote(name: string): Promise; 154 | 155 | fetch(remote?: string, ref?: string): Promise; 156 | pull(): Promise; 157 | push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; 158 | } 159 | 160 | export interface API { 161 | readonly git: Git; 162 | readonly repositories: Repository[]; 163 | readonly onDidOpenRepository: Event; 164 | readonly onDidCloseRepository: Event; 165 | } 166 | 167 | export interface GitExtension { 168 | 169 | readonly enabled: boolean; 170 | readonly onDidChangeEnablement: Event; 171 | 172 | /** 173 | * Returns a specific API version. 174 | * 175 | * Throws error if git extension is disabled. You can listed to the 176 | * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event 177 | * to know when the extension becomes enabled/disabled. 178 | * 179 | * @param version Version number. 180 | * @returns API instance 181 | */ 182 | getAPI(version: 1): API; 183 | } 184 | 185 | export const enum GitErrorCodes { 186 | BadConfigFile = 'BadConfigFile', 187 | AuthenticationFailed = 'AuthenticationFailed', 188 | NoUserNameConfigured = 'NoUserNameConfigured', 189 | NoUserEmailConfigured = 'NoUserEmailConfigured', 190 | NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', 191 | NotAGitRepository = 'NotAGitRepository', 192 | NotAtRepositoryRoot = 'NotAtRepositoryRoot', 193 | Conflict = 'Conflict', 194 | StashConflict = 'StashConflict', 195 | UnmergedChanges = 'UnmergedChanges', 196 | PushRejected = 'PushRejected', 197 | RemoteConnectionError = 'RemoteConnectionError', 198 | DirtyWorkTree = 'DirtyWorkTree', 199 | CantOpenResource = 'CantOpenResource', 200 | GitNotFound = 'GitNotFound', 201 | CantCreatePipe = 'CantCreatePipe', 202 | CantAccessRemote = 'CantAccessRemote', 203 | RepositoryNotFound = 'RepositoryNotFound', 204 | RepositoryIsLocked = 'RepositoryIsLocked', 205 | BranchNotFullyMerged = 'BranchNotFullyMerged', 206 | NoRemoteReference = 'NoRemoteReference', 207 | InvalidBranchName = 'InvalidBranchName', 208 | BranchAlreadyExists = 'BranchAlreadyExists', 209 | NoLocalChanges = 'NoLocalChanges', 210 | NoStashFound = 'NoStashFound', 211 | LocalChangesOverwritten = 'LocalChangesOverwritten', 212 | NoUpstreamBranch = 'NoUpstreamBranch', 213 | IsInSubmodule = 'IsInSubmodule', 214 | WrongCase = 'WrongCase', 215 | CantLockRef = 'CantLockRef', 216 | CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', 217 | } 218 | -------------------------------------------------------------------------------- /test/config.spec.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const nullable = schema => schema.optional().allow(null); 4 | 5 | const configScheme = { 6 | context: Joi.string(), 7 | protocol: Joi.any().valid('sftp', 'ftp', 'test'), 8 | 9 | host: Joi.string().required(), 10 | port: Joi.number().integer(), 11 | username: Joi.string().required(), 12 | password: nullable(Joi.string()), 13 | 14 | agent: nullable(Joi.string()), 15 | privateKeyPath: nullable(Joi.string()), 16 | passphrase: nullable(Joi.string().allow(true)), 17 | interactiveAuth: Joi.boolean().optional(), 18 | 19 | secure: Joi.any().valid(true, false, 'control', 'implicit').optional(), 20 | secureOptions: nullable(Joi.object()), 21 | passive: Joi.boolean().optional(), 22 | 23 | remotePath: Joi.string().required(), 24 | uploadOnSave: Joi.boolean().optional(), 25 | syncMode: Joi.any().valid('update', 'full'), 26 | ignore: Joi.array() 27 | .min(0) 28 | .items(Joi.string()), 29 | watcher: { 30 | files: Joi.string() 31 | .allow(false, null) 32 | .optional(), 33 | autoUpload: Joi.boolean().optional(), 34 | autoDelete: Joi.boolean().optional(), 35 | }, 36 | }; 37 | 38 | describe("validation config", () => { 39 | test("default config", () => { 40 | const config = { 41 | host: 'host', 42 | port: 22, 43 | username: 'username', 44 | password: null, 45 | protocol: 'sftp', 46 | agent: null, 47 | privateKeyPath: null, 48 | passive: false, 49 | interactiveAuth: false, 50 | 51 | remotePath: '/', 52 | uploadOnSave: false, 53 | 54 | syncMode: 'update', 55 | 56 | watcher: { 57 | files: false, 58 | autoUpload: false, 59 | autoDelete: false, 60 | }, 61 | 62 | ignore: [ 63 | '**/.vscode', 64 | '**/.git', 65 | '**/.DS_Store', 66 | ], 67 | }; 68 | 69 | const result = Joi.validate(config, configScheme, { 70 | convert: false, 71 | }); 72 | expect(result.error).toBe(null); 73 | }); 74 | 75 | test("partial config", () => { 76 | const config = { 77 | host: 'host', 78 | port: 22, 79 | username: 'username', 80 | protocol: 'sftp', 81 | 82 | remotePath: '/', 83 | 84 | syncMode: 'update', 85 | 86 | watcher: {}, 87 | 88 | ignore: [ 89 | '**/.vscode', 90 | '**/.git', 91 | '**/.DS_Store', 92 | ], 93 | }; 94 | 95 | let result = Joi.validate(config, configScheme, { 96 | convert: false, 97 | }); 98 | expect(result.error).toBe(null); 99 | 100 | delete config.watcher; 101 | result = Joi.validate(config, configScheme, { 102 | convert: false, 103 | }); 104 | expect(result.error).toBe(null); 105 | }); 106 | 107 | describe("key validaiton", () => { 108 | test("protocol must be one of ['sftp', 'ftp']", () => { 109 | const config = { 110 | host: 'host', 111 | port: 22, 112 | username: 'username', 113 | protocol: 'unknown', 114 | passive: false, 115 | interactiveAuth: false, 116 | 117 | remotePath: '/', 118 | uploadOnSave: false, 119 | 120 | syncMode: 'update', 121 | 122 | watcher: { 123 | files: false, 124 | autoUpload: false, 125 | autoDelete: false, 126 | }, 127 | 128 | ignore: [ 129 | '**/.vscode', 130 | '**/.git', 131 | '**/.DS_Store', 132 | ], 133 | }; 134 | 135 | const result = Joi.validate(config, configScheme, { 136 | convert: false, 137 | }); 138 | expect(result.error).not.toBe(null); 139 | }); 140 | 141 | test("watcher files must be false or string", () => { 142 | const config = { 143 | host: 'host', 144 | port: 22, 145 | username: 'username', 146 | protocol: 'sftp', 147 | passive: false, 148 | interactiveAuth: false, 149 | 150 | remotePath: '/', 151 | uploadOnSave: false, 152 | 153 | syncMode: 'update', 154 | 155 | watcher: { 156 | files: false, 157 | autoUpload: false, 158 | autoDelete: false, 159 | }, 160 | 161 | ignore: [ 162 | '**/.vscode', 163 | '**/.git', 164 | '**/.DS_Store', 165 | ], 166 | }; 167 | 168 | let result = Joi.validate(config, configScheme, { 169 | convert: false, 170 | }); 171 | expect(result.error).toBe(null); 172 | 173 | config.watcher.files = '**/*.js'; 174 | result = Joi.validate(config, configScheme, { 175 | convert: false, 176 | }); 177 | expect(result.error).toBe(null); 178 | 179 | config.watcher.files = null; 180 | result = Joi.validate(config, configScheme, { 181 | convert: false, 182 | }); 183 | expect(result.error).toBe(null); 184 | 185 | config.watcher.files = true; 186 | result = Joi.validate(config, configScheme, { 187 | convert: false, 188 | }); 189 | expect(result.error).not.toBe(null); 190 | 191 | delete config.watcher; 192 | result = Joi.validate(config, configScheme, { 193 | convert: false, 194 | }); 195 | expect(result.error).toBe(null); 196 | }); 197 | 198 | test("ignore must be an array of string", () => { 199 | const config = { 200 | host: 'host', 201 | port: 22, 202 | username: 'username', 203 | protocol: 'sftp', 204 | passive: false, 205 | interactiveAuth: false, 206 | 207 | remotePath: '/', 208 | uploadOnSave: false, 209 | 210 | syncMode: 'update', 211 | 212 | watcher: { 213 | files: false, 214 | autoUpload: false, 215 | autoDelete: false, 216 | }, 217 | 218 | ignore: [ 219 | 1, 220 | '**/.git', 221 | '**/.DS_Store', 222 | ], 223 | }; 224 | 225 | let result = Joi.validate(config, configScheme, { 226 | convert: false, 227 | }); 228 | expect(result.error).not.toBe(null); 229 | 230 | config.ignore = []; 231 | result = Joi.validate(config, configScheme, { 232 | convert: false, 233 | }); 234 | expect(result.error).toBe(null); 235 | }); 236 | 237 | test("pass", () => { 238 | const config = { 239 | host: 'host', 240 | port: 22, 241 | username: 'username', 242 | protocol: 'sftp', 243 | passive: false, 244 | interactiveAuth: false, 245 | passphrase: 'true', 246 | 247 | remotePath: '/', 248 | uploadOnSave: false, 249 | 250 | syncMode: 'update', 251 | 252 | watcher: { 253 | files: false, 254 | autoUpload: false, 255 | autoDelete: false, 256 | }, 257 | 258 | ignore: [ 259 | '**/.git', 260 | '**/.DS_Store', 261 | ], 262 | }; 263 | 264 | let result = Joi.validate(config, configScheme, { 265 | convert: false, 266 | }); 267 | expect(result.error).toBe(null); 268 | 269 | config.passphrase = false; 270 | result = Joi.validate(config, configScheme, { 271 | convert: false, 272 | }); 273 | expect(result.error).not.toBe(null); 274 | }); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /test/core/scheduler.spec.js: -------------------------------------------------------------------------------- 1 | const Scheduler = require('../../src/core/scheduler').default; 2 | 3 | const randomInt = function(min, max) { 4 | if (max === undefined) { 5 | max = min; 6 | min = 0; 7 | } 8 | 9 | if (typeof min !== 'number' || typeof max !== 'number') { 10 | throw new TypeError('Expected all arguments to be numbers'); 11 | } 12 | 13 | return Math.floor(Math.random() * (max - min + 1) + min); 14 | }; 15 | const delay = millisecends => 16 | new Promise(resolve => { 17 | setTimeout(() => { 18 | resolve(); 19 | }, millisecends); 20 | }); 21 | const fixture = Symbol('fixture'); 22 | 23 | const wrapTask = fn => ({ 24 | run: fn, 25 | }); 26 | 27 | describe('scheduler', () => { 28 | test('.add()', () => { 29 | let result; 30 | const queue = new Scheduler(); 31 | queue.add({ 32 | run: () => { 33 | result = 1; 34 | }, 35 | }); 36 | queue.add(wrapTask(async () => fixture)); 37 | expect(queue.size).toEqual(0); 38 | expect(queue.pendingCount).toEqual(2); 39 | expect(result).toEqual(1); 40 | }); 41 | 42 | test('.add() - limited concurrency', () => { 43 | const queue = new Scheduler({ concurrency: 2 }); 44 | queue.add(async () => fixture); 45 | queue.add(wrapTask(async () => delay(100).then(() => fixture))); 46 | queue.add(wrapTask(async () => fixture)); 47 | expect(queue.size).toEqual(1); 48 | expect(queue.pendingCount).toEqual(2); 49 | }); 50 | 51 | test('.add() - concurrency: 1', done => { 52 | const input = [[10, 30], [20, 20], [30, 10]]; 53 | 54 | const startTime = new Date().getTime(); 55 | const queue = new Scheduler({ concurrency: 1 }); 56 | input.forEach(([val, ms]) => queue.add(wrapTask(() => delay(ms).then(() => val)))); 57 | queue.onIdle(() => { 58 | const time = new Date().getTime() - startTime; 59 | expect(50 <= time && time <= 100).toBeTruthy(); 60 | done(); 61 | }); 62 | }); 63 | 64 | test('.add() - concurrency: 5', done => { 65 | const concurrency = 5; 66 | const queue = new Scheduler({ concurrency }); 67 | let running = 0; 68 | 69 | new Array(50).fill(0).forEach(() => 70 | queue.add( 71 | wrapTask(async () => { 72 | running++; 73 | expect(running <= concurrency).toBeTruthy(); 74 | expect(queue.pendingCount <= concurrency).toBeTruthy(); 75 | await delay(randomInt(0, 30)); 76 | running--; 77 | }) 78 | ) 79 | ); 80 | 81 | queue.onIdle(done); 82 | }); 83 | 84 | test('.add() - priority', done => { 85 | const result = []; 86 | const queue = new Scheduler({ concurrency: 1 }); 87 | queue.add(wrapTask(async () => result.push(0)), { priority: 0 }); 88 | queue.add(wrapTask(async () => result.push(1)), { priority: 1 }); 89 | queue.add(wrapTask(async () => result.push(2)), { priority: 1 }); 90 | queue.add(wrapTask(async () => result.push(3)), { priority: 2 }); 91 | queue.onIdle(() => { 92 | expect(result).toEqual([0, 3, 1, 2]); 93 | done(); 94 | }); 95 | }); 96 | 97 | test('.addAll()', () => { 98 | const queue = new Scheduler(); 99 | const fn = async () => fixture; 100 | const fns = [fn, fn]; 101 | queue.addAll(fns.map(wrapTask)); 102 | expect(queue.size).toEqual(0); 103 | expect(queue.pendingCount).toEqual(2); 104 | }); 105 | 106 | test('onTaskDone', done => { 107 | const queue = new Scheduler({ concurrency: 1 }); 108 | const result = []; 109 | const tasks = [{ run: () => delay(10) }, { run: () => 'sync 1' }, { run: () => 'sync 2' }]; 110 | queue.addAll(tasks); 111 | queue.onTaskDone((err, t) => { 112 | expect(err).toBeNull(); 113 | 114 | result.push(t); 115 | if (queue.size === 0 && queue.pendingCount === 0) { 116 | expect(result).toEqual(tasks); 117 | done(); 118 | } 119 | }); 120 | }); 121 | 122 | test('onTaskDone(error)', done => { 123 | const queue = new Scheduler({ concurrency: 2 }); 124 | const task = { run: () => Promise.reject(new Error('error')) }; 125 | queue.add(task); 126 | queue.onTaskDone((err, t) => { 127 | expect(err).toBeDefined(); 128 | expect(err.message).toEqual('error'); 129 | expect(t).toBe(task); 130 | done(); 131 | }); 132 | }); 133 | 134 | test('onIdle', done => { 135 | const queue = new Scheduler({ concurrency: 1 }); 136 | const task = { run: () => Promise.reject(new Error('error')) }; 137 | const result = []; 138 | queue.add(wrapTask(() => delay(10).then(_ => result.push(1)))); 139 | queue.add(wrapTask(() => delay(20).then(_ => result.push(2)))); 140 | queue.add(wrapTask(() => delay(30).then(_ => result.push(3)))); 141 | queue.onIdle(() => { 142 | expect(result).toEqual([1, 2, 3]); 143 | done(); 144 | }); 145 | }); 146 | 147 | test('enforce number in options.concurrency', () => { 148 | expect(() => { 149 | new Scheduler({ concurrency: 0 }); 150 | }).toThrow(TypeError); 151 | expect(() => { 152 | new Scheduler({ concurrency: undefined }); 153 | }).toThrow(TypeError); 154 | expect(() => { 155 | new Scheduler({ concurrency: 1 }); 156 | }).not.toThrow(); 157 | expect(() => { 158 | new Scheduler({ concurrency: 10 }); 159 | }).not.toThrow(); 160 | expect(() => { 161 | new Scheduler({ concurrency: Infinity }); 162 | }).not.toThrow(); 163 | }); 164 | 165 | test('autoStart: false', () => { 166 | const queue = new Scheduler({ concurrency: 2, autoStart: false }); 167 | 168 | queue.add(wrapTask(() => delay(20000))); 169 | queue.add(wrapTask(() => delay(20000))); 170 | queue.add(wrapTask(() => delay(20000))); 171 | queue.add(wrapTask(() => delay(20000))); 172 | expect(queue.size).toEqual(4); 173 | expect(queue.pendingCount).toEqual(0); 174 | expect(queue.isRunning).toEqual(false); 175 | 176 | queue.start(); 177 | expect(queue.size).toEqual(2); 178 | expect(queue.pendingCount).toEqual(2); 179 | expect(queue.isRunning).toEqual(true); 180 | }); 181 | 182 | test('.pause()', () => { 183 | const queue = new Scheduler({ concurrency: 2 }); 184 | 185 | queue.pause(); 186 | queue.add(wrapTask(() => delay(20000))); 187 | queue.add(wrapTask(() => delay(20000))); 188 | queue.add(wrapTask(() => delay(20000))); 189 | queue.add(wrapTask(() => delay(20000))); 190 | queue.add(wrapTask(() => delay(20000))); 191 | expect(queue.size).toEqual(5); 192 | expect(queue.pendingCount).toEqual(0); 193 | expect(queue.isRunning).toEqual(false); 194 | 195 | queue.start(); 196 | expect(queue.size).toEqual(3); 197 | expect(queue.pendingCount).toEqual(2); 198 | expect(queue.isRunning).toEqual(true); 199 | 200 | queue.add(wrapTask(() => delay(20000))); 201 | queue.pause(); 202 | expect(queue.size).toEqual(4); 203 | expect(queue.pendingCount).toEqual(2); 204 | expect(queue.isRunning).toEqual(false); 205 | 206 | queue.start(); 207 | expect(queue.size).toEqual(4); 208 | expect(queue.pendingCount).toEqual(2); 209 | expect(queue.isRunning).toEqual(true); 210 | }); 211 | 212 | test('.add() sync/async mixed tasks', () => { 213 | const queue = new Scheduler({ concurrency: 1 }); 214 | queue.add(wrapTask(() => 'sync 1')); 215 | queue.add(wrapTask(() => delay(1000))); 216 | queue.add(wrapTask(() => 'sync 2')); 217 | queue.add(wrapTask(() => fixture)); 218 | expect(queue.size).toEqual(3); 219 | expect(queue.pendingCount).toEqual(1); 220 | }); 221 | 222 | test('.addAll() sync/async mixed tasks', () => { 223 | const queue = new Scheduler(); 224 | const fns = [() => 'sync 1', () => delay(2000), () => 'sync 2', async () => fixture]; 225 | queue.addAll(fns.map(wrapTask)); 226 | expect(queue.size).toEqual(0); 227 | expect(queue.pendingCount).toEqual(4); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /src/modules/remoteExplorer/treeDataProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { showTextDocument } from '../../host'; 3 | import { 4 | upath, 5 | UResource, 6 | Resource, 7 | FileService, 8 | FileType, 9 | FileEntry, 10 | Ignore, 11 | ServiceConfig, 12 | } from '../../core'; 13 | import { 14 | COMMAND_REMOTEEXPLORER_VIEW_CONTENT, 15 | COMMAND_REMOTEEXPLORER_EDITINLOCAL, 16 | } from '../../constants'; 17 | import { getAllFileService } from '../serviceManager'; 18 | import { getExtensionSetting } from '../ext'; 19 | 20 | type Id = number; 21 | 22 | const previewDocumentPathPrefix = '/~ '; 23 | 24 | const DEFAULT_FILES_EXCLUDE = ['.git', '.svn', '.hg', 'CVS', '.DS_Store']; 25 | /** 26 | * covert the url path for a customed docuemnt title 27 | * 28 | * There is no api to custom title. 29 | * So we change url path for custom title. 30 | * This is not break anything because we get fspth from uri.query.' 31 | */ 32 | function makePreivewUrl(uri: vscode.Uri) { 33 | // const query = querystring.parse(uri.query); 34 | // query.originPath = uri.path; 35 | // query.originQuery = uri.query; 36 | 37 | return uri.with({ 38 | path: previewDocumentPathPrefix + upath.basename(uri.path), 39 | // query: querystring.stringify(query), 40 | }); 41 | } 42 | 43 | interface ExplorerChild { 44 | resource: Resource; 45 | isDirectory: boolean; 46 | } 47 | 48 | export interface ExplorerRoot extends ExplorerChild { 49 | explorerContext: { 50 | fileService: FileService; 51 | config: ServiceConfig; 52 | id: Id; 53 | }; 54 | } 55 | 56 | export type ExplorerItem = ExplorerRoot | ExplorerChild; 57 | 58 | function dirFirstSort(fileA: ExplorerItem, fileB: ExplorerItem) { 59 | if (fileA.isDirectory === fileB.isDirectory) { 60 | return fileA.resource.fsPath.localeCompare(fileB.resource.fsPath); 61 | } 62 | 63 | return fileA.isDirectory ? -1 : 1; 64 | } 65 | 66 | export default class RemoteTreeData 67 | implements vscode.TreeDataProvider, vscode.TextDocumentContentProvider { 68 | private _roots: ExplorerRoot[] | null; 69 | private _rootsMap: Map | null; 70 | private _onDidChangeFolder: vscode.EventEmitter = new vscode.EventEmitter< 71 | ExplorerItem 72 | >(); 73 | private _onDidChangeFile: vscode.EventEmitter = new vscode.EventEmitter(); 74 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeFolder.event; 75 | readonly onDidChange: vscode.Event = this._onDidChangeFile.event; 76 | 77 | // FIXME: refresh can't work for user created ExplorerItem 78 | async refresh(item?: ExplorerItem): Promise { 79 | // refresh root 80 | if (!item) { 81 | // clear cache 82 | this._roots = null; 83 | this._rootsMap = null; 84 | 85 | this._onDidChangeFolder.fire(); 86 | return; 87 | } 88 | 89 | if (item.isDirectory) { 90 | this._onDidChangeFolder.fire(item); 91 | 92 | // refresh top level files as well 93 | const children = await this.getChildren(item); 94 | children 95 | .filter(i => !i.isDirectory) 96 | .forEach(i => this._onDidChangeFile.fire(makePreivewUrl(i.resource.uri))); 97 | } else { 98 | this._onDidChangeFile.fire(makePreivewUrl(item.resource.uri)); 99 | } 100 | } 101 | 102 | getTreeItem(item: ExplorerItem): vscode.TreeItem { 103 | const isRoot = (item as ExplorerRoot).explorerContext !== undefined; 104 | let customLabel; 105 | if (isRoot) { 106 | customLabel = (item as ExplorerRoot).explorerContext.fileService.name; 107 | } 108 | if (!customLabel) { 109 | customLabel = upath.basename(item.resource.fsPath); 110 | } 111 | return { 112 | label: customLabel, 113 | resourceUri: item.resource.uri, 114 | collapsibleState: item.isDirectory ? vscode.TreeItemCollapsibleState.Collapsed : undefined, 115 | contextValue: isRoot ? 'root' : item.isDirectory ? 'folder' : 'file', 116 | command: item.isDirectory 117 | ? undefined 118 | : { 119 | command: getExtensionSetting().downloadWhenOpenInRemoteExplorer 120 | ? COMMAND_REMOTEEXPLORER_EDITINLOCAL 121 | : COMMAND_REMOTEEXPLORER_VIEW_CONTENT, 122 | arguments: [item], 123 | title: 'View Remote Resource', 124 | }, 125 | }; 126 | } 127 | 128 | async getChildren(item?: ExplorerItem): Promise { 129 | if (!item) { 130 | return this._getRoots(); 131 | } 132 | 133 | const root = this.findRoot(item.resource.uri); 134 | if (!root) { 135 | throw new Error(`Can't find config for remote resource ${item.resource.uri}.`); 136 | } 137 | const config = root.explorerContext.config; 138 | const remotefs = await root.explorerContext.fileService.getRemoteFileSystem(config); 139 | const fileEntries = await remotefs.list(item.resource.fsPath); 140 | 141 | const filesExcludeList: string[] = 142 | config.remoteExplorer && config.remoteExplorer.filesExclude 143 | ? config.remoteExplorer.filesExclude.concat(DEFAULT_FILES_EXCLUDE) 144 | : DEFAULT_FILES_EXCLUDE; 145 | 146 | const ignore = new Ignore(filesExcludeList); 147 | function filterFile(file: FileEntry) { 148 | const relativePath = upath.relative(config.remotePath, file.fspath); 149 | return !ignore.ignores(relativePath); 150 | } 151 | 152 | return fileEntries 153 | .filter(filterFile) 154 | .map(file => { 155 | const isDirectory = file.type === FileType.Directory; 156 | 157 | return { 158 | resource: UResource.updateResource(item.resource, { 159 | remotePath: file.fspath, 160 | }), 161 | isDirectory, 162 | }; 163 | }) 164 | .sort(dirFirstSort); 165 | } 166 | 167 | getParent(item: ExplorerChild): ExplorerItem | null { 168 | const resourceUri = item.resource.uri; 169 | const root = this.findRoot(resourceUri); 170 | if (!root) { 171 | throw new Error(`Can't find config for remote resource ${resourceUri}.`); 172 | } 173 | 174 | if (item.resource.fsPath === root.resource.fsPath) { 175 | return null; 176 | } 177 | 178 | return { 179 | resource: UResource.updateResource(item.resource, { 180 | remotePath: upath.dirname(item.resource.fsPath), 181 | }), 182 | isDirectory: true, 183 | }; 184 | } 185 | 186 | findRoot(uri: vscode.Uri): ExplorerRoot | null | undefined { 187 | if (!this._rootsMap) { 188 | return null; 189 | } 190 | 191 | const rootId = UResource.makeResource(uri).remoteId; 192 | return this._rootsMap.get(rootId); 193 | } 194 | 195 | async provideTextDocumentContent( 196 | uri: vscode.Uri, 197 | token: vscode.CancellationToken 198 | ): Promise { 199 | const root = this.findRoot(uri); 200 | if (!root) { 201 | throw new Error(`Can't find remote for resource ${uri}.`); 202 | } 203 | 204 | const config = root.explorerContext.config; 205 | const remotefs = await root.explorerContext.fileService.getRemoteFileSystem(config); 206 | const buffer = await remotefs.readFile(UResource.makeResource(uri).fsPath); 207 | return buffer.toString(); 208 | } 209 | 210 | showItem(item: ExplorerItem): void { 211 | if (item.isDirectory) { 212 | return; 213 | } 214 | 215 | showTextDocument(makePreivewUrl(item.resource.uri)); 216 | } 217 | 218 | private _getRoots(): ExplorerRoot[] { 219 | if (this._roots) { 220 | return this._roots; 221 | } 222 | 223 | this._roots = []; 224 | this._rootsMap = new Map(); 225 | getAllFileService().forEach(fileService => { 226 | const config = fileService.getConfig(); 227 | const id = fileService.id; 228 | const item = { 229 | resource: UResource.makeResource({ 230 | remote: { 231 | host: config.host, 232 | port: config.port, 233 | }, 234 | fsPath: config.remotePath, 235 | remoteId: id, 236 | }), 237 | isDirectory: true, 238 | explorerContext: { 239 | fileService, 240 | config, 241 | id, 242 | }, 243 | }; 244 | this._roots!.push(item); 245 | this._rootsMap!.set(id, item); 246 | }); 247 | return this._roots; 248 | } 249 | } 250 | --------------------------------------------------------------------------------