├── .gitattributes ├── .github └── workflows │ ├── build-extension.yml │ └── publish-extension.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-version.cjs ├── releases │ └── yarn-4.6.0.cjs └── sdks │ ├── integrations.yml │ ├── prettier │ ├── bin-prettier.js │ ├── index.js │ └── package.json │ └── typescript │ ├── bin │ ├── tsc │ └── tsserver │ ├── lib │ ├── tsc.js │ ├── tsserver.js │ ├── tsserverlibrary.js │ └── typescript.js │ └── package.json ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── common ├── .gitignore ├── package.json ├── src │ ├── fileSystemConfig.ts │ ├── ssh2.ts │ └── webviewMessages.ts └── tsconfig.json ├── enhance-changelog.js ├── map-error.js ├── media ├── add-workspace-folder.png ├── config-editor.png ├── hop-config.png ├── paypal.png ├── prompt-username.png ├── proxy-settings.png ├── sftp-config.png ├── shell-tasks.png ├── terminals.png └── workspace-folder.png ├── package.json ├── resources ├── Icons.psd ├── Logo.png ├── config │ ├── Active.png │ ├── Connecting.png │ ├── Deleted.png │ ├── Error.png │ └── Idle.png └── icon.svg ├── src ├── config.ts ├── connect.ts ├── connection.ts ├── extension.ts ├── fileSystemRouter.ts ├── flags.ts ├── logging.ts ├── manager.ts ├── proxy.ts ├── pseudoTerminal.ts ├── putty.ts ├── shellConfig.ts ├── sshFileSystem.ts ├── treeViewManager.ts ├── ui-utils.ts ├── utils.ts └── webview.ts ├── tsconfig.json ├── tslint.json ├── webpack.config.js ├── webpack.plugin.js ├── webview ├── .gitignore ├── package.json ├── public │ └── index.html ├── src │ ├── ConfigEditor │ │ ├── configGroupField.tsx │ │ ├── fields.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── proxyFields.tsx │ ├── ConfigList │ │ ├── index.css │ │ └── index.tsx │ ├── ConfigLocator.tsx │ ├── FieldTypes │ │ ├── base.tsx │ │ ├── checkbox.tsx │ │ ├── config.tsx │ │ ├── dropdown.tsx │ │ ├── dropdownwithinput.tsx │ │ ├── group.tsx │ │ ├── index.css │ │ ├── list.tsx │ │ ├── number.tsx │ │ ├── path.tsx │ │ ├── string.tsx │ │ └── umask.tsx │ ├── NewConfig.tsx │ ├── Startscreen.tsx │ ├── data │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── reducers.ts │ │ └── state.ts │ ├── defaultStyles.css │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── redux.ts │ ├── router.tsx │ ├── tests │ │ └── redux.tsx │ ├── view │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── reducers.ts │ │ └── state.ts │ └── vscode.ts ├── tsconfig.json ├── tslint.json └── webpack.config.js └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary 3 | -------------------------------------------------------------------------------- /.github/workflows/build-extension.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Build extension 3 | 4 | on: 5 | push: 6 | tags: ['**'] 7 | branches: 8 | - '*' 9 | - 'issue/**' 10 | - 'feature/**' 11 | - 'release/**' 12 | pull_request: 13 | types: [opened, synchronize] 14 | branches: 15 | - '*' 16 | - 'issue/**' 17 | - 'feature/**' 18 | - 'release/**' 19 | workflow_dispatch: 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | name: Build and package 25 | timeout-minutes: 10 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Event Utilities 29 | uses: SchoofsKelvin/event-utilities@v1.1.0 30 | id: utils 31 | with: 32 | artifact_prefix: "vscode-sshfs" 33 | artifact_extension: "vsix" 34 | - name: Use Node.js 20 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 20 38 | cache: yarn 39 | cache-dependency-path: yarn.lock 40 | - name: Install dependencies 41 | run: yarn --immutable 42 | - name: Build extension 43 | run: yarn vsce package -o ${{ steps.utils.outputs.artifact_name }} --yarn --no-dependencies 44 | - name: Upload a Build Artifact 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: ${{ steps.utils.outputs.artifact_name }} 48 | path: ${{ steps.utils.outputs.artifact_name }} 49 | if-no-files-found: error 50 | - name: Create release with artifact 51 | if: ${{ success() && steps.utils.outputs.tag_version }} 52 | uses: softprops/action-gh-release@v2 53 | with: 54 | name: Release ${{ steps.utils.outputs.tag_version }} 55 | draft: true 56 | files: ${{ steps.utils.outputs.artifact_name }} 57 | -------------------------------------------------------------------------------- /.github/workflows/publish-extension.yml: -------------------------------------------------------------------------------- 1 | name: Publish extension 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | openvsx: 9 | name: "Open VSX Registry" 10 | if: endsWith(github.event.release.assets[0].name, '.vsix') 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Download release artifact 14 | run: "curl -L -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H 'Accept: application/octet-stream' ${{ github.event.release.assets[0].url }} --output extension.vsix" 15 | - name: Validate extension file 16 | run: unzip -f extension.vsix extension/package.json 17 | - name: Publish to Open VSX Registry 18 | uses: HaaLeo/publish-vscode-extension@v1 19 | with: 20 | pat: ${{ secrets.OPEN_VSX_TOKEN }} 21 | extensionFile: extension.vsix 22 | vs: 23 | name: "Visual Studio Marketplace" 24 | if: endsWith(github.event.release.assets[0].name, '.vsix') 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Download release artifact 28 | run: "curl -L -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H 'Accept: application/octet-stream' ${{ github.event.release.assets[0].url }} --output extension.vsix" 29 | - name: Validate extension file 30 | run: unzip -f extension.vsix extension/package.json 31 | - name: Publish to Visual Studio Marketplace 32 | uses: HaaLeo/publish-vscode-extension@v1 33 | with: 34 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 35 | registryUrl: https://marketplace.visualstudio.com 36 | extensionFile: extension.vsix 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Build output 3 | dist 4 | util 5 | 6 | # Build artifacts 7 | *.vsix 8 | stats.json 9 | 10 | # Yarn 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/releases 14 | !.yarn/plugins 15 | !.yarn/sdks 16 | !.yarn/versions 17 | !.yarn/yarn.lock 18 | .pnp.* 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "amodio.tsl-problem-matcher", 6 | "arcanis.vscode-zipfs", 7 | "esbenp.prettier-vscode" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.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": [ 11 | "--extensionDevelopmentPath=${workspaceRoot}" 12 | ], 13 | "sourceMaps": true, 14 | "outFiles": [ 15 | "${workspaceRoot}/dist/**/*.js", 16 | "${workspaceRoot}/common/out/**/*.js" 17 | ], 18 | "env": { 19 | "VSCODE_SSHFS_DEBUG": "TRUE" 20 | }, 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 3 | "files.exclude": { 4 | "*.vsix": true, 5 | "**/*.lock": true, 6 | "**/node_modules/": true, 7 | "dist/": true, 8 | "util/": true, 9 | "common/out": true, 10 | "webview/build/": true, 11 | ".yarn/": true, 12 | ".yarnrc.yml": true, 13 | ".pnp.*": true, 14 | "LICENSE.txt": true, 15 | "**/.*ignore": true, 16 | "**/.gitattributes": true, 17 | "**/.eslintcache": true, 18 | "**/webpack.config.js": true, 19 | "webpack.plugin.js": true, 20 | "**/tslint.json": true, 21 | "**/tsconfig.json": true 22 | }, 23 | "search.exclude": { 24 | "**/.yarn": true, 25 | "**/.pnp.*": true 26 | }, 27 | "typescript.enablePromptUseWorkspaceTsdk": true, 28 | "typescript.preferences.quoteStyle": "single", 29 | "prettier.prettierPath": ".yarn/sdks/prettier/index.js", 30 | "markdownlint.config": { 31 | "no-duplicate-header": { 32 | "allow_different_nesting": true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Extension - Watch all", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "dependsOrder": "sequence", 13 | "dependsOn": [ 14 | "Extension Common - Watch", 15 | "Extension - Watch non-Common" 16 | ], 17 | "problemMatcher": [], 18 | "isBackground": true, 19 | "runOptions": { 20 | "runOn": "folderOpen" 21 | } 22 | }, 23 | { 24 | "label": "Extension - Watch non-Common", 25 | "group": "build", 26 | "dependsOrder": "parallel", 27 | "dependsOn": [ 28 | "Extension - Watch", 29 | "Extension WebView - Watch" 30 | ], 31 | "problemMatcher": [], 32 | "isBackground": true 33 | }, 34 | { 35 | "type": "shell", 36 | "label": "Extension Common - Watch", 37 | "command": "yarn watch", 38 | "options": { 39 | "cwd": "./common" 40 | }, 41 | "group": "build", 42 | "problemMatcher": { 43 | "base": "$tsc-watch", 44 | "source": "tsc-watch", 45 | "owner": "tsc-watch", 46 | "applyTo": "allDocuments" 47 | }, 48 | "isBackground": true 49 | }, 50 | { 51 | "type": "npm", 52 | "label": "Extension - Watch", 53 | "script": "watch", 54 | "group": "build", 55 | "problemMatcher": { 56 | "base": "$ts-webpack-watch", 57 | "source": "webpack-ts-loader", 58 | "owner": "webpack-ts-loader", 59 | "applyTo": "allDocuments" 60 | }, 61 | "isBackground": true 62 | }, 63 | { 64 | "type": "shell", 65 | "label": "Extension WebView - Watch", 66 | "command": "yarn start", 67 | "options": { 68 | "cwd": "./webview" 69 | }, 70 | "group": "build", 71 | "problemMatcher": { 72 | "base": "$ts-webpack-watch", 73 | "source": "webpack-ts-loader", 74 | "owner": "webpack-ts-loader", 75 | "applyTo": "allDocuments" 76 | }, 77 | "isBackground": true 78 | } 79 | ] 80 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | 2 | ** 3 | 4 | !package.json 5 | !README.md 6 | !LICENSE.txt 7 | !dist/*.js 8 | !util 9 | !resources/config 10 | !resources/Logo.png 11 | !resources/icon.svg 12 | !media 13 | !webview/build/index.html 14 | !webview/build/static/css/*.css 15 | !webview/build/static/js/*.js 16 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/bin-prettier.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require prettier/bin-prettier.js 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real prettier/bin-prettier.js your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`prettier/bin-prettier.js`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require prettier 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real prettier your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`prettier`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "2.8.7-sdk", 4 | "main": "./index.js", 5 | "type": "commonjs", 6 | "bin": "./bin-prettier.js" 7 | } 8 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/bin/tsc 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript/bin/tsc your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsc`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/bin/tsserver 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript/bin/tsserver your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsserver`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/lib/tsc.js 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript/lib/tsc.js your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript/lib/tsc.js`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/lib/tsserver.js 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | const moduleWrapper = exports => { 32 | return wrapWithUserWrapper(moduleWrapperFn(exports)); 33 | }; 34 | 35 | const moduleWrapperFn = tsserver => { 36 | if (!process.versions.pnp) { 37 | return tsserver; 38 | } 39 | 40 | const {isAbsolute} = require(`path`); 41 | const pnpApi = require(`pnpapi`); 42 | 43 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 44 | const isPortal = str => str.startsWith("portal:/"); 45 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 46 | 47 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 48 | return `${locator.name}@${locator.reference}`; 49 | })); 50 | 51 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 52 | // doesn't understand. This layer makes sure to remove the protocol 53 | // before forwarding it to TS, and to add it back on all returned paths. 54 | 55 | function toEditorPath(str) { 56 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 57 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 58 | // We also take the opportunity to turn virtual paths into physical ones; 59 | // this makes it much easier to work with workspaces that list peer 60 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 61 | // file instances instead of the real ones. 62 | // 63 | // We only do this to modules owned by the the dependency tree roots. 64 | // This avoids breaking the resolution when jumping inside a vendor 65 | // with peer dep (otherwise jumping into react-dom would show resolution 66 | // errors on react). 67 | // 68 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 69 | if (resolved) { 70 | const locator = pnpApi.findPackageLocator(resolved); 71 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 72 | str = resolved; 73 | } 74 | } 75 | 76 | str = normalize(str); 77 | 78 | if (str.match(/\.zip\//)) { 79 | switch (hostInfo) { 80 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 81 | // VSCode only adds it automatically for supported schemes, 82 | // so we have to do it manually for the `zip` scheme. 83 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 84 | // 85 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 86 | // 87 | // 2021-10-08: VSCode changed the format in 1.61. 88 | // Before | ^zip:/c:/foo/bar.zip/package.json 89 | // After | ^/zip//c:/foo/bar.zip/package.json 90 | // 91 | // 2022-04-06: VSCode changed the format in 1.66. 92 | // Before | ^/zip//c:/foo/bar.zip/package.json 93 | // After | ^/zip/c:/foo/bar.zip/package.json 94 | // 95 | // 2022-05-06: VSCode changed the format in 1.68 96 | // Before | ^/zip/c:/foo/bar.zip/package.json 97 | // After | ^/zip//c:/foo/bar.zip/package.json 98 | // 99 | case `vscode <1.61`: { 100 | str = `^zip:${str}`; 101 | } break; 102 | 103 | case `vscode <1.66`: { 104 | str = `^/zip/${str}`; 105 | } break; 106 | 107 | case `vscode <1.68`: { 108 | str = `^/zip${str}`; 109 | } break; 110 | 111 | case `vscode`: { 112 | str = `^/zip/${str}`; 113 | } break; 114 | 115 | // To make "go to definition" work, 116 | // We have to resolve the actual file system path from virtual path 117 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 118 | case `coc-nvim`: { 119 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 120 | str = resolve(`zipfile:${str}`); 121 | } break; 122 | 123 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 124 | // We have to resolve the actual file system path from virtual path, 125 | // everything else is up to neovim 126 | case `neovim`: { 127 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 128 | str = `zipfile://${str}`; 129 | } break; 130 | 131 | default: { 132 | str = `zip:${str}`; 133 | } break; 134 | } 135 | } else { 136 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 137 | } 138 | } 139 | 140 | return str; 141 | } 142 | 143 | function fromEditorPath(str) { 144 | switch (hostInfo) { 145 | case `coc-nvim`: { 146 | str = str.replace(/\.zip::/, `.zip/`); 147 | // The path for coc-nvim is in format of //zipfile://.yarn/... 148 | // So in order to convert it back, we use .* to match all the thing 149 | // before `zipfile:` 150 | return process.platform === `win32` 151 | ? str.replace(/^.*zipfile:\//, ``) 152 | : str.replace(/^.*zipfile:/, ``); 153 | } break; 154 | 155 | case `neovim`: { 156 | str = str.replace(/\.zip::/, `.zip/`); 157 | // The path for neovim is in format of zipfile:////.yarn/... 158 | return str.replace(/^zipfile:\/\//, ``); 159 | } break; 160 | 161 | case `vscode`: 162 | default: { 163 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 164 | } break; 165 | } 166 | } 167 | 168 | // Force enable 'allowLocalPluginLoads' 169 | // TypeScript tries to resolve plugins using a path relative to itself 170 | // which doesn't work when using the global cache 171 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 172 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 173 | // TypeScript already does local loads and if this code is running the user trusts the workspace 174 | // https://github.com/microsoft/vscode/issues/45856 175 | const ConfiguredProject = tsserver.server.ConfiguredProject; 176 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 177 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 178 | this.projectService.allowLocalPluginLoads = true; 179 | return originalEnablePluginsWithOptions.apply(this, arguments); 180 | }; 181 | 182 | // And here is the point where we hijack the VSCode <-> TS communications 183 | // by adding ourselves in the middle. We locate everything that looks 184 | // like an absolute path of ours and normalize it. 185 | 186 | const Session = tsserver.server.Session; 187 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 188 | let hostInfo = `unknown`; 189 | 190 | Object.assign(Session.prototype, { 191 | onMessage(/** @type {string | object} */ message) { 192 | const isStringMessage = typeof message === 'string'; 193 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 194 | 195 | if ( 196 | parsedMessage != null && 197 | typeof parsedMessage === `object` && 198 | parsedMessage.arguments && 199 | typeof parsedMessage.arguments.hostInfo === `string` 200 | ) { 201 | hostInfo = parsedMessage.arguments.hostInfo; 202 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 203 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 204 | // The RegExp from https://semver.org/ but without the caret at the start 205 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 206 | ) ?? []).map(Number) 207 | 208 | if (major === 1) { 209 | if (minor < 61) { 210 | hostInfo += ` <1.61`; 211 | } else if (minor < 66) { 212 | hostInfo += ` <1.66`; 213 | } else if (minor < 68) { 214 | hostInfo += ` <1.68`; 215 | } 216 | } 217 | } 218 | } 219 | 220 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 221 | return typeof value === 'string' ? fromEditorPath(value) : value; 222 | }); 223 | 224 | return originalOnMessage.call( 225 | this, 226 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 227 | ); 228 | }, 229 | 230 | send(/** @type {any} */ msg) { 231 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 232 | return typeof value === `string` ? toEditorPath(value) : value; 233 | }))); 234 | } 235 | }); 236 | 237 | return tsserver; 238 | }; 239 | 240 | const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10)); 241 | // In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well. 242 | // Ref https://github.com/microsoft/TypeScript/pull/55326 243 | if (major > 5 || (major === 5 && minor >= 5)) { 244 | moduleWrapper(absRequire(`typescript`)); 245 | } 246 | 247 | // Defer to the real typescript/lib/tsserver.js your application uses 248 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 249 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | const moduleWrapper = exports => { 32 | return wrapWithUserWrapper(moduleWrapperFn(exports)); 33 | }; 34 | 35 | const moduleWrapperFn = tsserver => { 36 | if (!process.versions.pnp) { 37 | return tsserver; 38 | } 39 | 40 | const {isAbsolute} = require(`path`); 41 | const pnpApi = require(`pnpapi`); 42 | 43 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 44 | const isPortal = str => str.startsWith("portal:/"); 45 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 46 | 47 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 48 | return `${locator.name}@${locator.reference}`; 49 | })); 50 | 51 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 52 | // doesn't understand. This layer makes sure to remove the protocol 53 | // before forwarding it to TS, and to add it back on all returned paths. 54 | 55 | function toEditorPath(str) { 56 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 57 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 58 | // We also take the opportunity to turn virtual paths into physical ones; 59 | // this makes it much easier to work with workspaces that list peer 60 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 61 | // file instances instead of the real ones. 62 | // 63 | // We only do this to modules owned by the the dependency tree roots. 64 | // This avoids breaking the resolution when jumping inside a vendor 65 | // with peer dep (otherwise jumping into react-dom would show resolution 66 | // errors on react). 67 | // 68 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 69 | if (resolved) { 70 | const locator = pnpApi.findPackageLocator(resolved); 71 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 72 | str = resolved; 73 | } 74 | } 75 | 76 | str = normalize(str); 77 | 78 | if (str.match(/\.zip\//)) { 79 | switch (hostInfo) { 80 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 81 | // VSCode only adds it automatically for supported schemes, 82 | // so we have to do it manually for the `zip` scheme. 83 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 84 | // 85 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 86 | // 87 | // 2021-10-08: VSCode changed the format in 1.61. 88 | // Before | ^zip:/c:/foo/bar.zip/package.json 89 | // After | ^/zip//c:/foo/bar.zip/package.json 90 | // 91 | // 2022-04-06: VSCode changed the format in 1.66. 92 | // Before | ^/zip//c:/foo/bar.zip/package.json 93 | // After | ^/zip/c:/foo/bar.zip/package.json 94 | // 95 | // 2022-05-06: VSCode changed the format in 1.68 96 | // Before | ^/zip/c:/foo/bar.zip/package.json 97 | // After | ^/zip//c:/foo/bar.zip/package.json 98 | // 99 | case `vscode <1.61`: { 100 | str = `^zip:${str}`; 101 | } break; 102 | 103 | case `vscode <1.66`: { 104 | str = `^/zip/${str}`; 105 | } break; 106 | 107 | case `vscode <1.68`: { 108 | str = `^/zip${str}`; 109 | } break; 110 | 111 | case `vscode`: { 112 | str = `^/zip/${str}`; 113 | } break; 114 | 115 | // To make "go to definition" work, 116 | // We have to resolve the actual file system path from virtual path 117 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 118 | case `coc-nvim`: { 119 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 120 | str = resolve(`zipfile:${str}`); 121 | } break; 122 | 123 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 124 | // We have to resolve the actual file system path from virtual path, 125 | // everything else is up to neovim 126 | case `neovim`: { 127 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 128 | str = `zipfile://${str}`; 129 | } break; 130 | 131 | default: { 132 | str = `zip:${str}`; 133 | } break; 134 | } 135 | } else { 136 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 137 | } 138 | } 139 | 140 | return str; 141 | } 142 | 143 | function fromEditorPath(str) { 144 | switch (hostInfo) { 145 | case `coc-nvim`: { 146 | str = str.replace(/\.zip::/, `.zip/`); 147 | // The path for coc-nvim is in format of //zipfile://.yarn/... 148 | // So in order to convert it back, we use .* to match all the thing 149 | // before `zipfile:` 150 | return process.platform === `win32` 151 | ? str.replace(/^.*zipfile:\//, ``) 152 | : str.replace(/^.*zipfile:/, ``); 153 | } break; 154 | 155 | case `neovim`: { 156 | str = str.replace(/\.zip::/, `.zip/`); 157 | // The path for neovim is in format of zipfile:////.yarn/... 158 | return str.replace(/^zipfile:\/\//, ``); 159 | } break; 160 | 161 | case `vscode`: 162 | default: { 163 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 164 | } break; 165 | } 166 | } 167 | 168 | // Force enable 'allowLocalPluginLoads' 169 | // TypeScript tries to resolve plugins using a path relative to itself 170 | // which doesn't work when using the global cache 171 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 172 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 173 | // TypeScript already does local loads and if this code is running the user trusts the workspace 174 | // https://github.com/microsoft/vscode/issues/45856 175 | const ConfiguredProject = tsserver.server.ConfiguredProject; 176 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 177 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 178 | this.projectService.allowLocalPluginLoads = true; 179 | return originalEnablePluginsWithOptions.apply(this, arguments); 180 | }; 181 | 182 | // And here is the point where we hijack the VSCode <-> TS communications 183 | // by adding ourselves in the middle. We locate everything that looks 184 | // like an absolute path of ours and normalize it. 185 | 186 | const Session = tsserver.server.Session; 187 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 188 | let hostInfo = `unknown`; 189 | 190 | Object.assign(Session.prototype, { 191 | onMessage(/** @type {string | object} */ message) { 192 | const isStringMessage = typeof message === 'string'; 193 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 194 | 195 | if ( 196 | parsedMessage != null && 197 | typeof parsedMessage === `object` && 198 | parsedMessage.arguments && 199 | typeof parsedMessage.arguments.hostInfo === `string` 200 | ) { 201 | hostInfo = parsedMessage.arguments.hostInfo; 202 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 203 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 204 | // The RegExp from https://semver.org/ but without the caret at the start 205 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 206 | ) ?? []).map(Number) 207 | 208 | if (major === 1) { 209 | if (minor < 61) { 210 | hostInfo += ` <1.61`; 211 | } else if (minor < 66) { 212 | hostInfo += ` <1.66`; 213 | } else if (minor < 68) { 214 | hostInfo += ` <1.68`; 215 | } 216 | } 217 | } 218 | } 219 | 220 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 221 | return typeof value === 'string' ? fromEditorPath(value) : value; 222 | }); 223 | 224 | return originalOnMessage.call( 225 | this, 226 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 227 | ); 228 | }, 229 | 230 | send(/** @type {any} */ msg) { 231 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 232 | return typeof value === `string` ? toEditorPath(value) : value; 233 | }))); 234 | } 235 | }); 236 | 237 | return tsserver; 238 | }; 239 | 240 | const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10)); 241 | // In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well. 242 | // Ref https://github.com/microsoft/TypeScript/pull/55326 243 | if (major > 5 || (major === 5 && minor >= 5)) { 244 | moduleWrapper(absRequire(`typescript`)); 245 | } 246 | 247 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 248 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 249 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.7.3-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "tsc": "./bin/tsc", 8 | "tsserver": "./bin/tsserver" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: true 2 | 3 | nodeLinker: pnp 4 | 5 | packageExtensions: 6 | eslint-plugin-flowtype@*: 7 | peerDependenciesMeta: 8 | "@babel/plugin-syntax-flow": 9 | optional: true 10 | "@babel/plugin-transform-react-jsx": 11 | optional: true 12 | 13 | preferInteractive: true 14 | 15 | yarnPath: .yarn/releases/yarn-4.6.0.cjs 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SSH FS 3 | 4 | ![Logo](./resources/Logo.png) 5 | 6 | [![GitHub package version](https://img.shields.io/github/v/release/SchoofsKelvin/vscode-sshfs?include_prereleases&label=GitHub%20version)](https://github.com/SchoofsKelvin/vscode-sshfs) 7 | [![Visual Studio Marketplace](https://img.shields.io/visual-studio-marketplace/v/Kelvin.vscode-sshfs?label=VS%20Marketplace&logo=sdf)](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs) 8 | [![OpenVSX Registry](https://img.shields.io/open-vsx/v/Kelvin/vscode-sshfs?label=Open%20VSX)](https://open-vsx.org/extension/Kelvin/vscode-sshfs) 9 | 10 | [![VS Market installs](https://img.shields.io/visual-studio-marketplace/i/Kelvin.vscode-sshfs?color=green&label=Installs)](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs) 11 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/SchoofsKelvin?color=green&label=GitHub%20Sponsors)](https://github.com/sponsors/SchoofsKelvin) 12 | [![Donate](./media/paypal.png)](https://www.paypal.me/KSchoofs) 13 | 14 | This extension allows mounting remote folders as local workspace folders, launch integrated remote terminals and run `ssh-shell` tasks. 15 | 16 | ## Features 17 | 18 | ### Config editor 19 | 20 | The built-in config editor makes it easy to create and edit configurations: 21 | ![Config editor](./media/config-editor.png) 22 | 23 | The config editors stores this, by default, in your User Settings (`settings.json`) as: 24 | 25 | ```json 26 | "sshfs.configs": [ 27 | { 28 | "name": "hetzner", 29 | "putty": "Hetzner", 30 | "label": "Hetzner", 31 | "hop": "hetzner2", 32 | "root": "/root" 33 | } 34 | ], 35 | ``` 36 | 37 | This config is configured to copy settings (e.g. username, host, ...) from my PuTTY session. Due to me having loaded my private key in Pageant (PuTTY's agent), this config allows me to create a connection without having to provide a password/passphrase. It also specifies that all file operations _(`ssh://hetzner/some/file.js`)_ are relative to the `/root` directory on the server. 38 | 39 | Configurations are read from your global User Settings, the current workspace's settings, and any JSON files configured with `sshfs.configpaths`. Even when the workspace overrides this setting, the globally-configured paths will still be read. The workspace versions do have higher priority for merging or ignoring duplicates. 40 | 41 | ### Terminals 42 | 43 | Using a simple button or the command palette, a remote terminal can be started: 44 | ![Terminals](./media/terminals.png) 45 | 46 | _Uses `$SHELL` by default to launch your default user shell. A config option exists to change this, e.g. `"ksh -"` or `"exec .special-profile; $SHELL"`_ 47 | 48 | If a connection is already opened for a configuration, there is no need to reauthenticate. As long as the configuration hasn't changed, existing connections (both for workspace folders and terminals) will be reused. 49 | 50 | ### Remote shell tasks 51 | 52 | A new task type `ssh-shell` is added to run shell commands remotely: 53 | ![Remote shell tasks](./media/shell-tasks.png) 54 | 55 | The task terminal opens a full PTY terminal on the server. 56 | 57 | ### Remote workspace folders 58 | 59 | Using a simple button or the command palette, we can mount a remote workspace folder as a regular local workspace folder: 60 | ![Remote workspace folder](./media/workspace-folder.png) 61 | 62 | _Same configuration used as from the [Config editor](#Config editor) above._ 63 | 64 | This works seamlessly with extensions using the `vscode.workspace.fs` API _(added in VS Code 1.37.0)_, although not all extensions switched over, especially ones making use of binary files. 65 | 66 | As can be seen, right-clicking a remote directory gives the option to instantly open a remote terminal in this directory. 67 | 68 | The extension supports any `ssh://` URI. I actually opened `ssh://hetzner/ng-ui` as my folder, which resolves to `/root/ng-ui` on my remote server. By default, the button/command opens `ssh://hetzner/` which would then mount `/root`, as that is what my `Root` config field is set to. You can set it to whatever, including `~/path`. 69 | 70 | ### Miscellaneous 71 | 72 | The extension comes with a bunch of other improvements/features. Internally the [ssh2](https://www.npmjs.com/package/ssh2) package is used. The raw config JSON objects _(as seen in [Config editor](#Config editor))_ is, apart from some special fields, a one-on-one mapping of the config options supported by this package. Power users can edit their `settings.json` to e.g. make use of the `algorithms.cipher` field to specify a list of ciphers to use. 73 | 74 | Some other features worth mentioning: 75 | 76 | #### Prompt host/username/password/... for every connection 77 | 78 | ![Prompt username](./media/prompt-username.png) 79 | 80 | Active connections are reused to minimize prompts. A connection gets closed if there's no terminal or file system using it for over 5 seconds. 81 | 82 | #### Proxy settings 83 | 84 | Several proxy types (SSH hopping, HTTP and SOCKS 4/5) are supported: 85 | 86 | ![Proxy settings](./media/proxy-settings.png) 87 | 88 | `SSH Hop` refers to using another configuration to hop through, similar to OpenSSH's `ProxyJump`: 89 | 90 | ![Hop config field](./media/hop-config.png) 91 | 92 | #### SFTP Command/Sudo and Terminal command 93 | 94 | ![SFTP and Terminal Command config fields](./media/sftp-config.png) 95 | 96 | The extension supports using a custom `sftp` subsystem command. By default, it uses the `sftp` subsystem as indicated by the remote SSH server. In reality, this usually results in `/usr/lib/openssh/sftp-server` being used. 97 | 98 | The `SFTP Command` setting allows specifying to use a certain command instead of the default subsystem. The `SFTP Sudo` setting makes the extension try to create a sudo shell _(for the given user, or whatever sudo defaults to)_ and run `SFTP Command` _(or `/usr/lib/openssh/sftp-server` by default)_. For most users, setting this to `` should allow operating on the remote file system as `root`. Power users with esoteric setups can resort to changing `SFTP Command` to e.g. `sudo /some-sftp-server`, but might run into trouble with password prompts. 99 | 100 | The `Terminal command` option, as mentioned in [Terminals](#terminals), allows overriding the command used to launch the remote shell. By default, the extension launches a remote shell over the SSH connection, runs `cd ...` if necessary, followed by `$SHELL` to start the user's default shell. This config option allows to replace this `$SHELL` with a custom way way of starting the shell, or configuring the provided default SSH shell. 101 | 102 | ## Links 103 | 104 | - [GitHub](https://github.com/SchoofsKelvin/vscode-sshfs) ([Issues](https://github.com/SchoofsKelvin/vscode-sshfs/issues) | [(Pre)-releases](https://github.com/SchoofsKelvin/vscode-sshfs/releases) | [Roadmap](https://github.com/SchoofsKelvin/vscode-sshfs/projects/1) | [Sponsor](https://github.com/sponsors/SchoofsKelvin)) 105 | - [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=Kelvin.vscode-sshfs) 106 | - [Open VSX Registry](https://open-vsx.org/extension/Kelvin/vscode-sshfs) 107 | -------------------------------------------------------------------------------- /common/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Build output 3 | out/ 4 | -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | "./*": "./out/*" 7 | }, 8 | "typesVersions": { 9 | ">=0": { 10 | "*": [ 11 | "out/*" 12 | ] 13 | } 14 | }, 15 | "scripts": { 16 | "watch": "tsc -w", 17 | "build": "tsc" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^20.12.1", 21 | "@types/ssh2": "^0.5.41", 22 | "typescript": "~5.7.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /common/src/fileSystemConfig.ts: -------------------------------------------------------------------------------- 1 | import type { ConnectConfig } from 'ssh2'; 2 | import './ssh2'; 3 | 4 | export interface ProxyConfig { 5 | type: 'socks4' | 'socks5' | 'http'; 6 | host: string; 7 | port: number; 8 | } 9 | 10 | export type ConfigLocation = number | string; 11 | 12 | /** Might support conditional stuff later, although ssh2/OpenSSH might not support that natively */ 13 | export interface EnvironmentVariable { 14 | key: string; 15 | value: string; 16 | } 17 | 18 | export function formatConfigLocation(location?: ConfigLocation): string { 19 | if (!location) return 'Unknown location'; 20 | if (typeof location === 'number') { 21 | return `${[, 'Global', 'Workspace', 'WorkspaceFolder'][location] || 'Unknown'} settings.json`; 22 | } 23 | return location; 24 | } 25 | 26 | export function getLocations(configs: FileSystemConfig[]): ConfigLocation[] { 27 | const res: ConfigLocation[] = [1, 2 /*, 3*/]; // No WorkspaceFolder support (for now) 28 | // TODO: Suggest creating sshfs.jsonc etc in current workspace folder(s) (UI feature?) 29 | for (const { _location } of configs) { 30 | if (!_location) continue; 31 | if (!res.find(l => l === _location)) { 32 | res.push(_location); 33 | } 34 | } 35 | return res; 36 | } 37 | 38 | export function getGroups(configs: FileSystemConfig[], expanded = false): string[] { 39 | const res: string[] = []; 40 | function addGroup(group: string) { 41 | if (!res.find(l => l === group)) { 42 | res.push(group); 43 | } 44 | } 45 | for (const { group } of configs) { 46 | if (!group) continue; 47 | const groups = expanded ? group.split('.') : [group]; 48 | groups.forEach((g, i) => addGroup([...groups.slice(0, i), g].join('.'))); 49 | } 50 | return res; 51 | } 52 | 53 | export function groupByLocation(configs: FileSystemConfig[]): [ConfigLocation, FileSystemConfig[]][] { 54 | const res: [ConfigLocation, FileSystemConfig[]][] = []; 55 | function getForLoc(loc: ConfigLocation = 'Unknown') { 56 | let found = res.find(([l]) => l === loc); 57 | if (found) return found; 58 | found = [loc, []]; 59 | res.push(found); 60 | return found; 61 | } 62 | for (const config of configs) { 63 | getForLoc(config._location!)[1].push(config); 64 | } 65 | return res; 66 | } 67 | 68 | export function groupByGroup(configs: FileSystemConfig[]): [string, FileSystemConfig[]][] { 69 | const res: [string, FileSystemConfig[]][] = []; 70 | function getForGroup(group: string = '') { 71 | let found = res.find(([l]) => l === group); 72 | if (found) return found; 73 | found = [group, []]; 74 | res.push(found); 75 | return found; 76 | } 77 | for (const config of configs) { 78 | getForGroup(config.group)[1].push(config); 79 | } 80 | return res; 81 | } 82 | 83 | export interface FileSystemConfig extends ConnectConfig { 84 | /** Name of the config. Can only exists of lowercase alphanumeric characters, slashes and any of these: _.+-@ */ 85 | name: string; 86 | /** Optional label to display in some UI places (e.g. popups) */ 87 | label?: string; 88 | /** Optional group for this config, to group configs together in some UI places. Allows subgroups, in the format "Group1.SubGroup1.Subgroup2" */ 89 | group?: string; 90 | /** Whether to merge this "lower" config (e.g. from workspace settings) into higher configs (e.g. from global settings) */ 91 | merge?: boolean; 92 | /** Names of other existing configs to merge into this config. Earlier entries overridden by later entries overridden by this config itself */ 93 | extend?: string | string[]; 94 | /** Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/` */ 95 | root?: string; 96 | /** A name of a PuTTY session, or `true` to find the PuTTY session from the host address */ 97 | putty?: string | boolean; 98 | /** Optional object defining a proxy to use */ 99 | proxy?: ProxyConfig; 100 | /** Optional path to a private keyfile to authenticate with */ 101 | privateKeyPath?: string; 102 | /** A name of another config to use as a hop */ 103 | hop?: string; 104 | /** The command to run on the remote SSH session to start a SFTP session (defaults to sftp subsystem) */ 105 | sftpCommand?: string; 106 | /** Whether to use a sudo shell (and for which user) to run the sftpCommand in (sftpCommand defaults to /usr/lib/openssh/sftp-server if missing) */ 107 | sftpSudo?: string | boolean; 108 | /** The command(s) to run when a new SSH terminal gets created. Defaults to `$SHELL`. Internally the command `cd ...` is run first */ 109 | terminalCommand?: string | string[]; 110 | /** The command(s) to run when a `ssh-shell` task gets run. Defaults to the placeholder `$COMMAND`. Internally the command `cd ...` is run first */ 111 | taskCommand?: string | string[]; 112 | /** An object with environment variables to add to the SSH connection. Affects the whole connection thus all terminals */ 113 | environment?: EnvironmentVariable[] | Record; 114 | /** The filemode to assign to new files created using VS Code, not the terminal. Similar to umask. Defaults to `rw-rw-r--` (regardless of server config, whether you are root, ...) */ 115 | newFileMode?: number | string; 116 | /** Whether this config was created from an instant connection string. Enables fuzzy matching for e.g. PuTTY, config-by-host, ... */ 117 | instantConnection?: boolean; 118 | /** List of special flags to enable/disable certain fixes/features. Flags are usually used for issues or beta testing. Flags can disappear/change anytime! */ 119 | flags?: string[]; 120 | /** Specifies the character encoding used for the SSH terminal. If undefined, UTF-8 will be used */ 121 | encoding?: string; 122 | /** Internal property saying where this config comes from. Undefined if this config is merged or something */ 123 | _location?: ConfigLocation; 124 | /** Internal property keeping track of where this config comes from (including merges) */ 125 | _locations: ConfigLocation[]; 126 | /** Internal property keeping track of whether this config is an actually calculated one, and if so, which config it originates from (normally itself) */ 127 | _calculated?: FileSystemConfig; 128 | } 129 | 130 | export function isFileSystemConfig(config: any): config is FileSystemConfig { 131 | return typeof config === 'object' && typeof config.name === 'string' && Array.isArray(config._locations); 132 | } 133 | 134 | export function invalidConfigName(name: string) { 135 | if (!name) return 'Missing a name for this SSH FS'; 136 | if (name.match(/^[\w_\\/.@\-+]+$/)) return null; 137 | return `A SSH FS name can only exists of lowercase alphanumeric characters, slashes and any of these: _.+-@`; 138 | } 139 | 140 | /** 141 | * https://regexr.com/5m3gl (mostly based on https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04) 142 | * Supports several formats, the first one being the "full" format, with others being partial: 143 | * - `user;abc=def,a-b=1-5@server.example.com:22/some/file.ext` 144 | * - `user@server.example.com/directory` 145 | * - `server:22/directory` 146 | * - `test-user@server` 147 | * - `server` 148 | * - `@server/path` - Unlike OpenSSH, we allow a @ (and connection parameters) without a username 149 | * 150 | * The resulting FileSystemConfig will have as name basically the input, but without the path. If there is no 151 | * username given, the name will start with `@`, as to differentiate between connection strings and config names. 152 | */ 153 | const CONNECTION_REGEX = /^((?[\w\-._]+)?(;[\w-]+=[\w\d-]+(,[\w\d-]+=[\w\d-]+)*)?@)?(?[^\s@\\/:,=]+)(:(?\d+))?(?\/\S*)?$/; 154 | 155 | export function parseConnectionString(input: string): [config: FileSystemConfig, path?: string] | string { 156 | input = input.trim(); 157 | const match = input.match(CONNECTION_REGEX); 158 | if (!match) return 'Invalid format, expected something like "user@example.com:22/some/path"'; 159 | const { user, host, path } = match.groups!; 160 | const portStr = match.groups!.port; 161 | const port = portStr ? Number.parseInt(portStr) : undefined; 162 | if (portStr && (!port || port < 1 || port > 65535)) return `The string '${port}' is not a valid port number`; 163 | const name = `${user || ''}@${host}${port ? `:${port}` : ''}${path || ''}`; 164 | return [{ 165 | name, host, port, 166 | instantConnection: true, 167 | username: user || '$USERNAME', 168 | _locations: [], 169 | }, path]; 170 | } 171 | -------------------------------------------------------------------------------- /common/src/webviewMessages.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLocation, FileSystemConfig } from './fileSystemConfig'; 2 | 3 | /* Type of messages*/ 4 | 5 | export interface RequestDataMessage { 6 | type: 'requestData'; 7 | reload?: boolean; 8 | } 9 | export interface ResponseDataMessage { 10 | type: 'responseData'; 11 | configs: FileSystemConfig[]; 12 | locations: ConfigLocation[]; 13 | } 14 | 15 | export interface SaveConfigMessage { 16 | type: 'saveConfig'; 17 | config: FileSystemConfig; 18 | name?: string; 19 | uniqueId?: string; 20 | remove?: boolean; 21 | } 22 | export interface SaveConfigResultMessage { 23 | type: 'saveConfigResult'; 24 | error?: string; 25 | config: FileSystemConfig; 26 | uniqueId?: string; 27 | } 28 | 29 | export interface PromptPathMessage { 30 | type: 'promptPath'; 31 | uniqueId?: string; 32 | } 33 | export interface PromptPathResultMessage { 34 | type: 'promptPathResult'; 35 | error?: string; 36 | path?: string; 37 | uniqueId?: string; 38 | } 39 | 40 | export interface NavigateMessage { 41 | type: 'navigate'; 42 | navigation: Navigation; 43 | } 44 | export interface NavigatedMessage { 45 | type: 'navigated'; 46 | view: string; 47 | } 48 | 49 | export interface MessageTypes { 50 | requestData: RequestDataMessage; 51 | responseData: ResponseDataMessage; 52 | saveConfig: SaveConfigMessage; 53 | saveConfigResult: SaveConfigResultMessage; 54 | promptPath: PromptPathMessage; 55 | promptPathResult: PromptPathResultMessage; 56 | navigate: NavigateMessage; 57 | navigated: NavigatedMessage; 58 | } 59 | 60 | export type Message = MessageTypes[keyof MessageTypes]; 61 | 62 | /* Types related to NavigateMessage */ 63 | 64 | export interface NewConfigNavigation { 65 | type: 'newconfig'; 66 | } 67 | export interface EditConfigNavigation { 68 | type: 'editconfig'; 69 | config: FileSystemConfig | FileSystemConfig[]; 70 | } 71 | export type Navigation = NewConfigNavigation | EditConfigNavigation; 72 | -------------------------------------------------------------------------------- /common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "target": "ES2019", 7 | "outDir": "out", 8 | "lib": [ 9 | "ES2019" 10 | ], 11 | "sourceMap": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "rootDir": "src" 15 | }, 16 | "compileOnSave": true, 17 | "include": [ 18 | "src" 19 | ] 20 | } -------------------------------------------------------------------------------- /enhance-changelog.js: -------------------------------------------------------------------------------- 1 | const { execSync, spawnSync } = require("child_process"); 2 | const fs = require("fs"); 3 | 4 | const tag = execSync("git describe --tags --abbrev=0").toString().trim(); 5 | console.log("Checking commits since", tag); 6 | 7 | const args = ["-n", "1", `${tag}..HEAD`, "--abbrev=7", "--pretty=%h", "--", "CHANGELOG.md"]; 8 | function findCommit(line) { 9 | const result = spawnSync("git", ["log", "-S", line, ...args], { shell: false }); 10 | if (result.status === 0) return result.stdout.toString().trim(); 11 | throw new Error(result.stderr.toString()); 12 | } 13 | 14 | const lines = fs.readFileSync("CHANGELOG.md").toString().split(/\r?\n/g); 15 | 16 | let enhanced = 0; 17 | let shouldEnhance = false; 18 | for (let i = 0; i < lines.length; i++) { 19 | const line = lines[i]; 20 | if (line.startsWith("## Unreleased")) shouldEnhance = true; 21 | else if (line.startsWith("## ")) break; 22 | if (!line.startsWith("- ")) continue; 23 | const commit = findCommit(line); 24 | if (!commit) continue; 25 | console.log(line, "=>", commit); 26 | const brackets = line.match(/ \((.*?)\)$/); 27 | if (brackets) { 28 | if (brackets[1].match(/[\da-fA-F]{7}/)) continue; 29 | if (!brackets[1].includes(" ")) { 30 | lines[i] = line.replace(/\(.*?\)$/, `(${commit}, ${brackets[1]})`); 31 | enhanced++; 32 | continue; 33 | } 34 | } 35 | lines[i] = `${line} (${commit})`; 36 | enhanced++; 37 | } 38 | 39 | console.log(`Enhanced ${enhanced} lines`); 40 | fs.writeFileSync("CHANGELOG.md", lines.join("\n")); 41 | -------------------------------------------------------------------------------- /map-error.js: -------------------------------------------------------------------------------- 1 | 2 | const sm = require('source-map'); 3 | const rl = require('readline'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | const { formatId } = require('./webpack.plugin.js'); 8 | 9 | /** @type {Record>} */ 10 | const maps = {}; 11 | 12 | for (const file of fs.readdirSync('./dist')) { 13 | if (!file.endsWith('.js.map')) continue; 14 | const name = file.replace('.js.map', '.js'); 15 | /** @type {sm.RawSourceMap} */ 16 | const json = JSON.parse(fs.readFileSync(`./dist/${file}`)); 17 | json.sourceRoot = 'src'; 18 | maps[name] = new sm.SourceMapConsumer(json); 19 | console.log('Added map for', name, 'wrapping', json.sources.length, 'source files'); 20 | } 21 | 22 | console.log(); 23 | 24 | const SOURCE_NAME_REGEX = /^\s*at .*? \(.*?[/\\]dist[/\\]((?:[\da-zA-Z]+\.)?extension\.js):(\d+):(\d+)\)$/; 25 | const SOURCE_ANOM_REGEX = /^\s*at .*?[/\\]dist[/\\]((?:[\da-zA-Z]+\.)?extension\.js):(\d+):(\d+)$/; 26 | 27 | const ROOT_PATH = path.resolve(__dirname); 28 | const FORMAT_ID = process.argv.includes('--format-id'); 29 | 30 | let error = ''; 31 | rl.createInterface(process.stdin).on('line', async l => { 32 | if (l) return error += l + '\n'; 33 | for (let stack of error.split('\n')) { 34 | const named = stack.match(SOURCE_NAME_REGEX); 35 | const mat = named || stack.match(SOURCE_ANOM_REGEX); 36 | if (mat) { 37 | let [, file, line, column] = mat; 38 | line = parseInt(line); 39 | column = parseInt(column); 40 | const map = await maps[file]; 41 | if (!map) { 42 | stack += ' [MISSING]'; 43 | console.log(stack); 44 | continue; 45 | } 46 | const pos = map.originalPositionFor({ line, column }); 47 | if (!pos.line) { 48 | stack += ' [MISMAPPED]'; 49 | console.log(stack); 50 | continue; 51 | } 52 | const ws = stack.match(/^\s*/)[0]; 53 | const source = FORMAT_ID ? formatId(pos.source, ROOT_PATH) : pos.source; 54 | if (named && pos.name) { 55 | stack = `${ws}at ${pos.name} (${source}:${pos.line}:${pos.column})`; 56 | } else { 57 | stack = `${ws}at ${source}:${pos.line}:${pos.column}`; 58 | } 59 | } 60 | console.log(stack); 61 | } 62 | console.log(); 63 | error = ''; 64 | }); 65 | -------------------------------------------------------------------------------- /media/add-workspace-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/add-workspace-folder.png -------------------------------------------------------------------------------- /media/config-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/config-editor.png -------------------------------------------------------------------------------- /media/hop-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/hop-config.png -------------------------------------------------------------------------------- /media/paypal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/paypal.png -------------------------------------------------------------------------------- /media/prompt-username.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/prompt-username.png -------------------------------------------------------------------------------- /media/proxy-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/proxy-settings.png -------------------------------------------------------------------------------- /media/sftp-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/sftp-config.png -------------------------------------------------------------------------------- /media/shell-tasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/shell-tasks.png -------------------------------------------------------------------------------- /media/terminals.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/terminals.png -------------------------------------------------------------------------------- /media/workspace-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/media/workspace-folder.png -------------------------------------------------------------------------------- /resources/Icons.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/Icons.psd -------------------------------------------------------------------------------- /resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/Logo.png -------------------------------------------------------------------------------- /resources/config/Active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/config/Active.png -------------------------------------------------------------------------------- /resources/config/Connecting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/config/Connecting.png -------------------------------------------------------------------------------- /resources/config/Deleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/config/Deleted.png -------------------------------------------------------------------------------- /resources/config/Error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/config/Error.png -------------------------------------------------------------------------------- /resources/config/Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SchoofsKelvin/vscode-sshfs/757d5f726f8510693cc5bd0ec87a03e9bc88a268/resources/config/Idle.png -------------------------------------------------------------------------------- /resources/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | > _ 11 | 12 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { FileSystemConfig } from 'common/fileSystemConfig'; 3 | import * as vscode from 'vscode'; 4 | import { loadConfigs, reloadWorkspaceFolderConfigs } from './config'; 5 | import type { Connection } from './connection'; 6 | import { FileSystemRouter } from './fileSystemRouter'; 7 | import { Logging, setDebug } from './logging'; 8 | import { Manager } from './manager'; 9 | import type { SSHPseudoTerminal } from './pseudoTerminal'; 10 | import { ConfigTreeProvider, ConnectionTreeProvider } from './treeViewManager'; 11 | import { PickComplexOptions, pickComplex, pickConnection, setGetExtensionUri, setupWhenClauseContexts } from './ui-utils'; 12 | 13 | interface CommandHandler { 14 | /** If set, a string/undefined prompts using the given options. 15 | * If the input was a string, promptOptions.nameFilter is set to it */ 16 | promptOptions: PickComplexOptions; 17 | handleString?(string: string): void; 18 | handleUri?(uri: vscode.Uri): void; 19 | handleConfig?(config: FileSystemConfig): void; 20 | handleConnection?(connection: Connection): void; 21 | handleTerminal?(terminal: SSHPseudoTerminal): void; 22 | } 23 | 24 | /** `findConfigs` in config.ts ignores URIs for still-connecting connections */ 25 | export let MANAGER: Manager | undefined; 26 | 27 | export function activate(context: vscode.ExtensionContext) { 28 | const extension = vscode.extensions.getExtension('Kelvin.vscode-sshfs'); 29 | const version = extension?.packageJSON?.version; 30 | 31 | Logging.info`Extension activated, version ${version}, mode ${context.extensionMode}`; 32 | Logging.debug`Running VS Code version ${vscode.version} ${process.versions}`; 33 | 34 | setDebug(process.env.VSCODE_SSHFS_DEBUG?.toLowerCase() === 'true'); 35 | 36 | const versionHistory = context.globalState.get<[string, number, number][]>('versionHistory', []); 37 | const lastVersion = versionHistory[versionHistory.length - 1]; 38 | if (!lastVersion) { 39 | const classicLastVersion = context.globalState.get('lastVersion'); 40 | if (classicLastVersion) { 41 | Logging.debug`Previously used ${classicLastVersion}, switching over to new version history`; 42 | versionHistory.push([classicLastVersion, Date.now(), Date.now()]); 43 | } else { 44 | Logging.debug`No previous version detected. Fresh or pre-v1.21.0 installation?`; 45 | } 46 | versionHistory.push([version, Date.now(), Date.now()]); 47 | } else if (lastVersion[0] !== version) { 48 | Logging.debug`Previously used ${lastVersion[0]}, currently first launch since switching to ${version}`; 49 | versionHistory.push([version, Date.now(), Date.now()]); 50 | } else { 51 | lastVersion[2] = Date.now(); 52 | } 53 | Logging.info`Version history: ${versionHistory.map(v => v.join(':')).join(' > ')}`; 54 | context.globalState.update('versionHistory', versionHistory); 55 | 56 | // Really too bad we *need* the ExtensionContext for relative resources 57 | // I really don't like having to pass context to *everything*, so let's do it this way 58 | setGetExtensionUri(path => vscode.Uri.joinPath(context.extensionUri, path)); 59 | 60 | const manager = MANAGER = new Manager(context); 61 | 62 | const subscribe = context.subscriptions.push.bind(context.subscriptions) as typeof context.subscriptions.push; 63 | const registerCommand = (command: string, callback: (...args: any[]) => any, thisArg?: any) => 64 | subscribe(vscode.commands.registerCommand(command, callback, thisArg)); 65 | 66 | subscribe(vscode.workspace.registerFileSystemProvider('ssh', new FileSystemRouter(manager), { isCaseSensitive: true })); 67 | subscribe(vscode.window.createTreeView('sshfs-configs', { treeDataProvider: new ConfigTreeProvider(), showCollapseAll: true })); 68 | const connectionTreeProvider = new ConnectionTreeProvider(manager.connectionManager); 69 | subscribe(vscode.window.createTreeView('sshfs-connections', { treeDataProvider: connectionTreeProvider, showCollapseAll: true })); 70 | subscribe(vscode.tasks.registerTaskProvider('ssh-shell', manager)); 71 | subscribe(vscode.window.registerTerminalLinkProvider(manager)); 72 | 73 | setupWhenClauseContexts(manager.connectionManager); 74 | 75 | function registerCommandHandler(name: string, handler: CommandHandler) { 76 | const callback = async (arg?: string | FileSystemConfig | Connection | SSHPseudoTerminal | vscode.Uri) => { 77 | if (handler.promptOptions && (!arg || typeof arg === 'string')) { 78 | arg = await pickComplex(manager, { ...handler.promptOptions, nameFilter: arg }); 79 | } 80 | if (typeof arg === 'string') return handler.handleString?.(arg); 81 | if (!arg) return; 82 | if (arg instanceof vscode.Uri) { 83 | return handler.handleUri?.(arg); 84 | } else if ('handleInput' in arg) { 85 | return handler.handleTerminal?.(arg); 86 | } else if ('client' in arg) { 87 | return handler.handleConnection?.(arg); 88 | } else if ('name' in arg) { 89 | return handler.handleConfig?.(arg); 90 | } 91 | Logging.warning(`CommandHandler for '${name}' could not handle input '${arg}'`); 92 | }; 93 | registerCommand(name, callback); 94 | } 95 | 96 | // sshfs.new() 97 | registerCommand('sshfs.new', () => manager.openSettings({ type: 'newconfig' })); 98 | 99 | // sshfs.add(target?: string | FileSystemConfig) 100 | registerCommandHandler('sshfs.add', { 101 | promptOptions: { promptConfigs: true, promptConnections: true, promptInstantConnection: true }, 102 | handleConfig: config => manager.commandConnect(config), 103 | }); 104 | 105 | // sshfs.disconnect(target: string | FileSystemConfig | Connection) 106 | registerCommandHandler('sshfs.disconnect', { 107 | promptOptions: { promptConnections: true }, 108 | handleString: name => manager.commandDisconnect(name), 109 | handleConfig: config => manager.commandDisconnect(config.name), 110 | handleConnection: con => manager.commandDisconnect(con), 111 | }); 112 | 113 | // sshfs.disconnectAll() 114 | registerCommand('sshfs.disconnectAll', () => { 115 | const conns = manager.connectionManager; 116 | // Does not close pending connections (yet?) 117 | conns.getActiveConnections().forEach(conn => conns.closeConnection(conn, 'command:disconnectAll')); 118 | }); 119 | 120 | // sshfs.terminal(target?: FileSystemConfig | Connection | vscode.Uri) 121 | registerCommandHandler('sshfs.terminal', { 122 | promptOptions: { promptConfigs: true, promptConnections: true, promptInstantConnection: true }, 123 | handleConfig: config => manager.commandTerminal(config), 124 | handleConnection: con => manager.commandTerminal(con), 125 | handleUri: async uri => { 126 | const con = await pickConnection(manager, uri.authority); 127 | con && manager.commandTerminal(con, uri); 128 | }, 129 | }); 130 | 131 | // sshfs.focusTerminal(target?: SSHPseudoTerminal) 132 | registerCommandHandler('sshfs.focusTerminal', { 133 | promptOptions: { promptTerminals: true }, 134 | handleTerminal: ({ terminal }) => terminal?.show(false), 135 | }); 136 | 137 | // sshfs.closeTerminal(target?: SSHPseudoTerminal) 138 | registerCommandHandler('sshfs.closeTerminal', { 139 | promptOptions: { promptTerminals: true }, 140 | handleTerminal: terminal => terminal.close(), 141 | }); 142 | 143 | // sshfs.configure(target?: string | FileSystemConfig) 144 | registerCommandHandler('sshfs.configure', { 145 | promptOptions: { promptConfigs: true }, 146 | handleConfig: config => manager.commandConfigure(config), 147 | }); 148 | 149 | // sshfs.reload() 150 | registerCommand('sshfs.reload', loadConfigs); 151 | 152 | // sshfs.settings() 153 | registerCommand('sshfs.settings', () => manager.openSettings()); 154 | 155 | // sshfs.refresh() 156 | registerCommand('sshfs.refresh', () => connectionTreeProvider.refresh()); 157 | 158 | subscribe(manager.connectionManager.onConnectionAdded(async con => { 159 | await reloadWorkspaceFolderConfigs(con.actualConfig.name); 160 | })); 161 | } 162 | -------------------------------------------------------------------------------- /src/fileSystemRouter.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getFlag, subscribeToGlobalFlags } from './flags'; 3 | import { Logging } from './logging'; 4 | import type { Manager } from './manager'; 5 | 6 | const ALL_DEBUG_FLAGS = [ 7 | 'stat', 'readDirectory', 'createDirectory', 8 | 'readFile', 'writeFile', 'delete', 'rename', 9 | ].map(v => v.toLowerCase()); 10 | 11 | export class FileSystemRouter implements vscode.FileSystemProvider { 12 | public onDidChangeFile: vscode.Event; 13 | protected onDidChangeFileEmitter = new vscode.EventEmitter(); 14 | protected debugFlags: string[]; 15 | constructor(protected readonly manager: Manager) { 16 | this.onDidChangeFile = this.onDidChangeFileEmitter.event; 17 | subscribeToGlobalFlags(() => { 18 | this.debugFlags = `${getFlag('DEBUG_FSR')?.[0] || ''}`.toLowerCase().split(/,\s*|\s+/g); 19 | if (this.debugFlags.includes('all')) this.debugFlags.push(...ALL_DEBUG_FLAGS); 20 | }); 21 | } 22 | public async assertFs(uri: vscode.Uri): Promise { 23 | const fs = this.manager.getFs(uri); 24 | if (fs) return fs; 25 | return this.manager.createFileSystem(uri.authority); 26 | } 27 | /* FileSystemProvider */ 28 | public watch(uri: vscode.Uri, options: { recursive: boolean; excludes: string[]; }): vscode.Disposable { 29 | // TODO: Store watched files/directories in an array and periodically check if they're modified 30 | /*let disp = () => {}; 31 | assertFs(this, uri).then((fs) => { 32 | disp = fs.watch(uri, options).dispose.bind(fs); 33 | }).catch(console.error); 34 | return new vscode.Disposable(() => disp());*/ 35 | return new vscode.Disposable(() => { }); 36 | } 37 | public async stat(uri: vscode.Uri): Promise { 38 | if (this.debugFlags.includes('stat')) 39 | Logging.debug`Performing stat for ${uri}`; 40 | return (await this.assertFs(uri)).stat(uri); 41 | } 42 | public async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { 43 | if (this.debugFlags.includes('readdirectory')) 44 | Logging.debug`Reading directory ${uri}`; 45 | return (await this.assertFs(uri)).readDirectory(uri); 46 | } 47 | public async createDirectory(uri: vscode.Uri): Promise { 48 | if (this.debugFlags.includes('createdirectory')) 49 | Logging.debug`Creating directory ${uri}`; 50 | return (await this.assertFs(uri)).createDirectory(uri); 51 | } 52 | public async readFile(uri: vscode.Uri): Promise { 53 | if (this.debugFlags.includes('readfile')) 54 | Logging.debug`Reading ${uri}`; 55 | return (await this.assertFs(uri)).readFile(uri); 56 | } 57 | public async writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean; }): Promise { 58 | if (this.debugFlags.includes('writefile')) 59 | Logging.debug`Writing ${content.length} bytes to ${uri} (options: ${options})`; 60 | return (await this.assertFs(uri)).writeFile(uri, content, options); 61 | } 62 | public async delete(uri: vscode.Uri, options: { recursive: boolean; }): Promise { 63 | if (this.debugFlags.includes('delete')) 64 | Logging.debug`Deleting ${uri} (options: ${options})`; 65 | return (await this.assertFs(uri)).delete(uri, options); 66 | } 67 | public async rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }): Promise { 68 | if (this.debugFlags.includes('rename')) 69 | Logging.debug`Renaming ${oldUri} to ${newUri}`; 70 | const fs = await this.assertFs(oldUri); 71 | if (fs !== (await this.assertFs(newUri))) 72 | throw new Error(`Can't rename between different SSH filesystems`); 73 | return fs.rename(oldUri, newUri, options); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/flags.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver'; 2 | import * as vscode from 'vscode'; 3 | import { Logging } from './logging'; 4 | import { catchingPromise } from './utils'; 5 | 6 | /* List of flags 7 | DF-GE (boolean) (default=false) 8 | - Disables the 'diffie-hellman-group-exchange' kex algorithm as a default option 9 | - Originally for issue #239 10 | - Automatically enabled for Electron v11.0, v11.1 and v11.2 11 | DEBUG_SSH2 (boolean) (default=false) 12 | - Enables debug logging in the ssh2 library (set at the start of each connection) 13 | WINDOWS_COMMAND_SEPARATOR (boolean) (default=false) 14 | - Makes it that commands are joined together using ` && ` instead of `; ` 15 | - Automatically enabled when the remote shell is detected to be PowerShell or Command Prompt (cmd.exe) 16 | CHECK_HOME (boolean) (default=true) 17 | - Determines whether we check if the home directory exists during `createFileSystem` in the Manager 18 | - If `tryGetHome` fails while creating the connection, throw an error if this flag is set, otherwise default to `/` 19 | REMOTE_COMMANDS (boolean) (default=false) 20 | - Enables automatically launching a background command terminal during connection setup 21 | - Enables attempting to inject a file to be sourced by the remote shells (which adds the `code` alias) 22 | DEBUG_REMOTE_COMMANDS (boolean) (default=false) 23 | - Enables debug logging for the remote command terminal (thus useless if REMOTE_COMMANDS isn't true) 24 | DEBUG_FS (string) (default='') 25 | - A comma-separated list of debug flags for logging errors in the sshFileSystem 26 | - The presence of `showignored` will log `FileNotFound` that got ignored 27 | - The presence of `disableignored` will make the code ignore nothing (making `showignored` useless) 28 | - The presence of `minimal` will log all errors as single lines, but not `FileNotFound` 29 | - The presence of `full` is the same as `minimal` but with full stacktraces 30 | - The presence of `converted` will log the resulting converted errors (if required and successful) 31 | - The presence of `all` enables all of the above except `disableignored` (similar to `showignored,full,converted`) 32 | DEBUG_FSR (string) (default='', global) 33 | - A comma-separated list of method names to enable logging for in the FileSystemRouter 34 | - The presence of `all` is equal to `stat,readDirectory,createDirectory,readFile,writeFile,delete,rename` 35 | - The router logs handles `ssh://`, and will even log operations to non-existing configurations/connections 36 | FS_NOTIFY_ERRORS (string) 37 | - A comma-separated list of operations to display notifications for should they error 38 | - Mind that `FileNotFound` errors for ignored paths are always ignored, except with `DEBUG_FS=showignored` 39 | - The presence of `all` will show notification for every operation 40 | - The presence of `write` is equal to `createDirectory,writeFile,delete,rename` 41 | - Besides those provided by `write`, there's also `readDirectory`, `readFile` and `stat` 42 | - Automatically set to `write` for VS Code 1.56 and later (see issue #282), otherwise '' 43 | SHELL_CONFIG (string) 44 | - Forces the use of a specific shell configuration. Check shellConfig.ts for possible values 45 | - By default, when this flag is absent (or an empty or not a string), the extension will try to detect the correct type to use 46 | */ 47 | 48 | function parseFlagList(list: string[] | undefined, origin: string): Record { 49 | if (list === undefined) 50 | return {}; 51 | if (!Array.isArray(list)) 52 | throw new Error(`Expected string array for flags, but got: ${list}`); 53 | const scope: Record = {}; 54 | for (const flag of list) { 55 | let name: string = flag; 56 | let value: FlagValue = null; 57 | const eq = flag.indexOf('='); 58 | if (eq !== -1) { 59 | name = flag.substring(0, eq); 60 | value = flag.substring(eq + 1); 61 | } else if (flag.startsWith('+')) { 62 | name = flag.substring(1); 63 | value = true; 64 | } else if (flag.startsWith('-')) { 65 | name = flag.substring(1); 66 | value = false; 67 | } 68 | name = name.toLocaleLowerCase(); 69 | if (name in scope) 70 | continue; 71 | scope[name] = [value, origin]; 72 | } 73 | return scope; 74 | } 75 | 76 | export type FlagValue = string | boolean | null; 77 | export type FlagCombo = [value: V, origin: string]; 78 | const globalFlagsSubscribers = new Set<() => void>(); 79 | export function subscribeToGlobalFlags(listener: () => void): vscode.Disposable { 80 | listener(); 81 | globalFlagsSubscribers.add(listener); 82 | return new vscode.Disposable(() => globalFlagsSubscribers.delete(listener)); 83 | } 84 | 85 | const DEFAULT_FLAGS: string[] = []; 86 | let cachedFlags: Record = {}; 87 | function calculateFlags(): Record { 88 | const flags: Record = {}; 89 | const config = vscode.workspace.getConfiguration('sshfs').inspect('flags'); 90 | if (!config) 91 | throw new Error(`Could not inspect "sshfs.flags" config field`); 92 | const applyList = (list: string[] | undefined, origin: string) => Object.assign(flags, parseFlagList(list, origin)); 93 | applyList(DEFAULT_FLAGS, 'Built-in Default'); 94 | applyList(config.defaultValue, 'Default Settings'); 95 | // Electron v11 crashes for DiffieHellman GroupExchange, although it's fixed in 11.3.0 96 | if ((process.versions as { electron?: string; }).electron?.match(/^11\.(0|1|2)\./)) { 97 | applyList(['+DF-GE'], 'Fix for issue #239'); 98 | } 99 | // Starting with 1.56, FileSystemProvider errors aren't shown to the user and just silently fail 100 | // https://github.com/SchoofsKelvin/vscode-sshfs/issues/282 101 | if (semver.gte(vscode.version, '1.56.0')) { 102 | applyList(['FS_NOTIFY_ERRORS=write'], 'Fix for issue #282'); 103 | } 104 | applyList(config.globalValue, 'Global Settings'); 105 | applyList(config.workspaceValue, 'Workspace Settings'); 106 | applyList(config.workspaceFolderValue, 'WorkspaceFolder Settings'); 107 | Logging.info`Calculated config flags: ${flags}`; 108 | for (const listener of globalFlagsSubscribers) { 109 | catchingPromise(listener).catch(e => Logging.error`onGlobalFlagsChanged listener errored: ${e}`); 110 | } 111 | return cachedFlags = flags; 112 | } 113 | vscode.workspace.onDidChangeConfiguration(event => { 114 | if (event.affectsConfiguration('sshfs.flags')) 115 | calculateFlags(); 116 | }); 117 | calculateFlags(); 118 | 119 | /** 120 | * Returns (a copy of the) global flags. Gets updated by ConfigurationChangeEvent events. 121 | * In case `flags` is given, flags specified in this array will override global ones in the returned result. 122 | * @param flags An optional array of flags to check before the global ones 123 | */ 124 | function getFlags(flags?: string[]): Record { 125 | return { 126 | ...cachedFlags, 127 | ...parseFlagList(flags, 'Override'), 128 | }; 129 | } 130 | 131 | /** 132 | * Checks the `sshfs.flags` config (overridable by e.g. workspace settings). 133 | * - Flag names are case-insensitive 134 | * - If a flag appears twice, the first mention of it is used 135 | * - If a flag appears as "NAME", `null` is returned 136 | * - If a flag appears as "FLAG=VALUE", `VALUE` is returned as a string 137 | * - If a flag appears as `+FLAG` (and no `=`), `true` is returned (as a boolean) 138 | * - If a flag appears as `-FLAG` (and no `=`), `false` is returned (as a boolean) 139 | * - If a flag is missing, `undefined` is returned (different from `null`!) 140 | * 141 | * For `undefined`, an actual `undefined` is returned. For all other cases, a FlagCombo 142 | * is returned, e.g. "NAME" returns `[null, "someOrigin"]` and `"+F"` returns `[true, "someOrigin"]` 143 | * @param target The name of the flag to look for 144 | * @param flags An optional array of flags to check before the global ones 145 | */ 146 | export function getFlag(target: string, flags?: string[]): FlagCombo | undefined { 147 | return getFlags(flags)[target.toLowerCase()]; 148 | } 149 | 150 | /** 151 | * Built on top of getFlag. Tries to convert the flag value to a boolean using these rules: 152 | * - If the flag isn't present, `missingValue` is returned 153 | * Although this probably means I'm using a flag that I never added to `DEFAULT_FLAGS` 154 | * - Booleans are kept 155 | * - `null` is counted as `true` (means a flag like "NAME" was present without any value or prefix) 156 | * - Strings try to get converted in a case-insensitive way: 157 | * - `true/t/yes/y` becomes true 158 | * - `false/f/no/n` becomes false 159 | * - All other strings result in an error 160 | * @param target The name of the flag to look for 161 | * @param defaultValue The value to return when no flag with the given name is present 162 | * @param flags An optional array of flags to check before the global ones 163 | * @returns The matching FlagCombo or `[missingValue, 'missing']` instead 164 | */ 165 | export function getFlagBoolean(target: string, missingValue: boolean, flags?: string[]): FlagCombo { 166 | const combo = getFlag(target, flags); 167 | if (!combo) 168 | return [missingValue, 'missing']; 169 | const [value, reason] = combo; 170 | if (value == null) 171 | return [true, reason]; 172 | if (typeof value === 'boolean') 173 | return [value, reason]; 174 | const lower = value.toLowerCase(); 175 | if (lower === 'true' || lower === 't' || lower === 'yes' || lower === 'y') 176 | return [true, reason]; 177 | if (lower === 'false' || lower === 'f' || lower === 'no' || lower === 'n') 178 | return [false, reason]; 179 | throw new Error(`Could not convert '${value}' for flag '${target}' to a boolean!`); 180 | } 181 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { FileSystemConfig } from 'common/fileSystemConfig'; 3 | import * as dns from 'dns'; 4 | import { request } from 'http'; 5 | import { SocksClient } from 'socks'; 6 | import { Logging } from './logging'; 7 | import { toPromise, validatePort } from './utils'; 8 | 9 | async function resolveHostname(hostname: string): Promise { 10 | return toPromise(cb => dns.lookup(hostname, cb)).then((ip) => { 11 | Logging.debug`Resolved hostname "${hostname}" to: ${ip}`; 12 | return ip; 13 | }); 14 | } 15 | 16 | function validateConfig(config: FileSystemConfig) { 17 | if (!config.proxy) throw new Error(`Missing field 'config.proxy'`); 18 | if (!config.proxy.type) throw new Error(`Missing field 'config.proxy.type'`); 19 | if (!config.proxy.host) throw new Error(`Missing field 'config.proxy.host'`); 20 | if (!config.proxy.port) throw new Error(`Missing field 'config.proxy.port'`); 21 | config.proxy.port = validatePort(config.proxy.port); 22 | } 23 | 24 | export async function socks(config: FileSystemConfig): Promise { 25 | Logging.info`Creating socks proxy connection for ${config.name}`; 26 | validateConfig(config); 27 | if (config.proxy!.type !== 'socks4' && config.proxy!.type !== 'socks5') { 28 | throw new Error(`Expected 'config.proxy.type' to be 'socks4' or 'socks5'`); 29 | } 30 | try { 31 | const ipaddress = (await resolveHostname(config.proxy!.host)); 32 | if (!ipaddress) throw new Error(`Couldn't resolve '${config.proxy!.host}'`); 33 | Logging.debug`\tConnecting to ${config.host}:${config.port} over ${config.proxy!.type} proxy at ${ipaddress}:${config.proxy!.port}`; 34 | const con = await SocksClient.createConnection({ 35 | command: 'connect', 36 | destination: { 37 | host: config.host!, 38 | port: config.port!, 39 | }, 40 | proxy: { 41 | ipaddress, 42 | port: config.proxy!.port, 43 | type: config.proxy!.type === 'socks4' ? 4 : 5, 44 | }, 45 | }); 46 | return con.socket as NodeJS.ReadableStream; 47 | } catch (e) { 48 | throw new Error(`Error while connecting to the the proxy: ${e.message}`); 49 | } 50 | } 51 | 52 | export function http(config: FileSystemConfig): Promise { 53 | Logging.info`Creating http proxy connection for ${config.name}`; 54 | validateConfig(config); 55 | return new Promise((resolve, reject) => { 56 | if (config.proxy!.type !== 'http') { 57 | reject(new Error(`Expected config.proxy.type' to be 'http'`)); 58 | } 59 | try { 60 | Logging.debug`\tConnecting to ${config.host}:${config.port} over http proxy at ${config.proxy!.host}:${config.proxy!.port}`; 61 | const req = request({ 62 | port: config.proxy!.port, 63 | hostname: config.proxy!.host, 64 | method: 'CONNECT', 65 | path: `${config.host}:${config.port}`, 66 | }); 67 | req.end(); 68 | req.on('connect', (res, socket) => { 69 | resolve(socket as NodeJS.ReadableStream); 70 | }); 71 | } catch (e) { 72 | reject(new Error(`Error while connecting to the the proxy: ${e.message}`)); 73 | } 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/putty.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as Winreg from 'winreg'; 3 | import { Logging } from './logging'; 4 | import { toPromise } from './utils'; 5 | 6 | const winreg = new Winreg({ 7 | hive: Winreg.HKCU, 8 | key: `\\Software\\SimonTatham\\PuTTY\\Sessions`, 9 | }); 10 | 11 | export type NumberAsBoolean = 0 | 1; 12 | export interface PuttySession { 13 | //[key: string]: string | number | undefined; 14 | // General settings 15 | name: string; 16 | hostname: string; 17 | protocol: string; 18 | portnumber: number; 19 | username?: string; 20 | usernamefromenvironment: NumberAsBoolean; 21 | tryagent: NumberAsBoolean; 22 | publickeyfile?: string; 23 | // Proxy settings 24 | proxyhost?: string; 25 | proxyport: number; 26 | proxylocalhost: NumberAsBoolean; 27 | proxymethod: number; // Key of ['None', 'SOCKS 4', 'SOCKS 5', 'HTTP', 'Telnet', 'Local'] 28 | } 29 | 30 | function valueFromItem(item: Winreg.RegistryItem) { 31 | switch (item.type) { 32 | case 'REG_DWORD': 33 | return parseInt(item.value, 16); 34 | case 'REG_SZ': 35 | return item.value; 36 | } 37 | throw new Error(`Unknown RegistryItem type: '${item.type}'`); 38 | } 39 | 40 | const FORMATTED_FIELDS: (keyof PuttySession)[] = [ 41 | 'name', 'hostname', 'protocol', 'portnumber', 42 | 'username', 'usernamefromenvironment', 'tryagent', 'publickeyfile', 43 | 'proxyhost', 'proxyport', 'proxylocalhost', 'proxymethod', 44 | ]; 45 | export function formatSession(session: PuttySession): string { 46 | const partial: Partial = {}; 47 | for (const field of FORMATTED_FIELDS) partial[field] = session[field] as any; 48 | return JSON.stringify(partial); 49 | } 50 | 51 | export async function getSessions() { 52 | Logging.info`Fetching PuTTY sessions from registry`; 53 | const values = await toPromise(cb => winreg.keys(cb)); 54 | const sessions: PuttySession[] = []; 55 | await Promise.all(values.map(regSession => (async () => { 56 | const name = decodeURIComponent(regSession.key.slice(winreg.key.length + 1)); 57 | const props = await toPromise(cb => regSession.values(cb)); 58 | const properties: { [key: string]: string | number } = {}; 59 | props.forEach(prop => properties[prop.name.toLowerCase()] = valueFromItem(prop)); 60 | sessions.push({ name, ...(properties as any) }); 61 | })())); 62 | Logging.debug`\tFound ${sessions.length} sessions:`; 63 | sessions.forEach(s => Logging.debug`\t- ${formatSession(s)}`); 64 | return sessions; 65 | } 66 | 67 | export async function findSession(sessions: PuttySession[], name?: string, host?: string, username?: string, nameOnly = true): Promise { 68 | if (name) { 69 | name = name.toLowerCase(); 70 | const session = sessions.find(s => s.name.toLowerCase() === name); 71 | if (nameOnly || session) return session; 72 | } 73 | if (!host) return undefined; 74 | host = host.toLowerCase(); 75 | const hosts = sessions.filter(s => s.hostname && s.hostname.toLowerCase() === host); 76 | if (!username) return hosts[0] || null; 77 | username = username.toLowerCase(); 78 | return hosts.find(s => !s.username || s.username.toLowerCase() === username); 79 | } 80 | 81 | export async function getSession(name?: string, host?: string, username?: string, nameOnly = true): Promise { 82 | const sessions = await getSessions(); 83 | return findSession(sessions, name, host, username, nameOnly); 84 | } 85 | 86 | export async function getCachedFinder(): Promise { 87 | const sessions = await getSessions(); 88 | return (...args) => findSession(sessions, ...args); 89 | } 90 | -------------------------------------------------------------------------------- /src/shellConfig.ts: -------------------------------------------------------------------------------- 1 | import { posix as path } from 'path'; 2 | import type { Client, ClientChannel, SFTP } from 'ssh2'; 3 | import type { Connection } from './connection'; 4 | import { Logger, Logging, LOGGING_NO_STACKTRACE } from './logging'; 5 | import { toPromise } from './utils'; 6 | 7 | const SCRIPT_COMMAND_CODE = `#!/bin/sh 8 | if [ "$#" -ne 1 ] || [ $1 = "help" ] || [ $1 = "--help" ] || [ $1 = "-h" ] || [ $1 = "-?" ]; then 9 | echo "Usage:"; 10 | echo " code Will make VS Code open the file"; 11 | echo " code Will make VS Code add the folder as an additional workspace folder"; 12 | echo " code Will prompt VS Code to create an empty file, then open it afterwards"; 13 | elif [ ! -n "$KELVIN_SSHFS_CMD_PATH" ]; then 14 | echo "Not running in a terminal spawned by SSH FS? Failed to sent!" 15 | elif [ -c "$KELVIN_SSHFS_CMD_PATH" ]; then 16 | echo "::sshfs:code:$(pwd):::$1" >> $KELVIN_SSHFS_CMD_PATH; 17 | echo "Command sent to SSH FS extension"; 18 | else 19 | echo "Missing command shell pty of SSH FS extension? Failed to sent!" 20 | fi 21 | `; 22 | 23 | type RemoteCommandInitializer = (connection: Connection) => void 24 | | string | string[] | undefined 25 | | Promise; 26 | 27 | async function ensureCachedFile(connection: Connection, key: string, path: string, content: string, sftp?: SFTP): 28 | Promise<[written: boolean, path: string | null]> { 29 | const rc_files: Record = connection.cache.rc_files ||= {}; 30 | if (rc_files[key]) return [false, rc_files[key]]; 31 | try { 32 | sftp ||= await toPromise(cb => connection.client.sftp(cb)); 33 | await toPromise(cb => sftp!.writeFile(path, content, { mode: 0o755 }, cb)); 34 | return [true, rc_files[key] = path]; 35 | } catch (e) { 36 | Logging.error`Failed to write ${key} file to '${path}':\n${e}`; 37 | return [false, null]; 38 | } 39 | } 40 | 41 | async function rcInitializePATH(connection: Connection): Promise { 42 | const dir = `/tmp/.Kelvin_sshfs.RcBin.${connection.actualConfig.username || Date.now()}`; 43 | const sftp = await toPromise(cb => connection.client.sftp(cb)); 44 | await toPromise(cb => sftp!.mkdir(dir, { mode: 0o755 }, cb)).catch(() => { }); 45 | const [, path] = await ensureCachedFile(connection, 'CmdCode', `${dir}/code`, SCRIPT_COMMAND_CODE, sftp); 46 | return path ? [ 47 | connection.shellConfig.setEnv('PATH', `${dir}:$PATH`), 48 | ] : 'echo "An error occured while adding REMOTE_COMMANDS support"'; 49 | } 50 | 51 | export interface ShellConfig { 52 | shell: string; 53 | isWindows: boolean; 54 | setEnv(key: string, value: string): string; 55 | setupRemoteCommands?: RemoteCommandInitializer; 56 | embedSubstitutions?(command: TemplateStringsArray, ...substitutions: (string | number)[]): string; 57 | } 58 | export const KNOWN_SHELL_CONFIGS: Record = {}; { 59 | const add = (shell: string, 60 | setEnv: (key: string, value: string) => string, 61 | setupRemoteCommands?: RemoteCommandInitializer, 62 | embedSubstitutions?: (command: TemplateStringsArray, ...substitutions: (string | number)[]) => string, 63 | isWindows = false) => { 64 | KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions, isWindows }; 65 | } 66 | // Ways to set an environment variable 67 | const setEnvExport = (key: string, value: string) => `export ${key}=${value}`; 68 | const setEnvSetGX = (key: string, value: string) => `set -gx ${key} ${value}`; 69 | const setEnvSetEnv = (key: string, value: string) => `setenv ${key} ${value}`; 70 | const setEnvPowerShell = (key: string, value: string) => `$env:${key}="${value}"`; 71 | const setEnvSet = (key: string, value: string) => `set ${key}=${value}`; 72 | // Ways to embed a substitution 73 | const embedSubstitutionsBackticks = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => 74 | '"' + substitutions.reduce((str, sub, i) => `${str}\`${sub}\`${command[i + 1]}`, command[0]) + '"'; 75 | const embedSubstitutionsFish = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => 76 | substitutions.reduce((str, sub, i) => `${str}"(${sub})"${command[i + 1]}`, '"' + command[0]) + '"'; 77 | const embedSubstitutionsPowershell = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => 78 | substitutions.reduce((str, sub, i) => `${str}$(${sub})${command[i + 1]}`, '"' + command[0]) + '"'; 79 | // Register the known shells 80 | add('sh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 81 | add('bash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 82 | add('rbash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 83 | add('ash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 84 | add('dash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 85 | add('ksh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 86 | add('zsh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); 87 | add('fish', setEnvSetGX, rcInitializePATH, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions 88 | add('csh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); 89 | add('tcsh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); 90 | add('powershell', setEnvPowerShell, undefined, embedSubstitutionsPowershell, true); // experimental 91 | add('cmd.exe', setEnvSet, undefined, undefined, true); // experimental 92 | } 93 | 94 | export async function tryCommand(ssh: Client, command: string): Promise { 95 | const exec = await toPromise(cb => ssh.exec(command, cb)); 96 | const output = ['', ''] as [string, string]; 97 | exec.on('data', (chunk: any) => output[0] += chunk); 98 | exec.stderr!.on('data', (chunk: any) => output[1] += chunk); 99 | await toPromise(cb => { 100 | exec.once('error', cb); 101 | exec.once('close', cb); 102 | }).catch(e => { 103 | if (typeof e !== 'number') throw e; 104 | throw new Error(`Command '${command}' failed with exit code ${e}${output[1] ? `:\n${output[1].trim()}` : ''}`); 105 | }); 106 | if (!output[0]) { 107 | if (!output[1]) return null; 108 | throw new Error(`Command '${command}' only produced stderr:\n${output[1].trim()}`); 109 | } 110 | return output[0]; 111 | } 112 | 113 | export async function tryEcho(ssh: Client, shellConfig: ShellConfig, variable: string): Promise { 114 | if (!shellConfig.embedSubstitutions) throw new Error(`Shell '${shellConfig.shell}' does not support embedding substitutions`); 115 | const uniq = Date.now() % 1e5; 116 | const output = await tryCommand(ssh, `echo ${shellConfig.embedSubstitutions`::${'echo ' + uniq}:echo_result:${`echo ${variable}`}:${'echo ' + uniq}::`}`); 117 | return output?.match(`::${uniq}:echo_result:(.*?):${uniq}::`)?.[1] || null; 118 | } 119 | 120 | async function getPowershellVersion(client: Client): Promise { 121 | const version = await tryCommand(client, 'echo $PSversionTable.PSVersion.ToString()').catch(e => { 122 | console.error(e); 123 | return null; 124 | }); 125 | return version?.match(/\d+\.\d+\.\d+\.\d+/)?.[0] || null; 126 | } 127 | 128 | async function getWindowsVersion(client: Client): Promise { 129 | const version = await tryCommand(client, 'systeminfo | findstr /BC:"OS Version"').catch(e => { 130 | console.error(e); 131 | return null; 132 | }); 133 | const match = version?.trim().match(/^OS Version:[ \t]+(.*)$/); 134 | return match?.[1] || null; 135 | } 136 | 137 | export async function calculateShellConfig(client: Client, logging?: Logger): Promise { 138 | try { 139 | const shellStdout = await tryCommand(client, 'echo :::SHELL:$SHELL:SHELL:::'); 140 | const shell = shellStdout?.match(/:::SHELL:([^$].*?):SHELL:::/)?.[1]; 141 | if (!shell) { 142 | const psVersion = await getPowershellVersion(client); 143 | if (psVersion) { 144 | logging?.debug(`Detected PowerShell version ${psVersion}`); 145 | return { ...KNOWN_SHELL_CONFIGS['powershell'], shell: 'PowerShell' }; 146 | } 147 | const windowsVersion = await getWindowsVersion(client); 148 | if (windowsVersion) { 149 | logging?.debug(`Detected Command Prompt for Windows ${windowsVersion}`); 150 | return { ...KNOWN_SHELL_CONFIGS['cmd.exe'], shell: 'cmd.exe' }; 151 | } 152 | if (shellStdout) logging?.error(`Could not get $SHELL from following output:\n${shellStdout}`, LOGGING_NO_STACKTRACE); 153 | throw new Error('Could not get $SHELL'); 154 | } 155 | const known = KNOWN_SHELL_CONFIGS[path.basename(shell)]; 156 | if (known) { 157 | logging?.debug(`Detected known $SHELL '${shell}' (${known.shell})`); 158 | return known; 159 | } else { 160 | logging?.warning(`Unrecognized $SHELL '${shell}', using default ShellConfig instead`); 161 | return { ...KNOWN_SHELL_CONFIGS['sh'], shell }; 162 | } 163 | } catch (e) { 164 | logging && logging.error`Error calculating ShellConfig: ${e}`; 165 | return { ...KNOWN_SHELL_CONFIGS['sh'], shell: '???' }; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/treeViewManager.ts: -------------------------------------------------------------------------------- 1 | 2 | import { FileSystemConfig, getGroups } from 'common/fileSystemConfig'; 3 | import * as vscode from 'vscode'; 4 | import { getConfigs, UPDATE_LISTENERS } from './config'; 5 | import type { Connection, ConnectionManager } from './connection'; 6 | import type { SSHPseudoTerminal } from './pseudoTerminal'; 7 | import type { SSHFileSystem } from './sshFileSystem'; 8 | import { formatItem } from './ui-utils'; 9 | 10 | type PendingConnection = [string, FileSystemConfig | undefined]; 11 | type TreeData = Connection | PendingConnection | SSHFileSystem | SSHPseudoTerminal; 12 | export class ConnectionTreeProvider implements vscode.TreeDataProvider { 13 | protected onDidChangeTreeDataEmitter = new vscode.EventEmitter(); 14 | public onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; 15 | constructor(protected readonly manager: ConnectionManager) { 16 | manager.onConnectionAdded(() => this.onDidChangeTreeDataEmitter.fire()); 17 | manager.onConnectionRemoved(() => this.onDidChangeTreeDataEmitter.fire()); 18 | manager.onConnectionUpdated(con => this.onDidChangeTreeDataEmitter.fire(con)); 19 | } 20 | public refresh() { 21 | this.onDidChangeTreeDataEmitter.fire(); 22 | } 23 | public getTreeItem(element: TreeData): vscode.TreeItem | Thenable { 24 | if ('onDidChangeFile' in element || 'handleInput' in element) { // SSHFileSystem | SSHPseudoTerminal 25 | return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.None } 26 | } else if (Array.isArray(element)) { // PendingConnection 27 | const [name, config] = element; 28 | if (!config) return { label: name, description: 'Connecting...' }; 29 | return { 30 | ...formatItem(config), 31 | contextValue: 'pendingconnection', 32 | collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, 33 | // Doesn't seem to actually spin, but still gets rendered properly otherwise 34 | iconPath: new vscode.ThemeIcon('loading~spin'), 35 | }; 36 | } 37 | // Connection 38 | return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed }; 39 | } 40 | public getChildren(element?: TreeData): vscode.ProviderResult { 41 | if (!element) return [ 42 | ...this.manager.getActiveConnections(), 43 | ...this.manager.getPendingConnections(), 44 | ]; 45 | if ('onDidChangeFile' in element) return []; // SSHFileSystem 46 | if ('handleInput' in element) return []; // SSHPseudoTerminal 47 | if (Array.isArray(element)) return []; // PendingConnection 48 | return [...element.terminals, ...element.filesystems]; // Connection 49 | } 50 | } 51 | 52 | export class ConfigTreeProvider implements vscode.TreeDataProvider { 53 | protected onDidChangeTreeDataEmitter = new vscode.EventEmitter(); 54 | public onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; 55 | constructor() { 56 | // Would be very difficult (and a bit useless) to pinpoint the exact 57 | // group/config that changes, so let's just update the whole tree 58 | UPDATE_LISTENERS.push(() => this.onDidChangeTreeDataEmitter.fire()); 59 | // ^ Technically a memory leak, but there should only be one ConfigTreeProvider that never gets discarded 60 | } 61 | public getTreeItem(element: FileSystemConfig | string): vscode.TreeItem | Thenable { 62 | if (typeof element === 'string') { 63 | return { 64 | label: element.replace(/^.+\./, ''), contextValue: 'group', 65 | collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, 66 | iconPath: vscode.ThemeIcon.Folder, 67 | }; 68 | } 69 | return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.None }; 70 | } 71 | public getChildren(element: FileSystemConfig | string = ''): vscode.ProviderResult<(FileSystemConfig | string)[]> { 72 | if (typeof element !== 'string') return []; // Configs don't have children 73 | const configs = getConfigs(); 74 | const matching = configs.filter(({ group }) => (group || '') === element); 75 | matching.sort((a, b) => a.name > b.name ? 1 : -1); 76 | let groups = getGroups(configs, true); 77 | if (element) { 78 | groups = groups.filter(g => g.startsWith(element) && g[element.length] === '.' && !g.includes('.', element.length + 1)); 79 | } else { 80 | groups = groups.filter(g => !g.includes('.')); 81 | } 82 | return [...matching, ...groups.sort()]; 83 | } 84 | } -------------------------------------------------------------------------------- /src/ui-utils.ts: -------------------------------------------------------------------------------- 1 | 2 | import { FileSystemConfig, parseConnectionString } from 'common/fileSystemConfig'; 3 | import * as vscode from 'vscode'; 4 | import { getConfigs } from './config'; 5 | import type { Connection, ConnectionManager } from './connection'; 6 | import type { Manager } from './manager'; 7 | import type { SSHPseudoTerminal } from './pseudoTerminal'; 8 | import type { SSHFileSystem } from './sshFileSystem'; 9 | 10 | export interface FormattedItem extends vscode.QuickPickItem, vscode.TreeItem { 11 | item: any; 12 | label: string; 13 | description?: string; 14 | iconPath?: vscode.IconPath; 15 | } 16 | 17 | export function formatAddress(config: FileSystemConfig): string { 18 | const { username, host, port } = config; 19 | return `${username ? `${username}@` : ''}${host}${port ? `:${port}` : ''}`; 20 | } 21 | 22 | export function setWhenClauseContext(key: string, value: any) { 23 | return vscode.commands.executeCommand('setContext', `sshfs.${key}`, value); 24 | } 25 | 26 | export function setupWhenClauseContexts(connectionManager: ConnectionManager): Promise { 27 | async function refresh() { 28 | const active = connectionManager.getActiveConnections(); 29 | const pending = connectionManager.getPendingConnections(); 30 | await setWhenClauseContext('openConnections', active.length + pending.length); 31 | await setWhenClauseContext('openTerminals', active.reduce((tot, con) => tot + con.terminals.length, 0)); 32 | await setWhenClauseContext('openFileSystems', active.reduce((tot, con) => tot + con.filesystems.length, 0)); 33 | } 34 | connectionManager.onConnectionAdded(refresh); 35 | connectionManager.onConnectionRemoved(refresh); 36 | connectionManager.onConnectionUpdated(refresh); 37 | connectionManager.onPendingChanged(refresh); 38 | return refresh(); 39 | } 40 | 41 | export let getExtensionUri: ((resource: string) => vscode.Uri) | undefined; 42 | export const setGetExtensionUri = (value: typeof getExtensionUri) => getExtensionUri = value; 43 | 44 | /** Converts the supported types to something basically ready-to-use as vscode.QuickPickItem and vscode.TreeItem */ 45 | export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | SSHPseudoTerminal, iconInLabel = false): FormattedItem { 46 | if ('handleInput' in item) { // SSHPseudoTerminal 47 | return { 48 | item, contextValue: 'terminal', 49 | label: `${iconInLabel ? '$(terminal) ' : ''}${item.terminal?.name || 'Unnamed terminal'}`, 50 | iconPath: new vscode.ThemeIcon('terminal'), 51 | command: { 52 | title: 'Focus', 53 | command: 'sshfs.focusTerminal', 54 | arguments: [item], 55 | }, 56 | }; 57 | } else if ('client' in item) { // Connection 58 | const { label, name, group } = item.config; 59 | const description = group ? `${group}.${name} ` : (label && name); 60 | const detail = formatAddress(item.actualConfig); 61 | return { 62 | item, description, detail, tooltip: detail, 63 | label: `${iconInLabel ? '$(plug) ' : ''}${label || name} `, 64 | iconPath: new vscode.ThemeIcon('plug'), 65 | collapsibleState: vscode.TreeItemCollapsibleState.Expanded, 66 | contextValue: 'connection', 67 | }; 68 | } else if ('onDidChangeFile' in item) { // SSHFileSystem 69 | const { label, name, group } = item.config; 70 | const description = group ? `${group}.${name} ` : (label && name); 71 | return { 72 | item, description, contextValue: 'filesystem', 73 | label: `${iconInLabel ? '$(root-folder) ' : ''}ssh://${item.authority}/`, 74 | iconPath: getExtensionUri?.('resources/icon.svg'), 75 | } 76 | } 77 | // FileSystemConfig 78 | const { label, name, group, putty } = item; 79 | const description = group ? `${group}.${name} ` : (label && name); 80 | const detail = putty === true ? 'PuTTY session (decuded from config)' : 81 | (typeof putty === 'string' ? `PuTTY session '${putty}'` : formatAddress(item)); 82 | return { 83 | item: item, description, detail, tooltip: detail, contextValue: 'config', 84 | label: `${iconInLabel ? '$(settings-gear) ' : ''}${item.label || item.name} `, 85 | iconPath: new vscode.ThemeIcon('settings-gear'), 86 | } 87 | } 88 | 89 | type QuickPickItemWithItem = vscode.QuickPickItem & { item: any }; 90 | 91 | export interface PickComplexOptions { 92 | /** If true and there is only one or none item is available, immediately resolve with it/undefined */ 93 | immediateReturn?: boolean; 94 | /** If true, add all connections. If this is a string, filter by config name first */ 95 | promptConnections?: boolean | string; 96 | /** If true, add an option to enter a connection string */ 97 | promptInstantConnection?: boolean; 98 | /** If true, add all configurations. If this is a string, filter by config name first */ 99 | promptConfigs?: boolean | string; 100 | /** If true, add all terminals. If this is a string, filter by config name first */ 101 | promptTerminals?: boolean | string; 102 | /** If set, filter the connections/configs by (config) name first */ 103 | nameFilter?: string; 104 | } 105 | 106 | async function inputInstantConnection(value?: string): Promise { 107 | const valueSelection = value ? [value.length, value.length] as [number, number] : undefined; 108 | const name = await vscode.window.showInputBox({ 109 | value, valueSelection, 110 | placeHolder: 'user@host:/home/user', 111 | prompt: 'SSH connection string', 112 | validateInput(value: string) { 113 | const result = parseConnectionString(value); 114 | return typeof result === 'string' ? result : undefined; 115 | } 116 | }); 117 | if (!name) return; 118 | const result = parseConnectionString(name); 119 | if (typeof result === 'string') return; 120 | return result[0]; 121 | } 122 | 123 | export async function pickComplex(manager: Manager, options: PickComplexOptions): 124 | Promise { 125 | return new Promise((resolve) => { 126 | const { promptConnections, promptConfigs, nameFilter } = options; 127 | const items: QuickPickItemWithItem[] = []; 128 | const toSelect: string[] = []; 129 | if (promptConnections) { 130 | let cons = manager.connectionManager.getActiveConnections(); 131 | if (typeof promptConnections === 'string') cons = cons.filter(con => con.actualConfig.name === promptConnections); 132 | if (nameFilter) cons = cons.filter(con => con.actualConfig.name === nameFilter); 133 | items.push(...cons.map(con => formatItem(con, true))); 134 | toSelect.push('connection'); 135 | } 136 | if (promptConfigs) { 137 | let configs = getConfigs(); 138 | if (typeof promptConfigs === 'string') configs = configs.filter(config => config.name === promptConfigs); 139 | if (nameFilter) configs = configs.filter(config => config.name === nameFilter); 140 | items.push(...configs.map(config => formatItem(config, true))); 141 | toSelect.push('configuration'); 142 | } 143 | if (options.promptTerminals) { 144 | let cons = manager.connectionManager.getActiveConnections(); 145 | if (typeof promptConnections === 'string') cons = cons.filter(con => con.actualConfig.name === promptConnections); 146 | if (nameFilter) cons = cons.filter(con => con.actualConfig.name === nameFilter); 147 | const terminals = cons.reduce((all, con) => [...all, ...con.terminals], []); 148 | items.push(...terminals.map(config => formatItem(config, true))); 149 | toSelect.push('terminal'); 150 | } 151 | if (options.promptInstantConnection) { 152 | items.unshift({ 153 | label: '$(terminal) Create instant connection', 154 | detail: 'Opens an input box where you can type e.g. `user@host:22/home/user`', 155 | picked: true, alwaysShow: true, 156 | item: inputInstantConnection, 157 | }); 158 | } 159 | if (options.immediateReturn && items.length <= 1) return resolve(items[0]?.item); 160 | const quickPick = vscode.window.createQuickPick(); 161 | quickPick.items = items; 162 | quickPick.title = 'Select ' + toSelect.join(' / '); 163 | quickPick.onDidAccept(() => { 164 | let value = quickPick.activeItems[0]?.item; 165 | quickPick.hide(); 166 | if (typeof value === 'function') { 167 | value = value(quickPick.value); 168 | } 169 | resolve(value); 170 | }); 171 | quickPick.onDidHide(() => resolve(undefined)); 172 | quickPick.show(); 173 | }); 174 | } 175 | 176 | export const pickConfig = (manager: Manager) => pickComplex(manager, { promptConfigs: true }) as Promise; 177 | export const pickConnection = (manager: Manager, name?: string) => 178 | pickComplex(manager, { promptConnections: name || true, immediateReturn: !!name }) as Promise; 179 | export const pickTerminal = (manager: Manager) => pickComplex(manager, { promptTerminals: true }) as Promise; 180 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { EnvironmentVariable } from 'common/fileSystemConfig'; 2 | import { DEBUG } from './logging'; 3 | 4 | function prepareStackTraceDefault(error: Error, stackTraces: NodeJS.CallSite[]): string { 5 | return stackTraces.reduce((s, c) => `${s}\n\tat ${c} (${c.getFunction()})`, `${error.name || "Error"}: ${error.message || ""}`); 6 | } 7 | function trimError(error: Error, depth: number): [string[], Error] { 8 | const pst = Error.prepareStackTrace; 9 | let trimmed = ''; 10 | Error.prepareStackTrace = (err, stack) => { 11 | const result = (pst || prepareStackTraceDefault)(err, stack.slice(depth + 1)); 12 | trimmed = (pst || prepareStackTraceDefault)(err, stack.slice(0, depth + 1)); 13 | return result; 14 | }; 15 | Error.captureStackTrace(error); 16 | error.stack = error.stack; 17 | Error.prepareStackTrace = pst; 18 | return [trimmed.split('\n').slice(1), error]; 19 | } 20 | /** Wrapper around async callback-based functions */ 21 | export async function catchingPromise(executor: (resolve: (value?: T | PromiseLike) => void, reject: (reason?: any) => void) => any, trimStack = 0, causeName = 'catchingPromise'): Promise { 22 | let [trimmed, promiseCause]: [string[], Error] = [] as any; 23 | return new Promise((resolve, reject) => { 24 | [trimmed, promiseCause] = trimError(new Error(), trimStack + 2); 25 | if (DEBUG) promiseCause.stack = promiseCause.stack!.split('\n', 2)[0] + trimmed.map(l => l.replace('at', '~at')).join('\n') + '\n' + promiseCause.stack!.split('\n').slice(1).join('\n'); 26 | try { 27 | const p = executor(resolve, reject); 28 | if (p instanceof Promise) { 29 | p.catch(reject); 30 | } 31 | } catch (e) { 32 | reject(e); 33 | } 34 | }).catch(e => { 35 | if (e instanceof Error) { 36 | let stack = e.stack; 37 | if (stack) { 38 | const lines = stack.split('\n'); 39 | let index = lines.indexOf(trimmed[3]); 40 | if (index !== -1) { 41 | index -= 2 + trimStack; 42 | e.stack = lines[0] + '\n' + lines.slice(1, index).join('\n'); 43 | if (DEBUG) e.stack += '\n' + lines.slice(index).map(l => l.replace('at', '~at')).join('\n'); 44 | } 45 | } 46 | let t = (e as any).promiseCause; 47 | if (!(t instanceof Error)) t = e; 48 | if (!('promiseCause' in t)) { 49 | Object.defineProperty(t, 'promiseCause', { 50 | value: promiseCause, 51 | configurable: true, 52 | enumerable: false, 53 | }); 54 | Object.defineProperty(t, 'promiseCauseName', { 55 | value: causeName, 56 | configurable: true, 57 | enumerable: false, 58 | }); 59 | } 60 | } 61 | throw e; 62 | }); 63 | } 64 | 65 | export type toPromiseCallback = (err?: Error | null | void, res?: T) => void; 66 | /** Wrapper around async callback-based functions */ 67 | export async function toPromise(func: (cb: toPromiseCallback) => void): Promise { 68 | return catchingPromise((resolve, reject) => { 69 | func((err, res) => err ? reject(err) : resolve(res!)); 70 | }, 2, 'toPromise'); 71 | } 72 | 73 | /** Converts the given number/string to a port number. Throws an error for invalid strings or ports outside the 1-65565 range */ 74 | export function validatePort(port: string | number): number { 75 | const p = Number(port); 76 | if (!Number.isInteger(p)) throw new Error(`Wanting to use non-int '${port}' as port`); 77 | if (p < 0 || p > 65565) throw new Error(`Wanting to use port ${p} outside the 1-65565 range`); 78 | return p; 79 | } 80 | 81 | const CLEAN_BASH_VALUE_REGEX = /^[\w-/\\]+$/; 82 | /** Based on way 1 in https://stackoverflow.com/a/20053121 */ 83 | export function escapeBashValue(value: string) { 84 | if (CLEAN_BASH_VALUE_REGEX.test(value)) return value; 85 | return `'${value.replace(/'/g, `'\\''`)}'`; 86 | } 87 | 88 | /** Convert an {@link EnvironmentVariable} array to a `export var1=val; export var2='escaped$val'` etc */ 89 | export function environmentToExportString(env: EnvironmentVariable[], createSetEnv: (key: string, value: string) => string): string { 90 | return env.map(({ key, value }) => createSetEnv(escapeBashValue(key), escapeBashValue(value))).join('; '); 91 | } 92 | 93 | /** Returns a new {@link EnvironmentVariable} array with all the given environments merged into it, overwriting same-key variables */ 94 | export function mergeEnvironment(...environments: (EnvironmentVariable[] | Record | undefined)[]): EnvironmentVariable[] { 95 | const result = new Map(); 96 | for (let other of environments) { 97 | if (!other) continue; 98 | if (Array.isArray(other)) { 99 | for (const variable of other) result.set(variable.key, variable); 100 | } else { 101 | for (const [key, value] of Object.entries(other)) { 102 | result.set(key, { key, value }); 103 | } 104 | } 105 | } 106 | return [...result.values()]; 107 | } 108 | 109 | /** Joins the commands together using the given separator. Automatically ignores `undefined` and empty strings */ 110 | export function joinCommands(commands: string | string[] | undefined, separator: string): string | undefined { 111 | if (typeof commands === 'string') return commands; 112 | return commands?.filter(c => c?.trim()).join(separator); 113 | } 114 | -------------------------------------------------------------------------------- /src/webview.ts: -------------------------------------------------------------------------------- 1 | 2 | import { getLocations } from 'common/fileSystemConfig'; 3 | import type { Message, Navigation } from 'common/webviewMessages'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import * as vscode from 'vscode'; 7 | import { deleteConfig, getConfigs, loadConfigs, updateConfig } from './config'; 8 | import { DEBUG, LOGGING_NO_STACKTRACE, Logging as _Logging } from './logging'; 9 | import { toPromise } from './utils'; 10 | 11 | const Logging = _Logging.scope('WebView'); 12 | 13 | let webviewPanel: vscode.WebviewPanel | undefined; 14 | let pendingNavigation: Navigation | undefined; 15 | 16 | function getExtensionPath(): string | undefined { 17 | const ext = vscode.extensions.getExtension('Kelvin.vscode-sshfs'); 18 | return ext && ext.extensionPath; 19 | } 20 | 21 | async function getDebugContent(): Promise { 22 | if (!DEBUG) return false; 23 | const URL = `http://localhost:3000/`; 24 | const http = await import('http'); 25 | return toPromise(cb => http.get(URL, async (message) => { 26 | if (message.statusCode !== 200) return cb(new Error(`Error code ${message.statusCode} (${message.statusMessage}) connecting to React dev server}`)); 27 | let body = ''; 28 | message.on('data', chunk => body += chunk); 29 | await toPromise(cb => message.on('end', cb)); 30 | body = body.toString().replace(/\/static\/js\/bundle\.js/, `${URL}/static/js/bundle.js`); 31 | // Make sure the CSP meta tag also includes the React dev server (including connect-src for the socket, which uses both http:// and ws://) 32 | body = body.replace(/\$WEBVIEW_CSPSOURCE/g, `$WEBVIEW_CSPSOURCE ${URL}`); 33 | body = body.replace(/\$WEBVIEW_CSPEXTRA/g, `connect-src ${URL} ${URL.replace('http://', 'ws://')};`); 34 | body = body.replace(/src="\/static\//g, `src="${URL}/static/`); 35 | cb(null, body); 36 | }).on('error', err => { 37 | Logging.warning(`Error connecting to React dev server: ${err}`); 38 | cb(new Error('Could not connect to React dev server. Not running?')); 39 | })); 40 | } 41 | 42 | export async function open() { 43 | if (!webviewPanel) { 44 | const extensionPath = getExtensionPath(); 45 | webviewPanel = vscode.window.createWebviewPanel('sshfs-settings', 'SSH-FS', vscode.ViewColumn.One, { enableFindWidget: true, enableScripts: true }); 46 | webviewPanel.onDidDispose(() => webviewPanel = undefined); 47 | if (extensionPath) webviewPanel.iconPath = vscode.Uri.file(path.join(extensionPath, 'resources/icon.svg')); 48 | const { webview } = webviewPanel; 49 | webview.onDidReceiveMessage(handleMessage); 50 | let content = await getDebugContent().catch((e: Error) => (vscode.window.showErrorMessage(e.message), null)); 51 | if (!content) { 52 | if (!extensionPath) throw new Error('Could not get extensionPath'); 53 | // If we got here, we're either not in debug mode, or something went wrong (and an error message is shown) 54 | content = fs.readFileSync(path.resolve(extensionPath, 'webview/build/index.html')).toString(); 55 | // Built index.html has e.g. `href="/static/js/stuff.js"`, need to make it use vscode-resource: and point to the built static directory 56 | // Scrap that last part, vscode-resource: is deprecated and we need to use Webview.asWebviewUri 57 | //content = content.replace(/\/static\//g, vscode.Uri.file(path.join(extensionPath, 'webview/build/static/')).with({ scheme: 'vscode-resource' }).toString()); 58 | content = content.replace(/\/static\//g, webview.asWebviewUri(vscode.Uri.file(path.join(extensionPath, 'webview/build/static/'))).toString()); 59 | } 60 | // Make sure the CSP meta tag has the right cspSource 61 | content = content.replace(/\$WEBVIEW_CSPSOURCE/g, webview.cspSource); 62 | // The EXTRA tag is used in debug mode to define connect-src. By default we can (and should) just delete it 63 | content = content.replace(/\$WEBVIEW_CSPEXTRA/g, ''); 64 | webview.html = content; 65 | } 66 | webviewPanel.reveal(); 67 | } 68 | 69 | export async function navigate(navigation: Navigation) { 70 | Logging.debug`Navigation requested: ${navigation}`; 71 | pendingNavigation = navigation; 72 | postMessage({ navigation, type: 'navigate' }); 73 | return open(); 74 | } 75 | 76 | function postMessage(message: T) { 77 | webviewPanel?.webview.postMessage(message); 78 | } 79 | 80 | async function handleMessage(message: Message): Promise { 81 | if (!webviewPanel) return Logging.warning`Got message without webviewPanel: ${message}`; 82 | Logging.debug`Got message: ${message}`; 83 | if (pendingNavigation) { 84 | postMessage({ 85 | type: 'navigate', 86 | navigation: pendingNavigation, 87 | }); 88 | pendingNavigation = undefined; 89 | } 90 | switch (message.type) { 91 | case 'requestData': { 92 | const configs = await (message.reload ? loadConfigs : getConfigs)(); 93 | const locations = getLocations(configs); 94 | return postMessage({ 95 | configs, locations, 96 | type: 'responseData', 97 | }); 98 | } 99 | case 'saveConfig': { 100 | const { uniqueId, config, name, remove } = message; 101 | let error: string | undefined; 102 | try { 103 | if (remove) { 104 | await deleteConfig(config); 105 | } else { 106 | await updateConfig(config, name); 107 | } 108 | } catch (e) { 109 | Logging.error('Error handling saveConfig message for settings UI:', LOGGING_NO_STACKTRACE); 110 | Logging.error(JSON.stringify(message), LOGGING_NO_STACKTRACE); 111 | Logging.error(e); 112 | error = e.message; 113 | } 114 | return postMessage({ 115 | uniqueId, config, error, 116 | type: 'saveConfigResult', 117 | }); 118 | } 119 | case 'promptPath': { 120 | const { uniqueId } = message; 121 | let uri: vscode.Uri | undefined; 122 | let error: string | undefined; 123 | try { 124 | const uris = await vscode.window.showOpenDialog({}); 125 | if (uris) [uri] = uris; 126 | } catch (e) { 127 | Logging.error`Error handling promptPath message for settings UI:\n${message}\n${e}`; 128 | error = e.message; 129 | } 130 | return postMessage({ 131 | uniqueId, 132 | path: uri && uri.fsPath, 133 | type: 'promptPathResult', 134 | }); 135 | } 136 | case 'navigated': { 137 | const { view } = message; 138 | type View = 'startscreen' | 'newconfig' | 'configeditor' | 'configlocator'; 139 | let title: string | undefined; 140 | switch (view as View) { 141 | case 'configeditor': 142 | title = 'SSH FS - Edit config'; 143 | break; 144 | case 'configlocator': 145 | title = 'SSH FS - Locate config'; 146 | break; 147 | case 'newconfig': 148 | title = 'SSH FS - New config'; 149 | break; 150 | } 151 | webviewPanel.title = title || 'SSH FS'; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "target": "ES2019", 7 | "outDir": "out", 8 | "lib": [ 9 | "ES2019" 10 | ], 11 | "sourceMap": true, 12 | "rootDir": "src" 13 | }, 14 | "compileOnSave": true, 15 | "include": [ 16 | "src" 17 | ] 18 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-airbnb" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "no-empty": false, 10 | "no-console": false, 11 | "curly": [ 12 | true, 13 | "ignore-same-line" 14 | ], 15 | "max-line-length": false, 16 | "array-type": false, 17 | "no-else-after-return": false, 18 | "import-name": false, 19 | "object-literal-sort-keys": false, 20 | "no-parameter-reassignment": false, 21 | "no-var-requires": false, 22 | "interface-name": false, 23 | "max-classes-per-file": false 24 | }, 25 | "rulesDirectory": [] 26 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | //@ts-check 3 | 'use strict'; 4 | 5 | const { join, resolve, dirname } = require('path'); 6 | const fs = require('fs'); 7 | const webpack = require('webpack'); 8 | const { WebpackPlugin } = require('./webpack.plugin'); 9 | 10 | /** 11 | * @template T 12 | * @param { (cb: (e?: Error | null, r?: T) => void) => any } func 13 | * @return { Promise } 14 | */ 15 | function wrap(func) { 16 | return new Promise((res, rej) => { 17 | try { 18 | // @ts-ignore 19 | func((e, r) => e ? rej(e) : res(r)); 20 | } catch (e) { 21 | rej(e); 22 | } 23 | }); 24 | } 25 | 26 | class CopyPuttyExecutable { 27 | /** 28 | * @param {webpack.Compiler} compiler 29 | */ 30 | apply(compiler) { 31 | const path = require.resolve('ssh2/util/pagent.exe'); 32 | // @ts-ignore 33 | const target = join(compiler.options.output.path, '../util/pagent.exe'); 34 | compiler.hooks.beforeRun.tapPromise('CopyPuttyExecutable-BeforeRun', () => new Promise((resolve, reject) => { 35 | fs.exists(path, exists => exists ? resolve() : reject(`Couldn't find executable at: ${path}`)); 36 | })); 37 | compiler.hooks.emit.tapPromise('CopyPuttyExecutable-Emit', async () => { 38 | /** @type {Buffer} */ 39 | const data = await wrap(cb => fs.readFile(path, cb)); 40 | await wrap(cb => fs.exists(dirname(target), res => cb(null, res))).then( 41 | exists => !exists && wrap(cb => fs.mkdir(dirname(target), cb)) 42 | ); 43 | await wrap(cb => compiler.outputFileSystem.writeFile(target, data, cb)); 44 | console.log(`[CopyPuttyExecutable] Wrote '${path}' to '${target}'`); 45 | }); 46 | } 47 | } 48 | 49 | /**@type {webpack.Configuration}*/ 50 | const config = { 51 | mode: 'development', 52 | target: 'node', 53 | node: false, 54 | entry: './src/extension.ts', 55 | output: { 56 | path: resolve(__dirname, 'dist'), 57 | filename: 'extension.js', 58 | libraryTarget: 'commonjs2', 59 | devtoolModuleFilenameTemplate: '../[resource-path]', 60 | clean: true, 61 | }, 62 | devtool: 'source-map', 63 | performance: { 64 | hints: 'warning' 65 | }, 66 | externals: { 67 | vscode: 'commonjs vscode', 68 | request: 'commonjs request', 69 | '.pnp.cjs': 'commonjs ../.pnp.cjs', 70 | 'source-map-support': 'commonjs source-map-support', 71 | }, 72 | resolve: { 73 | extensions: ['.ts', '.js'] 74 | }, 75 | module: { 76 | rules: [{ 77 | test: /\.ts$/, 78 | include: /src/, 79 | use: [{ 80 | loader: 'ts-loader', 81 | }] 82 | }] 83 | }, 84 | plugins: [ 85 | new CopyPuttyExecutable(), 86 | new WebpackPlugin(), 87 | new webpack.IgnorePlugin({ 88 | resourceRegExp: /\.node$/, 89 | }), 90 | ], 91 | optimization: { 92 | splitChunks: { 93 | minSize: 0, 94 | cacheGroups: { 95 | default: false, 96 | defaultVendors: false, 97 | }, 98 | }, 99 | }, 100 | stats: { 101 | ids: true, 102 | assets: false, 103 | chunks: false, 104 | entrypoints: true, 105 | modules: true, 106 | groupModulesByPath: true, 107 | modulesSpace: 50, 108 | }, 109 | } 110 | 111 | module.exports = config; 112 | -------------------------------------------------------------------------------- /webpack.plugin.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | "use strict"; 3 | 4 | const webpack = require("webpack"); 5 | const { createHash } = require("crypto"); 6 | 7 | const _formatIdCache = new Map(); 8 | /** @type {(id: string, rootPath: string) => string} */ 9 | function formatId(id, rootPath) { 10 | // Make sure all paths use / 11 | id = id.replace(/\\/g, "/"); 12 | // For `[path]` we unwrap, format then rewrap 13 | if (id[0] === "[" && id.endsWith("]")) { 14 | return `[${formatId(id.slice(1, id.length - 1), rootPath)}]`; 15 | } 16 | // When dealing with `path1!path2`, format each segment separately 17 | if (id.includes("!")) { 18 | id = id 19 | .split("!") 20 | .map((s) => formatId(s, rootPath)) 21 | .join("!"); 22 | } 23 | // Make the paths relative to the project's rooth path if possible 24 | if (id.startsWith(rootPath)) { 25 | id = id.slice(rootPath.length); 26 | id = (id[0] === "/" ? "." : "./") + id; 27 | } 28 | let formatted = _formatIdCache.get(id); 29 | if (formatted) return formatted; 30 | // Check if we're dealing with a Yarn directory 31 | let match = id.match(/^.*\/(\.?Yarn\/Berry|\.yarn)\/(.*)$/i); 32 | if (!match) { 33 | _formatIdCache.set(id, (formatted = id)); 34 | return formatted; 35 | } 36 | const [, yarn, filepath] = match; 37 | // Check if we can extract the package name/version from the path 38 | match = 39 | filepath.match(/^unplugged\/([^/]+?)\-[\da-f]{10}\/node_modules\/(.*)$/i) || 40 | filepath.match(/^cache\/([^/]+?)\-[\da-f]{10}\-\d+\.zip\/node_modules\/(.*)$/i); 41 | if (!match) { 42 | formatted = `/${yarn.toLowerCase() === ".yarn" ? "." : ""}yarn/${filepath}`; 43 | _formatIdCache.set(id, formatted); 44 | return formatted; 45 | } 46 | const [, name, path] = match; 47 | formatted = `${yarn.toLowerCase() === ".yarn" ? "." : "/"}yarn/${name}/${path}`; 48 | _formatIdCache.set(id, formatted); 49 | return formatted; 50 | } 51 | module.exports.formatId = formatId; 52 | 53 | class WebpackPlugin { 54 | _hashModuleCache = new Map(); 55 | /** @type {(mod: webpack.Module, rootPath: string) => string} */ 56 | hashModule(mod, rootPath) { 57 | // Prefer `nameForCondition()` as it usually gives the actual file path 58 | // while `identifier()` can have extra `!` or `|` suffixes, i.e. a hash that somehow differs between devices 59 | const identifier = formatId(mod.nameForCondition() || mod.identifier(), rootPath); 60 | let hash = this._hashModuleCache.get(identifier); 61 | if (hash) return hash; 62 | hash = createHash("sha1").update(identifier).digest("hex"); 63 | this._hashModuleCache.set(identifier, hash); 64 | return hash; 65 | } 66 | /** @param {webpack.Compiler} compiler */ 67 | apply(compiler) { 68 | // Output start/stop messages making the $ts-webpack-watch problemMatcher (provided by an extension) work 69 | let compilationDepth = 0; // We ignore nested compilations 70 | compiler.hooks.beforeCompile.tap("WebpackPlugin-BeforeCompile", (params) => { 71 | if (compilationDepth++) return; 72 | console.log("Compilation starting"); 73 | }); 74 | compiler.hooks.afterCompile.tap("WebpackPlugin-AfterCompile", () => { 75 | if (--compilationDepth) return; 76 | console.log("Compilation finished"); 77 | }); 78 | compiler.hooks.compilation.tap("WebpackPlugin-Compilation", (compilation) => { 79 | const rootPath = (compilation.options.context || "").replace(/\\/g, "/"); 80 | compilation.options.optimization.chunkIds = false; 81 | // Format `../../../Yarn/Berry/` with all the `cache`/`unplugged`/`__virtual__` to be more readable 82 | // (i.e. `/yarn/package-npm-x.y.z/package/index.js` for global Yarn cache or `/.yarn/...` for local) 83 | compilation.hooks.statsPrinter.tap("WebpackPlugin-StatsPrinter", (stats) => { 84 | /** @type {(id: string | {}, context: any) => string} */ 85 | const tapModId = (id, context) => (typeof id === "string" ? formatId(context.formatModuleId(id), rootPath) : "???"); 86 | stats.hooks.print.for("module.name").tap("WebpackPlugin-ModuleName", tapModId); 87 | }); 88 | // Include an `excludeModules` to `options.stats` to exclude modules loaded by dependencies 89 | compilation.hooks.statsNormalize.tap("WebpackPlugin-StatsNormalize", (stats) => { 90 | (stats.excludeModules || (stats.excludeModules = [])).push((name, { issuerPath }) => { 91 | if (name.startsWith('external "')) return true; 92 | const issuer = issuerPath && (issuerPath[issuerPath.length - 1].name || "").replace(/\\/g, "/"); 93 | if (!issuer) return false; 94 | const lower = formatId(issuer, rootPath).toLowerCase(); 95 | if (lower.startsWith("/yarn/")) return true; 96 | if (lower.startsWith(".yarn/")) return true; 97 | return false; 98 | }); 99 | }); 100 | // Determines how chunk IDs are generated, which is now actually deterministic 101 | // (we make sure to clean Yarn paths to prevent issues with `../../Yarn/Berry` being different on devices) 102 | compilation.hooks.chunkIds.tap("WebpackPlugin-ChunkIds", (chunks) => { 103 | const chunkIds = new Map(); 104 | const overlapMap = new Set(); 105 | let minLength = 4; // show at least 3 characters 106 | // Calculate the hashes for all the chunks 107 | for (const chunk of chunks) { 108 | if (chunk.id) { 109 | console.log(`Chunk ${chunk.id} already has an ID`); 110 | } 111 | // We're kinda doing something similar to Webpack 5's DeterministicChunkIdsPlugin but different 112 | const modules = compilation.chunkGraph.getChunkRootModules(chunk); 113 | const hashes = modules.map((m) => this.hashModule(m, rootPath)).sort(); 114 | const hasher = createHash("sha1"); 115 | for (const hash of hashes) hasher.update(hash); 116 | const hash = hasher.digest("hex"); 117 | // With a 160-bit value, a clash is very unlikely, but let's check anyway 118 | if (chunkIds.has(hash)) throw new Error("Hash collision for chunk IDs"); 119 | chunkIds.set(chunk, hash); 120 | chunk.id = hash; 121 | // Make sure the minLength remains high enough to avoid collisions 122 | for (let i = minLength; i < hash.length; i++) { 123 | const part = hash.slice(0, i); 124 | if (overlapMap.has(part)) continue; 125 | overlapMap.add(part); 126 | minLength = i; 127 | break; 128 | } 129 | } 130 | // Assign the shortened (collision-free) hashes for all the chunks 131 | for (const [chunk, hash] of chunkIds) { 132 | chunk.id = hash.slice(0, minLength); 133 | chunk.ids = [chunk.id]; 134 | } 135 | }); 136 | }); 137 | } 138 | } 139 | module.exports.WebpackPlugin = WebpackPlugin; 140 | -------------------------------------------------------------------------------- /webview/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | .eslintcache 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /webview/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webview", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "webpack serve", 7 | "build": "webpack --mode production" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.21.3", 11 | "@babel/eslint-parser": "^7.21.3", 12 | "@babel/plugin-transform-react-jsx": "^7.21.0", 13 | "@babel/preset-react": "^7.18.6", 14 | "@pmmmwh/react-refresh-webpack-plugin": "0.5.0-rc.3", 15 | "@types/react": "^17.0.18", 16 | "@types/react-dom": "^17.0.9", 17 | "@types/react-redux": "^7.1.7", 18 | "@typescript-eslint/eslint-plugin": "^5.56.0", 19 | "@typescript-eslint/parser": "^5.56.0", 20 | "babel-loader": "^9.1.2", 21 | "babel-preset-react-app": "^10.0.1", 22 | "css-loader": "4.3.0", 23 | "css-minimizer-webpack-plugin": "^3.0.2", 24 | "dotenv": "8.2.0", 25 | "eslint": "^8.36.0", 26 | "eslint-config-react-app": "^7.0.1", 27 | "eslint-plugin-flowtype": "^8.0.3", 28 | "eslint-plugin-import": "^2.27.5", 29 | "eslint-plugin-jsx-a11y": "^6.7.1", 30 | "eslint-plugin-react": "^7.32.2", 31 | "eslint-plugin-react-hooks": "^4.6.0", 32 | "eslint-webpack-plugin": "^4.0.0", 33 | "html-webpack-plugin": "^5.3.2", 34 | "mini-css-extract-plugin": "0.11.3", 35 | "pnp-webpack-plugin": "^1.7.0", 36 | "react": "^17.0.2", 37 | "react-dom": "^17.0.2", 38 | "react-redux": "^7.2.0", 39 | "react-refresh": "^0.10.0", 40 | "redux": "^4.0.5", 41 | "style-loader": "1.3.0", 42 | "ts-loader": "^9.4.2", 43 | "tslib": "^2.5.0", 44 | "typescript": "~5.7.3", 45 | "url-loader": "4.1.1", 46 | "webpack": "^5.76.3", 47 | "webpack-cli": "^5.0.1", 48 | "webpack-dev-server": "^4.13.1" 49 | }, 50 | "browserslist": [ 51 | "Electron >= 29.1.0" 52 | ], 53 | "eslintConfig": { 54 | "extends": [ 55 | "react-app" 56 | ], 57 | "rules": { 58 | "no-sparse-arrays": 0, 59 | "no-sequences": 0, 60 | "react/jsx-uses-react": 0, 61 | "react/react-in-jsx-scope": 0 62 | } 63 | }, 64 | "babel": { 65 | "presets": [ 66 | "react-app" 67 | ] 68 | }, 69 | "dependencies": { 70 | "common": "workspace:*" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /webview/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React App 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /webview/src/ConfigEditor/configGroupField.tsx: -------------------------------------------------------------------------------- 1 | import { getGroups } from 'common/fileSystemConfig'; 2 | import { FieldDropdownWithInput } from '../FieldTypes/dropdownwithinput'; 3 | import { connect } from '../redux'; 4 | 5 | export interface StateProps { 6 | values: string[]; 7 | } 8 | 9 | export default connect(FieldDropdownWithInput)( 10 | state => ({ values: getGroups(state.data.configs).sort() }), 11 | ); 12 | -------------------------------------------------------------------------------- /webview/src/ConfigEditor/index.css: -------------------------------------------------------------------------------- 1 | 2 | div.ConfigEditor { 3 | padding: 5px; 4 | } 5 | 6 | div.ConfigEditor div.header { 7 | display: flex; 8 | } 9 | div.ConfigEditor div.title { 10 | margin: 6px; 11 | height: 100%; 12 | } 13 | 14 | div.ConfigEditor h3 { 15 | margin: 0; 16 | } 17 | div.ConfigEditor h4 { 18 | margin: 0; 19 | font-size: 85%; 20 | } 21 | 22 | div.ConfigEditor > div.divider { 23 | margin-top: 20px; 24 | } 25 | -------------------------------------------------------------------------------- /webview/src/ConfigEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import { FileSystemConfig, formatConfigLocation } from 'common/fileSystemConfig'; 2 | import * as React from 'react'; 3 | import { FieldGroup } from '../FieldTypes/group'; 4 | import { connect, pickProperties } from '../redux'; 5 | import type { IConfigEditorState } from '../view'; 6 | import { configEditorSetNewConfig, configEditorSetStatusMessage, openStartScreen } from '../view/actions'; 7 | import { deleteConfig, saveConfig } from '../vscode'; 8 | import * as Fields from './fields'; 9 | import './index.css'; 10 | 11 | interface StateProps { 12 | newConfig: FileSystemConfig; 13 | oldConfig: FileSystemConfig; 14 | statusMessage?: string; 15 | } 16 | interface DispatchProps { 17 | setNewConfig(config: FileSystemConfig): void; 18 | confirm(config: FileSystemConfig, oldName: string): void; 19 | delete(config: FileSystemConfig): void; 20 | cancel(): void; 21 | } 22 | class ConfigEditor extends React.Component { 23 | public render() { 24 | const { newConfig, oldConfig } = this.props; 25 | return 26 |
27 |
28 | 29 |
30 |

{oldConfig.label || oldConfig.name}

31 |

{formatConfigLocation(oldConfig._location!)}

32 |
33 |
34 | {Fields.FIELDS.map(f => f(newConfig, this.onChange, this.onChangeMultiple)).filter(e => e)} 35 |
36 | {group => 37 | 38 | 39 | 40 | } 41 |
42 | ; 43 | } 44 | protected onChange: Fields.FSCChanged = (key, value) => { 45 | console.log(`Changed field '${key}' to: ${value}`); 46 | this.props.setNewConfig({ ...this.props.newConfig, [key]: value }); 47 | }; 48 | protected onChangeMultiple: Fields.FSCChangedMultiple = (newConfig) => { 49 | console.log('Overwriting config fields:', newConfig); 50 | this.props.setNewConfig({ ...this.props.newConfig, ...newConfig }); 51 | }; 52 | } 53 | 54 | interface SubState { view: IConfigEditorState }; 55 | export default connect(ConfigEditor)( 56 | (state) => pickProperties(state.view, 'newConfig', 'oldConfig', 'statusMessage'), 57 | (dispatch) => ({ 58 | setNewConfig(config) { 59 | dispatch(configEditorSetNewConfig(config)); 60 | }, 61 | async confirm(config, oldName) { 62 | dispatch(configEditorSetStatusMessage('Saving...')); 63 | try { 64 | await saveConfig(config, oldName); 65 | } catch (e) { 66 | dispatch(configEditorSetStatusMessage(`Error while saving: ${e}`)); 67 | return; 68 | } 69 | dispatch(openStartScreen()); 70 | }, 71 | async delete(config) { 72 | dispatch(configEditorSetStatusMessage('Deleting...')); 73 | try { 74 | await deleteConfig(config); 75 | } catch (e) { 76 | dispatch(configEditorSetStatusMessage(`Error while deleting: ${e}`)); 77 | return; 78 | } 79 | dispatch(openStartScreen()); 80 | }, 81 | cancel() { 82 | dispatch(openStartScreen()); 83 | }, 84 | }) 85 | ); 86 | -------------------------------------------------------------------------------- /webview/src/ConfigEditor/proxyFields.tsx: -------------------------------------------------------------------------------- 1 | import type { FileSystemConfig } from 'common/fileSystemConfig'; 2 | import * as React from 'react'; 3 | import { FieldConfig } from '../FieldTypes/config'; 4 | import { FieldDropdown } from '../FieldTypes/dropdown'; 5 | import { FieldNumber } from '../FieldTypes/number'; 6 | import { FieldString } from '../FieldTypes/string'; 7 | import type { FieldFactory, FSCChanged, FSCChangedMultiple } from './fields'; 8 | 9 | function hostAndPort(config: FileSystemConfig, onChange: FSCChanged<'proxy'>): React.ReactElement { 10 | const onChangeHost = (host: string) => onChange('proxy', { ...config.proxy!, host }); 11 | const onChangePort = (port: number) => onChange('proxy', { ...config.proxy!, port }); 12 | console.log('Current config:', config); 13 | return 14 | 16 | 18 | ; 19 | } 20 | 21 | function hop(config: FileSystemConfig, onChange: FSCChanged<'hop'>): React.ReactElement { 22 | const callback = (newValue?: FileSystemConfig) => onChange('hop', newValue?.name); 23 | const description = 'Use another configuration as proxy, using a SSH tunnel through the targeted config to the actual remote system'; 24 | return ; 25 | } 26 | 27 | enum ProxyType { http, socks4, socks5, hop } 28 | const ProxyTypeNames: Record = { 29 | [ProxyType.http]: 'HTTP', 30 | [ProxyType.socks4]: 'SOCKS 4', 31 | [ProxyType.socks5]: 'SOCKS 5', 32 | [ProxyType.hop]: 'SSH Hop', 33 | }; 34 | 35 | function Merged(props: { config: FileSystemConfig, onChange: FSCChanged, onChangeMultiple: FSCChangedMultiple }): React.ReactElement | null { 36 | const { config, onChange, onChangeMultiple } = props; 37 | const [showHop, setShowHop] = React.useState(!!config.hop); 38 | function callback(newValue?: ProxyType) { 39 | if (!newValue) { 40 | setShowHop(false); 41 | return onChangeMultiple({ 42 | hop: undefined, 43 | proxy: undefined, 44 | }); 45 | } 46 | if (newValue === ProxyType.hop) { 47 | setShowHop(true); 48 | return onChangeMultiple({ proxy: undefined }); 49 | } 50 | setShowHop(false); 51 | return onChangeMultiple({ 52 | hop: undefined, 53 | proxy: { 54 | host: '', 55 | port: 22, 56 | ...config.proxy, 57 | type: ProxyType[newValue] as any, 58 | } 59 | }); 60 | } 61 | const description = 'The type of proxy to use when connecting to the remote system'; 62 | const values: ProxyType[] = [ProxyType.hop, ProxyType.socks4, ProxyType.socks5, ProxyType.http]; 63 | const type = config.proxy?.type; 64 | const value = (config.hop || showHop) ? ProxyType.hop : (type && ProxyType[type]); 65 | return 66 | ProxyTypeNames[i!]} onChange={callback} optional /> 67 | {(config.hop || showHop) && hop(config, onChange)} 68 | {config.proxy && hostAndPort(config, onChange)} 69 | ; 70 | } 71 | 72 | export const PROXY_FIELD: FieldFactory = (config, onChange, onChangeMultiple) => 73 | ; 74 | -------------------------------------------------------------------------------- /webview/src/ConfigList/index.css: -------------------------------------------------------------------------------- 1 | 2 | div.ConfigList { 3 | border: 1px solid var(--vscode-settings-dropdownBorder); 4 | border-radius: 4px; 5 | padding: 0; 6 | } 7 | 8 | div.ConfigList > ul { 9 | padding: 0; 10 | margin: 0; 11 | } 12 | 13 | div.ConfigList > ul > li { 14 | display: block; 15 | margin: 0; 16 | padding: 5px; 17 | border-bottom: 1px solid var(--vscode-settings-dropdownListBorder); 18 | background: var(--vscode-settings-dropdownBackground); 19 | color: var(--vscode-settings-dropdownForeground); 20 | } 21 | div.ConfigList > ul > li:hover { 22 | background: var(--vscode-list-hoverBackground); 23 | } 24 | div.ConfigList > ul > li:last-of-type { 25 | border: none; 26 | } 27 | -------------------------------------------------------------------------------- /webview/src/ConfigList/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FileSystemConfig } from 'common/fileSystemConfig'; 2 | import * as React from 'react'; 3 | import { connect } from '../redux'; 4 | import { openConfigEditor } from '../view/actions'; 5 | import './index.css'; 6 | 7 | interface StateProps { 8 | list: FileSystemConfig[]; 9 | } 10 | interface DispatchProps { 11 | editConfig(config: FileSystemConfig): void; 12 | } 13 | interface OwnProps { 14 | configs?: FileSystemConfig[]; 15 | displayName?(config: FileSystemConfig): string | undefined; 16 | } 17 | class ConfigList extends React.Component { 18 | public render() { 19 | const { list } = this.props; 20 | if (!list.length) return

No configurations found

; 21 | return
22 |
    23 | {list.map(this.editConfigClickHandler, this)} 24 |
25 |
; 26 | } 27 | public editConfigClickHandler(config: FileSystemConfig) { 28 | const { displayName } = this.props; 29 | const name = displayName?.(config) || config.label || config.name; 30 | const onClick = () => this.props.editConfig(config); 31 | return
  • {name}
  • ; 32 | } 33 | } 34 | 35 | export default connect(ConfigList)( 36 | (state, props: OwnProps) => ({ list: props.configs || state.data.configs }), 37 | dispatch => ({ 38 | editConfig(config: FileSystemConfig) { 39 | dispatch(openConfigEditor(config)); 40 | }, 41 | }) 42 | ); 43 | -------------------------------------------------------------------------------- /webview/src/ConfigLocator.tsx: -------------------------------------------------------------------------------- 1 | import { FileSystemConfig, formatConfigLocation } from 'common/fileSystemConfig'; 2 | import * as React from 'react'; 3 | import ConfigList from './ConfigList'; 4 | import { connect, pickProperties } from './redux'; 5 | import type { IConfigLocatorState } from './view'; 6 | 7 | function displayName(config: FileSystemConfig) { 8 | return formatConfigLocation(config._location); 9 | } 10 | 11 | interface StateProps { 12 | configs: FileSystemConfig[]; 13 | name: string; 14 | } 15 | class ConfigLocator extends React.Component { 16 | public render() { 17 | const { configs, name } = this.props; 18 | return
    19 |

    Locations of {name}

    20 | 21 |
    ; 22 | } 23 | } 24 | 25 | interface SubState { view: IConfigLocatorState } 26 | export default connect(ConfigLocator)( 27 | state => pickProperties(state.view, 'configs', 'name'), 28 | ); 29 | -------------------------------------------------------------------------------- /webview/src/FieldTypes/base.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FieldGroup } from './group'; 3 | import './index.css'; 4 | 5 | export interface Props { 6 | label?: string; 7 | description?: React.ReactNode; 8 | value: T; 9 | optional?: boolean; 10 | group?: FieldGroup; 11 | preface?: React.ReactNode; 12 | postface?: React.ReactNode; 13 | validator?(value: T): string | null; 14 | onChange(newValue?: T): void; 15 | } 16 | interface State { 17 | oldValue: T; 18 | newValue?: T; 19 | } 20 | 21 | type WrappedState = { 22 | [key in keyof S]: key extends keyof State ? State[key] : S[key]; 23 | } & State; 24 | 25 | export abstract class FieldBase extends React.Component & P, WrappedState> { 26 | constructor(props: Props & P) { 27 | super(props); 28 | this.state = { 29 | newValue: props.value, 30 | oldValue: props.value, 31 | ...this.getInitialSubState(props) as any, 32 | } 33 | } 34 | public getInitialSubState(props: Props & P): S { 35 | return {} as S; 36 | } 37 | public onChange = (newValue?: T) => { 38 | this.setState({ newValue }, () => this.props.onChange(newValue)); 39 | } 40 | public componentDidUpdate(oldProps: Props) { 41 | const { value } = this.props; 42 | if (oldProps.value === value) return; 43 | this.setState({ oldValue: value, newValue: value }); 44 | } 45 | protected abstract renderInput(): React.ReactNode; 46 | public getError(): string | null { 47 | const { newValue } = this.state; 48 | const { validator, optional } = this.props; 49 | if (newValue === undefined) { 50 | if (optional) return null; 51 | return 'No value given'; 52 | } 53 | return validator ? validator(newValue!) : null; 54 | } 55 | public getValue(): T | undefined { 56 | const { newValue, oldValue } = this.state; 57 | if (newValue === undefined) { 58 | return this.props.optional ? newValue : oldValue; 59 | } 60 | return newValue!; 61 | } 62 | public getLabel(): string { 63 | return this.props.label || ''; 64 | } 65 | protected getClassName(): string { return 'Field'; } 66 | protected getValueClassName(): string { return 'value'; } 67 | public render() { 68 | const error = this.getError(); 69 | const { description, label, optional, preface, postface } = this.props; 70 | return
    71 | {group => (group?.register(this), [])} 72 | {label && <>
    {label}
    {optional &&
    Optional
    }} 73 | {description &&
    {description}
    } 74 | {preface} 75 | {error &&
    {error}
    } 76 |
    77 | {this.renderInput()} 78 |
    79 | {postface} 80 |
    ; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /webview/src/FieldTypes/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { FieldBase } from './base'; 2 | 3 | interface Props { 4 | optional?: false; 5 | } 6 | export class FieldCheckbox extends FieldBase { 7 | protected getClassName() { 8 | return super.getClassName() + ' FieldCheckbox'; 9 | } 10 | protected getValueClassName() { 11 | return super.getValueClassName() + ' checkbox-box'; 12 | } 13 | public renderInput() { 14 | const { description } = this.props; 15 | return <> 16 |
    17 | {description &&
    {this.props.description}
    } 18 | ; 19 | } 20 | public getError() { 21 | const { newValue } = this.state; 22 | const { validator, optional } = this.props; 23 | if (newValue === undefined) { 24 | if (optional) return null; 25 | return 'No value given'; 26 | } else if (typeof newValue !== 'boolean') { 27 | return 'Not a boolean'; 28 | } 29 | return validator ? validator(newValue!) : null; 30 | } 31 | public getValue(): boolean | undefined { 32 | const { newValue, oldValue } = this.state; 33 | if (newValue === undefined) { 34 | return this.props.optional ? newValue : oldValue; 35 | } 36 | return newValue; 37 | } 38 | public onChange = (newValue?: boolean) => { 39 | this.setState({ newValue }, () => this.props.onChange(newValue)); 40 | } 41 | public onClick = () => this.onChange(!this.getValue()); 42 | } -------------------------------------------------------------------------------- /webview/src/FieldTypes/config.tsx: -------------------------------------------------------------------------------- 1 | import { FileSystemConfig, formatConfigLocation } from 'common/fileSystemConfig'; 2 | import { connect } from '../redux'; 3 | import type { Props as FieldBaseProps } from './base'; 4 | import { FieldDropdown, Props as FieldDropdownProps } from './dropdown'; 5 | 6 | type Props = Omit, 'value'> & FieldDropdownProps & { 7 | /** 8 | * Defaults to `'full'`. Determines how `values` and the default `displayName`. 9 | * In which way to display configs in the dropdown and how to handle duplicate: 10 | * - `full`: Display `