├── .gitattributes ├── CONTRIBUTING.md ├── .eslintignore ├── img ├── icon.png └── icon-large.png ├── .prettierrc.yml ├── .prettierignore ├── .gitignore ├── .eslintrc.js ├── tests ├── unit │ ├── .mocharc.yml │ ├── mocha-reporter-config.json │ ├── .c8rc.json │ ├── config.js │ ├── log.js │ ├── watcher.js │ ├── lib.js │ └── file-handler.js └── data.js ├── .vscodeignore ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── mac.yml │ ├── windows.yml │ ├── linux.yml │ ├── quality.yml │ └── release.yml ├── CHANGELOG.md ├── .vscode └── launch.shared.json ├── sonar-project.properties ├── src ├── log.js ├── index.js ├── lib.js ├── watcher.js └── file-handler.js ├── LICENSE ├── package.json └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png binary 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To be filled in... 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .coverage/ 2 | .testresults/ 3 | .github/ 4 | .vscode/ 5 | node_modules/ 6 | 7 | -------------------------------------------------------------------------------- /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swellaby/vscode-workspace-config-plus/HEAD/img/icon.png -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | bracketSpacing: true 3 | endOfLine: lf 4 | arrowParens: avoid 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | /.coverage 4 | /.testresults 5 | **/*.c8rc.json 6 | -------------------------------------------------------------------------------- /img/icon-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swellaby/vscode-workspace-config-plus/HEAD/img/icon-large.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .testresults/ 3 | .coverage/ 4 | *.tar* 5 | *.tgz* 6 | .ntvs_analysis.dat* 7 | npm-debug* 8 | .vsix/ 9 | .vscode/*.json 10 | !.vscode/*.shared.json 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: '@swellaby', 5 | parserOptions: { 6 | ecmaVersion: 2018, 7 | }, 8 | rules: { 9 | indent: ['error', 2], 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /tests/unit/.mocharc.yml: -------------------------------------------------------------------------------- 1 | ui: 'tdd' 2 | timeout: 500 3 | slow: 150 4 | reporter: 'mocha-multi-reporters' 5 | reporter-options: 'configFile=tests/unit/mocha-reporter-config.json' 6 | recursive: true 7 | spec: './tests/unit/**/*.js' 8 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .coverage/ 2 | .github/ 3 | .testresults/ 4 | .vscode/ 5 | .vsix/ 6 | .editorconfig 7 | .eslintignore 8 | .eslintrc.js 9 | .prettierignore 10 | .prettierrc.yml 11 | sonar-project.properties 12 | 13 | tests/ 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: monthly 7 | day: wednesday 8 | time: '14:00' 9 | open-pull-requests-limit: 25 10 | labels: 11 | - dependencies 12 | -------------------------------------------------------------------------------- /tests/unit/mocha-reporter-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporterEnabled": "spec, xunit, mocha-sonarqube-reporter", 3 | "xunitReporterOptions": { 4 | "output": ".testresults/unit/xunit.xml" 5 | }, 6 | "mochaSonarqubeReporterReporterOptions": { 7 | "output": ".testresults/unit/sonar.xml" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.5 4 | 5 | - Support deep merging when merging _.shared.json and _.local.json pairs and use by default 6 | - Add extension configuration option, `arrayMerge`, that allows users to control the merge behavior of array-type keys defined in both the _.shared.json and _.local.json files: either by deeply merging/combining the two array values, or utilize the prior behavior of just using the array from _.local.json/ignoring the overlapping key in _.shared.json 7 | -------------------------------------------------------------------------------- /tests/unit/.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "src/index.js", 4 | "tests/**", 5 | ".eslintrc.js", 6 | ".coverage" 7 | ], 8 | "reporter": [ 9 | "html", 10 | "lcov", 11 | "cobertura", 12 | "text", 13 | "text-summary" 14 | ], 15 | "cache": true, 16 | "all": true, 17 | "per-file": true, 18 | "lines": 100, 19 | "statements": 100, 20 | "functions": 100, 21 | "branches": 100, 22 | "check-coverage": false, 23 | "report-dir": ".coverage/unit" 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/launch.shared.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"] 9 | }, 10 | { 11 | "name": "Extension Tests", 12 | "type": "extensionHost", 13 | "request": "launch", 14 | "args": [ 15 | "--extensionDevelopmentPath=${workspaceFolder}", 16 | "--extensionTestsPath=${workspaceFolder}/test/suite/index" 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=swellaby:vscode-workspace-config-plus 2 | sonar.projectName=vscode-workspace-config-plus 3 | sonar.organization=swellaby 4 | 5 | sonar.links.homepage=https://github.com/swellaby/vscode-workspace-config-plus 6 | #sonar.links.ci= 7 | sonar.links.scm=https://github.com/swellaby/vscode-workspace-config-plus 8 | sonar.links.issue=https://github.com/swellaby/vscode-workspace-config-plus/issues 9 | 10 | sonar.sources=src 11 | sonar.inclusions=**/*.js 12 | sonar.tests=tests 13 | 14 | sonar.javascript.environments=node,mocha 15 | sonar.javascript.lcov.reportPaths=.coverage/unit/lcov.info 16 | sonar.testExecutionReportPaths=.testresults/unit/sonar.xml 17 | sonar.coverage.exclusions=src/index.js 18 | -------------------------------------------------------------------------------- /.github/workflows/mac.yml: -------------------------------------------------------------------------------- 1 | name: macos-ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-latest 11 | name: macos-node-v${{ matrix.node-version }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [12, 14, 15, 16] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: install dependencies 28 | run: npm install 29 | 30 | - name: run build script 31 | run: npm run coverage 32 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: windows-ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: windows-latest 11 | name: windows-node-v${{ matrix.node-version }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [12, 14, 15, 16] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: install dependencies 28 | run: npm install 29 | 30 | - name: run build script 31 | run: npm run coverage 32 | -------------------------------------------------------------------------------- /tests/unit/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { assert } = require('chai'); 4 | 5 | const fileHandler = require('../../src/file-handler'); 6 | const { 7 | contributes: { configuration }, 8 | } = require('../../package.json'); 9 | const { properties: config } = configuration; 10 | 11 | suite('config Suite', () => { 12 | test('Should have the correct number of configuration settings', () => { 13 | assert.deepEqual(Object.keys(config).length, 1); 14 | }); 15 | 16 | test('Should have correct config setting values for array merge behavior', () => { 17 | const arrayMergeConfig = config[fileHandler._arrayMergeKey]; 18 | assert.deepEqual( 19 | arrayMergeConfig.default, 20 | fileHandler._arrayMergeDefaultValue 21 | ); 22 | assert.deepEqual(arrayMergeConfig.enum, ['combine', 'overwrite']); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createFileSystemWatcher = (_a, _b) => {}; 4 | const createRelativePattern = _c => {}; 5 | const joinPath = (_d, _e, _f) => {}; 6 | const readFile = (_g, _h, _i, _j) => {}; 7 | const writeFile = () => {}; 8 | const callbacks = { 9 | createFileSystemWatcher, 10 | createRelativePattern, 11 | joinPath, 12 | readFile, 13 | writeFile, 14 | }; 15 | 16 | const vscodeFileUri = { uri: 'foo/.vscode/settings.json' }; 17 | const sharedFileUri = { uri: 'foo/.vscode/settings.shared.json' }; 18 | const localFileUri = { uri: 'foo/.vscode/settings.local.json' }; 19 | const uris = { 20 | vscodeFileUri, 21 | sharedFileUri, 22 | localFileUri, 23 | }; 24 | const globPattern = { 25 | path: 'foo/.vscode/{settings.local,settings.shared}.json', 26 | }; 27 | 28 | module.exports = { 29 | callbacks, 30 | globPattern, 31 | uris, 32 | }; 33 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _outputChannel; 4 | 5 | const write = (msg, logLevel) => { 6 | const dateString = new Date().toLocaleString('sv'); 7 | module.exports._outputChannel.appendLine( 8 | `[${dateString}] [${logLevel}] ${msg}` 9 | ); 10 | }; 11 | 12 | const debug = msg => { 13 | write(msg, 'DEBUG'); 14 | }; 15 | 16 | const error = msg => { 17 | write(msg, 'ERROR'); 18 | }; 19 | 20 | const info = msg => { 21 | write(msg, 'INFO'); 22 | }; 23 | 24 | const warn = msg => { 25 | write(msg, 'WARN'); 26 | }; 27 | 28 | const initialize = createChannel => { 29 | module.exports._outputChannel = createChannel('Workspace Config+'); 30 | }; 31 | 32 | const dispose = () => { 33 | module.exports._outputChannel.dispose(); 34 | }; 35 | 36 | module.exports = { 37 | debug, 38 | error, 39 | info, 40 | warn, 41 | initialize, 42 | dispose, 43 | // Private, exported for unit testing 44 | _outputChannel, 45 | }; 46 | -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | name: linux-ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | name: linux-node-v${{ matrix.node-version }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [12, 14, 15, 16] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: install dependencies 28 | run: npm install 29 | 30 | - name: run build script 31 | run: npm run coverage 32 | 33 | - name: publish code coverage results 34 | uses: codecov/codecov-action@v2 35 | if: matrix.node-version == 16 36 | with: 37 | files: .coverage/unit/cobertura-coverage.xml 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Swellaby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { RelativePattern, Uri, workspace, window } = require('vscode'); 4 | const { 5 | deactivate, 6 | handleWorkspaceFolderUpdates, 7 | initializeLog, 8 | initializeWorkspaceFolder, 9 | } = require('./lib'); 10 | 11 | // The VS Code module is only readily available within a VS Code context 12 | // which makes true unit testing impossible for any modules which need 13 | // to directly interact with the VS Code Extension APIs. 14 | // Accordingly, these lightweight functions are created as wrappers around 15 | // their respective VS Code API calls so that the core logic of this extension 16 | // can be tested with unit and component tests, in addition to the integration tests. 17 | const createFileSystemWatcher = pattern => 18 | workspace.createFileSystemWatcher(pattern); 19 | const createRelativePattern = (base, pattern) => 20 | new RelativePattern(base, pattern); 21 | const joinPath = (base, pattern) => Uri.joinPath(base, pattern); 22 | const readFile = fileUri => 23 | workspace.fs 24 | .readFile(fileUri) 25 | .then(c => c) 26 | .catch(_ => {}); 27 | const writeFile = (fileUri, contents, options) => 28 | workspace.fs.writeFile(fileUri, contents, options); 29 | 30 | const activate = () => { 31 | if (!workspace.workspaceFolders) { 32 | return; 33 | } 34 | initializeLog(name => window.createOutputChannel(name)); 35 | workspace.workspaceFolders.forEach(f => 36 | initializeWorkspaceFolder({ 37 | folderUri: f.uri, 38 | createFileSystemWatcher, 39 | createRelativePattern, 40 | joinPath, 41 | readFile, 42 | writeFile, 43 | }) 44 | ); 45 | workspace.onDidChangeWorkspaceFolders(({ added, removed }) => 46 | handleWorkspaceFolderUpdates({ 47 | added, 48 | removed, 49 | createFileSystemWatcher, 50 | createRelativePattern, 51 | joinPath, 52 | readFile, 53 | writeFile, 54 | }) 55 | ); 56 | }; 57 | 58 | module.exports = { 59 | activate, 60 | deactivate, 61 | }; 62 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: quality-ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | sonar: 10 | runs-on: ubuntu-latest 11 | name: sonar 12 | # Only run Sonar on PRs originating in repo, except Dependabot PRs which 13 | # do not have access to secrets https://github.com/dependabot/dependabot-core/issues/3253#issuecomment-852541544 14 | if: | 15 | github.event.pull_request.head.repo.full_name == github.repository && 16 | github.actor != 'dependabot[bot]' 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: use Node.js 16 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: 16 26 | 27 | - name: install dependencies 28 | run: npm install 29 | 30 | - name: run build script 31 | run: npm run coverage 32 | 33 | - name: get current package version 34 | id: get_version 35 | run: echo ::set-output name=PACKAGE_VERSION::$(node -e "console.log(require('./package.json').version);") 36 | 37 | - name: SonarCloud Scan 38 | uses: sonarsource/sonarcloud-github-action@master 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 42 | with: 43 | args: > 44 | -Dsonar.projectVersion=${{ steps.get_version.outputs.PACKAGE_VERSION }} 45 | 46 | format: 47 | runs-on: ubuntu-latest 48 | name: format 49 | 50 | steps: 51 | - name: checkout 52 | uses: actions/checkout@v2 53 | 54 | - name: use Node.js 16 55 | uses: actions/setup-node@v2 56 | with: 57 | node-version: 16 58 | 59 | - name: install dependencies 60 | run: npm install 61 | 62 | - name: run formatting check 63 | run: npm run format:check 64 | 65 | lint: 66 | runs-on: ubuntu-latest 67 | name: lint 68 | 69 | steps: 70 | - name: checkout 71 | uses: actions/checkout@v2 72 | 73 | - name: use Node.js 16 74 | uses: actions/setup-node@v2 75 | with: 76 | node-version: 16 77 | 78 | - name: install dependencies 79 | run: npm install 80 | 81 | - name: run lint check 82 | run: npm run lint 83 | -------------------------------------------------------------------------------- /tests/unit/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { assert } = require('chai'); 4 | const Sinon = require('sinon'); 5 | 6 | const log = require('../../src/log'); 7 | 8 | suite('log Suite', () => { 9 | let clock; 10 | 11 | setup(() => { 12 | clock = Sinon.useFakeTimers(); 13 | }); 14 | 15 | teardown(() => { 16 | clock.restore(); 17 | Sinon.restore(); 18 | }); 19 | 20 | suite('initialize Suite', () => { 21 | test('Should create output channel with correct title', () => { 22 | let actName = ''; 23 | log.initialize(name => { 24 | actName = name; 25 | }); 26 | assert.deepEqual(actName, 'Workspace Config+'); 27 | }); 28 | 29 | test('Should initialize internal channel', () => { 30 | const channel = { name: 'foo' }; 31 | log.initialize(() => channel); 32 | assert.deepEqual(log._outputChannel, channel); 33 | }); 34 | }); 35 | 36 | suite('log levels Suite', () => { 37 | const msg = 'bar'; 38 | const localString = '2021-08-15 14:16:52'; 39 | let actLogMessage; 40 | 41 | setup(() => { 42 | log._outputChannel = { 43 | appendLine: m => (actLogMessage = m), 44 | }; 45 | Sinon.stub(clock.Date.prototype, 'toLocaleString') 46 | .withArgs('sv') 47 | .callsFake(() => localString); 48 | }); 49 | 50 | teardown(() => { 51 | actLogMessage = undefined; 52 | }); 53 | 54 | const assertLogMessage = level => { 55 | assert.deepEqual(`[${localString}] [${level}] ${msg}`, actLogMessage); 56 | }; 57 | 58 | test('Warn should set correct log level', () => { 59 | log.warn(msg); 60 | assertLogMessage('WARN'); 61 | }); 62 | 63 | test('Info should set correct log level', () => { 64 | log.info(msg); 65 | assertLogMessage('INFO'); 66 | }); 67 | 68 | test('Error should set correct log level', () => { 69 | log.error(msg); 70 | assertLogMessage('ERROR'); 71 | }); 72 | 73 | test('Info should set correct log level', () => { 74 | log.debug(msg); 75 | assertLogMessage('DEBUG'); 76 | }); 77 | }); 78 | 79 | suite('dispose Suite', () => { 80 | test('Should dispose the output channel', () => { 81 | log._outputChannel = { dispose: () => {} }; 82 | const stub = Sinon.stub(log._outputChannel, 'dispose'); 83 | log.dispose(); 84 | assert.isTrue(stub.calledOnce); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/lib.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fileHandler = require('./file-handler'); 4 | const watcher = require('./watcher'); 5 | const log = require('./log'); 6 | 7 | const workspaceConfigFileNames = ['launch', 'settings', 'tasks']; 8 | 9 | const initializeWorkspaceFolder = ({ 10 | folderUri, 11 | createFileSystemWatcher, 12 | createRelativePattern, 13 | joinPath, 14 | readFile, 15 | writeFile, 16 | }) => { 17 | workspaceConfigFileNames.forEach(configFile => { 18 | const workspaceVscodeDirUri = joinPath(folderUri, '.vscode'); 19 | const sharedFile = `${configFile}.shared`; 20 | const localFile = `${configFile}.local`; 21 | const globPattern = createRelativePattern( 22 | workspaceVscodeDirUri, 23 | `{${localFile},${sharedFile}}.json` 24 | ); 25 | const vscodeFileUri = joinPath(workspaceVscodeDirUri, `${configFile}.json`); 26 | const sharedFileUri = joinPath(workspaceVscodeDirUri, `${sharedFile}.json`); 27 | const localFileUri = joinPath(workspaceVscodeDirUri, `${localFile}.json`); 28 | watcher.generateFileSystemWatcher({ 29 | globPattern, 30 | createFileSystemWatcher, 31 | readFile, 32 | writeFile, 33 | folderUri, 34 | vscodeFileUri, 35 | sharedFileUri, 36 | localFileUri, 37 | }); 38 | fileHandler.mergeConfigFiles({ 39 | vscodeFileUri, 40 | sharedFileUri, 41 | localFileUri, 42 | readFile, 43 | writeFile, 44 | }); 45 | }); 46 | }; 47 | 48 | const handleWorkspaceFolderUpdates = ({ 49 | added, 50 | removed, 51 | createFileSystemWatcher, 52 | createRelativePattern, 53 | joinPath, 54 | readFile, 55 | writeFile, 56 | }) => { 57 | if (added && Array.isArray(added)) { 58 | added.forEach(f => 59 | module.exports.initializeWorkspaceFolder({ 60 | folderUri: f.uri, 61 | createFileSystemWatcher, 62 | createRelativePattern, 63 | joinPath, 64 | readFile, 65 | writeFile, 66 | }) 67 | ); 68 | } 69 | if (removed && Array.isArray(removed)) { 70 | removed.forEach(f => watcher.disposeWorkspaceWatcher(f.uri)); 71 | } 72 | }; 73 | 74 | const initializeLog = createOutputChannel => { 75 | log.initialize(createOutputChannel); 76 | }; 77 | 78 | const deactivate = () => { 79 | log.info('Deactivating and disposing all watchers'); 80 | watcher.disposeAllWatchers(); 81 | log.dispose(); 82 | }; 83 | 84 | module.exports = { 85 | deactivate, 86 | handleWorkspaceFolderUpdates, 87 | initializeLog, 88 | initializeWorkspaceFolder, 89 | }; 90 | -------------------------------------------------------------------------------- /src/watcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fileHandler = require('./file-handler'); 4 | 5 | const _fileSystemWatchers = {}; 6 | 7 | const _registerSharedFileSystemWatcher = ( 8 | globPattern, 9 | createFileSystemWatcher, 10 | workspaceUri, 11 | onFileSystemEventHandler 12 | ) => { 13 | const fileSystemChangeWatcher = createFileSystemWatcher(globPattern); 14 | module.exports._fileSystemWatchers[workspaceUri] = [ 15 | fileSystemChangeWatcher.onDidChange(onFileSystemEventHandler), 16 | fileSystemChangeWatcher.onDidCreate(onFileSystemEventHandler), 17 | fileSystemChangeWatcher.onDidDelete(onFileSystemEventHandler), 18 | ]; 19 | }; 20 | 21 | const generateFileSystemWatcher = ({ 22 | globPattern, 23 | createFileSystemWatcher, 24 | readFile, 25 | writeFile, 26 | folderUri, 27 | vscodeFileUri, 28 | sharedFileUri, 29 | localFileUri, 30 | }) => { 31 | const cache = {}; 32 | async function handleFileEvent(e) { 33 | const current = new Date().valueOf(); 34 | let entry = cache[e]; 35 | // Node.js' fs will fire two events in rapid succession on file saves. 36 | // Attempt to avoid running duplicative merges by checking whether 37 | // we've just detected and executed a merge for the modified file within 38 | // the last 350ms. N.B the 350ms threshold is a bit of a magic number, 39 | // based on observed deltas between the events typically being ~250ms but 40 | // occasionally coming in around ~325ms. 41 | if (!entry || current - entry.prior > 350) { 42 | await fileHandler.mergeConfigFiles({ 43 | vscodeFileUri, 44 | sharedFileUri, 45 | localFileUri, 46 | readFile, 47 | writeFile, 48 | }); 49 | } 50 | cache[e] = { prior: current }; 51 | } 52 | module.exports._registerSharedFileSystemWatcher( 53 | globPattern, 54 | createFileSystemWatcher, 55 | folderUri, 56 | handleFileEvent 57 | ); 58 | }; 59 | 60 | const disposeWorkspaceWatcher = workspaceUri => { 61 | if (module.exports._fileSystemWatchers[workspaceUri]) { 62 | module.exports._fileSystemWatchers[workspaceUri].forEach(w => w.dispose()); 63 | } 64 | }; 65 | 66 | const disposeAllWatchers = () => { 67 | Object.values(module.exports._fileSystemWatchers).forEach(watchers => { 68 | watchers.forEach(w => w.dispose()); 69 | }); 70 | }; 71 | 72 | module.exports = { 73 | disposeAllWatchers, 74 | disposeWorkspaceWatcher, 75 | generateFileSystemWatcher, 76 | // Private, only export for test stubbing 77 | _fileSystemWatchers, 78 | _registerSharedFileSystemWatcher, 79 | }; 80 | -------------------------------------------------------------------------------- /src/file-handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { isDeepStrictEqual } = require('util'); 4 | const jsoncParser = require('jsonc-parser'); 5 | const deepMerge = require('deepmerge'); 6 | const log = require('./log'); 7 | 8 | const _arrayMergeKey = 'workspaceConfigPlus.arrayMerge'; 9 | const _arrayMergeDefaultValue = 'combine'; 10 | 11 | const _loadConfigFromFile = async (fileUri, readFile) => { 12 | const contents = await readFile(fileUri); 13 | if (!contents) { 14 | return undefined; 15 | } 16 | const errors = []; 17 | const config = jsoncParser.parse(contents.toString(), errors, { 18 | allowTrailingComma: true, 19 | }); 20 | if (errors.length > 0) { 21 | throw new Error(`Failed to parse contents of: ${fileUri.fsPath}`); 22 | } 23 | return config; 24 | }; 25 | 26 | const getMergedConfigs = ({ sharedConfig, localConfig }) => { 27 | const shared = sharedConfig || {}; 28 | const local = localConfig || {}; 29 | let arrayMerge = 30 | local[_arrayMergeKey] || shared[_arrayMergeKey] || _arrayMergeDefaultValue; 31 | const invalidValueErrorMessage = `Invalid value for 'arrayMerge' setting: '${arrayMerge}'. Must be 'overwrite' or 'combine'`; 32 | if (typeof arrayMerge != 'string') { 33 | throw new Error(invalidValueErrorMessage); 34 | } 35 | 36 | arrayMerge = arrayMerge.toLowerCase(); 37 | let options = {}; 38 | if (arrayMerge == 'overwrite') { 39 | options.arrayMerge = (_dest, source, _options) => source; 40 | } else if (arrayMerge !== 'combine') { 41 | throw new Error(invalidValueErrorMessage); 42 | } 43 | return deepMerge(shared, local, options); 44 | }; 45 | 46 | // This function does exceed our preferred ceiling for statement counts 47 | // but worth an override here for readability. However, we should split 48 | // this up if we end up needing to add anything else to it. 49 | // eslint-disable-next-line max-statements 50 | const mergeConfigFiles = async ({ 51 | vscodeFileUri, 52 | sharedFileUri, 53 | localFileUri, 54 | readFile, 55 | writeFile, 56 | }) => { 57 | const loadConfigFromFile = module.exports._loadConfigFromFile; 58 | try { 59 | const sharedConfig = await loadConfigFromFile(sharedFileUri, readFile); 60 | const localConfig = await loadConfigFromFile(localFileUri, readFile); 61 | 62 | // If neither of these files exists then there's no work to be done 63 | if (!sharedConfig && !localConfig) { 64 | return; 65 | } 66 | 67 | const vscodeFileContents = await loadConfigFromFile( 68 | vscodeFileUri, 69 | readFile 70 | ); 71 | const merged = getMergedConfigs({ sharedConfig, localConfig }); 72 | 73 | // Avoid rewriting the file if there are no changes to be applied 74 | if (isDeepStrictEqual(vscodeFileContents, merged)) { 75 | return; 76 | } 77 | 78 | log.info(`Updating config in ${vscodeFileUri.fsPath}`); 79 | await writeFile( 80 | vscodeFileUri, 81 | Buffer.from( 82 | JSON.stringify({ ...vscodeFileContents, ...merged }, null, 2) 83 | ), 84 | { create: true, overwrite: true } 85 | ); 86 | } catch (e) { 87 | log.error(e.message); 88 | log.debug(e); 89 | } 90 | }; 91 | 92 | module.exports = { 93 | mergeConfigFiles, 94 | // Private, only exported for test mocking 95 | _loadConfigFromFile, 96 | _arrayMergeKey, 97 | _arrayMergeDefaultValue, 98 | }; 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workspace-config-plus", 3 | "displayName": "Workspace Config+", 4 | "description": "VS Code extension for improved workspace configuration", 5 | "main": "src/index.js", 6 | "version": "0.2.5", 7 | "license": "MIT", 8 | "preview": true, 9 | "private": true, 10 | "publisher": "Swellaby", 11 | "icon": "img/icon.png", 12 | "galleryBanner": { 13 | "color": "#18bc9c", 14 | "theme": "dark" 15 | }, 16 | "activationEvents": [ 17 | "onStartupFinished" 18 | ], 19 | "contributes": { 20 | "languages": [ 21 | { 22 | "id": "jsonc", 23 | "filenames": [ 24 | "settings.local.json", 25 | "settings.shared.json", 26 | "launch.local.json", 27 | "launch.shared.json", 28 | "tasks.local.json", 29 | "tasks.shared.json" 30 | ] 31 | } 32 | ], 33 | "jsonValidation": [ 34 | { 35 | "fileMatch": "/.vscode/settings.shared.json", 36 | "url": "vscode://schemas/settings/folder" 37 | }, 38 | { 39 | "fileMatch": "/.vscode/settings.local.json", 40 | "url": "vscode://schemas/settings/folder" 41 | }, 42 | { 43 | "fileMatch": "/.vscode/launch.shared.json", 44 | "url": "vscode://schemas/launch" 45 | }, 46 | { 47 | "fileMatch": "/.vscode/launch.local.json", 48 | "url": "vscode://schemas/launch" 49 | }, 50 | { 51 | "fileMatch": "/.vscode/tasks.shared.json", 52 | "url": "vscode://schemas/tasks" 53 | }, 54 | { 55 | "fileMatch": "/.vscode/tasks.local.json", 56 | "url": "vscode://schemas/tasks" 57 | } 58 | ], 59 | "configuration": { 60 | "type": "object", 61 | "title": "Workspace Config+", 62 | "properties": { 63 | "workspaceConfigPlus.arrayMerge": { 64 | "description": "Defines the merge behavior to use for arrays", 65 | "type": "string", 66 | "scope": "resource", 67 | "default": "combine", 68 | "enum": [ 69 | "combine", 70 | "overwrite" 71 | ], 72 | "enumDescriptions": [ 73 | "Performs a deep merge of array-type properties overlapping in both local and shared user settings, where the arrays are combined", 74 | "If both the local and shared files define a value for the same array-type property, then the array value in the local file is used and the shared array value is ignored" 75 | ] 76 | } 77 | } 78 | } 79 | }, 80 | "keywords": [ 81 | "local settings", 82 | "shared settings", 83 | "shared", 84 | "local", 85 | "configuration", 86 | "workspace configuration", 87 | "config", 88 | "config plus", 89 | "configuration plus", 90 | "workspace config plus", 91 | "workspace configuration plus", 92 | "config +", 93 | "configuration +", 94 | "workspace config +", 95 | "workspace configuration +" 96 | ], 97 | "engines": { 98 | "node": ">=12", 99 | "vscode": "^1.49.0" 100 | }, 101 | "homepage": "https://github.com/swellaby/vscode-workspace-config-plus", 102 | "bugs": { 103 | "url": "https://github.com/swellaby/vscode-workspace-config-plus" 104 | }, 105 | "repository": { 106 | "type": "git", 107 | "url": "https://github.com/swellaby/vscode-workspace-config-plus.git" 108 | }, 109 | "author": { 110 | "email": "opensource@swellaby.com", 111 | "name": "Swellaby", 112 | "url": "https://swellaby.com" 113 | }, 114 | "contributors": [ 115 | { 116 | "email": "opensource@swellaby.com", 117 | "name": "Caleb Cartwright", 118 | "url": "https://github.com/calebcartwright" 119 | } 120 | ], 121 | "devDependencies": { 122 | "@swellaby/eslint-config": "^2.0.0", 123 | "c8": "^8.0.1", 124 | "chai": "^4.3.10", 125 | "eslint": "^8.48.0", 126 | "mocha": "^10.2.0", 127 | "mocha-multi-reporters": "^1.5.1", 128 | "mocha-sonarqube-reporter": "^1.0.2", 129 | "prettier": "^2.8.8", 130 | "rimraf": "^5.0.1", 131 | "sinon": "^15.2.0", 132 | "vsce": "^2.15.0" 133 | }, 134 | "dependencies": { 135 | "jsonc-parser": "^3.2.0", 136 | "deepmerge": "^4.3.1" 137 | }, 138 | "scripts": { 139 | "build": "npm test", 140 | "clean": "rimraf .testresults .coverage .vsix", 141 | "coverage": "npm run coverage:unit", 142 | "coverage:unit": "c8 -c tests/unit/.c8rc.json npm run test:unit", 143 | "test": "npm run test:unit", 144 | "test:unit": "mocha --config tests/unit/.mocharc.yml", 145 | "format:fix": "prettier --write \"**/*.@(js|md|json|yml)\"", 146 | "format:check": "prettier --check \"**/*.@(js|md|json|yml)\"", 147 | "lint": "eslint **/*.js", 148 | "lint:fix": "eslint --fix **/*.js", 149 | "prepackage:vsix": "mkdirp .vsix", 150 | "package:vsix": "vsce package -o .vsix", 151 | "publish:vsix": "vsce publish --packagePath .vsix/$npm_package_name-$npm_package_version.vsix", 152 | "publish:vsix:windows": "vsce publish --packagePath .vsix/%npm_package_name%-%npm_package_version%.vsix", 153 | "publish:ext": "vsce publish patch", 154 | "vsce:login": "vsce login swellaby", 155 | "vsce": "vsce" 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | prep: 9 | ## To ensure Windows and non-Windows binaries are obtained 10 | runs-on: windows-latest 11 | name: cd-prep 12 | 13 | outputs: 14 | version: ${{ steps.package.outputs.version }} 15 | 16 | steps: 17 | - name: checkout 18 | uses: actions/checkout@v3 19 | with: 20 | persist-credentials: false 21 | 22 | - name: use Node.js 16 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: 16 26 | 27 | - name: install dependencies 28 | run: npm install 29 | 30 | - name: create vsix 31 | id: package 32 | shell: bash 33 | run: | 34 | git checkout main 35 | git config user.name swellbot 36 | git config user.email opensource@swellaby.com 37 | npm version patch -m "chore: bump version [skip ci]" 38 | npm run package:vsix 39 | echo ::set-output name=version::$(node -e "console.log(require('./package.json').version);") 40 | 41 | - name: archive workspace 42 | shell: bash 43 | run: | 44 | mkdir -p ../staging 45 | cp -ar ./. ../staging/ 46 | tar -C ../staging -czf workspace.tar.gz . 47 | 48 | - uses: actions/upload-artifact@v2 49 | with: 50 | name: workspace 51 | path: ./workspace.tar.gz 52 | 53 | unit-tests: 54 | needs: prep 55 | runs-on: ${{ matrix.runner }}-latest 56 | name: cd-${{ matrix.os }}-unit-tests-node-v${{ matrix.node-version }} 57 | 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | node-version: [12, 14, 15, 16] 62 | os: [linux, mac, windows] 63 | include: 64 | - os: linux 65 | runner: ubuntu 66 | - os: windows 67 | runner: windows 68 | - os: mac 69 | runner: macos 70 | 71 | steps: 72 | - name: download workspace 73 | uses: actions/download-artifact@v2 74 | with: 75 | name: workspace 76 | 77 | - name: extract workspace archive 78 | shell: bash 79 | run: | 80 | tar -xzf ./workspace.tar.gz 81 | ls -la 82 | 83 | - name: use Node.js ${{ matrix.node-version }} 84 | uses: actions/setup-node@v2 85 | with: 86 | node-version: ${{ matrix.node-version }} 87 | 88 | - name: run unit tests 89 | run: npm test 90 | 91 | lint: 92 | needs: prep 93 | runs-on: ubuntu-latest 94 | name: cd-lint 95 | 96 | steps: 97 | - name: download workspace 98 | uses: actions/download-artifact@v2 99 | with: 100 | name: workspace 101 | 102 | - name: extract workspace archive 103 | shell: bash 104 | run: | 105 | tar -xzf ./workspace.tar.gz 106 | ls -la 107 | 108 | - name: use Node.js 16 109 | uses: actions/setup-node@v2 110 | with: 111 | node-version: 16 112 | 113 | - name: run lint check 114 | run: npm run lint 115 | 116 | format: 117 | needs: prep 118 | runs-on: ubuntu-latest 119 | name: cd-format 120 | 121 | steps: 122 | - name: download workspace 123 | uses: actions/download-artifact@v2 124 | with: 125 | name: workspace 126 | 127 | - name: extract workspace archive 128 | shell: bash 129 | run: | 130 | tar -xzf ./workspace.tar.gz 131 | ls -la 132 | 133 | - name: use Node.js 16 134 | uses: actions/setup-node@v2 135 | with: 136 | node-version: 16 137 | 138 | - name: run formatting check 139 | run: npm run format:check 140 | 141 | sonar: 142 | runs-on: ubuntu-latest 143 | needs: prep 144 | name: cd-sonar 145 | 146 | steps: 147 | - name: download workspace 148 | uses: actions/download-artifact@v2 149 | with: 150 | name: workspace 151 | 152 | - name: extract workspace archive 153 | shell: bash 154 | run: | 155 | tar -xzf ./workspace.tar.gz 156 | ls -la 157 | 158 | - name: use Node.js 16 159 | uses: actions/setup-node@v2 160 | with: 161 | node-version: 16 162 | 163 | - name: run build script 164 | run: npm run coverage 165 | 166 | - name: SonarCloud Scan 167 | uses: sonarsource/sonarcloud-github-action@master 168 | env: 169 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 170 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 171 | with: 172 | args: > 173 | -Dsonar.projectVersion=${{ needs.prep.outputs.version }} 174 | 175 | publish: 176 | runs-on: ubuntu-latest 177 | name: cd-publish 178 | # be sure to keep dependency on `prep` to maintain access to the output variable 179 | needs: [prep, lint, format, sonar, unit-tests] 180 | environment: publish 181 | 182 | steps: 183 | - name: download workspace 184 | uses: actions/download-artifact@v2 185 | with: 186 | name: workspace 187 | 188 | - name: extract workspace archive 189 | run: | 190 | tar -xzf ./workspace.tar.gz 191 | ls -la 192 | ls -la .vsix 193 | 194 | - name: push artifacts 195 | run: | 196 | git config user.name swellbot 197 | git config user.email opensource@swellaby.com 198 | git status 199 | git checkout main 200 | git remote set-url origin https://swellbot:${{ secrets.GH_TOKEN }}@github.com/swellaby/vscode-workspace-config-plus 201 | git push --atomic origin main v${{ needs.prep.outputs.version }} 202 | npm run publish:vsix -- -p ${{ secrets.VSCE_PAT }} 203 | 204 | - name: create GitHub Release 205 | uses: ncipollo/release-action@v1 206 | with: 207 | artifacts: .vsix/*.vsix 208 | prerelease: true 209 | tag: v${{ needs.prep.outputs.version }} 210 | token: ${{ secrets.GITHUB_TOKEN }} 211 | -------------------------------------------------------------------------------- /tests/unit/watcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { assert } = require('chai'); 4 | const Sinon = require('sinon'); 5 | 6 | const { callbacks, globPattern, uris } = require('../data'); 7 | const fileHandler = require('../../src/file-handler'); 8 | const watcher = require('../../src/watcher'); 9 | 10 | suite('watcher Suite', () => { 11 | const firstWatcher = { dispose: () => null }; 12 | const secondWatcher = { dispose: () => null }; 13 | const thirdWatcher = { dispose: () => null }; 14 | const fourthWatcher = { dispose: () => null }; 15 | 16 | /** @type {Sinon.SinonStub} */ 17 | let firstWatcherDisposeStub; 18 | /** @type {Sinon.SinonStub} */ 19 | let secondWatcherDisposeStub; 20 | /** @type {Sinon.SinonStub} */ 21 | let thirdWatcherDisposeStub; 22 | /** @type {Sinon.SinonStub} */ 23 | let fourthWatcherDisposeStub; 24 | 25 | setup(() => { 26 | watcher._fileSystemWatchers = { 27 | first: [firstWatcher, secondWatcher], 28 | second: [thirdWatcher, fourthWatcher], 29 | }; 30 | firstWatcherDisposeStub = Sinon.stub(firstWatcher, 'dispose'); 31 | secondWatcherDisposeStub = Sinon.stub(secondWatcher, 'dispose'); 32 | thirdWatcherDisposeStub = Sinon.stub(thirdWatcher, 'dispose'); 33 | fourthWatcherDisposeStub = Sinon.stub(fourthWatcher, 'dispose'); 34 | }); 35 | 36 | teardown(() => { 37 | Sinon.restore(); 38 | }); 39 | 40 | suite('generateFileSystemWatcher Suite', () => { 41 | /** @type {Sinon.SinonFakeTimers} */ 42 | let clock; 43 | /** @type {Sinon.SinonStub} */ 44 | let registerSharedFileSystemWatcherStub; 45 | /** @type {Sinon.SinonStub} */ 46 | let mergeFilesStub; 47 | // ~ 2021-08-16T20-17-50Z 48 | const initialTime = 1629162959014; 49 | const { generateFileSystemWatcher } = watcher; 50 | const folderUri = 'my-project/.vscode'; 51 | const args = { 52 | ...callbacks, 53 | ...uris, 54 | folderUri, 55 | globPattern, 56 | }; 57 | let handleFileEvent; 58 | 59 | setup(() => { 60 | clock = Sinon.useFakeTimers(); 61 | clock.tick(initialTime); 62 | registerSharedFileSystemWatcherStub = Sinon.stub( 63 | watcher, 64 | '_registerSharedFileSystemWatcher' 65 | ).callsFake((_g, _c, _f, cb) => { 66 | handleFileEvent = cb; 67 | }); 68 | mergeFilesStub = Sinon.stub(fileHandler, 'mergeConfigFiles'); 69 | }); 70 | 71 | teardown(() => { 72 | handleFileEvent = null; 73 | }); 74 | 75 | test('Should not merge twice on duplicate events in rapid succession', async () => { 76 | generateFileSystemWatcher(args); 77 | await handleFileEvent(uris.sharedFileUri); 78 | clock.tick(349); 79 | await handleFileEvent(uris.sharedFileUri); 80 | assert.isTrue(mergeFilesStub.calledOnce); 81 | }); 82 | 83 | test('Should merge again if same type of events happen outside the cache boundary', async () => { 84 | generateFileSystemWatcher(args); 85 | await handleFileEvent(uris.sharedFileUri); 86 | clock.tick(351); 87 | await handleFileEvent(uris.sharedFileUri); 88 | assert.isTrue(mergeFilesStub.calledTwice); 89 | }); 90 | 91 | test('Should register watcher correctly', async () => { 92 | generateFileSystemWatcher(args); 93 | assert.isTrue(registerSharedFileSystemWatcherStub.calledOnce); 94 | const callArgs = registerSharedFileSystemWatcherStub.firstCall.args; 95 | // The fourth arg is the inner callback function, which is validated above. 96 | assert.deepEqual(callArgs[0], globPattern); 97 | assert.deepEqual(callArgs[1], callbacks.createFileSystemWatcher); 98 | assert.deepEqual(callArgs[2], folderUri); 99 | }); 100 | }); 101 | 102 | suite('_registerSharedFileSystemWatcher Suite', () => { 103 | /** @type {Sinon.SinonStub} */ 104 | let createFileSystemWatcherStub; 105 | /** @type {Sinon.SinonStub} */ 106 | let onDidChangeStub; 107 | /** @type {Sinon.SinonStub} */ 108 | let onDidCreateStub; 109 | /** @type {Sinon.SinonStub} */ 110 | let onDidDeleteStub; 111 | const fileSystemWatcher = { 112 | onDidChange: () => null, 113 | onDidCreate: () => null, 114 | onDidDelete: () => null, 115 | }; 116 | const vsCodeUri = { uri: 'foo/.vscode' }; 117 | const pattern = { path: '{a,b}.json' }; 118 | const handleEvent = (_a, _b, _c, _d = '') => {}; 119 | const didChange = 'indeed'; 120 | const didCreate = { foo: 'bar' }; 121 | const didDelete = false; 122 | 123 | setup(() => { 124 | createFileSystemWatcherStub = Sinon.stub( 125 | callbacks, 126 | 'createFileSystemWatcher' 127 | ) 128 | .withArgs(pattern) 129 | .callsFake(() => fileSystemWatcher); 130 | onDidChangeStub = Sinon.stub(fileSystemWatcher, 'onDidChange').callsFake( 131 | () => didChange 132 | ); 133 | onDidCreateStub = Sinon.stub(fileSystemWatcher, 'onDidCreate').callsFake( 134 | () => didCreate 135 | ); 136 | onDidDeleteStub = Sinon.stub(fileSystemWatcher, 'onDidDelete').callsFake( 137 | () => didDelete 138 | ); 139 | }); 140 | 141 | test('Should register the provided uri correctly', () => { 142 | watcher._registerSharedFileSystemWatcher( 143 | pattern, 144 | callbacks.createFileSystemWatcher, 145 | vsCodeUri, 146 | handleEvent 147 | ); 148 | assert.deepEqual(watcher._fileSystemWatchers[vsCodeUri], [ 149 | didChange, 150 | didCreate, 151 | didDelete, 152 | ]); 153 | }); 154 | }); 155 | 156 | suite('disposeAllWatchers Suite', () => { 157 | test('Should invoke dispose on all watcher disposables', () => { 158 | watcher.disposeAllWatchers(); 159 | assert.isTrue(firstWatcherDisposeStub.calledOnce); 160 | assert.isTrue(secondWatcherDisposeStub.calledOnce); 161 | assert.isTrue(thirdWatcherDisposeStub.calledOnce); 162 | assert.isTrue(fourthWatcherDisposeStub.calledOnce); 163 | }); 164 | }); 165 | 166 | suite('disposeWorkspaceWatcher Suite', () => { 167 | test('Should invoke dispose on all disposables for workspace', () => { 168 | watcher.disposeWorkspaceWatcher('first'); 169 | assert.isTrue(firstWatcherDisposeStub.calledOnce); 170 | assert.isTrue(secondWatcherDisposeStub.calledOnce); 171 | assert.isFalse(thirdWatcherDisposeStub.calledOnce); 172 | assert.isFalse(fourthWatcherDisposeStub.calledOnce); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /tests/unit/lib.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { assert } = require('chai'); 4 | const Sinon = require('sinon'); 5 | 6 | const fileHandler = require('../../src/file-handler'); 7 | const lib = require('../../src/lib'); 8 | const log = require('../../src/log'); 9 | const data = require('../data'); 10 | const watcher = require('../../src/watcher'); 11 | 12 | suite('lib Suite', () => { 13 | teardown(() => { 14 | Sinon.restore(); 15 | }); 16 | 17 | suite('deactivate Suite', () => { 18 | test('Should handle deactivation correctly', () => { 19 | const logInfoStub = Sinon.stub(log, 'info'); 20 | const logDisposeStub = Sinon.stub(log, 'dispose'); 21 | const disposeAllWatchersStub = Sinon.stub(watcher, 'disposeAllWatchers'); 22 | lib.deactivate(); 23 | assert.isTrue( 24 | logInfoStub.calledOnceWithExactly( 25 | 'Deactivating and disposing all watchers' 26 | ) 27 | ); 28 | assert.isTrue(disposeAllWatchersStub.calledOnce); 29 | assert.isTrue(logDisposeStub.calledOnce); 30 | }); 31 | }); 32 | 33 | suite('handleWorkspaceFolderUpdates Suite', () => { 34 | /** @type {Sinon.SinonStub} */ 35 | let initializeWorkspaceFolderStub; 36 | /** @type {Sinon.SinonStub} */ 37 | let disposeWorkspaceWatcherStub; 38 | 39 | setup(() => { 40 | initializeWorkspaceFolderStub = Sinon.stub( 41 | lib, 42 | 'initializeWorkspaceFolder' 43 | ); 44 | disposeWorkspaceWatcherStub = Sinon.stub( 45 | watcher, 46 | 'disposeWorkspaceWatcher' 47 | ); 48 | }); 49 | 50 | test('Should handle falsy value for added folders', () => { 51 | lib.handleWorkspaceFolderUpdates({}); 52 | assert.isFalse(initializeWorkspaceFolderStub.called); 53 | }); 54 | 55 | test('Should handle non-array value for added folders', () => { 56 | lib.handleWorkspaceFolderUpdates({ added: 7 }); 57 | assert.isFalse(initializeWorkspaceFolderStub.called); 58 | }); 59 | 60 | test('Should properly initialize added folders', () => { 61 | const added = [{ uri: 'foo/bar' }, { uri: 'baz/qux' }]; 62 | lib.handleWorkspaceFolderUpdates({ 63 | added, 64 | ...data.callbacks, 65 | }); 66 | assert.deepEqual(initializeWorkspaceFolderStub.firstCall.firstArg, { 67 | folderUri: added[0].uri, 68 | ...data.callbacks, 69 | }); 70 | }); 71 | 72 | test('Should handle falsy value for removed folders', () => { 73 | lib.handleWorkspaceFolderUpdates({}); 74 | assert.isFalse(disposeWorkspaceWatcherStub.called); 75 | }); 76 | 77 | test('Should handle non-array value for removed folders', () => { 78 | lib.handleWorkspaceFolderUpdates({ removed: 'why' }); 79 | assert.isFalse(initializeWorkspaceFolderStub.called); 80 | }); 81 | 82 | test('Should properly handle removed folders', () => { 83 | const removed = [{ uri: 'bar/foo' }, { uri: 'qux/baz' }]; 84 | lib.handleWorkspaceFolderUpdates({ removed }); 85 | assert.isTrue(disposeWorkspaceWatcherStub.calledTwice); 86 | assert.isTrue( 87 | disposeWorkspaceWatcherStub.calledWithExactly(removed[0].uri) 88 | ); 89 | assert.isTrue( 90 | disposeWorkspaceWatcherStub.calledWithExactly(removed[1].uri) 91 | ); 92 | }); 93 | }); 94 | 95 | suite('initializeLog Suite', () => { 96 | test('Should initialize log correctly', () => { 97 | const createOutputChannel = () => { 98 | return 7; 99 | }; 100 | const logInitializeStub = Sinon.stub(log, 'initialize'); 101 | lib.initializeLog(createOutputChannel); 102 | assert.isTrue( 103 | logInitializeStub.calledOnceWithExactly(createOutputChannel) 104 | ); 105 | }); 106 | }); 107 | 108 | suite('initializeWorkspaceFolder Suite', () => { 109 | const { callbacks } = data; 110 | const { createFileSystemWatcher, readFile, writeFile } = callbacks; 111 | /** @type {Sinon.SinonStub} */ 112 | let generateFileSystemWatcherStub; 113 | /** @type {Sinon.SinonStub} */ 114 | let mergeConfigFilesStub; 115 | /** @type {Sinon.SinonStub} */ 116 | let joinPathStub; 117 | /** @type {Sinon.SinonStub} */ 118 | let createRelativePatternStub; 119 | const workspaceVscodeDirUri = { uri: 'foo/.vscode' }; 120 | const folderUri = 'foo'; 121 | const { pattern } = data; 122 | const { vscodeFileUri, localFileUri, sharedFileUri } = data.uris; 123 | 124 | setup(() => { 125 | generateFileSystemWatcherStub = Sinon.stub( 126 | watcher, 127 | 'generateFileSystemWatcher' 128 | ); 129 | mergeConfigFilesStub = Sinon.stub(fileHandler, 'mergeConfigFiles'); 130 | joinPathStub = Sinon.stub(callbacks, 'joinPath'); 131 | joinPathStub 132 | .withArgs(folderUri, '.vscode') 133 | .callsFake(() => workspaceVscodeDirUri); 134 | createRelativePatternStub = Sinon.stub( 135 | callbacks, 136 | 'createRelativePattern' 137 | ); 138 | }); 139 | 140 | const assertGenerateWatcherCall = pattern => { 141 | assert.isTrue( 142 | generateFileSystemWatcherStub.calledWithExactly({ 143 | globPattern: pattern, 144 | folderUri, 145 | ...data.uris, 146 | createFileSystemWatcher, 147 | readFile, 148 | writeFile, 149 | }) 150 | ); 151 | }; 152 | 153 | const assertMergeFilesCall = () => { 154 | assert.isTrue( 155 | mergeConfigFilesStub.calledWithExactly({ 156 | ...data.uris, 157 | readFile, 158 | writeFile, 159 | }) 160 | ); 161 | }; 162 | 163 | test('Should run for every entry in target config files', () => { 164 | const expectedCount = 3; 165 | lib.initializeWorkspaceFolder({ folderUri, ...callbacks }); 166 | assert.deepEqual(generateFileSystemWatcherStub.callCount, expectedCount); 167 | assert.deepEqual(mergeConfigFilesStub.callCount, expectedCount); 168 | }); 169 | 170 | test('Should initialize settings.json correctly', () => { 171 | createRelativePatternStub 172 | .withArgs( 173 | workspaceVscodeDirUri, 174 | '{settings.local,settings.shared}.json' 175 | ) 176 | .callsFake(() => pattern); 177 | joinPathStub 178 | .withArgs(workspaceVscodeDirUri, 'settings.json') 179 | .callsFake(() => vscodeFileUri); 180 | joinPathStub 181 | .withArgs(workspaceVscodeDirUri, 'settings.shared.json') 182 | .callsFake(() => sharedFileUri); 183 | joinPathStub 184 | .withArgs(workspaceVscodeDirUri, 'settings.local.json') 185 | .callsFake(() => localFileUri); 186 | 187 | lib.initializeWorkspaceFolder({ folderUri, ...callbacks }); 188 | assertGenerateWatcherCall(pattern); 189 | assertMergeFilesCall(); 190 | }); 191 | 192 | test('Should initialize launch.json correctly', () => { 193 | createRelativePatternStub 194 | .withArgs(workspaceVscodeDirUri, '{launch.local,launch.shared}.json') 195 | .callsFake(() => pattern); 196 | joinPathStub 197 | .withArgs(workspaceVscodeDirUri, 'launch.json') 198 | .callsFake(() => vscodeFileUri); 199 | joinPathStub 200 | .withArgs(workspaceVscodeDirUri, 'launch.shared.json') 201 | .callsFake(() => sharedFileUri); 202 | joinPathStub 203 | .withArgs(workspaceVscodeDirUri, 'launch.local.json') 204 | .callsFake(() => localFileUri); 205 | 206 | lib.initializeWorkspaceFolder({ folderUri, ...callbacks }); 207 | assertGenerateWatcherCall(pattern); 208 | assertMergeFilesCall(); 209 | }); 210 | 211 | test('Should initialize tasks.json correctly', () => { 212 | createRelativePatternStub 213 | .withArgs(workspaceVscodeDirUri, '{tasks.local,tasks.shared}.json') 214 | .callsFake(() => pattern); 215 | joinPathStub 216 | .withArgs(workspaceVscodeDirUri, 'tasks.json') 217 | .callsFake(() => vscodeFileUri); 218 | joinPathStub 219 | .withArgs(workspaceVscodeDirUri, 'tasks.shared.json') 220 | .callsFake(() => sharedFileUri); 221 | joinPathStub 222 | .withArgs(workspaceVscodeDirUri, 'tasks.local.json') 223 | .callsFake(() => localFileUri); 224 | 225 | lib.initializeWorkspaceFolder({ folderUri, ...callbacks }); 226 | assertGenerateWatcherCall(pattern); 227 | assertMergeFilesCall(); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workspace Config+ 2 | 3 | Provides additional capabilities to manage your "workspace" configuration settings, including the ability to utilize `shared` and `local` versions of the VS Code workspace configuration files. 4 | 5 | > **Note**: A VS Code "workspace" is usually just your project root folder. This extension can be immediately used in standard, single root/projects, _as well as_ more advanced, multi-root workspaces. See the VS Code docs [What is a VS Code "Workspace"?](https://code.visualstudio.com/docs/editor/workspaces) and [Workspace Settings](https://code.visualstudio.com/docs/getstarted/settings#_workspace-settings) for more information. 6 | 7 | --- 8 | 9 | **Functional, but still in Beta !!!!** 10 | 11 | --- 12 | 13 | [![Version Badge][version-badge]][ext-url] 14 | [![Installs Badge][installs-badge]][ext-url] 15 | [![Rating Badge][rating-badge]][ext-url] 16 | [![License Badge][license-badge]][license-url] 17 | 18 | [![Linux CI Badge][linux-ci-badge]][linux-ci-url] 19 | [![Mac CI Badge][mac-ci-badge]][mac-ci-url] 20 | [![Windows CI Badge][windows-ci-badge]][windows-ci-url] 21 | 22 | [![Test Results Badge][tests-badge]][tests-url] 23 | [![Coverage Badge][coverage-badge]][coverage-url] 24 | [![Sonar Quality GateBadge][quality-gate-badge]][sonar-project-url] 25 | 26 | ## Current Features 27 | 28 | - Adds support for `shared` and `local` configuration files (`.vscode/*.shared.json`, and `.vscode/*.local.json`) 29 | 30 | ### Shared and Local Configuration Files 31 | 32 | With this extension you can now split your project's workspace configuration between shared files that can be checked into version control and shared with other team members, as well as local configuration overrides/extension that are excluded from version control. 33 | 34 | It currently supports: 35 | 36 | - `settings.json` - (`settings.shared.json`, `settings.local.json`) 37 | - `tasks.json` - (`tasks.shared.json`, `tasks.local.json`) 38 | - `launch.json` - (`launch.shared.json`, `launch.local.json`) 39 | 40 | #### Setup 41 | 42 | Be sure your `*.local.json` files and the main VS Code files are excluded from version control by adding the corresponding entries to your project's ignore file (e.g. `.gitignore`, `.hgignore`). For example: 43 | 44 | ``` 45 | # .gitignore 46 | .vscode/* 47 | !.vscode/*.shared.json 48 | ``` 49 | 50 | Then just add your desired `*.shared.json` and/or `*.local.json` files to your `.vscode` directory in your workspace folder(s). The extension works with both standard (single root) workspace projects and [multi root workspaces][multi-root-workspace-docs]. 51 | 52 | Enter the values that you want to share with other contributors into the `*.shared.json` file, and any personal/local overrides and additional settings to the corresponding `*.local.json` file. The configuration values defined in a `*.local.json` file will take precedence over any conflicting values defined in the corresponding `*.shared.json` file. 53 | 54 | The extension will re-evaluate and, if necessary, automatically apply any configuration updates any time any supported `*.shared.json` or `*.local.json` files are added, modified, or removed, as well as when additional folders are added to a workspace. As such you never have to worry about running any commands! 55 | 56 | This extension is not an all-or-nothing proposition. Team members and contributors that want to take advantage of the shared configuration defined in the `*.shared.json` files only need to have this extension installed and enabled. Any contributor that _doesn't_ want to pull in any of the project's shared configuration can either not install or disable this extension, or they can create a corresponding `*.local.json` file to override the shared settings. 57 | 58 | [multi-root-workspace-docs]: https://code.visualstudio.com/docs/editor/multi-root-workspaces 59 | 60 | #### Settings 61 | 62 | > Note that currently Workspace Config+ requires you to specify any of the below settings in your "workspace" files, and it doesn't yet support setting them at the user/machine global level. 63 | > 64 | > If you'd like to use these settings to modify the behavior of Workspace Config+ ,then you'll need to add the setting to either `settings.local.json` or `settings.shared.json` 65 | 66 | ##### `arrayMerge` 67 | 68 | Allows you to specify how the extension should handle array-type keys that are defined in multiple places (i.e. in both the `*.shared.json` and `*.local.json)`) 69 | 70 | **Default**: `combine` 71 | **Supported values**: [ `combine`, `overwrite` ] 72 | 73 | When set to `combine`, the two array values will be combined in the final result. 74 | 75 | For example, if you have the following: 76 | 77 | `tasks.local.json` 78 | 79 | ```json 80 | { 81 | "version": "0.2.0", 82 | "configurations": [ 83 | { 84 | "name": "My awesome personal task", 85 | ... 86 | } 87 | ] 88 | } 89 | ``` 90 | 91 | and `tasks.shared.json` 92 | 93 | ```json 94 | { 95 | "version": "0.1.0", 96 | "foo": "bar", 97 | "configurations": [ 98 | { 99 | "name": "Shared Team task #1", 100 | ... 101 | } 102 | ] 103 | } 104 | ``` 105 | 106 | The end result in `tasks.json` that VS Code will see and honor will have both of the tasks defined under the `configurations` arrays in the shared and local files: 107 | 108 | `tasks.json` 109 | 110 | ```json 111 | { 112 | "version": "0.2.0", 113 | "foo": "bar", 114 | "configurations": [ 115 | { 116 | "name": "My awesome personal task", 117 | ... 118 | }, 119 | { 120 | "name": "Shared Team task #1", 121 | ... 122 | } 123 | ] 124 | } 125 | ``` 126 | 127 | However, if you change the value of the setting to `overwrite`, then the overlapping `configurations` array value in `*.local.json` will be used instead, producing the below for `tasks.json`: 128 | 129 | ```json 130 | { 131 | "version": "0.2.0", 132 | "foo": "bar", 133 | "configurations": [ 134 | { 135 | "name": "Shared Team task #1", 136 | ... 137 | } 138 | ] 139 | } 140 | ``` 141 | 142 | #### Limitations 143 | 144 | All configuration setting values are ultimately stored and persisted in the native VS Code workspace configuration files (e.g. `.vscode/settings.json`). However, because these features are added via an extension there are some associated limitations and accordingly we'd strongly advise against manually modifying those native files when using the extension, and instead advise managing your configuration in the shared/local files. 145 | 146 | - You can utilize inline comments in the `*.local.json` and `*.shared.json` files, but any comments from those files are not persisted into the native VS Code configuration file. 147 | - Any comments added to the native VS Code configuration file (e.g. `settings.json`) will be lost when any configuration updates are applied based on changes to the local/shared files. 148 | - Some other extensions may automatically add configuration values to the native files with comments, so we'd advise moving any such configuration values to the corresponding `*.local.json` or `*.shared.json` file if you'd like to maintain the comments. 149 | - The extension does _not_ monitor any changes made to the native VS Code configuration files (e.g. `settings.json`), so if you manually modify a value in one of those files then that takes precedence with VS Code. 150 | - If you mistakenly modify the native file, the easiest way to trigger this extension to correct the configuration is to modify the corresponding shared or local file (e.g. add a blank line and then save). 151 | - This extension only works with the workspace configuration files, and doesn't allow for configuration values to be edited in the [VS Code Settings Editor][vscode-settings-editor-docs] interface. 152 | - We've tested and validated the extension with both single and [multi-root workspace][vscode-multiroot-docs] projects. We have _not_ had a chance to test with [VS Code Remote SSH][vscode-ssh-docs] based workspaces, nor browser-based workspaces like [GitHub Codespaces][github-codespaces-docs]. We don't necessarily expect any particular issues in those types of projects, but just haven't been able to test and validate (if you do, and want to test, please let us know!) 153 | - Our understanding is that you will not be able to sync your `*.local.json` files if you are a user of the native [VS Code Settings Sync][vscode-settings-sync] feature. However, the [Settings Sync Extension][settings-sync-ext] may support synchronizing the `*.local.json` configuration files too. 154 | 155 | [vscode-settings-editor-docs]: https://code.visualstudio.com/docs/getstarted/settings#_settings-editor 156 | [vscode-multiroot-docs]: https://code.visualstudio.com/docs/editor/multi-root-workspaces 157 | [vscode-ssh-docs]: https://code.visualstudio.com/docs/remote/ssh 158 | [github-codespaces-docs]: https://github.com/features/codespaces 159 | [vscode-settings-sync]: https://code.visualstudio.com/docs/editor/settings-sync 160 | [settings-sync-ext]: https://marketplace.visualstudio.com/items?itemName=Shan.code-settings-sync 161 | 162 | #### Background 163 | 164 | VS Code is highly configurable, and allows you to [configure specific workspaces in addition to your global user settings.]([vscode-settings-docs]). This includes things like general settings, such as the zoom level, as well as [tasks and launch configurations] amongst others. These configurations are stored in various respective files within the `.vscode` directory in the workspace. For example, the workspace task configuration is stored in `.vscode/tasks.json`. 165 | 166 | This works fantastically, but unfortunately often poses a challenging question for teams or projects that have more than one author since they have to determine whether or not to track the configuration file(s) in version control. If they include the files in version control then they'll often run into conflicting opinions or even conflicting settings, such as those from extensions which are specific to the developer's local file system. However, if they exclude the files from version control then they give up the ability to share elements that are helpful for other developers and force contributors to manually duplicate part of their setup. 167 | 168 | There are some longstanding requests from the VS Code community ([microsoft/vscode#40233][vscode-github-issue-40233], [microsoft/vscode#37519][vscode-github-issue-37519], [microsoft/vscode#15909][vscode-github-issue-15909]) to extend the product to address these concerns, and we hope to see a resolution natively within VS Code some day. This extension should help fill the gap in the interim however. 169 | 170 | [vscode-settings-docs]: https://code.visualstudio.com/docs/getstarted/settings 171 | [tasks-launch-docs]: https://code.visualstudio.com/docs/editor/workspaces#_workspace-tasks-and-launch-configurations 172 | [vscode-github-issue-40233]: https://github.com/microsoft/vscode/issues/40233 173 | [vscode-github-issue-37519]: https://github.com/microsoft/vscode/issues/37519 174 | [vscode-github-issue-15909]: https://github.com/microsoft/vscode/issues/15909 175 | 176 | ## Feedback 177 | 178 | Found a bug, have an idea for a new feature, or a question? Please reach out to us on the [project GitHub repository][github-repo-url] by opening an Issue or starting a Discussion! 179 | 180 | Like this extension? Please consider starring the repo on GitHub! ![][stars-badge] 181 | 182 | You can also share feedback by rating the extension and/or leaving a [review][marketplace-reviews-url] on the Marketplace. 183 | 184 | [stars-badge]: https://img.shields.io/github/stars/swellaby/vscode-workspace-config-plus?style=social 185 | [marketplace-reviews-url]: https://marketplace.visualstudio.com/items?itemName=swellaby.workspace-config-plus&ssr=false#review-details 186 | 187 | ## Contributing 188 | 189 | All contributions are welcomed and appreciated! See the [Contributing guide](./CONTRIBUTING.md) for more information. 190 | 191 | ## License 192 | 193 | MIT - see license details [here][license-url] 194 | 195 | ## Code of Conduct 196 | 197 | This project follows the standard [Code of Conduct](https://github.com/swellaby/.github/blob/master/CODE_OF_CONDUCT.md) as other Swellaby projects, which is the [Contributor Covenant](https://www.contributor-covenant.org/) 198 | 199 | [installs-badge]: https://img.shields.io/vscode-marketplace/i/swellaby.workspace-config-plus?style=flat-square&label=installs 200 | [version-badge]: https://img.shields.io/vscode-marketplace/v/swellaby.workspace-config-plus?style=flat-square&label=version 201 | [rating-badge]: https://img.shields.io/vscode-marketplace/r/swellaby.workspace-config-plus?style=flat-square 202 | [ext-url]: https://marketplace.visualstudio.com/items?itemName=swellaby.workspace-config-plus 203 | [license-url]: https://github.com/swellaby/vscode-workspace-config-plus/blob/main/LICENSE 204 | [license-badge]: https://img.shields.io/github/license/swellaby/vscode-workspace-config-plus?style=flat-square&color=blue 205 | [linux-ci-badge]: https://img.shields.io/github/actions/workflow/status/swellaby/vscode-workspace-config-plus/linux.yml?label=linux%20build&style=flat-square&branch=main 206 | [linux-ci-url]: https://github.com/swellaby/vscode-workspace-config-plus/actions/workflows/linux.yml?query=branch%3Amain 207 | [mac-ci-badge]: https://img.shields.io/github/actions/workflow/status/swellaby/vscode-workspace-config-plus/mac.yml?label=mac%20build&style=flat-square&branch=main 208 | [mac-ci-url]: https://github.com/swellaby/vscode-workspace-config-plus/actions/workflows/mac.yml?query=branch%3Amain 209 | [windows-ci-badge]: https://img.shields.io/github/actions/workflow/status/swellaby/vscode-workspace-config-plus/windows.yml?label=windows%20build&style=flat-square&branch=main 210 | [windows-ci-url]: https://github.com/swellaby/vscode-workspace-config-plus/actions/workflows/windows.yml?query=branch%3Amain 211 | [coverage-badge]: https://img.shields.io/codecov/c/github/swellaby/vscode-workspace-config-plus/main?style=flat-square 212 | [coverage-url]: https://codecov.io/gh/swellaby/vscode-workspace-config-plus 213 | [tests-badge]: https://img.shields.io/sonar/tests/swellaby:vscode-workspace-config-plus?server=https%3A%2F%2Fsonarcloud.io&style=flat-square 214 | [tests-url]: https://sonarcloud.io/component_measures?id=swellaby%3Avscode-workspace-config-plus&metric=test_success_density&selected=swellaby%3Avscode-workspace-config-plus%3Atests%2Funit%2Fwatcher.js&view=list 215 | [quality-gate-badge]: https://img.shields.io/sonar/quality_gate/swellaby:vscode-workspace-config-plus?server=https%3A%2F%2Fsonarcloud.io&style=flat-square 216 | [sonar-project-url]: https://sonarcloud.io/project/overview?id=swellaby%3Avscode-workspace-config-plus 217 | [github-repo-url]: https://github.com/swellaby/vscode-workspace-config-plus 218 | -------------------------------------------------------------------------------- /tests/unit/file-handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { assert } = require('chai'); 4 | const jsoncParser = require('jsonc-parser'); 5 | const Sinon = require('sinon'); 6 | 7 | const { callbacks } = require('../data'); 8 | const fileHandler = require('../../src/file-handler'); 9 | const log = require('../../src/log'); 10 | 11 | suite('file handler Suite', () => { 12 | /** @type {Sinon.SinonStub} */ 13 | let readFileStub; 14 | const fileUri = { fsPath: 'projA/.vscode/settings.shared.json' }; 15 | const config = { 'window.zoomLevel': -1 }; 16 | const contents = JSON.stringify(config); 17 | const buffer = Buffer.from(contents); 18 | 19 | setup(() => { 20 | readFileStub = Sinon.stub(callbacks, 'readFile') 21 | .withArgs(fileUri) 22 | .callsFake(() => buffer); 23 | }); 24 | 25 | teardown(() => { 26 | Sinon.restore(); 27 | }); 28 | 29 | suite('_loadConfigFromFile Suite', () => { 30 | /** @type {Sinon.SinonStub} */ 31 | let parseStub; 32 | const _loadConfigFromFile = fileHandler._loadConfigFromFile; 33 | 34 | setup(() => { 35 | parseStub = Sinon.stub(jsoncParser, 'parse') 36 | .withArgs(contents, []) 37 | .callsFake(() => config); 38 | }); 39 | 40 | test('Should return undefined when file does not exist', async () => { 41 | readFileStub.callsFake(() => undefined); 42 | assert.isUndefined( 43 | await _loadConfigFromFile(fileUri, callbacks.readFile) 44 | ); 45 | assert.isFalse(parseStub.called); 46 | }); 47 | 48 | test('Should return config object on valid json/jsonc', async () => { 49 | assert.deepEqual( 50 | await _loadConfigFromFile(fileUri, callbacks.readFile), 51 | config 52 | ); 53 | }); 54 | 55 | test('Should throw error on invalid json/jsonc', async () => { 56 | parseStub.callsFake((_c, errors) => { 57 | errors.push('oops'); 58 | }); 59 | try { 60 | await _loadConfigFromFile(fileUri, callbacks.readFile); 61 | assert.fail('Should have thrown'); 62 | } catch (e) { 63 | assert.deepEqual( 64 | e.message, 65 | `Failed to parse contents of: ${fileUri.fsPath}` 66 | ); 67 | } 68 | }); 69 | }); 70 | 71 | suite('mergeConfigFiles Suite', () => { 72 | /** @type {Sinon.SinonStub} */ 73 | let loadConfigFromFileStub; 74 | /** @type {Sinon.SinonStub} */ 75 | let loadVSConfigFromFileStub; 76 | /** @type {Sinon.SinonStub} */ 77 | let writeFileStub; 78 | /** @type {Sinon.SinonStub} */ 79 | let logInfoStub; 80 | /** @type {Sinon.SinonStub} */ 81 | let logDebugStub; 82 | /** @type {Sinon.SinonStub} */ 83 | let logErrorStub; 84 | const vscodeFileUri = { fsPath: '.vscode/settings.json' }; 85 | const sharedFileUri = { path: '.vscode/settings.shared.json' }; 86 | const localFileUri = { path: '.vscode/settings.local.json' }; 87 | const mergeConfigFiles = fileHandler.mergeConfigFiles; 88 | const vscodeConfig = { 89 | foo: 'abc', 90 | 'window.zoomLevel': 0, 91 | bar: 'def', 92 | }; 93 | const sharedArrayCombineConfig = { 94 | foo: false, 95 | 'editor.rulers': [100], 96 | '[typescript]': { 97 | 'editor.dragAndDrop': false, 98 | 'editor.tabCompletion': 'on', 99 | }, 100 | [fileHandler._arrayMergeKey]: 'combine', 101 | }; 102 | const localArrayCombineConfig = { 103 | foo: true, 104 | 'window.zoomLevel': 1, 105 | 'editor.rulers': [80], 106 | '[typescript]': { 107 | 'editor.dragAndDrop': true, 108 | 'editor.autoIndent': false, 109 | }, 110 | baz: false, 111 | [fileHandler._arrayMergeKey]: 'combine', 112 | }; 113 | 114 | const expArrayCombineConfig = { 115 | foo: true, 116 | 'window.zoomLevel': 1, 117 | 'editor.rulers': [100, 80], 118 | '[typescript]': { 119 | 'editor.dragAndDrop': true, 120 | 'editor.tabCompletion': 'on', 121 | 'editor.autoIndent': false, 122 | }, 123 | [fileHandler._arrayMergeKey]: 'combine', 124 | baz: false, 125 | }; 126 | const finalExpArrayCombineConfig = { 127 | ...vscodeConfig, 128 | ...expArrayCombineConfig, 129 | }; 130 | const expArrayCombineConfigNoExplicitMerge = JSON.parse( 131 | JSON.stringify(finalExpArrayCombineConfig) 132 | ); 133 | delete expArrayCombineConfigNoExplicitMerge[fileHandler._arrayMergeKey]; 134 | 135 | const sharedArrayOverwriteConfig = { 136 | foo: false, 137 | 'editor.rulers': [100], 138 | '[typescript]': { 139 | 'editor.dragAndDrop': false, 140 | 'editor.tabCompletion': 'on', 141 | }, 142 | // Note the casing in the value, added intentionally to provide 143 | // defensive testing and ensure we aren't pedantic about case. 144 | [fileHandler._arrayMergeKey]: 'OVERwrite', 145 | }; 146 | const sharedConfigNoArrayMerge = JSON.parse( 147 | JSON.stringify(sharedArrayOverwriteConfig) 148 | ); 149 | delete sharedConfigNoArrayMerge[fileHandler._arrayMergeKey]; 150 | const localArrayOverwriteConfig = { 151 | foo: true, 152 | 'window.zoomLevel': 1, 153 | 'editor.rulers': [80], 154 | '[typescript]': { 155 | 'editor.dragAndDrop': true, 156 | 'editor.autoIndent': false, 157 | }, 158 | [fileHandler._arrayMergeKey]: 'overwrite', 159 | baz: false, 160 | }; 161 | const localConfigNoArrayMerge = JSON.parse( 162 | JSON.stringify(localArrayOverwriteConfig) 163 | ); 164 | delete localConfigNoArrayMerge[fileHandler._arrayMergeKey]; 165 | const expArrayOverwriteConfig = { 166 | foo: true, 167 | 'window.zoomLevel': 1, 168 | bar: 'def', 169 | 'editor.rulers': [80], 170 | '[typescript]': { 171 | 'editor.dragAndDrop': true, 172 | 'editor.tabCompletion': 'on', 173 | 'editor.autoIndent': false, 174 | }, 175 | [fileHandler._arrayMergeKey]: 'overwrite', 176 | baz: false, 177 | }; 178 | 179 | setup(() => { 180 | loadConfigFromFileStub = Sinon.stub(fileHandler, '_loadConfigFromFile'); 181 | loadVSConfigFromFileStub = loadConfigFromFileStub.withArgs( 182 | vscodeFileUri, 183 | callbacks.readFile 184 | ); 185 | loadConfigFromFileStub 186 | .withArgs(sharedFileUri, callbacks.readFile) 187 | .callsFake(() => Promise.resolve(sharedArrayCombineConfig)); 188 | loadConfigFromFileStub 189 | .withArgs(localFileUri, callbacks.readFile) 190 | .callsFake(() => Promise.resolve(localArrayCombineConfig)); 191 | 192 | loadVSConfigFromFileStub.callsFake(() => Promise.resolve(vscodeConfig)); 193 | writeFileStub = Sinon.stub(callbacks, 'writeFile'); 194 | logDebugStub = Sinon.stub(log, 'debug'); 195 | logErrorStub = Sinon.stub(log, 'error'); 196 | logInfoStub = Sinon.stub(log, 'info'); 197 | }); 198 | 199 | test('Should return early with no custom files exist', async () => { 200 | loadConfigFromFileStub 201 | .withArgs(sharedFileUri, callbacks.readFile) 202 | .callsFake(() => Promise.resolve(undefined)); 203 | loadConfigFromFileStub 204 | .withArgs(localFileUri, callbacks.readFile) 205 | .callsFake(() => Promise.resolve(undefined)); 206 | await mergeConfigFiles({ 207 | vscodeFileUri, 208 | sharedFileUri, 209 | localFileUri, 210 | ...callbacks, 211 | }); 212 | assert.isTrue(loadConfigFromFileStub.calledTwice); 213 | assert.isFalse(writeFileStub.called); 214 | }); 215 | 216 | test('Should return early when there are no changes to be made', async () => { 217 | loadVSConfigFromFileStub.callsFake(() => { 218 | return Promise.resolve(expArrayCombineConfig); 219 | }); 220 | await mergeConfigFiles({ 221 | vscodeFileUri, 222 | sharedFileUri, 223 | localFileUri, 224 | ...callbacks, 225 | }); 226 | assert.isTrue(loadConfigFromFileStub.calledThrice); 227 | assert.isFalse(writeFileStub.called); 228 | }); 229 | 230 | test('Should handle a nonexistent local file', async () => { 231 | loadConfigFromFileStub 232 | .withArgs(localFileUri, callbacks.readFile) 233 | .callsFake(() => Promise.resolve(undefined)); 234 | await mergeConfigFiles({ 235 | vscodeFileUri, 236 | sharedFileUri, 237 | localFileUri, 238 | ...callbacks, 239 | }); 240 | assert.isTrue(loadConfigFromFileStub.calledThrice); 241 | assert.isTrue( 242 | logInfoStub.calledOnceWithExactly( 243 | `Updating config in ${vscodeFileUri.fsPath}` 244 | ) 245 | ); 246 | assert.isTrue( 247 | writeFileStub.calledOnceWithExactly( 248 | vscodeFileUri, 249 | Buffer.from( 250 | JSON.stringify( 251 | { ...vscodeConfig, ...sharedArrayCombineConfig }, 252 | null, 253 | 2 254 | ) 255 | ), 256 | { create: true, overwrite: true } 257 | ) 258 | ); 259 | }); 260 | 261 | test('Should handle a nonexistent shared file', async () => { 262 | loadConfigFromFileStub 263 | .withArgs(sharedFileUri, callbacks.readFile) 264 | .callsFake(() => Promise.resolve(undefined)); 265 | await mergeConfigFiles({ 266 | vscodeFileUri, 267 | sharedFileUri, 268 | localFileUri, 269 | ...callbacks, 270 | }); 271 | assert.isTrue(loadConfigFromFileStub.calledThrice); 272 | assert.isTrue( 273 | logInfoStub.calledOnceWithExactly( 274 | `Updating config in ${vscodeFileUri.fsPath}` 275 | ) 276 | ); 277 | assert.isTrue( 278 | writeFileStub.calledOnceWithExactly( 279 | vscodeFileUri, 280 | Buffer.from( 281 | JSON.stringify( 282 | { ...vscodeConfig, ...localArrayCombineConfig }, 283 | null, 284 | 2 285 | ) 286 | ), 287 | { create: true, overwrite: true } 288 | ) 289 | ); 290 | }); 291 | 292 | test('Should write to config file with correct priority order using array combine', async () => { 293 | await mergeConfigFiles({ 294 | vscodeFileUri, 295 | sharedFileUri, 296 | localFileUri, 297 | ...callbacks, 298 | }); 299 | assert.isTrue(loadConfigFromFileStub.calledThrice); 300 | assert.isTrue( 301 | logInfoStub.calledOnceWithExactly( 302 | `Updating config in ${vscodeFileUri.fsPath}` 303 | ) 304 | ); 305 | assert.isTrue( 306 | writeFileStub.calledOnceWithExactly( 307 | vscodeFileUri, 308 | Buffer.from(JSON.stringify(finalExpArrayCombineConfig, null, 2)), 309 | { create: true, overwrite: true } 310 | ) 311 | ); 312 | }); 313 | 314 | test('Should write to config file with correct priority order using shared array overwrite', async () => { 315 | loadConfigFromFileStub 316 | .withArgs(sharedFileUri, callbacks.readFile) 317 | .callsFake(() => Promise.resolve(sharedArrayOverwriteConfig)); 318 | loadConfigFromFileStub 319 | .withArgs(localFileUri, callbacks.readFile) 320 | .callsFake(() => Promise.resolve(localArrayCombineConfig)); 321 | await mergeConfigFiles({ 322 | vscodeFileUri, 323 | sharedFileUri, 324 | localFileUri, 325 | ...callbacks, 326 | }); 327 | assert.isTrue(loadConfigFromFileStub.calledThrice); 328 | assert.isTrue( 329 | logInfoStub.calledOnceWithExactly( 330 | `Updating config in ${vscodeFileUri.fsPath}` 331 | ) 332 | ); 333 | assert.isTrue( 334 | writeFileStub.calledOnceWithExactly( 335 | vscodeFileUri, 336 | Buffer.from(JSON.stringify(finalExpArrayCombineConfig, null, 2)), 337 | { create: true, overwrite: true } 338 | ) 339 | ); 340 | }); 341 | 342 | test('Should write to config file with correct priority order using shared array overwrite', async () => { 343 | loadConfigFromFileStub 344 | .withArgs(localFileUri, callbacks.readFile) 345 | .callsFake(() => Promise.resolve(localArrayOverwriteConfig)); 346 | await mergeConfigFiles({ 347 | vscodeFileUri, 348 | sharedFileUri, 349 | localFileUri, 350 | ...callbacks, 351 | }); 352 | assert.isTrue(loadConfigFromFileStub.calledThrice); 353 | assert.isTrue( 354 | logInfoStub.calledOnceWithExactly( 355 | `Updating config in ${vscodeFileUri.fsPath}` 356 | ) 357 | ); 358 | assert.isTrue( 359 | writeFileStub.calledOnceWithExactly( 360 | vscodeFileUri, 361 | Buffer.from(JSON.stringify(expArrayOverwriteConfig, null, 2)), 362 | { create: true, overwrite: true } 363 | ) 364 | ); 365 | }); 366 | 367 | test('Should write to config file with correct priority order using correct array merge default', async () => { 368 | loadConfigFromFileStub 369 | .withArgs(sharedFileUri, callbacks.readFile) 370 | .callsFake(() => Promise.resolve(sharedConfigNoArrayMerge)); 371 | loadConfigFromFileStub 372 | .withArgs(localFileUri, callbacks.readFile) 373 | .callsFake(() => Promise.resolve(localConfigNoArrayMerge)); 374 | await mergeConfigFiles({ 375 | vscodeFileUri, 376 | sharedFileUri, 377 | localFileUri, 378 | ...callbacks, 379 | }); 380 | assert.isTrue(loadConfigFromFileStub.calledThrice); 381 | assert.isTrue( 382 | logInfoStub.calledOnceWithExactly( 383 | `Updating config in ${vscodeFileUri.fsPath}` 384 | ) 385 | ); 386 | assert.isTrue( 387 | writeFileStub.calledOnceWithExactly( 388 | vscodeFileUri, 389 | Buffer.from( 390 | JSON.stringify(expArrayCombineConfigNoExplicitMerge, null, 2) 391 | ), 392 | { create: true, overwrite: true } 393 | ) 394 | ); 395 | }); 396 | 397 | test('Should maintain idempotency for unmodified settings', async () => { 398 | loadVSConfigFromFileStub.onSecondCall().callsFake(() => { 399 | return Promise.resolve(finalExpArrayCombineConfig); 400 | }); 401 | const updatedLocalConfig = JSON.parse( 402 | JSON.stringify(localArrayCombineConfig) 403 | ); 404 | updatedLocalConfig.cow = 'moo'; 405 | const secondExpectedConfig = JSON.parse( 406 | JSON.stringify(finalExpArrayCombineConfig) 407 | ); 408 | secondExpectedConfig.cow = 'moo'; 409 | loadConfigFromFileStub 410 | .withArgs(localFileUri, callbacks.readFile) 411 | .onSecondCall() 412 | .callsFake(() => Promise.resolve(updatedLocalConfig)); 413 | await mergeConfigFiles({ 414 | vscodeFileUri, 415 | sharedFileUri, 416 | localFileUri, 417 | ...callbacks, 418 | }); 419 | await mergeConfigFiles({ 420 | vscodeFileUri, 421 | sharedFileUri, 422 | localFileUri, 423 | ...callbacks, 424 | }); 425 | 426 | assert.deepEqual(loadConfigFromFileStub.callCount, 6); 427 | assert.isTrue(logInfoStub.calledTwice); 428 | assert.isTrue( 429 | logInfoStub.calledWith(`Updating config in ${vscodeFileUri.fsPath}`) 430 | ); 431 | assert.isTrue(writeFileStub.calledTwice); 432 | assert.isTrue( 433 | writeFileStub.firstCall.calledWithExactly( 434 | vscodeFileUri, 435 | Buffer.from(JSON.stringify(finalExpArrayCombineConfig, null, 2)), 436 | { create: true, overwrite: true } 437 | ) 438 | ); 439 | assert.isTrue( 440 | writeFileStub.secondCall.calledWithExactly( 441 | vscodeFileUri, 442 | Buffer.from(JSON.stringify(secondExpectedConfig, null, 2)), 443 | { create: true, overwrite: true } 444 | ) 445 | ); 446 | }); 447 | 448 | test('Should handle errors correctly', async () => { 449 | const err = new Error('i/o error'); 450 | writeFileStub.callsFake(() => Promise.reject(err)); 451 | await mergeConfigFiles({ 452 | vscodeFileUri, 453 | sharedFileUri, 454 | localFileUri, 455 | ...callbacks, 456 | }); 457 | assert.isTrue(logErrorStub.calledOnceWithExactly(err.message)); 458 | assert.isTrue(logDebugStub.calledOnceWithExactly(err)); 459 | }); 460 | 461 | test('Throws correct error on invalid type for array merge behavior', async () => { 462 | const arrayMerge = 2; 463 | const invalidArrayMergeTypeConfig = JSON.parse( 464 | JSON.stringify(localArrayCombineConfig) 465 | ); 466 | invalidArrayMergeTypeConfig[fileHandler._arrayMergeKey] = arrayMerge; 467 | loadConfigFromFileStub 468 | .withArgs(localFileUri, callbacks.readFile) 469 | .callsFake(() => Promise.resolve(invalidArrayMergeTypeConfig)); 470 | const expErrMessage = `Invalid value for 'arrayMerge' setting: '${arrayMerge}'. Must be 'overwrite' or 'combine'`; 471 | await mergeConfigFiles({ 472 | vscodeFileUri, 473 | sharedFileUri, 474 | localFileUri, 475 | ...callbacks, 476 | }); 477 | assert.isTrue(logErrorStub.calledOnceWithExactly(expErrMessage)); 478 | }); 479 | 480 | test('Throws correct error on invalid value for array merge behavior', async () => { 481 | const arrayMerge = 'shuffle'; 482 | const invalidArrayMergeValueConfig = JSON.parse( 483 | JSON.stringify(localArrayCombineConfig) 484 | ); 485 | invalidArrayMergeValueConfig[fileHandler._arrayMergeKey] = arrayMerge; 486 | loadConfigFromFileStub 487 | .withArgs(localFileUri, callbacks.readFile) 488 | .callsFake(() => Promise.resolve(invalidArrayMergeValueConfig)); 489 | const expErrMessage = `Invalid value for 'arrayMerge' setting: '${arrayMerge}'. Must be 'overwrite' or 'combine'`; 490 | await mergeConfigFiles({ 491 | vscodeFileUri, 492 | sharedFileUri, 493 | localFileUri, 494 | ...callbacks, 495 | }); 496 | assert.isTrue(logErrorStub.calledOnceWithExactly(expErrMessage)); 497 | }); 498 | }); 499 | }); 500 | --------------------------------------------------------------------------------