├── .nvmrc ├── src ├── test │ ├── fixtures │ │ ├── file.txt │ │ └── folder │ │ │ ├── file.txt │ │ │ └── another-file.txt │ ├── unit │ │ ├── extension.test.ts │ │ ├── helpers │ │ │ ├── error-message.test.ts │ │ │ ├── files.test.ts │ │ │ ├── prompt.test.ts │ │ │ └── string-manipulations.test.ts │ │ └── actions.test.ts │ └── index.ts ├── helpers │ ├── error-message.ts │ ├── files.ts │ ├── prompt.ts │ └── string-manipulations.ts ├── types.ts ├── extension.ts └── actions.ts ├── images ├── logo.png ├── command-list.png └── command-list-explorer.png ├── .prettierrc ├── .gitignore ├── .vscodeignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── .editorconfig ├── tslint.json ├── tsconfig.json ├── .travis.yml ├── CONTRIBUTING.md ├── appveyor.yml ├── LICENSE ├── webpack.config.js ├── vsc-extension-quickstart.md ├── README.md ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.15.1 2 | -------------------------------------------------------------------------------- /src/test/fixtures/file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/fixtures/folder/file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/fixtures/folder/another-file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willmendesneto/vscode-file-extra/HEAD/images/logo.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /images/command-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willmendesneto/vscode-file-extra/HEAD/images/command-list.png -------------------------------------------------------------------------------- /images/command-list-explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willmendesneto/vscode-file-extra/HEAD/images/command-list-explorer.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | .DS_Store 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .nyc-output 7 | coverage 8 | tsconfig.json 9 | webpack.config.json 10 | dist 11 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .vscode-test 3 | node_modules 4 | out 5 | src/** 6 | package-lock.json 7 | tsconfig.json 8 | webpack.config.js 9 | .travis.yml 10 | appveyor.yml 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "ms-vscode.vscode-typescript-tslint-plugin" 6 | ] 7 | } -------------------------------------------------------------------------------- /src/helpers/error-message.ts: -------------------------------------------------------------------------------- 1 | import { window as vsWindow } from 'vscode'; 2 | 3 | const errorMessage = (err: Error) => { 4 | const errMsg = err.message.replace(/[\\|\/]/g, '').replace(/`|'/g, '**'); 5 | 6 | vsWindow.showErrorMessage(`Error: ${errMsg}`); 7 | }; 8 | 9 | export { errorMessage }; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-string-throw": true, 4 | "no-unused-expression": true, 5 | "no-duplicate-variable": true, 6 | "curly": true, 7 | "class-name": true, 8 | "semicolon": [true, "always"], 9 | "triple-equals": true 10 | }, 11 | "defaultSeverity": "warning" 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": ["es6"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "esModuleInterop": true, 10 | }, 11 | "include": [ 12 | "src/**/*.ts", 13 | "./node_modules/vscode/vscode.d.ts", 14 | "./node_modules/vscode/lib/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | os: 6 | - osx 7 | 8 | stages: 9 | - test 10 | 11 | before_install: 12 | - if [ $TRAVIS_OS_NAME == "linux" ]; then 13 | export CXX="g++-4.9" CC="gcc-4.9" DISPLAY=:99.0; 14 | sudo apt-get --assume-yes install libsecret-1-0; 15 | sh -e /etc/init.d/xvfb start; 16 | sleep 3; 17 | fi 18 | 19 | install: 20 | - npm install 21 | - npm run build:prod 22 | 23 | script: 24 | - npm run lint --silent 25 | - npm run test-compile && npm run test-ci --silent 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "javascript.format.enable": false, 12 | "editor.tabSize": 2 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Contribute with this extension is easy: 4 | 5 | - You can report bugs and request features using the [issues page][issues]. 6 | 7 | [issues]: https://github.com/willmendesneto/vscode-file-extra/issues 8 | 9 | We love pull requests from everyone: 10 | 11 | - Fork the project. 12 | - Make the respective code changes. 13 | - Go to the debugger in VS Code, choose `Run Extension` and click run. You can test your changes. 14 | - Choose `Extension Tests` to run the tests. 15 | - Push to your fork and [submit a pull request][https://github.com/willmendesneto/vscode-file-extra/compare/]. 16 | -------------------------------------------------------------------------------- /src/test/unit/extension.test.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Note: This example test is leveraging the Mocha test framework. 3 | // Please refer to their documentation on https://mochajs.org/ for help. 4 | // 5 | 6 | // The module 'assert' provides assertion methods from node 7 | import assert from 'assert'; 8 | 9 | import { activate } from '../../extension'; 10 | 11 | // TODO: Use `ExtensionContext` type for context 12 | const context: any = { 13 | subscriptions: [], 14 | }; 15 | 16 | describe('Extension Tests', () => { 17 | it('Should have only 7 commands', () => { 18 | activate(context); 19 | 20 | assert.equal(context.subscriptions.length, 7); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/helpers/files.ts: -------------------------------------------------------------------------------- 1 | import { commands, window, workspace, TextEditor, TextDocument } from 'vscode'; 2 | 3 | /** 4 | * Open file after duplicate action. 5 | */ 6 | const openFile = async (filepath: string): Promise => { 7 | const document = await (workspace.openTextDocument( 8 | filepath 9 | ) as Promise); 10 | await commands.executeCommand('workbench.files.action.refreshFilesExplorer'); 11 | 12 | return window.showTextDocument(document); 13 | }; 14 | 15 | const buildFilepath = (newName: string, workspaceRootPath: string): string => { 16 | return `${workspaceRootPath}/${newName}`; 17 | }; 18 | 19 | export { openFile, buildFilepath }; 20 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # appveyor file 2 | # http://www.appveyor.com/docs/appveyor-yml 3 | 4 | # build version format 5 | version: "{build}" 6 | # Fix line endings on Windows 7 | init: 8 | - git config --global core.autocrlf input 9 | - git config --system core.longpaths true 10 | environment: 11 | matrix: 12 | - nodejs_version: "10.18.1" 13 | platform: 14 | - x86 15 | - x64 16 | install: 17 | - ps: Install-Product node $env:nodejs_version 18 | - npm install 19 | - npm run build:prod 20 | test_script: 21 | # Output useful info for debugging. 22 | - node --version 23 | - npm --version 24 | # Run the test 25 | - cmd: npm run lint --silent 26 | - cmd: npm run test-compile && npm run test-ci --silent 27 | 28 | build: off 29 | matrix: 30 | fast_finish: true 31 | -------------------------------------------------------------------------------- /src/helpers/prompt.ts: -------------------------------------------------------------------------------- 1 | import { window, MessageItem } from 'vscode'; 2 | 3 | const showWarningMessage = ( 4 | message: string, 5 | options: Object = {} 6 | ): Promise => 7 | window.showWarningMessage(message, { 8 | title: 'OK', 9 | isCloseAffordance: false, 10 | ...options, 11 | }) as Promise; 12 | 13 | const showInformationMessage = ( 14 | message: string 15 | ): Thenable => window.showInformationMessage(message); 16 | 17 | const showInputBox = ( 18 | placeHolder: string, 19 | filename: string 20 | ): Promise => { 21 | return window.showInputBox({ 22 | placeHolder, 23 | value: filename, 24 | }) as Promise; 25 | }; 26 | 27 | export { showInputBox, showWarningMessage, showInformationMessage }; 28 | -------------------------------------------------------------------------------- /src/test/unit/helpers/error-message.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import sinon from 'sinon'; 3 | import proxyquire from 'proxyquire'; 4 | 5 | describe('Error Helper', () => { 6 | let errorMessage; 7 | const sandbox = sinon.createSandbox(); 8 | const showErrorMessage = sandbox.stub(); 9 | 10 | beforeEach(() => { 11 | errorMessage = proxyquire('../../../helpers/error-message', { 12 | vscode: { 13 | window: { showErrorMessage }, 14 | }, 15 | }).errorMessage; 16 | }); 17 | 18 | afterEach(() => sandbox.reset()); 19 | 20 | after(() => sandbox.restore()); 21 | 22 | it('should build path to file', () => { 23 | const error = new Error('Dummy message'); 24 | errorMessage(error); 25 | 26 | assert.equal(showErrorMessage.firstCall.args[0], `Error: ${error.message}`); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Stats } from 'fs'; 2 | import { Uri, TextEditor, TextDocument, WorkspaceFolder } from 'vscode'; 3 | import { ParsedPath } from 'path'; 4 | 5 | export type FileStats = Stats; 6 | export type FileParsedPath = ParsedPath; 7 | 8 | export interface IPluginSettings { 9 | openFileAfterDuplication: boolean; 10 | closeFileAfterRemove: boolean; 11 | } 12 | 13 | export type ActionParams = { 14 | uri: Uri; 15 | settings: IPluginSettings; 16 | workspaceFolders?: WorkspaceFolder[]; 17 | }; 18 | 19 | export type URITextEditorCommand = TextDocument | Uri; 20 | 21 | export type ActionParamsBuilder = { 22 | uri: URITextEditorCommand; 23 | settings: IPluginSettings; 24 | editor: TextEditor | undefined; 25 | workspaceFolders?: WorkspaceFolder[]; 26 | }; 27 | 28 | export type CopyOptions = { 29 | removeRoot: boolean; 30 | }; 31 | -------------------------------------------------------------------------------- /src/helpers/string-manipulations.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceFolder } from 'vscode'; 2 | 3 | const removeFirstSlashInString = (s: string = '') => s.replace(/^\/+/g, ''); 4 | const removeLastSlashInString = (s: string = '') => s.replace(/\/$/, ''); 5 | 6 | const removeWorkspaceUrlRootFromUrl = ( 7 | workspaceFolders: WorkspaceFolder[], 8 | url: string 9 | ) => { 10 | const workspaceUrl = getWorkspaceUrlRootFromUrl(workspaceFolders, url); 11 | 12 | return url.replace(workspaceUrl, ''); 13 | }; 14 | 15 | const getWorkspaceUrlRootFromUrl = ( 16 | workspaceFolders: WorkspaceFolder[], 17 | url: string 18 | ) => { 19 | const workspacePaths = workspaceFolders.map((w) => w.uri.fsPath); 20 | return workspacePaths.find((workspacePath) => url.includes(workspacePath)); 21 | }; 22 | 23 | export { 24 | removeFirstSlashInString, 25 | removeLastSlashInString, 26 | removeWorkspaceUrlRootFromUrl, 27 | getWorkspaceUrlRootFromUrl, 28 | }; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Will Mendes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/test/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING 3 | // 4 | // This file is providing the test runner to use when running extension tests. 5 | // By default the test runner in use is Mocha based. 6 | // 7 | // You can provide your own test runner if you want to override it by exporting 8 | // a function run(testsRoot: string, clb: (error: Error, failures?: number) => void): void 9 | // that the extension host can call to run the tests. The test runner is expected to use console.log 10 | // to report the results back to the caller. When the tests are finished, return 11 | // a possible error to the callback or null if none. 12 | 13 | import * as testRunner from 'vscode/lib/testrunner'; 14 | 15 | // You can directly control Mocha options by configuring the test runner below 16 | // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options 17 | // for more info 18 | testRunner.configure({ 19 | ui: 'bdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) 20 | reporter: 'list', 21 | useColors: true, // colored output from test results 22 | }); 23 | 24 | module.exports = testRunner; 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 14 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 15 | "preLaunchTask": "npm: webpack" 16 | }, 17 | { 18 | "name": "Extension Tests", 19 | "type": "extensionHost", 20 | "request": "launch", 21 | "runtimeExecutable": "${execPath}", 22 | "args": [ 23 | "--disable-extensions", 24 | "--extensionDevelopmentPath=${workspaceFolder}", 25 | "--extensionTestsPath=${workspaceFolder}/out/test" 26 | ], 27 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 28 | "preLaunchTask": "npm: test-compile" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/test/unit/helpers/files.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import sinon from 'sinon'; 3 | import proxyquire from 'proxyquire'; 4 | 5 | const WORKSPACE_DIR = '/Workspace/example'; 6 | const FILENAME = 'folder/test/file.ts'; 7 | 8 | describe('Files Helper', () => { 9 | let files; 10 | 11 | const sandbox = sinon.createSandbox(); 12 | const showTextDocument = sandbox.stub(); 13 | const executeCommand = sandbox.stub(); 14 | const openTextDocument = sandbox.stub(); 15 | 16 | beforeEach(() => { 17 | openTextDocument.returns(Promise.resolve(FILENAME)); 18 | executeCommand.returns(Promise.resolve(true)); 19 | files = proxyquire('../../../helpers/files', { 20 | vscode: { 21 | window: { showTextDocument }, 22 | commands: { executeCommand }, 23 | workspace: { openTextDocument }, 24 | }, 25 | }); 26 | }); 27 | 28 | afterEach(() => sandbox.reset()); 29 | 30 | after(() => sandbox.restore()); 31 | 32 | describe('#buildFilepath', () => { 33 | it('should build file path based on workspace root and file location', () => { 34 | assert.equal( 35 | files.buildFilepath('folder/test/file.ts', WORKSPACE_DIR), 36 | '/Workspace/example/folder/test/file.ts' 37 | ); 38 | }); 39 | }); 40 | 41 | describe('#openFile', () => { 42 | it('should force refresh for file explorer', async () => { 43 | await files.openFile(FILENAME); 44 | 45 | assert.equal( 46 | executeCommand.firstCall.args[0], 47 | 'workbench.files.action.refreshFilesExplorer' 48 | ); 49 | }); 50 | 51 | it('should show the received file in the editor', async () => { 52 | await files.openFile(FILENAME); 53 | 54 | assert.equal(showTextDocument.firstCall.args[0], FILENAME); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | //@ts-check 7 | 8 | const path = require('path'); 9 | 10 | /**@type {import('webpack').Configuration}*/ 11 | const config = { 12 | target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 13 | 14 | entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 15 | output: { 16 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 17 | path: path.resolve(__dirname, 'dist'), 18 | filename: 'extension.js', 19 | libraryTarget: 'commonjs2', 20 | devtoolModuleFilenameTemplate: '../[resource-path]', 21 | }, 22 | devtool: 'source-map', 23 | externals: { 24 | vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 25 | }, 26 | resolve: { 27 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 28 | extensions: ['.ts', '.js'], 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.ts$/, 34 | exclude: /node_modules/, 35 | use: [ 36 | { 37 | loader: 'ts-loader', 38 | options: { 39 | compilerOptions: { 40 | module: 'es6', // override `tsconfig.json` so that TypeScript emits native JavaScript modules. 41 | }, 42 | }, 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | }; 49 | 50 | module.exports = config; 51 | -------------------------------------------------------------------------------- /src/test/unit/helpers/prompt.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import sinon from 'sinon'; 3 | import proxyquire from 'proxyquire'; 4 | 5 | const WORKSPACE_DIR = '/Workspace/example'; 6 | 7 | describe('Prompt Helper', () => { 8 | let prompt; 9 | 10 | const FILENAME = 'folder/test/file.ts'; 11 | 12 | const sandbox = sinon.createSandbox(); 13 | const showInformationMessage = sandbox.stub(); 14 | const showInputBox = sandbox.stub(); 15 | const showWarningMessage = sandbox.stub(); 16 | 17 | beforeEach(() => { 18 | prompt = proxyquire('../../../helpers/prompt', { 19 | vscode: { 20 | window: { showWarningMessage, showInformationMessage, showInputBox }, 21 | }, 22 | }); 23 | }); 24 | 25 | afterEach(() => sandbox.reset()); 26 | 27 | after(() => sandbox.restore()); 28 | 29 | describe('#showWarningMessage', () => { 30 | it('should render a warning message with the given message and using the given options', async () => { 31 | const message = 'message'; 32 | const opts = { modal: true }; 33 | await prompt.showWarningMessage(message, opts); 34 | assert.deepEqual(showWarningMessage.firstCall.args[0], message); 35 | 36 | assert.deepEqual(showWarningMessage.firstCall.args[1], { 37 | title: 'OK', 38 | isCloseAffordance: false, 39 | ...opts, 40 | }); 41 | }); 42 | }); 43 | 44 | describe('#showInformationMessage', () => { 45 | it('should render an information modal with the given message', async () => { 46 | const message = 'message'; 47 | await prompt.showInformationMessage(message); 48 | 49 | assert.deepEqual(showInformationMessage.firstCall.args[0], message); 50 | }); 51 | }); 52 | 53 | describe('#showInputBox', () => { 54 | it('should render an input box with the using the given default text and placeholder', async () => { 55 | const placeHolder = 'Placeholder'; 56 | const value = 'folder/test/file.ts'; 57 | await prompt.showInputBox(placeHolder, value); 58 | 59 | assert.deepEqual(showInputBox.firstCall.args[0], { 60 | placeHolder, 61 | value, 62 | }); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/test/unit/helpers/string-manipulations.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { resolve, join } from 'path'; 3 | 4 | import { 5 | removeFirstSlashInString, 6 | removeLastSlashInString, 7 | removeWorkspaceUrlRootFromUrl, 8 | getWorkspaceUrlRootFromUrl, 9 | } from '../../../helpers/string-manipulations'; 10 | 11 | const STRING_TEST = '/Folder/internal/file.js/'; 12 | 13 | describe('String Manipulation Helper', () => { 14 | it('should remove first slash in the string', () => { 15 | assert.equal( 16 | removeFirstSlashInString(STRING_TEST), 17 | 'Folder/internal/file.js/' 18 | ); 19 | }); 20 | 21 | it('should remove first slash in the string', () => { 22 | assert.equal( 23 | removeLastSlashInString(STRING_TEST), 24 | '/Folder/internal/file.js' 25 | ); 26 | }); 27 | 28 | it('should remove workspace root from file path url', () => { 29 | const filename = 'file.txt'; 30 | 31 | const workspaceRootPath = resolve(join(__dirname, './../fixtures')); 32 | const workspaceFolders: any[] = [ 33 | { 34 | index: 0, 35 | name: 'fixtures', 36 | uri: { fsPath: workspaceRootPath }, 37 | }, 38 | ]; 39 | 40 | assert.equal( 41 | removeWorkspaceUrlRootFromUrl( 42 | workspaceFolders, 43 | `${workspaceRootPath}/${filename}` 44 | ), 45 | `/${filename}` 46 | ); 47 | }); 48 | 49 | it('should return active workspace root from file path url', () => { 50 | const filename = 'file.txt'; 51 | 52 | const workspaceRootPath = resolve(join(__dirname, './../fixtures')); 53 | const anotherWorkspaceRootPath = resolve(join(__dirname, './../unit')); 54 | const workspaceFolders: any[] = [ 55 | { 56 | index: 0, 57 | name: 'fixtures', 58 | uri: { fsPath: workspaceRootPath }, 59 | }, 60 | { 61 | index: 0, 62 | name: 'unit', 63 | uri: { fsPath: anotherWorkspaceRootPath }, 64 | }, 65 | ]; 66 | 67 | assert.equal( 68 | getWorkspaceUrlRootFromUrl( 69 | workspaceFolders, 70 | `${workspaceRootPath}/${filename}` 71 | ), 72 | workspaceRootPath 73 | ); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | * This folder contains all of the files necessary for your extension. 6 | * `package.json` - this is the manifest file in which you declare your extension and command. 7 | * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | * `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Get up and running straight away 13 | 14 | * Press `F5` to open a new window with your extension loaded. 15 | * Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 16 | * Set breakpoints in your code inside `src/extension.ts` to debug your extension. 17 | * Find output from your extension in the debug console. 18 | 19 | ## Make changes 20 | 21 | * You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 22 | * You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 23 | 24 | ## Explore the API 25 | 26 | * You can open the full set of our API when you open the file `node_modules/vscode/vscode.d.ts`. 27 | 28 | ## Run tests 29 | 30 | * Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. 31 | * Press `F5` to run the tests in a new window with your extension loaded. 32 | * See the output of the test result in the debug console. 33 | * Make changes to `test/extension.test.ts` or create new test files inside the `test` folder. 34 | * By convention, the test runner will only consider files matching the name pattern `**.test.ts`. 35 | * You can create folders inside the `test` folder to structure your tests any way you want. 36 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Uri, 3 | workspace, 4 | window as vsWindow, 5 | commands, 6 | ExtensionContext, 7 | } from 'vscode'; 8 | 9 | import { 10 | IPluginSettings, 11 | ActionParams, 12 | URITextEditorCommand, 13 | ActionParamsBuilder, 14 | } from './types'; 15 | import { 16 | duplicate, 17 | remove, 18 | renameFile, 19 | add, 20 | copyFilePath, 21 | copyRelativeFilePath, 22 | copyFileName as copyFileNameOnly, 23 | } from './actions'; 24 | import { showInformationMessage } from './helpers/prompt'; 25 | 26 | const getActionParams = ({ 27 | settings, 28 | uri, 29 | editor, 30 | workspaceFolders = [], 31 | }: ActionParamsBuilder): ActionParams | undefined => { 32 | let URI: Uri = uri; 33 | if (!URI || !(URI as Uri).fsPath) { 34 | if (!editor) { 35 | return; 36 | } 37 | URI = editor.document.uri; 38 | } 39 | 40 | return { 41 | settings, 42 | workspaceFolders, 43 | uri: URI, 44 | }; 45 | }; 46 | 47 | const startCommand = (uri: URITextEditorCommand, callback: Function) => { 48 | const params: ActionParams | undefined = getActionParams({ 49 | uri, 50 | workspaceFolders: workspace.workspaceFolders, 51 | settings: workspace.getConfiguration().get('fileExtra') as IPluginSettings, 52 | editor: vsWindow.activeTextEditor, 53 | }); 54 | 55 | if (!params) { 56 | return showInformationMessage( 57 | `Please make sure your editor is focused before run this command.` 58 | ); 59 | } 60 | return callback(params); 61 | }; 62 | 63 | const activate = (context: ExtensionContext): void => { 64 | const duplicateFileOrFolder = commands.registerCommand( 65 | 'fileExtraDuplicate.execute', 66 | (uri: URITextEditorCommand) => startCommand(uri, duplicate) 67 | ); 68 | 69 | const removeFileOrFolder = commands.registerCommand( 70 | 'fileExtraRemove.execute', 71 | (uri: URITextEditorCommand) => startCommand(uri, remove) 72 | ); 73 | 74 | const renameFileOrFolder = commands.registerCommand( 75 | 'fileExtraRename.execute', 76 | (uri: URITextEditorCommand) => startCommand(uri, renameFile) 77 | ); 78 | 79 | const addFileOrFolder = commands.registerCommand( 80 | 'fileExtraAdd.execute', 81 | (uri: URITextEditorCommand) => startCommand(uri, add) 82 | ); 83 | 84 | const copyFileUrl = commands.registerCommand( 85 | 'fileExtraCopyFilePath.execute', 86 | (uri: URITextEditorCommand) => startCommand(uri, copyFilePath) 87 | ); 88 | 89 | const copyRelativeFileUrl = commands.registerCommand( 90 | 'fileExtraCopyRelativeFilePath.execute', 91 | (uri: URITextEditorCommand) => startCommand(uri, copyRelativeFilePath) 92 | ); 93 | 94 | const copyFileName = commands.registerCommand( 95 | 'fileExtraCopyFileName.execute', 96 | (uri: URITextEditorCommand) => startCommand(uri, copyFileNameOnly) 97 | ); 98 | 99 | context.subscriptions.push(duplicateFileOrFolder); 100 | context.subscriptions.push(removeFileOrFolder); 101 | context.subscriptions.push(renameFileOrFolder); 102 | context.subscriptions.push(addFileOrFolder); 103 | context.subscriptions.push(copyFileUrl); 104 | context.subscriptions.push(copyFileName); 105 | context.subscriptions.push(copyRelativeFileUrl); 106 | }; 107 | 108 | export { activate }; 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VSCode File Extra 2 | 3 | > Working with files in VSCode like a boss 😎 4 | 5 | [![Build Status](https://travis-ci.org/willmendesneto/vscode-file-extra.svg?branch=master)](https://travis-ci.org/willmendesneto/vscode-file-extra) 6 | [![Build Windows Status](https://ci.appveyor.com/api/projects/status/github/willmendesneto/vscode-file-extra?svg=true)](https://ci.appveyor.com/project/willmendesneto/vscode-file-extra/branch/master) 7 | [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 8 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) 9 | 10 | ![Yeoman](./images/logo.png) 11 | 12 | ## VSCode Marketplate 13 | 14 | You can check more about the package on [VSCode File Extra page in VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=willmendesneto.vscode-file-extra). 15 | 16 | ## Install 17 | 18 | Press `F1` or type `cmd+shift+p` or `cmd+shift+p`, type `ext install` and press [Enter]. After that search for `vscode-file-extra`. 19 | 20 | ## Features 21 | 22 | ### Editor Explorer 23 | 24 | Go to the explorer list of files and folder and click on the file. 25 | 26 | Options: 27 | 28 | - `Duplicate File or Folder`: Duplicates File or Folder based on the focus location; 29 | 30 | ![Available Commands in Editor Explorer](images/command-list-explorer.png) 31 | 32 | ### Command palette 33 | 34 | Type `cmd+shift+p` or `cmd+shift+p` for open the command pallete. All the commands have the prefix "FileExtra: ". So, after that, you can select one of these commands pressing [Enter] to confirm, or [Escape] to cancel. 35 | 36 | ![Available Commands in Command Pallete](images/command-list.png) 37 | 38 | ### Keyboard shortcuts 39 | 40 | - Add new file or folder - `shift+alt+n shift+alt+f` 41 | - Copy file name - `shift+alt+f shift+alt+n` 42 | - Copy file path - `shift+alt+c shift+alt+f` 43 | - Copy relative file path - `shift+alt+c shift+alt+r` 44 | - Delete/Remove file or folder - `shift+alt+r shift+alt+m` 45 | - Duplicate file or folder - `shift+alt+c shift+alt+p` 46 | - Rename file - `shift+alt+m shift+alt+v` 47 | 48 | ## Requirements 49 | 50 | If you have any requirements or dependencies, add a section describing those and how to install and configure them. 51 | 52 | ## Extension Settings 53 | 54 | - `fileExtra.openFileAfterDuplication`: Automatically open newly duplicated files 55 | - `fileExtra.closeFileAfterRemove`: Automatically close file in editor after remove 56 | 57 | ### Run tests 58 | 59 | 1. Run `npm test` for run tests. In case you want to test using watch, please use `npm run tdd` 60 | 61 | ### Publish 62 | 63 | this project is using `vsce` package to publish, which makes things straightforward. EX: `vsce publish ` 64 | 65 | > For more details, [please check vsce package on npmjs.com](https://www.npmjs.com/package/vsce) and read VSCode docs ["Publishing Extension"](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) 66 | 67 | ## Changes 68 | 69 | See [CHANGELOG.md](./CHANGELOG.md) for more details. 70 | 71 | ## Contributing 72 | 73 | Feel free to contribute for this extension. You can find more details in our [How to Contribute](./CONTRIBUTING.md) docs 74 | 75 | ## Author 76 | 77 | **Wilson Mendes (willmendesneto)** 78 | 79 | - 80 | - 81 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "vscode-file-extra" extension will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | ## [Unreleased] 8 | 9 | ## [3.1.0][] - 2020-12-11 10 | 11 | ### Fixing 12 | 13 | - Removing error when editor is not focused 14 | 15 | ### Updated 16 | 17 | - Updating dependencies to the latest 18 | 19 | ## [3.0.0][] - 2020-05-18 20 | 21 | ### Added 22 | 23 | - Adding `npm run clean` to remove repository artifacts 24 | 25 | ### Fixed 26 | 27 | - Fixing File methods when opening multiple workspaces 28 | - Fixing File Name methods (Copy, Copy Relative path, etc.) when opening multiple workspaces 29 | - Fixing folder addition when file is not hidden or doesn't have any extension 30 | 31 | ## [2.2.0][] - 2020-04-23 32 | 33 | ## Added 34 | 35 | - Adding `CONTRIBUTING.md` docs 36 | - Bumping dependencies to the latest version 37 | - Upgrading NodeJS to v10.20.1 38 | 39 | ## [2.0.0][] - 2019-03-23 40 | 41 | ## Fix 42 | 43 | - Changing keybindings to avoid conflict with default VSCode aliases 44 | 45 | ## [1.3.0][] - 2019-03-22 46 | 47 | ## Added 48 | 49 | - Keybindings for fileExtra events 50 | 51 | ## Updated 52 | 53 | - Updated keybindings 54 | 55 | ## [1.2.0][] - 2019-03-19 56 | 57 | ## Added 58 | 59 | - Added unit tests for proxyquire 60 | - Support copy file name 61 | 62 | ## Updated 63 | 64 | - Disabling extensions when running tests via editor 65 | 66 | ## [1.1.3][] - 2019-03-17 67 | 68 | ## Fixed 69 | 70 | - Fixing Build task in CI 71 | 72 | ## [1.1.2][] - 2019-03-17 73 | 74 | ## Fixed 75 | 76 | - Fixing Build task in CI 77 | 78 | ## [1.1.1][] - 2019-03-17 79 | 80 | ## Fixed 81 | 82 | - Fixing Test task in CI 83 | 84 | ## Added 85 | 86 | - Adding CHANGELOG automation 87 | - Adding link for page in VSCode Marketplace 88 | 89 | ## [1.1.0][] - 2019-03-17 90 | 91 | ### Updated 92 | 93 | - Decreased bundle size of the extension 94 | 95 | ## [1.0.0][] - 2019-03-17 96 | 97 | ### Added 98 | 99 | - Support for add file or folder 100 | - Support for rename file or folder 101 | - Support for remove file or folder 102 | - Support for duplicate file or folder 103 | - Support for copy file path 104 | - Support for copy relative file path 105 | - Adding Cross platform CI with Travis-CI and Appveyor 106 | 107 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v1.1.1...HEAD 108 | [1.1.1]: https://github.com/willmendesneto/vscode-file-extra/tree/v1.1.1 109 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v1.1.2...HEAD 110 | [1.1.2]: https://github.com/willmendesneto/vscode-file-extra/tree/v1.1.2 111 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v1.1.3...HEAD 112 | [1.1.3]: https://github.com/willmendesneto/vscode-file-extra/tree/v1.1.3 113 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v1.2.0...HEAD 114 | [1.2.0]: https://github.com/willmendesneto/vscode-file-extra/tree/v1.2.0 115 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v1.3.0...HEAD 116 | [1.3.0]: https://github.com/willmendesneto/vscode-file-extra/tree/v1.3.0 117 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v2.0.0...HEAD 118 | [2.0.0]: https://github.com/willmendesneto/vscode-file-extra/tree/v2.0.0 119 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v2.2.0...HEAD 120 | [2.2.0]: https://github.com/willmendesneto/vscode-file-extra/tree/v2.2.0 121 | [unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v3.0.0...HEAD 122 | [3.0.0]: https://github.com/willmendesneto/vscode-file-extra/tree/v3.0.0 123 | 124 | 125 | [Unreleased]: https://github.com/willmendesneto/vscode-file-extra/compare/v3.1.0...HEAD 126 | [3.1.0]: https://github.com/willmendesneto/vscode-file-extra/tree/v3.1.0 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-file-extra", 3 | "displayName": "vscode-file-extra", 4 | "description": "VSCode Extension for files based on Atom Editor", 5 | "version": "3.1.0", 6 | "private": false, 7 | "license": "MIT", 8 | "publisher": "willmendesneto", 9 | "engines": { 10 | "vscode": "^1.32.0" 11 | }, 12 | "categories": [ 13 | "Other" 14 | ], 15 | "keywords": [ 16 | "utils", 17 | "extra", 18 | "helper", 19 | "files", 20 | "move", 21 | "duplicate", 22 | "add", 23 | "create", 24 | "rename", 25 | "remove", 26 | "delete" 27 | ], 28 | "icon": "images/logo.png", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/willmendesneto/vscode-file-extra.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/willmendesneto/vscode-file-extra/issues" 35 | }, 36 | "homepage": "https://github.com/willmendesneto/vscode-file-extra#readme", 37 | "galleryBanner": { 38 | "color": "#FFF", 39 | "theme": "light" 40 | }, 41 | "activationEvents": [ 42 | "onCommand:fileExtraDuplicate.execute", 43 | "onCommand:fileExtraRemove.execute", 44 | "onCommand:fileExtraRename.execute", 45 | "onCommand:fileExtraAdd.execute", 46 | "onCommand:fileExtraCopyFilePath.execute", 47 | "onCommand:fileExtraCopyRelativeFilePath.execute", 48 | "onCommand:fileExtraCopyFileName.execute" 49 | ], 50 | "main": "./dist/extension.js", 51 | "contributes": { 52 | "commands": [ 53 | { 54 | "command": "fileExtraDuplicate.execute", 55 | "category": "FileExtra", 56 | "title": "Duplicate File or Folder" 57 | }, 58 | { 59 | "command": "fileExtraRemove.execute", 60 | "category": "FileExtra", 61 | "title": "Delete/Remove File or Folder" 62 | }, 63 | { 64 | "command": "fileExtraRename.execute", 65 | "category": "FileExtra", 66 | "title": "Rename File" 67 | }, 68 | { 69 | "command": "fileExtraAdd.execute", 70 | "category": "FileExtra", 71 | "title": "Add New File or Folder" 72 | }, 73 | { 74 | "command": "fileExtraCopyFilePath.execute", 75 | "category": "FileExtra", 76 | "title": "Copy File Path" 77 | }, 78 | { 79 | "command": "fileExtraCopyRelativeFilePath.execute", 80 | "category": "FileExtra", 81 | "title": "Copy Relative File Path" 82 | }, 83 | { 84 | "command": "fileExtraCopyFileName.execute", 85 | "category": "FileExtra", 86 | "title": "Copy File Name" 87 | } 88 | ], 89 | "menus": { 90 | "explorer/context": [ 91 | { 92 | "command": "fileExtraDuplicate.execute" 93 | } 94 | ], 95 | "editor/title/context": [ 96 | { 97 | "command": "fileExtraDuplicate.execute" 98 | }, 99 | { 100 | "command": "fileExtraRemove.execute" 101 | }, 102 | { 103 | "command": "fileExtraRename.execute" 104 | }, 105 | { 106 | "command": "fileExtraAdd.execute" 107 | }, 108 | { 109 | "command": "fileExtraCopyFilePath.execute" 110 | }, 111 | { 112 | "command": "fileExtraCopyRelativeFilePath.execute" 113 | }, 114 | { 115 | "command": "fileExtraCopyFileName.execute" 116 | } 117 | ] 118 | }, 119 | "configuration": { 120 | "type": "object", 121 | "title": "File extra Configuration", 122 | "properties": { 123 | "fileExtra.openFileAfterDuplication": { 124 | "type": "boolean", 125 | "default": true, 126 | "description": "Automatically open newly duplicated files" 127 | }, 128 | "fileExtra.closeFileAfterRemove": { 129 | "type": "boolean", 130 | "default": true, 131 | "description": "Automatically close file in editor after remove" 132 | } 133 | } 134 | }, 135 | "keybindings": [ 136 | { 137 | "key": "shift+alt+n shift+alt+f", 138 | "linux": "shift+alt+n shift+alt+f", 139 | "mac": "shift+alt+n shift+alt+f", 140 | "when": "editorTextFocus", 141 | "command": "fileExtraAdd.execute" 142 | }, 143 | { 144 | "key": "shift+alt+f shift+alt+n", 145 | "linux": "shift+alt+f shift+alt+n", 146 | "mac": "shift+alt+f shift+alt+n", 147 | "when": "editorTextFocus", 148 | "command": "fileExtraCopyFileName.execute" 149 | }, 150 | { 151 | "key": "shift+alt+c shift+alt+f", 152 | "linux": "shift+alt+c shift+alt+f", 153 | "mac": "shift+alt+c shift+alt+f", 154 | "when": "editorTextFocus", 155 | "command": "fileExtraCopyFilePath.execute" 156 | }, 157 | { 158 | "key": "shift+alt+c shift+alt+r", 159 | "linux": "shift+alt+c shift+alt+r", 160 | "mac": "shift+alt+c shift+alt+r", 161 | "when": "editorTextFocus", 162 | "command": "fileExtraCopyRelativeFilePath.execute" 163 | }, 164 | { 165 | "key": "shift+alt+r shift+alt+m", 166 | "linux": "shift+alt+r shift+alt+m", 167 | "mac": "shift+alt+r shift+alt+m", 168 | "when": "editorTextFocus", 169 | "command": "fileExtraRemove.execute" 170 | }, 171 | { 172 | "key": "shift+alt+c shift+alt+p", 173 | "linux": "shift+alt+c shift+alt+p", 174 | "mac": "shift+alt+c shift+alt+p", 175 | "when": "editorTextFocus", 176 | "command": "fileExtraDuplicate.execute" 177 | }, 178 | { 179 | "key": "shift+alt+m shift+alt+v", 180 | "linux": "shift+alt+m shift+alt+v", 181 | "mac": "shift+alt+m shift+alt+v", 182 | "when": "editorTextFocus", 183 | "command": "fileExtraRename.execute" 184 | } 185 | ] 186 | }, 187 | "scripts": { 188 | "postinstall": "node ./node_modules/vscode/bin/install", 189 | "lint": "prettier-tslint check 'src/**/*.ts'", 190 | "test": "npm run clean && npm run test-compile && npm run test-ci", 191 | "webpack": "webpack --mode development", 192 | "webpack-dev": "webpack --mode development --watch", 193 | "build:prod": "webpack --mode production", 194 | "vscode:prepublish": "npm run changelog-check && npm run build:prod", 195 | "changelog-check": "version-changelog CHANGELOG.md && changelog-verify CHANGELOG.md && git add .", 196 | "compile": "webpack --mode none", 197 | "watch": "webpack --mode none --watch", 198 | "test-ci": "node node_modules/vscode/bin/test", 199 | "test-compile": "tsc -p ./", 200 | "clean": "rimraf ./out ./dist" 201 | }, 202 | "devDependencies": { 203 | "@types/clipboardy": "^2.0.1", 204 | "@types/fs-extra": "^9.0.4", 205 | "@types/lodash.escaperegexp": "^4.1.6", 206 | "@types/mocha": "^8.2.0", 207 | "@types/node": "^14.14.12", 208 | "@types/proxyquire": "^1.3.28", 209 | "@types/sinon": "^9.0.9", 210 | "changelog-verify": "^1.1.2", 211 | "prettier": "^2.2.1", 212 | "prettier-tslint": "^0.4.2", 213 | "proxyquire": "^2.1.3", 214 | "rimraf": "^3.0.2", 215 | "sinon": "^9.2.1", 216 | "ts-loader": "^8.0.11", 217 | "typescript": "^4.1.2", 218 | "version-changelog": "^3.1.1", 219 | "vscode": "^1.1.37", 220 | "webpack": "^5.10.0", 221 | "webpack-cli": "^4.2.0" 222 | }, 223 | "dependencies": { 224 | "clipboardy": "^2.3.0", 225 | "fs-extra": "^9.1.0", 226 | "spawn-sync": "^2.0.0" 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/test/unit/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve, join } from 'path'; 2 | import assert from 'assert'; 3 | import sinon from 'sinon'; 4 | import proxyquire from 'proxyquire'; 5 | 6 | const sandbox = sinon.createSandbox(); 7 | 8 | const workspaceRootPath = resolve(join(__dirname, './../fixtures')); 9 | 10 | const filename = 'file.txt'; 11 | const uri = { fsPath: `${workspaceRootPath}/${filename}` }; 12 | const workspaceFolders = [ 13 | { 14 | index: 0, 15 | name: 'fixtures', 16 | uri: { fsPath: workspaceRootPath }, 17 | }, 18 | ]; 19 | const parsedPath = { 20 | dir: `${workspaceRootPath}/${filename}`, 21 | ext: '.txt', 22 | name: filename, 23 | }; 24 | 25 | describe('Actions', () => { 26 | let actions; 27 | 28 | const executeCommand = sandbox.stub(); 29 | const showErrorMessage = sandbox.stub(); 30 | const showTextDocument = sandbox.stub(); 31 | const openTextDocument = sandbox.stub(); 32 | const showInputBox = sandbox.stub(); 33 | const showWarningMessage = sandbox.stub(); 34 | const showInformationMessage = sandbox.stub(); 35 | const writeSync = sandbox.stub(); 36 | const errorMessage = sandbox.stub(); 37 | const pathExists = sandbox.stub(); 38 | const stat = sandbox.stub(); 39 | const parse = sandbox.stub(); 40 | const createFileSync = sandbox.stub(); 41 | 42 | beforeEach(async () => { 43 | actions = proxyquire('../../actions', { 44 | vscode: { 45 | window: { showTextDocument, showErrorMessage }, 46 | commands: { executeCommand }, 47 | workspace: { openTextDocument }, 48 | }, 49 | './helpers/prompt': { 50 | showInputBox, 51 | showWarningMessage, 52 | showInformationMessage, 53 | }, 54 | clipboardy: { writeSync }, 55 | 'fs-extra': { pathExists, stat, createFileSync }, 56 | './helpers/error-message': { errorMessage }, 57 | path: { parse }, 58 | }); 59 | }); 60 | 61 | afterEach(() => { 62 | sandbox.reset(); 63 | }); 64 | 65 | after(() => sandbox.restore()); 66 | 67 | describe('#add', () => { 68 | it('should add a new file in the workspace', async () => { 69 | parse.returns(parsedPath); 70 | createFileSync.returns(Promise.resolve()); 71 | 72 | showInputBox.returns(Promise.resolve(filename)); 73 | openTextDocument.returns( 74 | Promise.resolve(`${workspaceRootPath}/${filename}`) 75 | ); 76 | await actions.add({ uri, workspaceFolders }); 77 | 78 | assert.equal( 79 | executeCommand.firstCall.args[0], 80 | 'workbench.files.action.refreshFilesExplorer' 81 | ); 82 | assert.equal( 83 | showTextDocument.firstCall.args[0], 84 | `${workspaceRootPath}/${filename}` 85 | ); 86 | assert.equal(errorMessage.callCount, 0); 87 | }); 88 | 89 | it('should add a new folder in the workspace', async () => { 90 | parse.returns(parsedPath); 91 | const folder = 'another-folder'; 92 | 93 | showInputBox.returns(Promise.resolve(folder)); 94 | openTextDocument.returns( 95 | Promise.resolve(`${workspaceRootPath}/${folder}`) 96 | ); 97 | 98 | await actions.add({ uri, workspaceFolders }); 99 | 100 | assert.equal( 101 | executeCommand.firstCall.args[0], 102 | 'workbench.files.action.refreshFilesExplorer' 103 | ); 104 | assert.equal( 105 | showTextDocument.firstCall.args[0], 106 | `${workspaceRootPath}/${folder}` 107 | ); 108 | assert.equal(errorMessage.callCount, 0); 109 | }); 110 | 111 | it('should not add if new file already exists', async () => { 112 | const existentFile = 'a.js'; 113 | 114 | parse.returns(parsedPath); 115 | showInputBox.returns(Promise.resolve(existentFile)); 116 | showWarningMessage.returns(Promise.resolve(false)); 117 | pathExists.returns(Promise.resolve(true)); 118 | 119 | await actions.add({ uri, workspaceFolders }); 120 | 121 | assert.equal( 122 | showWarningMessage.firstCall.args[0], 123 | `**${workspaceRootPath}/${existentFile}** alredy exists. Do you want to overwrite?` 124 | ); 125 | assert.equal(errorMessage.callCount, 0); 126 | }); 127 | 128 | it('should not add if new folder already exists', async () => { 129 | parse.returns({ 130 | dir: parsedPath.dir.replace(filename, 'folder'), 131 | ext: '', 132 | name: 'folder', 133 | }); 134 | showInputBox.returns(Promise.resolve('folder')); 135 | showWarningMessage.returns(Promise.resolve(false)); 136 | pathExists.returns(Promise.resolve(true)); 137 | 138 | await actions.add({ uri, workspaceFolders }); 139 | 140 | assert.equal( 141 | showInformationMessage.firstCall.args[0], 142 | `**${workspaceRootPath}/folder** alredy exists.` 143 | ); 144 | assert.equal(errorMessage.callCount, 0); 145 | }); 146 | 147 | it('should not add a new file if user cancel the action', async () => { 148 | parse.returns(parsedPath); 149 | showInputBox.returns(Promise.resolve(undefined)); 150 | 151 | await actions.add({ uri, workspaceFolders }); 152 | 153 | assert.equal(showErrorMessage.callCount, 0); 154 | assert.equal(showInformationMessage.callCount, 0); 155 | assert.equal(showWarningMessage.callCount, 0); 156 | assert.equal(errorMessage.callCount, 0); 157 | }); 158 | }); 159 | 160 | describe('remove', () => { 161 | it('should refresh file explorer after remove file', async () => { 162 | await actions.remove({ 163 | uri: { fsPath: `${workspaceRootPath}/${filename}` }, 164 | workspaceFolders, 165 | settings: {}, 166 | }); 167 | assert.equal( 168 | executeCommand.firstCall.args[0], 169 | 'workbench.files.action.refreshFilesExplorer' 170 | ); 171 | }); 172 | 173 | it('should refresh file explorer and remove deleted file after remove file if `closeFileAfterRemove` editor settings is true', async () => { 174 | await actions.remove({ 175 | uri: { fsPath: `${workspaceRootPath}/${filename}` }, 176 | workspaceFolders, 177 | settings: { closeFileAfterRemove: true }, 178 | }); 179 | assert.equal( 180 | executeCommand.firstCall.args[0], 181 | 'workbench.files.action.refreshFilesExplorer' 182 | ); 183 | assert.equal( 184 | executeCommand.secondCall.args[0], 185 | 'workbench.action.closeActiveEditor' 186 | ); 187 | }); 188 | }); 189 | 190 | describe('copyFilePath', () => { 191 | it('should copy full file path in clipboard', async () => { 192 | await actions.copyFilePath({ 193 | uri: { fsPath: `${workspaceRootPath}/${filename}` }, 194 | workspaceFolders, 195 | }); 196 | assert.equal( 197 | writeSync.firstCall.args[0], 198 | `${workspaceRootPath}/${filename}` 199 | ); 200 | }); 201 | }); 202 | 203 | describe('copyFileName', () => { 204 | it('should copy file name in clipboard', async () => { 205 | const isFile = sandbox.stub(); 206 | isFile.returns(true); 207 | stat.returns({ isFile }); 208 | parse.returns({ 209 | ...parsedPath, 210 | name: parsedPath.name.split('.')[0], 211 | }); 212 | 213 | const mockFilename = `${workspaceRootPath}/${filename}`; 214 | await actions.copyFileName({ 215 | uri: { fsPath: mockFilename }, 216 | workspaceFolders, 217 | }); 218 | assert.equal(writeSync.firstCall.args[0], filename); 219 | assert.equal(errorMessage.callCount, 0); 220 | }); 221 | 222 | it('should NOT copy file name in clipboard if file exists only in editor', async () => { 223 | await actions.copyFileName({ 224 | uri: { fsPath: 'Untitled-1' }, 225 | workspaceFolders, 226 | }); 227 | assert.equal(errorMessage.callCount, 1); 228 | }); 229 | 230 | it('should NOT copy file name in clipboard if content is not a file', async () => { 231 | await actions.copyFileName({ 232 | uri: { fsPath: `${workspaceRootPath}/` }, 233 | workspaceFolders, 234 | }); 235 | assert.equal(errorMessage.callCount, 1); 236 | }); 237 | }); 238 | 239 | describe('copyRelativeFilePath', () => { 240 | it('should copy relative file path in clipboard', async () => { 241 | await actions.copyRelativeFilePath({ 242 | uri: { fsPath: `${workspaceRootPath}/${filename}` }, 243 | workspaceFolders, 244 | }); 245 | assert.equal(writeSync.firstCall.args[0], filename); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextEditor, 3 | TextDocument, 4 | workspace, 5 | commands, 6 | window as vsWindow, 7 | } from 'vscode'; 8 | import { parse } from 'path'; 9 | import * as fs from 'fs-extra'; 10 | const writeToClipboard = require('clipboardy').writeSync; 11 | import { ActionParams, CopyOptions } from './types'; 12 | import { buildFilepath } from './helpers/files'; 13 | import { 14 | showInputBox, 15 | showWarningMessage, 16 | showInformationMessage, 17 | } from './helpers/prompt'; 18 | import { errorMessage } from './helpers/error-message'; 19 | import { 20 | removeFirstSlashInString, 21 | removeLastSlashInString, 22 | removeWorkspaceUrlRootFromUrl, 23 | getWorkspaceUrlRootFromUrl, 24 | } from './helpers/string-manipulations'; 25 | 26 | const filePathExists = async (newPath: string): Promise => { 27 | // Check if the current path exists 28 | const newPathExists = await fs.pathExists(newPath); 29 | return !!newPathExists; 30 | }; 31 | 32 | /** 33 | * Open file after duplicate action. 34 | */ 35 | const openFile = async (filepath: string): Promise => { 36 | const document = await (workspace.openTextDocument( 37 | filepath 38 | ) as Promise); 39 | await commands.executeCommand('workbench.files.action.refreshFilesExplorer'); 40 | 41 | return vsWindow.showTextDocument(document); 42 | }; 43 | 44 | /** 45 | * Duplicate action. 46 | */ 47 | const duplicate = async ({ 48 | uri, 49 | workspaceFolders, 50 | settings, 51 | }: ActionParams): Promise => { 52 | const oldPath = uri.fsPath; 53 | const oldPathParsed = parse(oldPath); 54 | 55 | const workspaceRootPath = getWorkspaceUrlRootFromUrl( 56 | workspaceFolders, 57 | oldPathParsed.dir 58 | ); 59 | 60 | try { 61 | const oldPathStats = await fs.stat(oldPath); 62 | // Add extension if is a file 63 | const extension = oldPathStats.isFile() ? oldPathParsed.ext : ''; 64 | const workspaceLocation = oldPathParsed.dir.replace(workspaceRootPath, ''); 65 | 66 | const newFileWithoutRoot = removeFirstSlashInString( 67 | `${workspaceLocation}/${removeLastSlashInString( 68 | oldPathParsed.name 69 | )}${extension}` 70 | ); 71 | // Get a new name for the resource 72 | const newName = await showInputBox( 73 | 'Enter the new path for the duplicate.', 74 | newFileWithoutRoot 75 | ); 76 | 77 | if (!newName) { 78 | return; 79 | } 80 | // Get the new full path 81 | const newPath = buildFilepath(newName, workspaceRootPath); 82 | 83 | // If a user tries to copy a file on the same path 84 | if (oldPath === newPath) { 85 | vsWindow.showErrorMessage( 86 | "You can't copy a file or directory on the same path." 87 | ); 88 | 89 | return; 90 | } 91 | 92 | const newPathExists = await filePathExists(newPath); 93 | 94 | // Check if the current path exists 95 | if (newPathExists) { 96 | const newPathStats = await fs.stat(newPath); 97 | if (!newPathStats.isFile()) { 98 | await showInformationMessage(`**${newPath}** alredy exists.`); 99 | return; 100 | } 101 | 102 | const userAnswer = await showWarningMessage( 103 | `**${newPath}** alredy exists. Do you want to overwrite?` 104 | ); 105 | if (!userAnswer) { 106 | return; 107 | } 108 | } 109 | 110 | const newPathParsed = parse(newPath); 111 | await fs.ensureDir(newPathParsed.dir); 112 | await fs.copy(oldPath, newPath); 113 | 114 | if (settings.openFileAfterDuplication && oldPathStats.isFile()) { 115 | return openFile(newPath); 116 | } 117 | } catch (err) { 118 | errorMessage(err); 119 | } 120 | 121 | return; 122 | }; 123 | 124 | /** 125 | * Remove action. 126 | */ 127 | const remove = async ({ 128 | uri, 129 | settings, 130 | }: ActionParams): Promise => { 131 | try { 132 | await fs.removeSync(uri.fsPath); 133 | await commands.executeCommand( 134 | 'workbench.files.action.refreshFilesExplorer' 135 | ); 136 | 137 | if (settings.closeFileAfterRemove) { 138 | await commands.executeCommand('workbench.action.closeActiveEditor'); 139 | } 140 | } catch (err) { 141 | errorMessage(err); 142 | } 143 | 144 | return; 145 | }; 146 | 147 | /** 148 | * Rename action. 149 | */ 150 | const renameFile = async ({ 151 | uri, 152 | workspaceFolders, 153 | }: ActionParams): Promise => { 154 | try { 155 | const oldPath = uri.fsPath; 156 | const oldPathParsed = parse(oldPath); 157 | const oldPathStats = await fs.stat(oldPath); 158 | const workspaceRootPath = getWorkspaceUrlRootFromUrl( 159 | workspaceFolders, 160 | oldPathParsed.dir 161 | ); 162 | 163 | // Add extension if is a file 164 | const extension = oldPathStats.isFile() ? oldPathParsed.ext : ''; 165 | 166 | const newFileWithoutRoot = removeFirstSlashInString( 167 | `${removeWorkspaceUrlRootFromUrl( 168 | workspaceFolders, 169 | oldPathParsed.dir 170 | )}/${removeLastSlashInString(oldPathParsed.name)}${extension}` 171 | ); 172 | 173 | // Get a new name for the resource 174 | const newName = await showInputBox( 175 | 'Enter the new file or folder name.', 176 | newFileWithoutRoot 177 | ); 178 | if (!newName) { 179 | return; 180 | } 181 | // Get the new full path 182 | const newPath = buildFilepath(newName, workspaceRootPath); 183 | 184 | // If a user tries to copy a file on the same path 185 | if (oldPath === newPath) { 186 | return; 187 | } 188 | 189 | // Check if the current path exists 190 | const newPathExists = await filePathExists(newPath); 191 | 192 | if (newPathExists) { 193 | const newPathStats = await fs.stat(newPath); 194 | if (!newPathStats.isFile()) { 195 | await showInformationMessage(`**${newPath}** alredy exists.`); 196 | return; 197 | } 198 | 199 | const userAnswer = await showWarningMessage( 200 | `**${newPath}** alredy exists. Do you want to overwrite?` 201 | ); 202 | if (!userAnswer) { 203 | return; 204 | } 205 | } 206 | 207 | const newPathParsed = parse(newPath); 208 | await fs.ensureDir(newPathParsed.dir); 209 | await fs.rename(oldPath, newPath); 210 | await commands.executeCommand('workbench.action.closeActiveEditor'); 211 | return openFile(newPath); 212 | } catch (err) { 213 | errorMessage(err); 214 | } 215 | 216 | return; 217 | }; 218 | 219 | /** 220 | * Create action. 221 | */ 222 | const add = async ({ 223 | uri, 224 | workspaceFolders, 225 | }: ActionParams): Promise => { 226 | const oldPath = uri.fsPath; 227 | const oldPathParsed = parse(oldPath); 228 | 229 | const workspaceRootPath = getWorkspaceUrlRootFromUrl( 230 | workspaceFolders, 231 | oldPathParsed.dir 232 | ); 233 | 234 | try { 235 | const newFileWithoutRoot = removeFirstSlashInString( 236 | removeWorkspaceUrlRootFromUrl(workspaceFolders, oldPathParsed.dir) 237 | ); 238 | // Get a new name for the resource 239 | const newFilename = await showInputBox( 240 | 'Enter the new file or folder name.', 241 | newFileWithoutRoot 242 | ); 243 | 244 | if (!newFilename) { 245 | return; 246 | } 247 | 248 | // Get the new full path 249 | const newPath = buildFilepath(newFilename, workspaceRootPath); 250 | // Check if the current path exists 251 | const newPathExists = await filePathExists(newPath); 252 | 253 | const newPathParsed = parse(newPath); 254 | 255 | if (newPathExists) { 256 | if (!newPathParsed.ext) { 257 | await showInformationMessage(`**${newPath}** alredy exists.`); 258 | return; 259 | } 260 | 261 | const userAnswer = await showWarningMessage( 262 | `**${newPath}** alredy exists. Do you want to overwrite?` 263 | ); 264 | if (!userAnswer) { 265 | return; 266 | } 267 | } 268 | 269 | // Creates main dir 270 | await fs.ensureDir(newPathParsed.dir); 271 | 272 | // Check if new file is a hidden file 273 | const isAHiddenFile = newPathParsed.name.startsWith('.'); 274 | 275 | const shouldCreateFolder = 276 | !newPathExists && !isAHiddenFile && !newPathParsed.ext; 277 | 278 | // We can create a file or a folder. If it's not a file, it's a folder by definition 279 | if (shouldCreateFolder) { 280 | await fs.ensureDir(newPath); 281 | } else { 282 | await fs.createFileSync(newPath); 283 | return openFile(newPath); 284 | } 285 | 286 | await commands.executeCommand( 287 | 'workbench.files.action.refreshFilesExplorer' 288 | ); 289 | } catch (err) { 290 | errorMessage(err); 291 | } 292 | 293 | return; 294 | }; 295 | 296 | const copyTextContent = (fileUrl: string) => { 297 | writeToClipboard(fileUrl); 298 | return showInformationMessage(`**${fileUrl}** copied.`); 299 | }; 300 | 301 | const copyFileUrl = async ( 302 | { uri, workspaceFolders }: ActionParams, 303 | { removeRoot }: CopyOptions 304 | ) => { 305 | try { 306 | const fileUrl = removeRoot 307 | ? removeFirstSlashInString( 308 | removeWorkspaceUrlRootFromUrl(workspaceFolders, uri.fsPath) 309 | ) 310 | : uri.fsPath; 311 | 312 | return copyTextContent(fileUrl); 313 | } catch (error) { 314 | errorMessage(error); 315 | } 316 | }; 317 | 318 | const copyRelativeFilePath = async (params: ActionParams) => { 319 | const copyOptions: CopyOptions = { removeRoot: true }; 320 | return copyFileUrl(params, copyOptions); 321 | }; 322 | 323 | const copyFilePath = async (params: ActionParams) => { 324 | const copyOptions: CopyOptions = { removeRoot: false }; 325 | return copyFileUrl(params, copyOptions); 326 | }; 327 | 328 | const copyFileName = async (params: ActionParams) => { 329 | try { 330 | const fileUrl = params.uri.fsPath; 331 | 332 | if (fileUrl.toLowerCase().match(/^untitled-(\d*)$/g)) { 333 | throw new Error( 334 | `**${fileUrl}** is not saved. Please make sure you saved the file before save` 335 | ); 336 | } 337 | 338 | const filePathStats = await fs.stat(fileUrl); 339 | if (!filePathStats.isFile()) { 340 | throw new Error(`**${fileUrl}** is not a file and can not be copied`); 341 | } 342 | 343 | const filePathParsed = parse(fileUrl); 344 | const filename = `${filePathParsed.name}${filePathParsed.ext}`; 345 | 346 | return copyTextContent(filename); 347 | } catch (error) { 348 | errorMessage(error); 349 | } 350 | }; 351 | 352 | export { 353 | duplicate, 354 | remove, 355 | renameFile, 356 | add, 357 | copyRelativeFilePath, 358 | copyFilePath, 359 | copyFileName, 360 | }; 361 | --------------------------------------------------------------------------------