├── .eslintrc.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature-request.md │ ├── improvement.md │ └── question.md ├── pull_request_template.md └── workflows │ ├── build-and-test.yml │ └── update-milestone-on-release.yml ├── .gitignore ├── .vscode ├── clean.js ├── extensions.json ├── install-package.js ├── launch.json ├── package-src.js ├── package-web.js ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── licenses ├── LICENSE_MICROSOFT └── LICENSE_OCTICONS ├── package.json ├── resources ├── cmd-icon-dark.svg ├── cmd-icon-light.svg ├── demo.gif ├── icon.png ├── webview-icon-dark.svg ├── webview-icon-light.svg └── webview-icon.svg ├── src ├── askpass │ ├── askpass-empty.sh │ ├── askpass.sh │ ├── askpassMain.ts │ └── askpassManager.ts ├── avatarManager.ts ├── commands.ts ├── config.ts ├── dataSource.ts ├── diffDocProvider.ts ├── extension.ts ├── extensionState.ts ├── gitGraphView.ts ├── life-cycle │ ├── startup.ts │ ├── uninstall.ts │ └── utils.ts ├── logger.ts ├── repoFileWatcher.ts ├── repoManager.ts ├── statusBarItem.ts ├── tsconfig.json ├── types.ts ├── utils.ts └── utils │ ├── @types │ └── node │ │ └── index.d.ts │ ├── bufferedQueue.ts │ ├── disposable.ts │ └── event.ts ├── tests ├── avatarManager.test.ts ├── bufferedQueue.test.ts ├── commands.test.ts ├── config.test.ts ├── dataSource.test.ts ├── diffDocProvider.test.ts ├── disposable.test.ts ├── event.test.ts ├── extensionState.test.ts ├── gitGraphView.test.ts ├── helpers │ ├── expectations.ts │ └── utils.ts ├── logger.test.ts ├── mocks │ ├── date.ts │ ├── spawn.ts │ └── vscode.ts ├── repoFileWatcher.test.ts ├── repoManager.test.ts ├── statusBarItem.test.ts ├── tsconfig.json └── utils.test.ts └── web ├── contextMenu.ts ├── dialog.ts ├── dropdown.ts ├── findWidget.ts ├── global.d.ts ├── graph.ts ├── main.ts ├── settingsWidget.ts ├── styles ├── contextMenu.css ├── dialog.css ├── dropdown.css ├── findWidget.css ├── main.css └── settingsWidget.css ├── textFormatter.ts ├── tsconfig.json └── utils.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": [ 6 | "./src/tsconfig.json", 7 | "./tests/tsconfig.json", 8 | "./web/tsconfig.json" 9 | ] 10 | }, 11 | "plugins": [ 12 | "@typescript-eslint" 13 | ], 14 | "rules": { 15 | "arrow-spacing": [ 16 | "warn", 17 | { 18 | "before": true, 19 | "after": true 20 | } 21 | ], 22 | "brace-style": [ 23 | "warn", 24 | "1tbs", 25 | { 26 | "allowSingleLine": true 27 | } 28 | ], 29 | "comma-dangle": "warn", 30 | "comma-spacing": "warn", 31 | "comma-style": "warn", 32 | "dot-location": [ 33 | "warn", 34 | "property" 35 | ], 36 | "eol-last": "warn", 37 | "eqeqeq": "warn", 38 | "func-call-spacing": "warn", 39 | "indent": [ 40 | "warn", 41 | "tab", 42 | { 43 | "SwitchCase": 1 44 | } 45 | ], 46 | "key-spacing": "warn", 47 | "linebreak-style": [ 48 | "warn", 49 | "windows" 50 | ], 51 | "new-cap": "warn", 52 | "new-parens": "warn", 53 | "no-alert": "error", 54 | "no-console": "error", 55 | "no-eval": "error", 56 | "no-extra-boolean-cast": "warn", 57 | "no-implied-eval": "error", 58 | "no-irregular-whitespace": "warn", 59 | "no-labels": "error", 60 | "no-multi-spaces": "warn", 61 | "no-proto": "error", 62 | "no-prototype-builtins": "error", 63 | "no-redeclare": "error", 64 | "no-global-assign": "error", 65 | "no-return-await": "warn", 66 | "no-shadow-restricted-names": "error", 67 | "no-script-url": "error", 68 | "no-sparse-arrays": "warn", 69 | "no-throw-literal": "warn", 70 | "no-trailing-spaces": "warn", 71 | "no-unneeded-ternary": "warn", 72 | "no-unsafe-negation": "warn", 73 | "no-unused-expressions": "warn", 74 | "no-var": "warn", 75 | "no-whitespace-before-property": "warn", 76 | "no-with": "error", 77 | "padded-blocks": [ 78 | "warn", 79 | { 80 | "classes": "never", 81 | "switches": "never" 82 | } 83 | ], 84 | "quotes": [ 85 | "warn", 86 | "single" 87 | ], 88 | "rest-spread-spacing": "warn", 89 | "semi": "warn", 90 | "sort-imports": [ 91 | "warn", 92 | { 93 | "allowSeparatedGroups": true, 94 | "ignoreDeclarationSort": true 95 | } 96 | ], 97 | "space-before-function-paren": [ 98 | "warn", 99 | { 100 | "anonymous": "always", 101 | "named": "never", 102 | "asyncArrow": "always" 103 | } 104 | ], 105 | "space-before-blocks": "warn", 106 | "space-infix-ops": "warn", 107 | "spaced-comment": "warn", 108 | "template-curly-spacing": "warn", 109 | "wrap-iife": [ 110 | "warn", 111 | "inside" 112 | ], 113 | "yoda": "warn", 114 | "@typescript-eslint/await-thenable": "warn", 115 | "@typescript-eslint/ban-ts-comment": "error", 116 | "@typescript-eslint/class-literal-property-style": [ 117 | "warn", 118 | "fields" 119 | ], 120 | "@typescript-eslint/explicit-member-accessibility": [ 121 | "warn", 122 | { 123 | "overrides": { 124 | "accessors": "off", 125 | "constructors": "off" 126 | } 127 | } 128 | ], 129 | "@typescript-eslint/method-signature-style": [ 130 | "warn", 131 | "property" 132 | ], 133 | "@typescript-eslint/naming-convention": [ 134 | "warn", 135 | { 136 | "selector": "class", 137 | "format": [ 138 | "StrictPascalCase" 139 | ] 140 | }, 141 | { 142 | "selector": "function", 143 | "format": [ 144 | "camelCase" 145 | ] 146 | } 147 | ], 148 | "@typescript-eslint/no-misused-new": "warn", 149 | "@typescript-eslint/no-this-alias": "warn", 150 | "@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn" 151 | }, 152 | "overrides": [ 153 | { 154 | "files": "./src/askpass/*.ts", 155 | "rules": { 156 | "no-console": "off", 157 | "spaced-comment": "off" 158 | } 159 | }, 160 | { 161 | "files": "./tests/mocks/*.ts", 162 | "rules": { 163 | "no-global-assign": "off" 164 | } 165 | } 166 | ] 167 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mhutchie 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Raise a bug you've found to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: mhutchie 7 | 8 | --- 9 | 10 | **Describe the Bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps to Reproduce** 14 | Steps to reproduce the behaviour: 15 | 1. Go to '...' 16 | 2. Click on '...' 17 | 3. Scroll down to '...' 18 | 4. See error 19 | 20 | **Expected Behaviour** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Environment** 24 | - Git Graph Extension Version: [e.g. 1.13.0] 25 | - Visual Studio Code Version: [e.g. 1.37.1] 26 | - Operating System: [e.g. Windows 10] 27 | 28 | **Screenshots (optional)** 29 | Add any screenshots showing the bug. 30 | 31 | **Additional Context (optional)** 32 | Add any other context about the problem here. 33 | 34 | 35 | ⚠ Please make sure you complete all of the required sections of this template. Without this required information, it will be harder to replicate this bug, and cause additional delays in resolving it. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this extension 4 | title: '' 5 | labels: feature request 6 | assignees: mhutchie 7 | 8 | --- 9 | 10 | **Describe the feature that you'd like** 11 | A clear and concise description of what you'd like to be included in Git Graph. 12 | 13 | **Additional context (optional)** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Improvement 3 | about: Suggest an improvement for this extension 4 | title: '' 5 | labels: improvement 6 | assignees: mhutchie 7 | 8 | --- 9 | 10 | **Describe the improvement that you'd like** 11 | A clear and concise description of what you'd like to be improved in Git Graph. 12 | 13 | **Additional context (optional)** 14 | Add any other context or screenshots about the improvement here. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Do you have a question about Git Graph? 4 | title: '' 5 | labels: question 6 | assignees: mhutchie 7 | 8 | --- 9 | 10 | If you have a simple question, please chat with us on [Discord](https://discord.gg/ZRRDYzt)! This allows myself and the Git Graph community to answer your question as fast as possible. 11 | 12 | More information for the following topics can be found at the provided links: 13 | * [Functionality currently available in Git Graph](https://github.com/mhutchie/vscode-git-graph/blob/master/README.md#features) 14 | * [Git Actions & Context Menus](https://github.com/mhutchie/vscode-git-graph/wiki/Context-Menus) 15 | * [Extension Settings](https://github.com/mhutchie/vscode-git-graph/wiki/Extension-Settings) 16 | 17 | Otherwise, ask any question here! If you're having difficulty explaining your question, sometimes providing a screenshot can help. 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Issue Number / Link: (If this is a minor change, then an issue does not need to be created and this line can be deleted.) 2 | 3 | Summary of the issue: 4 | 5 | Description outlining how this pull request resolves the issue: 6 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: 'Build & Test' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - master 11 | - develop 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: '12.x' 21 | - run: npm install 22 | - run: npm run compile 23 | - run: npm run test 24 | -------------------------------------------------------------------------------- /.github/workflows/update-milestone-on-release.yml: -------------------------------------------------------------------------------- 1 | name: 'Update Milestone on Release' 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update-milestone-on-release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Update Milestone on Release' 12 | uses: mhutchie/update-milestone-on-release@master 13 | with: 14 | github-token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | media 3 | node_modules 4 | out 5 | *.vsix 6 | -------------------------------------------------------------------------------- /.vscode/clean.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | function deleteFolderAndFiles(directory) { 5 | if (fs.existsSync(directory)) { 6 | fs.readdirSync(directory).forEach((fileName) => { 7 | const fullPath = path.join(directory, fileName); 8 | if (fs.statSync(fullPath).isDirectory()) { 9 | // The entry is a folder, recursively delete its contents 10 | deleteFolderAndFiles(fullPath); 11 | } else { 12 | // The entry is a file, delete it 13 | fs.unlinkSync(fullPath); 14 | } 15 | }); 16 | // The directory is now empty, so it can be deleted. 17 | fs.rmdirSync(directory); 18 | } 19 | } 20 | 21 | deleteFolderAndFiles('./media'); 22 | deleteFolderAndFiles('./out'); 23 | -------------------------------------------------------------------------------- /.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 | "dbaeumer.vscode-eslint" 6 | ] 7 | } -------------------------------------------------------------------------------- /.vscode/install-package.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process'); 2 | 3 | const PACKAGED_FILE = './' + process.env.npm_package_name + '-' + process.env.npm_package_version + '.vsix'; 4 | 5 | console.log(''); 6 | cp.exec('code --install-extension ' + PACKAGED_FILE, { cwd: process.cwd() }, (err, stdout, stderr) => { 7 | if (err) { 8 | console.log('ERROR:'); 9 | console.log(err); 10 | process.exit(1); 11 | } else { 12 | console.log(stderr + stdout); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "runtimeExecutable": "${execPath}", 9 | "args": [ 10 | "--extensionDevelopmentPath=${workspaceFolder}" 11 | ], 12 | "outFiles": [ 13 | "${workspaceFolder}/out/**/*.js" 14 | ] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.vscode/package-src.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const SRC_DIRECTORY = './src'; 6 | const OUT_DIRECTORY = './out'; 7 | const ASKPASS_DIRECTORY = 'askpass'; 8 | 9 | // Adjust any scripts that require the Node.js File System Module to use the Node.js version (as Electron overrides the fs module with its own version of the module) 10 | fs.readdirSync(OUT_DIRECTORY).forEach((fileName) => { 11 | if (fileName.endsWith('.js')) { 12 | const scriptFilePath = path.join(OUT_DIRECTORY, fileName); 13 | const mapFilePath = scriptFilePath + '.map'; 14 | 15 | let script = fs.readFileSync(scriptFilePath).toString(); 16 | if (script.match(/require\("fs"\)/g)) { 17 | // Adjust the requirement 18 | script = script.replace('"use strict";', '"use strict";\r\nfunction requireWithFallback(electronModule, nodeModule) { try { return require(electronModule); } catch (err) {} return require(nodeModule); }'); 19 | fs.writeFileSync(scriptFilePath, script.replace(/require\("fs"\)/g, 'requireWithFallback("original-fs", "fs")')); 20 | 21 | // Adjust the mapping file, as we added requireWithFallback on a new line at the start of the file. 22 | let data = JSON.parse(fs.readFileSync(mapFilePath).toString()); 23 | data.mappings = ';' + data.mappings; 24 | fs.writeFileSync(mapFilePath, JSON.stringify(data)); 25 | } 26 | } 27 | }); 28 | 29 | // Copy the askpass shell scripts to the output directory 30 | fs.readdirSync(path.join(SRC_DIRECTORY, ASKPASS_DIRECTORY)).forEach((fileName) => { 31 | if (fileName.endsWith('.sh')) { 32 | // If the file is a shell script, read its contents and write it to the output directory 33 | const scriptContents = fs.readFileSync(path.join(SRC_DIRECTORY, ASKPASS_DIRECTORY, fileName)).toString(); 34 | fs.writeFileSync(path.join(OUT_DIRECTORY, ASKPASS_DIRECTORY, fileName), scriptContents); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /.vscode/package-web.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const MEDIA_DIRECTORY = './media'; 6 | const STYLES_DIRECTORY = './web/styles'; 7 | 8 | const MAIN_CSS_FILE = 'main.css'; 9 | const MAIN_JS_FILE = 'main.js'; 10 | const UTILS_JS_FILE = 'utils.js'; 11 | 12 | const OUTPUT_MIN_CSS_FILE = 'out.min.css'; 13 | const OUTPUT_MIN_JS_FILE = 'out.min.js'; 14 | const OUTPUT_TMP_JS_FILE = 'out.tmp.js'; 15 | 16 | const DEBUG = process.argv.length > 2 && process.argv[2] === 'debug'; 17 | 18 | 19 | // Determine the JS files to be packaged. The order is: utils.ts, *.ts, and then main.ts 20 | let packageJsFiles = [path.join(MEDIA_DIRECTORY, UTILS_JS_FILE)]; 21 | fs.readdirSync(MEDIA_DIRECTORY).forEach((fileName) => { 22 | if (fileName.endsWith('.js') && fileName !== OUTPUT_MIN_JS_FILE && fileName !== UTILS_JS_FILE && fileName !== MAIN_JS_FILE) { 23 | packageJsFiles.push(path.join(MEDIA_DIRECTORY, fileName)); 24 | } 25 | }); 26 | packageJsFiles.push(path.join(MEDIA_DIRECTORY, MAIN_JS_FILE)); 27 | 28 | // Determine the CSS files to be packaged. The order is: main.css, and then *.css 29 | let packageCssFiles = [path.join(STYLES_DIRECTORY, MAIN_CSS_FILE)]; 30 | fs.readdirSync(STYLES_DIRECTORY).forEach((fileName) => { 31 | if (fileName.endsWith('.css') && fileName !== MAIN_CSS_FILE) { 32 | packageCssFiles.push(path.join(STYLES_DIRECTORY, fileName)); 33 | } 34 | }); 35 | 36 | // Log packaging information 37 | console.log('Packaging Mode = ' + (DEBUG ? "DEBUG" : "PRODUCTION")); 38 | console.log('Packaging CSS files: ' + packageCssFiles.join(', ')); 39 | console.log('Packaging JS files: ' + packageJsFiles.join(', ')); 40 | 41 | 42 | // Combine the JS files into an IIFE, with a single "use strict" directive 43 | let jsFileContents = ''; 44 | packageJsFiles.forEach((fileName) => { 45 | jsFileContents += fs.readFileSync(fileName).toString().replace('"use strict";\r\n', '') + '\r\n'; 46 | fs.unlinkSync(fileName); 47 | }); 48 | fs.writeFileSync(path.join(MEDIA_DIRECTORY, OUTPUT_TMP_JS_FILE), '"use strict";\r\n(function(document, window){\r\n' + jsFileContents + '})(document, window);\r\n'); 49 | 50 | 51 | // Run uglifyjs with the required arguments 52 | cp.exec('uglifyjs ' + path.join(MEDIA_DIRECTORY, OUTPUT_TMP_JS_FILE) + ' ' + (DEBUG ? '-b' : '--mangle') + ' --output ' + path.join(MEDIA_DIRECTORY, OUTPUT_MIN_JS_FILE), (err, stdout, stderr) => { 53 | if (err) { 54 | console.log('ERROR:'); 55 | console.log(err); 56 | process.exit(1); 57 | } else if (stderr) { 58 | console.log('ERROR:'); 59 | console.log(stderr); 60 | process.exit(1); 61 | } else { 62 | console.log(''); 63 | if (stdout !== '') console.log(stdout); 64 | fs.unlinkSync(path.join(MEDIA_DIRECTORY, OUTPUT_TMP_JS_FILE)); 65 | } 66 | }); 67 | 68 | // Combine the CSS files 69 | let cssFileContents = ''; 70 | packageCssFiles.forEach((fileName) => { 71 | let contents = fs.readFileSync(fileName).toString(); 72 | if (DEBUG) { 73 | cssFileContents += contents + '\r\n'; 74 | } else { 75 | let lines = contents.split(/\r\n|\r|\n/g); 76 | for (let j = 0; j < lines.length; j++) { 77 | if (lines[j].startsWith('\t')) lines[j] = lines[j].substring(1); 78 | } 79 | let j = 0; 80 | while (j < lines.length) { 81 | if (lines[j].startsWith('/*') && lines[j].endsWith('*/')) { 82 | lines.splice(j, 1); 83 | } else { 84 | j++; 85 | } 86 | } 87 | cssFileContents += lines.join(''); 88 | } 89 | }); 90 | fs.writeFileSync(path.join(MEDIA_DIRECTORY, OUTPUT_MIN_CSS_FILE), cssFileContents); 91 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false 4 | }, 5 | "search.exclude": { 6 | "out": true 7 | }, 8 | "task.problemMatchers.neverPrompt": { 9 | "npm": true 10 | }, 11 | "typescript.tsdk": "./node_modules/typescript/lib" 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "presentation": { 4 | "reveal": "always", 5 | "focus": false, 6 | "echo": true, 7 | "showReuseMessage": false, 8 | "panel": "shared" 9 | }, 10 | "tasks": [ 11 | { 12 | "type": "npm", 13 | "script": "compile", 14 | "label": "Compile" 15 | }, 16 | { 17 | "type": "npm", 18 | "script": "compile-src", 19 | "label": "Compile (Back-End Only)" 20 | }, 21 | { 22 | "type": "npm", 23 | "script": "compile-web", 24 | "label": "Compile (Front-End Only)" 25 | }, 26 | { 27 | "type": "npm", 28 | "script": "compile-web-debug", 29 | "label": "Compile (Front-End Only) - Debug" 30 | }, 31 | { 32 | "type": "npm", 33 | "script": "lint", 34 | "label": "Lint" 35 | }, 36 | { 37 | "type": "npm", 38 | "script": "package", 39 | "label": "Package VSIX" 40 | }, 41 | { 42 | "type": "npm", 43 | "script": "package-and-install", 44 | "label": "Package VSIX (Install on Completion)" 45 | }, 46 | { 47 | "type": "npm", 48 | "script": "test", 49 | "label": "Run Unit Tests" 50 | }, 51 | { 52 | "type": "npm", 53 | "script": "test-and-report-coverage", 54 | "label": "Run Unit Tests (Report Code Coverage)" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .vscode/ 3 | coverage/ 4 | node_modules/*/*.md 5 | out/**/*.d.ts 6 | out/**/*.js.map 7 | out/*.d.ts 8 | out/*.js.map 9 | resources/demo.gif 10 | src/ 11 | tests/ 12 | web/ 13 | .eslintrc.json 14 | .gitignore 15 | jest.config.js -------------------------------------------------------------------------------- /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 mhutchie@16right.com. 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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Git Graph 2 | 3 | Thank you for taking the time to contribute! 4 | 5 | The following are a set of guidelines for contributing to vscode-git-graph. 6 | 7 | ## Code of Conduct 8 | 9 | This project and everyone participating in it is governed by the [Git Graph Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [mhutchie@16right.com](mailto:mhutchie@16right.com). 10 | 11 | ## How Can I Contribute? 12 | 13 | ### Reporting Bugs 14 | 15 | Raise a bug you've found to help us improve! 16 | 17 | Check the [open bugs](https://github.com/mhutchie/vscode-git-graph/issues?q=is%3Aissue+is%3Aopen+label%3A"bugs"), and any fixed bugs ready for release on the [project board](https://github.com/mhutchie/vscode-git-graph/projects/1#column-4514040), to see if it's already being resolved. If it is, give the issue a thumbs up, and help provide additional context if the issue author was unable to provide some details. 18 | 19 | If the bug hasn't previously been reported, please follow these steps: 20 | 1. Raise an issue using the "Bug Report" template. [Create Bug Report](https://github.com/mhutchie/vscode-git-graph/issues/new?assignees=mhutchie&labels=bug&template=bug-report.md&title=) 21 | 2. Complete the template, providing information for all of the required sections. 22 | 3. Click "Submit new issue" 23 | 24 | We will respond promptly, and get it resolved as quickly as possible. 25 | 26 | ### Feature Requests 27 | 28 | Suggest a new feature for this extension! We want to make Git Graph an even more useful tool in Visual Studio Code, so any suggestions you have are greatly appreciated. 29 | 30 | Check the [open feature requests](https://github.com/mhutchie/vscode-git-graph/issues?q=is%3Aissue+is%3Aopen+label%3A"feature+request"), and any feature requests ready for release on the [project board](https://github.com/mhutchie/vscode-git-graph/projects/1#column-4514040), to see if your idea is already under consideration or on its way. If it is, give the issue a thumbs up so it will be higher prioritised. 31 | 32 | If your feature hasn't previously been suggested, please follow these steps: 33 | 1. Raise an issue using the "Feature Request" template. [Create Feature Request](https://github.com/mhutchie/vscode-git-graph/issues/new?assignees=mhutchie&labels=feature+request&template=feature-request.md&title=) 34 | 2. Follow the template as you see appropriate, it's only meant to be a guide. 35 | 3. Click "Submit new issue" 36 | 37 | We will respond promptly, and your request will be prioritised according to the Git Graph [Issue Prioritisation](https://github.com/mhutchie/vscode-git-graph/wiki/Issue-Prioritisation). 38 | 39 | ### Improvements 40 | 41 | Suggest an improvement to existing functionality of this extension! We want to make Git Graph an even more useful tool in Visual Studio Code, so any improvements you have are greatly appreciated. 42 | 43 | Check the [open improvements](https://github.com/mhutchie/vscode-git-graph/issues?q=is%3Aissue+is%3Aopen+label%3A"improvement"), and any improvements ready for release on the [project board](https://github.com/mhutchie/vscode-git-graph/projects/1#column-4514040), to see if your improvement is already under consideration or on its way. If it is, give the issue a thumbs up so it will be higher prioritised. 44 | 45 | If your improvement hasn't previously been suggested, please follow these steps: 46 | 1. Raise an issue using the "Improvement" template. [Create Improvement](https://github.com/mhutchie/vscode-git-graph/issues/new?assignees=mhutchie&labels=improvement&template=improvement.md&title=) 47 | 2. Follow the template as you see appropriate, it's only meant to be a guide. 48 | 3. Click "Submit new issue" 49 | 50 | We will respond promptly, and your request will be prioritised according to the Git Graph [Issue Prioritisation](https://github.com/mhutchie/vscode-git-graph/wiki/Issue-Prioritisation). 51 | 52 | ### Contributing To Development 53 | 54 | If you're interested in helping contribute, either: 55 | * Find an open issue you'd like to work on, and comment on it. Once the code owner has responded with some background information and initial ideas, it will be assigned to you to work on. 56 | * Raise an issue describing the feature you'd like to work on, mentioning that you'd like to implement it. Once it has been responded to by the code owner, it has been confirmed as a suitable feature of Git Graph and it will be assigned to you to work on. 57 | 58 | Step 1: To set up your development environment, please follow these steps: 59 | 1. Install [Node.js](https://nodejs.org/en/) if it is not already installed. 60 | 2. Clone the [vscode-git-graph](https://github.com/mhutchie/vscode-git-graph) repo on GitHub. 61 | 3. Open the repo in Visual Studio Code. 62 | 4. In the Visual Studio Code terminal, run `npm install` to automatically download all of the required Node.js dependencies. 63 | 5. Install the [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) extension if it is not already installed. 64 | 6. Create and checkout a branch for the issue you're going to work on. 65 | 66 | Step 2: Review the [Codebase Outline](https://github.com/mhutchie/vscode-git-graph/wiki/Codebase-Outline), so you have a general understanding of the structure of the codebase. 67 | 68 | Step 3: To compile the code, run the appropriate npm script in the Visual Studio Code terminal as follows: 69 | * `npm run compile`: Compiles both front and backend code 70 | * `npm run compile-src`: Compiles the backend code only 71 | * `npm run compile-web`: Compiles the frontend code only, with minification. 72 | * `npm run compile-web-debug`: Compiles the frontend code, without minification. 73 | 74 | _Note: When you first open the codebase, you'll need to run `npm run compile-src` so that the types defined by the backend are made available for the frontend to use, otherwise there will be a number of type errors in the frontend code. Similarly, if you make a change to the backend types that you also want to use in the frontend via the GG namespace, you'll need to run `npm run compile-src` before they can be used._ 75 | 76 | Step 4: To quickly test your changes: 77 | * Pressing F5 launches the Extension Development Host in a new window, overriding the installed version of Git Graph with the version compiled in Step 3. You can: 78 | * Use the extension to test your changes 79 | * View the Webview Developer Tools by running the Visual Studio Code command `Developer: Open Webview Developer Tools`. This allows you to: 80 | * View the front end JavaScript console 81 | * View and modify the CSS rules (temporarily) 82 | * View and modify the DOM tree (temporarily) 83 | * If you ran `npm run compile-web-debug` in Step 3, you can also add breakpoints to the compiled frontend JavaScript. 84 | * Switching back to the Visual Studio Code window that you were in (from Step 3), you can: 85 | * Add breakpoints to the backend TypeScript 86 | * Restart the Extension Development Host 87 | * Stop the Extension Development Host 88 | 89 | Step 5: To do a complete test of your changes: 90 | 1. Install Visual Studio Code Extensions `npm install -g vsce` if it is not already installed. 91 | 2. Change the version of the extension defined in `package.json` on line 4 to an alpha release, for example `1.13.0-alpha.0`. You should increment the alpha version each time you package a modified version of the extension. _Make sure you don't commit the version number with your changes._ 92 | 3. Run the npm script `npm run package-and-install` in the Visual Studio Code terminal. This will compile and package the extension into a `vsix` file, and then install it. 93 | 4. Restart Visual Studio Code, and verify that you have the correct alpha version installed. 94 | 5. Test out the extension, it will behave exactly the same as a published release. 95 | 96 | Step 6: Raise a pull request once you've completed development, we'll have a look at it. 97 | 98 | #### Style Guide 99 | 100 | The required style is produced by running "Format Document" in Visual Studio Code. 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-present, mhutchie 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to use, 5 | copy, modify, merge, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | Permission is NOT GRANTED to publish, distribute, sublicense, and/or sell 9 | derivative works of the Software. 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | 22 | Licenses for software that are referenced, used and/or modified in the Git Graph 23 | extension are provided in the ./licenses directory, in accordance with their 24 | respective licenses. This includes: 25 | - LICENSE_MICROSOFT - Copyright (c) 2015 - present Microsoft Corporation 26 | - LICENSE_OCTICONS - Copyright (c) 2019 GitHub Inc. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git Graph extension for Visual Studio Code 2 | 3 | View a Git Graph of your repository, and easily perform Git actions from the graph. Configurable to look the way you want! 4 | 5 | ![Recording of Git Graph](https://github.com/mhutchie/vscode-git-graph/raw/master/resources/demo.gif) 6 | 7 | ## Features 8 | 9 | * Git Graph View: 10 | * Display: 11 | * Local & Remote Branches 12 | * Local Refs: Heads, Tags & Remotes 13 | * Uncommitted Changes 14 | * Perform Git Actions (available by right clicking on a commit / branch / tag): 15 | * Create, Checkout, Delete, Fetch, Merge, Pull, Push, Rebase, Rename & Reset Branches 16 | * Add, Delete & Push Tags 17 | * Checkout, Cherry Pick, Drop, Merge & Revert Commits 18 | * Clean, Reset & Stash Uncommitted Changes 19 | * Apply, Create Branch From, Drop & Pop Stashes 20 | * View annotated tag details (name, email, date and message) 21 | * Copy commit hashes, and branch, stash & tag names to the clipboard 22 | * View commit details and file changes by clicking on a commit. On the Commit Details View you can: 23 | * View the Visual Studio Code Diff of any file change by clicking on it. 24 | * Open the current version of any file that was affected in the commit. 25 | * Copy the path of any file that was affected in the commit to the clipboard. 26 | * Click on any HTTP/HTTPS url in the commit body to open it in your default web browser. 27 | * Compare any two commits by clicking on a commit, and then CTRL/CMD clicking on another commit. On the Commit Comparison View you can: 28 | * View the Visual Studio Code Diff of any file change between the selected commits by clicking on it. 29 | * Open the current version of any file that was affected between the selected commits. 30 | * Copy the path of any file that was affected between the selected commits to the clipboard. 31 | * Code Review - Keep track of which files you have reviewed in the Commit Details & Comparison Views. 32 | * Code Review's can be performed on any commit, or between any two commits (not on Uncommitted Changes). 33 | * When a Code Review is started, all files needing to be reviewed are bolded. When you view the diff / open a file, it will then be un-bolded. 34 | * Code Reviews persist across Visual Studio Code sessions. They are automatically closed after 90 days of inactivity. 35 | * View uncommitted changes, and compare the uncommitted changes with any commit. 36 | * Hover over any commit vertex on the graph to see a tooltip indicating: 37 | * Whether the commit is included in the HEAD. 38 | * Which branches, tags and stashes include the commit. 39 | * Filter the branches shown in Git Graph using the 'Branches' dropdown menu. The options for filtering the branches are: 40 | * Show All branches 41 | * Select one or more branches to be viewed 42 | * Select from a user predefined array of custom glob patterns (by setting `git-graph.customBranchGlobPatterns`) 43 | * Fetch from Remote(s) _(available on the top control bar)_ 44 | * Find Widget allows you to quickly find one or more commits containing a specific phrase (in the commit message / date / author / hash, branch or tag names). 45 | * Repository Settings Widget: 46 | * Allows you to view, add, edit, delete, fetch & prune remotes of the repository. 47 | * Configure "Issue Linking" - Converts issue numbers in commit messages into hyperlinks, that open the issue in your issue tracking system. 48 | * Configure "Pull Request Creation" - Automates the opening and pre-filling of a Pull Request form, directly from a branches context menu. 49 | * Support for the publicly hosted Bitbucket, GitHub and GitLab Pull Request providers is built-in. 50 | * Custom Pull Request providers can be configured using the Extension Setting `git-graph.customPullRequestProviders` (e.g. for use with privately hosted Pull Request providers). Information on how to configure custom providers is available [here](https://github.com/mhutchie/vscode-git-graph/wiki/Configuring-a-custom-Pull-Request-Provider). 51 | * Export your Git Graph Repository Configuration to a file that can be committed in the repository. It allows others working in the same repository to automatically use the same Git Graph configuration. 52 | * Keyboard Shortcuts (available in the Git Graph View): 53 | * `CTRL/CMD + F`: Open the Find Widget. 54 | * `CTRL/CMD + H`: Scrolls the Git Graph View to be centered on the commit referenced by HEAD. 55 | * `CTRL/CMD + R`: Refresh the Git Graph View. 56 | * `CTRL/CMD + S`: Scrolls the Git Graph View to the first (or next) stash in the loaded commits. 57 | * `CTRL/CMD + SHIFT + S`: Scrolls the Git Graph View to the last (or previous) stash in the loaded commits. 58 | * When the Commit Details View is open on a commit: 59 | * `Up` / `Down`: The Commit Details View will be opened on the commit directly above or below it on the Git Graph View. 60 | * `CTRL/CMD + Up` / `CTRL/CMD + Down`: The Commit Details View will be opened on its child or parent commit on the same branch. 61 | * If the Shift Key is also pressed (i.e. `CTRL/CMD + SHIFT + Up` / `CTRL/CMD + SHIFT + Down`), when branches or merges are encountered the alternative branch is followed. 62 | * `Enter`: If a dialog is open, pressing enter submits the dialog, taking the primary (left) action. 63 | * `Escape`: Closes the active dialog, context menu or the Commit Details View. 64 | * Resize the width of each column, and show/hide the Date, Author & Commit columns. 65 | * Common Emoji Shortcodes are automatically replaced with the corresponding emoji in commit messages (including all [gitmoji](https://gitmoji.carloscuesta.me/)). Custom Emoji Shortcode mappings can be defined in `git-graph.customEmojiShortcodeMappings`. 66 | * A broad range of configurable settings (e.g. graph style, branch colours, and more...). See the 'Extension Settings' section below for more information. 67 | * "Git Graph" launch button in the Status Bar 68 | * "Git Graph: View Git Graph" launch command in the Command Palette 69 | 70 | ## Extension Settings 71 | 72 | Detailed information of all Git Graph settings is available [here](https://github.com/mhutchie/vscode-git-graph/wiki/Extension-Settings), including: descriptions, screenshots, default values and types. 73 | 74 | A summary of the Git Graph extension settings are: 75 | * **Commit Details View**: 76 | * **Auto Center**: Automatically center the Commit Details View when it is opened. 77 | * **File View**: 78 | * **File Tree**: 79 | * **Compact Folders**: Render the File Tree in the Commit Details View in a compacted form, such that folders with a single child folder are compressed into a single combined folder element. 80 | * **Type**: Sets the default type of File View used in the Commit Details View. 81 | * **Location**: Specifies where the Commit Details View is rendered in the Git Graph View. 82 | * **Context Menu Actions Visibility**: Customise which context menu actions are visible. For more information, see the documentation [here](https://github.com/mhutchie/vscode-git-graph/wiki/Extension-Settings#context-menu-actions-visibility). 83 | * **Custom Branch Glob Patterns**: An array of Custom Glob Patterns to be shown in the "Branches" dropdown. Example: `[{"name":"Feature Requests", "glob":"heads/feature/*"}]` 84 | * **Custom Emoji Shortcode Mappings**: An array of custom Emoji Shortcode mappings. Example: `[{"shortcode": ":sparkles:", "emoji":"✨"}]` 85 | * **Custom Pull Request Providers**: An array of custom Pull Request providers that can be used in the "Pull Request Creation" Integration. For information on how to configure this setting, see the documentation [here](https://github.com/mhutchie/vscode-git-graph/wiki/Configuring-a-custom-Pull-Request-Provider). 86 | * **Date**: 87 | * **Format**: Specifies the date format to be used in the "Date" column on the Git Graph View. 88 | * **Type**: Specifies the date type to be displayed in the "Date" column on the Git Graph View, either the author or commit date. 89 | * **Default Column Visibility**: An object specifying the default visibility of the Date, Author & Commit columns. Example: `{"Date": true, "Author": true, "Commit": true}` 90 | * **Dialog > \***: Set the default options on the following dialogs: Add Tag, Apply Stash, Cherry Pick, Create Branch, Delete Branch, Fetch into Local Branch, Fetch Remote, Merge, Pop Stash, Pull Branch, Rebase, Reset, and Stash Uncommitted Changes 91 | * **Enhanced Accessibility**: Visual file change A|M|D|R|U indicators in the Commit Details View for users with colour blindness. In the future, this setting will enable any additional accessibility related features of Git Graph that aren't enabled by default. 92 | * **File Encoding**: The character set encoding used when retrieving a specific version of repository files (e.g. in the Diff View). A list of all supported encodings can be found [here](https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings). 93 | * **Graph**: 94 | * **Colours**: Specifies the colours used on the graph. 95 | * **Style**: Specifies the style of the graph. 96 | * **Uncommitted Changes**: Specifies how the Uncommitted Changes are displayed on the graph. 97 | * **Integrated Terminal Shell**: Specifies the path and filename of the Shell executable to be used by the Visual Studio Code Integrated Terminal, when it is opened by Git Graph. 98 | * **Keyboard Shortcut > \***: Configures the keybindings used for all keyboard shortcuts in the Git Graph View. 99 | * **Markdown**: Parse and render a frequently used subset of inline Markdown formatting rules in commit messages and tag details (bold, italics, bold & italics, and inline code blocks). 100 | * **Max Depth Of Repo Search**: Specifies the maximum depth of subfolders to search when discovering repositories in the workspace. 101 | * **Open New Tab Editor Group**: Specifies the Editor Group where Git Graph should open new tabs, when performing the following actions from the Git Graph View: Viewing the Visual Studio Code Diff View, Opening a File, Viewing a File at a Specific Revision. 102 | * **Open to the Repo of the Active Text Editor Document**: Open the Git Graph View to the repository containing the active Text Editor document. 103 | * **Reference Labels**: 104 | * **Alignment**: Specifies how branch and tag reference labels are aligned for each commit. 105 | * **Combine Local and Remote Branch Labels**: Combine local and remote branch labels if they refer to the same branch, and are on the same commit. 106 | * **Repository**: 107 | * **Commits**: 108 | * **Fetch Avatars**: Fetch avatars of commit authors and committers. 109 | * **Initial Load**: Specifies the number of commits to initially load. 110 | * **Load More**: Specifies the number of additional commits to load when the "Load More Commits" button is pressed, or more commits are automatically loaded. 111 | * **Load More Automatically**: When the view has been scrolled to the bottom, automatically load more commits if they exist (instead of having to press the "Load More Commits" button). 112 | * **Mute**: 113 | * **Commits that are not ancestors of HEAD**: Display commits that aren't ancestors of the checked-out branch / commit with a muted text color. 114 | * **Merge Commits**: Display merge commits with a muted text color. 115 | * **Order**: Specifies the order of commits on the Git Graph View. See [git log](https://git-scm.com/docs/git-log#_commit_ordering) for more information on each order option. 116 | * **Show Signature Status**: Show the commit's signature status to the right of the Committer in the Commit Details View (only for signed commits). Hovering over the signature icon displays a tooltip with the signature details. 117 | * **Fetch and Prune**: Before fetching from remote(s) using the Fetch button on the Git Graph View Control Bar, remove any remote-tracking references that no longer exist on the remote(s). 118 | * **Fetch And Prune Tags**: Before fetching from remote(s) using the Fetch button on the Git Graph View Control Bar, remove any local tags that no longer exist on the remote(s). 119 | * **Include Commits Mentioned By Reflogs**: Include commits only mentioned by reflogs in the Git Graph View (only applies when showing all branches). 120 | * **On Load**: 121 | * **Scroll To Head**: Automatically scroll the Git Graph View to be centered on the commit referenced by HEAD. 122 | * **Show Checked Out Branch**: Show the checked out branch when a repository is loaded in the Git Graph View. 123 | * **Show Specific Branches**: Show specific branches when a repository is loaded in the Git Graph View. 124 | * **Only Follow First Parent**: Only follow the first parent of commits when discovering the commits to load in the Git Graph View. See [--first-parent](https://git-scm.com/docs/git-log#Documentation/git-log.txt---first-parent) to find out more about this setting. 125 | * **Show Commits Only Referenced By Tags**: Show Commits that are only referenced by tags in Git Graph. 126 | * **Show Remote Branches**: Show Remote Branches in Git Graph by default. 127 | * **Show Remote Heads**: Show Remote HEAD Symbolic References in Git Graph. 128 | * **Show Stashes**: Show Stashes in Git Graph by default. 129 | * **Show Tags**: Show Tags in Git Graph by default. 130 | * **Show Uncommitted Changes**: Show uncommitted changes. If you work on large repositories, disabling this setting can reduce the load time of the Git Graph View. 131 | * **Show Untracked Files**: Show untracked files when viewing the uncommitted changes. If you work on large repositories, disabling this setting can reduce the load time of the Git Graph View. 132 | * **Sign**: 133 | * **Commits**: Enables commit signing with GPG or X.509. 134 | * **Tags**: Enables tag signing with GPG or X.509. 135 | * **Use Mailmap**: Respect [.mailmap](https://git-scm.com/docs/git-check-mailmap#_mapping_authors) files when displaying author & committer names and email addresses. 136 | * **Repository Dropdown Order**: Specifies the order that repositories are sorted in the repository dropdown on the Git Graph View (only visible when more than one repository exists in the current Visual Studio Code Workspace). 137 | * **Retain Context When Hidden**: Specifies if the Git Graph view Visual Studio Code context is kept when the panel is no longer visible (e.g. moved to background tab). Enabling this setting will make Git Graph load significantly faster when switching back to the Git Graph tab, however has a higher memory overhead. 138 | * **Show Status Bar Item**: Show a Status Bar Item that opens the Git Graph View when clicked. 139 | * **Source Code Provider Integration Location**: Specifies where the "View Git Graph" action appears on the title of SCM Providers. 140 | * **Tab Icon Colour Theme**: Specifies the colour theme of the icon displayed on the Git Graph tab. 141 | 142 | This extension consumes the following settings: 143 | 144 | * `git.path`: Specifies the path and filename of a portable Git installation. 145 | 146 | ## Extension Commands 147 | 148 | This extension contributes the following commands: 149 | 150 | * `git-graph.view`: Git Graph: View Git Graph 151 | * `git-graph.addGitRepository`: Git Graph: Add Git Repository... _(used to add sub-repos to Git Graph)_ 152 | * `git-graph.clearAvatarCache`: Git Graph: Clear Avatar Cache 153 | * `git-graph.endAllWorkspaceCodeReviews`: Git Graph: End All Code Reviews in Workspace 154 | * `git-graph.endSpecificWorkspaceCodeReview`: Git Graph: End a specific Code Review in Workspace... _(used to end a specific Code Review without having to first open it in the Git Graph View)_ 155 | * `git-graph.fetch`: Git Graph: Fetch from Remote(s) _(used to open the Git Graph View and immediately run "Fetch from Remote(s)")_ 156 | * `git-graph.removeGitRepository`: Git Graph: Remove Git Repository... _(used to remove repositories from Git Graph)_ 157 | * `git-graph.resumeWorkspaceCodeReview`: Git Graph: Resume a specific Code Review in Workspace... _(used to open the Git Graph View to a Code Review that is already in progress)_ 158 | * `git-graph.version`: Git Graph: Get Version Information 159 | 160 | ## Release Notes 161 | 162 | Detailed Release Notes are available [here](CHANGELOG.md). 163 | 164 | ## Visual Studio Marketplace 165 | 166 | This extension is available on the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=mhutchie.git-graph) for Visual Studio Code. 167 | 168 | ## Acknowledgements 169 | 170 | Thank you to all of the contributors that help with the development of Git Graph! 171 | 172 | Some of the icons used in Git Graph are from the following sources, please support them for their excellent work! 173 | - [GitHub Octicons](https://octicons.github.com/) ([License](https://github.com/primer/octicons/blob/master/LICENSE)) 174 | - [Icons8](https://icons8.com/icon/pack/free-icons/ios11) ([License](https://icons8.com/license)) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./tests'], 3 | transform: { 4 | '^.+\\.ts$': 'ts-jest', 5 | }, 6 | testRegex: '\\.test\\.ts$', 7 | moduleFileExtensions: ['ts', 'js'], 8 | globals: { 9 | 'ts-jest': { 10 | tsconfig: './tests/tsconfig.json' 11 | } 12 | }, 13 | collectCoverageFrom: [ 14 | 'src/utils/*.ts', 15 | 'src/*.ts' 16 | ] 17 | }; 18 | -------------------------------------------------------------------------------- /licenses/LICENSE_MICROSOFT: -------------------------------------------------------------------------------- 1 | The following license is the MIT License of the Microsoft Visual Studio Code 2 | Git Extension, which was used as the base for the following components of this 3 | Git Graph extension: 4 | - Askpass (./src/askpass/* in the source code, and ./out/askpass/* in the 5 | distributed code). 6 | - Find Git Executable methods (in ./src/utils.ts in the source code, and in 7 | ./out/utils.js in the distributed code). 8 | 9 | ------------------------------------------------------------------------------- 10 | 11 | MIT License 12 | 13 | Copyright (c) 2015 - present Microsoft Corporation 14 | 15 | All rights reserved. 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | -------------------------------------------------------------------------------- /licenses/LICENSE_OCTICONS: -------------------------------------------------------------------------------- 1 | The following license is the MIT License of the Software used to generate some 2 | of the SVG icons used in Git Graph, which are stored in ./web/utils.ts in the 3 | source code, and in ./media/out.min.js in the minified distributed code. The 4 | Software itself is not used in this project, only several icons generated by 5 | it are used. 6 | 7 | https://github.com/primer/octicons 8 | 9 | ------------------------------------------------------------------------------- 10 | 11 | MIT License 12 | 13 | Copyright (c) 2019 GitHub Inc. 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in all 23 | copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | SOFTWARE. -------------------------------------------------------------------------------- /resources/cmd-icon-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/cmd-icon-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhutchie/vscode-git-graph/d7f43f429a9e024e896bac9fc65fdc530935c812/resources/demo.gif -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhutchie/vscode-git-graph/d7f43f429a9e024e896bac9fc65fdc530935c812/resources/icon.png -------------------------------------------------------------------------------- /resources/webview-icon-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/webview-icon-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/webview-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/askpass/askpass-empty.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo '' -------------------------------------------------------------------------------- /src/askpass/askpass.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | VSCODE_GIT_GRAPH_ASKPASS_PIPE=`mktemp` 3 | VSCODE_GIT_GRAPH_ASKPASS_PIPE="$VSCODE_GIT_GRAPH_ASKPASS_PIPE" "$VSCODE_GIT_GRAPH_ASKPASS_NODE" "$VSCODE_GIT_GRAPH_ASKPASS_MAIN" $* 4 | cat $VSCODE_GIT_GRAPH_ASKPASS_PIPE 5 | rm $VSCODE_GIT_GRAPH_ASKPASS_PIPE -------------------------------------------------------------------------------- /src/askpass/askpassMain.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * This code is based on the askpass implementation in the Microsoft Visual Studio Code Git Extension 3 | * https://github.com/microsoft/vscode/blob/473af338e1bd9ad4d9853933da1cd9d5d9e07dc9/extensions/git/src/askpass-main.ts, 4 | * which has the following copyright notice & license: 5 | * Copyright (c) Microsoft Corporation. All rights reserved. 6 | * Licensed under the MIT License. See ./licenses/LICENSE_MICROSOFT for license information. 7 | *--------------------------------------------------------------------------------------------*/ 8 | 9 | import * as fs from 'fs'; 10 | import * as http from 'http'; 11 | 12 | function fatal(err: any): void { 13 | console.error('Missing or invalid credentials.'); 14 | console.error(err); 15 | process.exit(1); 16 | } 17 | 18 | function main(argv: string[]): void { 19 | if (argv.length !== 5) return fatal('Wrong number of arguments'); 20 | if (!process.env['VSCODE_GIT_GRAPH_ASKPASS_HANDLE']) return fatal('Missing handle'); 21 | if (!process.env['VSCODE_GIT_GRAPH_ASKPASS_PIPE']) return fatal('Missing pipe'); 22 | 23 | const output = process.env['VSCODE_GIT_GRAPH_ASKPASS_PIPE']!; 24 | const socketPath = process.env['VSCODE_GIT_GRAPH_ASKPASS_HANDLE']!; 25 | 26 | const req = http.request({ socketPath, path: '/', method: 'POST' }, res => { 27 | if (res.statusCode !== 200) return fatal('Bad status code: ' + res.statusCode); 28 | 29 | let resData = ''; 30 | res.setEncoding('utf8'); 31 | res.on('data', (d) => resData += d); 32 | res.on('end', () => { 33 | try { 34 | let response = JSON.parse(resData); 35 | fs.writeFileSync(output, response + '\n'); 36 | } catch (err) { 37 | return fatal('Error parsing response'); 38 | } 39 | setTimeout(() => process.exit(0), 0); 40 | }); 41 | }); 42 | 43 | req.on('error', () => fatal('Error in request')); 44 | req.write(JSON.stringify({ request: argv[2], host: argv[4].substring(1, argv[4].length - 2) })); 45 | req.end(); 46 | } 47 | 48 | main(process.argv); 49 | -------------------------------------------------------------------------------- /src/askpass/askpassManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * This code is based on the askpass implementation in the Microsoft Visual Studio Code Git Extension 3 | * https://github.com/microsoft/vscode/blob/473af338e1bd9ad4d9853933da1cd9d5d9e07dc9/extensions/git/src/askpass.ts, 4 | * which has the following copyright notice & license: 5 | * Copyright (c) Microsoft Corporation. All rights reserved. 6 | * Licensed under the MIT License. See ./licenses/LICENSE_MICROSOFT for license information. 7 | *--------------------------------------------------------------------------------------------*/ 8 | 9 | import * as fs from 'fs'; 10 | import * as http from 'http'; 11 | import * as os from 'os'; 12 | import * as path from 'path'; 13 | import * as vscode from 'vscode'; 14 | import { getNonce } from '../utils'; 15 | import { Disposable, toDisposable } from '../utils/disposable'; 16 | 17 | export interface AskpassEnvironment { 18 | GIT_ASKPASS: string; 19 | ELECTRON_RUN_AS_NODE?: string; 20 | VSCODE_GIT_GRAPH_ASKPASS_NODE?: string; 21 | VSCODE_GIT_GRAPH_ASKPASS_MAIN?: string; 22 | VSCODE_GIT_GRAPH_ASKPASS_HANDLE?: string; 23 | } 24 | 25 | export interface AskpassRequest { 26 | host: string; 27 | request: string; 28 | } 29 | 30 | export class AskpassManager extends Disposable { 31 | private ipcHandlePath: string; 32 | private server: http.Server; 33 | private enabled = true; 34 | 35 | constructor() { 36 | super(); 37 | this.ipcHandlePath = getIPCHandlePath(getNonce()); 38 | this.server = http.createServer((req, res) => this.onRequest(req, res)); 39 | try { 40 | this.server.listen(this.ipcHandlePath); 41 | this.server.on('error', () => { }); 42 | } catch (err) { 43 | this.enabled = false; 44 | } 45 | fs.chmod(path.join(__dirname, 'askpass.sh'), '755', () => { }); 46 | fs.chmod(path.join(__dirname, 'askpass-empty.sh'), '755', () => { }); 47 | 48 | this.registerDisposable( 49 | // Close the Askpass Server 50 | toDisposable(() => { 51 | try { 52 | this.server.close(); 53 | if (process.platform !== 'win32') { 54 | fs.unlinkSync(this.ipcHandlePath); 55 | } 56 | } catch (e) { } 57 | }) 58 | ); 59 | } 60 | 61 | private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void { 62 | let reqData = ''; 63 | req.setEncoding('utf8'); 64 | req.on('data', (d) => reqData += d); 65 | req.on('end', () => { 66 | let data = JSON.parse(reqData) as AskpassRequest; 67 | vscode.window.showInputBox({ placeHolder: data.request, prompt: 'Git Graph: ' + data.host, password: /password/i.test(data.request), ignoreFocusOut: true }).then(result => { 68 | res.writeHead(200); 69 | res.end(JSON.stringify(result || '')); 70 | }, () => { 71 | res.writeHead(500); 72 | res.end(); 73 | }); 74 | }); 75 | } 76 | 77 | public getEnv(): AskpassEnvironment { 78 | return this.enabled 79 | ? { 80 | ELECTRON_RUN_AS_NODE: '1', 81 | GIT_ASKPASS: path.join(__dirname, 'askpass.sh'), 82 | VSCODE_GIT_GRAPH_ASKPASS_NODE: process.execPath, 83 | VSCODE_GIT_GRAPH_ASKPASS_MAIN: path.join(__dirname, 'askpassMain.js'), 84 | VSCODE_GIT_GRAPH_ASKPASS_HANDLE: this.ipcHandlePath 85 | } 86 | : { 87 | GIT_ASKPASS: path.join(__dirname, 'askpass-empty.sh') 88 | }; 89 | } 90 | } 91 | 92 | function getIPCHandlePath(nonce: string): string { 93 | if (process.platform === 'win32') { 94 | return '\\\\.\\pipe\\git-graph-askpass-' + nonce + '-sock'; 95 | } else if (process.env['XDG_RUNTIME_DIR']) { 96 | return path.join(process.env['XDG_RUNTIME_DIR'] as string, 'git-graph-askpass-' + nonce + '.sock'); 97 | } else { 98 | return path.join(os.tmpdir(), 'git-graph-askpass-' + nonce + '.sock'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/diffDocProvider.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | import { DataSource } from './dataSource'; 4 | import { GitFileStatus } from './types'; 5 | import { UNCOMMITTED, getPathFromStr, showErrorMessage } from './utils'; 6 | import { Disposable, toDisposable } from './utils/disposable'; 7 | 8 | export const enum DiffSide { 9 | Old, 10 | New 11 | } 12 | 13 | /** 14 | * Manages providing a specific revision of a repository file for use in the Visual Studio Code Diff View. 15 | */ 16 | export class DiffDocProvider extends Disposable implements vscode.TextDocumentContentProvider { 17 | public static scheme = 'git-graph'; 18 | private readonly dataSource: DataSource; 19 | private readonly docs = new Map(); 20 | private readonly onDidChangeEventEmitter = new vscode.EventEmitter(); 21 | 22 | /** 23 | * Creates the Git Graph Diff Document Provider. 24 | * @param dataSource The Git Graph DataSource instance. 25 | */ 26 | constructor(dataSource: DataSource) { 27 | super(); 28 | this.dataSource = dataSource; 29 | 30 | this.registerDisposables( 31 | vscode.workspace.onDidCloseTextDocument((doc) => this.docs.delete(doc.uri.toString())), 32 | this.onDidChangeEventEmitter, 33 | toDisposable(() => this.docs.clear()) 34 | ); 35 | } 36 | 37 | /** 38 | * An event to signal a resource has changed. 39 | */ 40 | get onDidChange() { 41 | return this.onDidChangeEventEmitter.event; 42 | } 43 | 44 | /** 45 | * Provides the content of a text document at a specific Git revision. 46 | * @param uri The `git-graph://file.ext?encoded-data` URI. 47 | * @returns The content of the text document. 48 | */ 49 | public provideTextDocumentContent(uri: vscode.Uri): string | Thenable { 50 | const document = this.docs.get(uri.toString()); 51 | if (document) { 52 | return document.value; 53 | } 54 | 55 | const request = decodeDiffDocUri(uri); 56 | if (!request.exists) { 57 | // Return empty file (used for one side of added / deleted file diff) 58 | return ''; 59 | } 60 | 61 | return this.dataSource.getCommitFile(request.repo, request.commit, request.filePath).then( 62 | (contents) => { 63 | const document = new DiffDocument(contents); 64 | this.docs.set(uri.toString(), document); 65 | return document.value; 66 | }, 67 | (errorMessage) => { 68 | showErrorMessage('Unable to retrieve file: ' + errorMessage); 69 | return ''; 70 | } 71 | ); 72 | } 73 | } 74 | 75 | /** 76 | * Represents the content of a Diff Document. 77 | */ 78 | class DiffDocument { 79 | private readonly body: string; 80 | 81 | /** 82 | * Creates a Diff Document with the specified content. 83 | * @param body The content of the document. 84 | */ 85 | constructor(body: string) { 86 | this.body = body; 87 | } 88 | 89 | /** 90 | * Get the content of the Diff Document. 91 | */ 92 | get value() { 93 | return this.body; 94 | } 95 | } 96 | 97 | 98 | /* Encoding and decoding URI's */ 99 | 100 | /** 101 | * Represents the data passed through `git-graph://file.ext?encoded-data` URI's by the DiffDocProvider. 102 | */ 103 | type DiffDocUriData = { 104 | filePath: string; 105 | commit: string; 106 | repo: string; 107 | exists: boolean; 108 | }; 109 | 110 | /** 111 | * Produce the URI of a file to be used in the Visual Studio Diff View. 112 | * @param repo The repository the file is within. 113 | * @param filePath The path of the file. 114 | * @param commit The commit hash specifying the revision of the file. 115 | * @param type The Git file status of the change. 116 | * @param diffSide The side of the Diff View that this URI will be displayed on. 117 | * @returns A URI of the form `git-graph://file.ext?encoded-data` or `file://path/file.ext` 118 | */ 119 | export function encodeDiffDocUri(repo: string, filePath: string, commit: string, type: GitFileStatus, diffSide: DiffSide): vscode.Uri { 120 | if (commit === UNCOMMITTED && type !== GitFileStatus.Deleted) { 121 | return vscode.Uri.file(path.join(repo, filePath)); 122 | } 123 | 124 | const fileDoesNotExist = (diffSide === DiffSide.Old && type === GitFileStatus.Added) || (diffSide === DiffSide.New && type === GitFileStatus.Deleted); 125 | const data: DiffDocUriData = { 126 | filePath: getPathFromStr(filePath), 127 | commit: commit, 128 | repo: repo, 129 | exists: !fileDoesNotExist 130 | }; 131 | 132 | let extension: string; 133 | if (fileDoesNotExist) { 134 | extension = ''; 135 | } else { 136 | const extIndex = data.filePath.indexOf('.', data.filePath.lastIndexOf('/') + 1); 137 | extension = extIndex > -1 ? data.filePath.substring(extIndex) : ''; 138 | } 139 | 140 | return vscode.Uri.file('file' + extension).with({ 141 | scheme: DiffDocProvider.scheme, 142 | query: Buffer.from(JSON.stringify(data)).toString('base64') 143 | }); 144 | } 145 | 146 | /** 147 | * Decode the data from a `git-graph://file.ext?encoded-data` URI. 148 | * @param uri The URI to decode data from. 149 | * @returns The decoded DiffDocUriData. 150 | */ 151 | export function decodeDiffDocUri(uri: vscode.Uri): DiffDocUriData { 152 | return JSON.parse(Buffer.from(uri.query, 'base64').toString()); 153 | } 154 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { AvatarManager } from './avatarManager'; 3 | import { CommandManager } from './commands'; 4 | import { getConfig } from './config'; 5 | import { DataSource } from './dataSource'; 6 | import { DiffDocProvider } from './diffDocProvider'; 7 | import { ExtensionState } from './extensionState'; 8 | import { onStartUp } from './life-cycle/startup'; 9 | import { Logger } from './logger'; 10 | import { RepoManager } from './repoManager'; 11 | import { StatusBarItem } from './statusBarItem'; 12 | import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, findGit, getGitExecutableFromPaths, showErrorMessage, showInformationMessage } from './utils'; 13 | import { EventEmitter } from './utils/event'; 14 | 15 | /** 16 | * Activate Git Graph. 17 | * @param context The context of the extension. 18 | */ 19 | export async function activate(context: vscode.ExtensionContext) { 20 | const logger = new Logger(); 21 | logger.log('Starting Git Graph ...'); 22 | 23 | const gitExecutableEmitter = new EventEmitter(); 24 | const onDidChangeGitExecutable = gitExecutableEmitter.subscribe; 25 | 26 | const extensionState = new ExtensionState(context, onDidChangeGitExecutable); 27 | 28 | let gitExecutable: GitExecutable | null; 29 | try { 30 | gitExecutable = await findGit(extensionState); 31 | gitExecutableEmitter.emit(gitExecutable); 32 | logger.log('Using ' + gitExecutable.path + ' (version: ' + gitExecutable.version + ')'); 33 | } catch (_) { 34 | gitExecutable = null; 35 | showErrorMessage(UNABLE_TO_FIND_GIT_MSG); 36 | logger.logError(UNABLE_TO_FIND_GIT_MSG); 37 | } 38 | 39 | const configurationEmitter = new EventEmitter(); 40 | const onDidChangeConfiguration = configurationEmitter.subscribe; 41 | 42 | const dataSource = new DataSource(gitExecutable, onDidChangeConfiguration, onDidChangeGitExecutable, logger); 43 | const avatarManager = new AvatarManager(dataSource, extensionState, logger); 44 | const repoManager = new RepoManager(dataSource, extensionState, onDidChangeConfiguration, logger); 45 | const statusBarItem = new StatusBarItem(repoManager.getNumRepos(), repoManager.onDidChangeRepos, onDidChangeConfiguration, logger); 46 | const commandManager = new CommandManager(context, avatarManager, dataSource, extensionState, repoManager, gitExecutable, onDidChangeGitExecutable, logger); 47 | const diffDocProvider = new DiffDocProvider(dataSource); 48 | 49 | context.subscriptions.push( 50 | vscode.workspace.registerTextDocumentContentProvider(DiffDocProvider.scheme, diffDocProvider), 51 | vscode.workspace.onDidChangeConfiguration((event) => { 52 | if (event.affectsConfiguration('git-graph')) { 53 | configurationEmitter.emit(event); 54 | } else if (event.affectsConfiguration('git.path')) { 55 | const paths = getConfig().gitPaths; 56 | if (paths.length === 0) return; 57 | 58 | getGitExecutableFromPaths(paths).then((gitExecutable) => { 59 | gitExecutableEmitter.emit(gitExecutable); 60 | const msg = 'Git Graph is now using ' + gitExecutable.path + ' (version: ' + gitExecutable.version + ')'; 61 | showInformationMessage(msg); 62 | logger.log(msg); 63 | repoManager.searchWorkspaceForRepos(); 64 | }, () => { 65 | const msg = 'The new value of "git.path" ("' + paths.join('", "') + '") does not ' + (paths.length > 1 ? 'contain a string that matches' : 'match') + ' the path and filename of a valid Git executable.'; 66 | showErrorMessage(msg); 67 | logger.logError(msg); 68 | }); 69 | } 70 | }), 71 | diffDocProvider, 72 | commandManager, 73 | statusBarItem, 74 | repoManager, 75 | avatarManager, 76 | dataSource, 77 | configurationEmitter, 78 | extensionState, 79 | gitExecutableEmitter, 80 | logger 81 | ); 82 | logger.log('Started Git Graph - Ready to use!'); 83 | 84 | extensionState.expireOldCodeReviews(); 85 | onStartUp(context).catch(() => { }); 86 | } 87 | 88 | /** 89 | * Deactivate Git Graph. 90 | */ 91 | export function deactivate() { } 92 | -------------------------------------------------------------------------------- /src/extensionState.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as vscode from 'vscode'; 3 | import { Avatar, AvatarCache } from './avatarManager'; 4 | import { getConfig } from './config'; 5 | import { BooleanOverride, CodeReview, ErrorInfo, FileViewType, GitGraphViewGlobalState, GitGraphViewWorkspaceState, GitRepoSet, GitRepoState, RepoCommitOrdering } from './types'; 6 | import { GitExecutable, getPathFromStr } from './utils'; 7 | import { Disposable } from './utils/disposable'; 8 | import { Event } from './utils/event'; 9 | 10 | const AVATAR_STORAGE_FOLDER = '/avatars'; 11 | const AVATAR_CACHE = 'avatarCache'; 12 | const CODE_REVIEWS = 'codeReviews'; 13 | const GLOBAL_VIEW_STATE = 'globalViewState'; 14 | const IGNORED_REPOS = 'ignoredRepos'; 15 | const LAST_ACTIVE_REPO = 'lastActiveRepo'; 16 | const LAST_KNOWN_GIT_PATH = 'lastKnownGitPath'; 17 | const REPO_STATES = 'repoStates'; 18 | const WORKSPACE_VIEW_STATE = 'workspaceViewState'; 19 | 20 | export const DEFAULT_REPO_STATE: GitRepoState = { 21 | cdvDivider: 0.5, 22 | cdvHeight: 250, 23 | columnWidths: null, 24 | commitOrdering: RepoCommitOrdering.Default, 25 | fileViewType: FileViewType.Default, 26 | hideRemotes: [], 27 | includeCommitsMentionedByReflogs: BooleanOverride.Default, 28 | issueLinkingConfig: null, 29 | lastImportAt: 0, 30 | name: null, 31 | onlyFollowFirstParent: BooleanOverride.Default, 32 | onRepoLoadShowCheckedOutBranch: BooleanOverride.Default, 33 | onRepoLoadShowSpecificBranches: null, 34 | pullRequestConfig: null, 35 | showRemoteBranches: true, 36 | showRemoteBranchesV2: BooleanOverride.Default, 37 | showStashes: BooleanOverride.Default, 38 | showTags: BooleanOverride.Default, 39 | workspaceFolderIndex: null 40 | }; 41 | 42 | const DEFAULT_GIT_GRAPH_VIEW_GLOBAL_STATE: GitGraphViewGlobalState = { 43 | alwaysAcceptCheckoutCommit: false, 44 | issueLinkingConfig: null, 45 | pushTagSkipRemoteCheck: false 46 | }; 47 | 48 | const DEFAULT_GIT_GRAPH_VIEW_WORKSPACE_STATE: GitGraphViewWorkspaceState = { 49 | findIsCaseSensitive: false, 50 | findIsRegex: false, 51 | findOpenCommitDetailsView: false 52 | }; 53 | 54 | export interface CodeReviewData { 55 | lastActive: number; 56 | lastViewedFile: string | null; 57 | remainingFiles: string[]; 58 | } 59 | export type CodeReviews = { [repo: string]: { [id: string]: CodeReviewData } }; 60 | 61 | /** 62 | * Manages the Git Graph Extension State, which stores data in both the Visual Studio Code Global & Workspace State. 63 | */ 64 | export class ExtensionState extends Disposable { 65 | private readonly globalState: vscode.Memento; 66 | private readonly workspaceState: vscode.Memento; 67 | private readonly globalStoragePath: string; 68 | private avatarStorageAvailable: boolean = false; 69 | 70 | /** 71 | * Creates the Git Graph Extension State. 72 | * @param context The context of the extension. 73 | * @param onDidChangeGitExecutable The Event emitting the Git executable for Git Graph to use. 74 | */ 75 | constructor(context: vscode.ExtensionContext, onDidChangeGitExecutable: Event) { 76 | super(); 77 | this.globalState = context.globalState; 78 | this.workspaceState = context.workspaceState; 79 | 80 | this.globalStoragePath = getPathFromStr(context.globalStoragePath); 81 | fs.stat(this.globalStoragePath + AVATAR_STORAGE_FOLDER, (err) => { 82 | if (!err) { 83 | this.avatarStorageAvailable = true; 84 | } else { 85 | fs.mkdir(this.globalStoragePath, () => { 86 | fs.mkdir(this.globalStoragePath + AVATAR_STORAGE_FOLDER, (err) => { 87 | if (!err || err.code === 'EEXIST') { 88 | // The directory was created, or it already exists 89 | this.avatarStorageAvailable = true; 90 | } 91 | }); 92 | }); 93 | } 94 | }); 95 | 96 | this.registerDisposable( 97 | onDidChangeGitExecutable((gitExecutable) => { 98 | this.setLastKnownGitPath(gitExecutable.path); 99 | }) 100 | ); 101 | } 102 | 103 | 104 | /* Known Repositories */ 105 | 106 | /** 107 | * Get the known repositories in the current workspace. 108 | * @returns The set of repositories. 109 | */ 110 | public getRepos() { 111 | const repoSet = this.workspaceState.get(REPO_STATES, {}); 112 | const outputSet: GitRepoSet = {}; 113 | let showRemoteBranchesDefaultValue: boolean | null = null; 114 | Object.keys(repoSet).forEach((repo) => { 115 | outputSet[repo] = Object.assign({}, DEFAULT_REPO_STATE, repoSet[repo]); 116 | if (typeof repoSet[repo].showRemoteBranchesV2 === 'undefined' && typeof repoSet[repo].showRemoteBranches !== 'undefined') { 117 | if (showRemoteBranchesDefaultValue === null) { 118 | showRemoteBranchesDefaultValue = getConfig().showRemoteBranches; 119 | } 120 | if (repoSet[repo].showRemoteBranches !== showRemoteBranchesDefaultValue) { 121 | outputSet[repo].showRemoteBranchesV2 = repoSet[repo].showRemoteBranches ? BooleanOverride.Enabled : BooleanOverride.Disabled; 122 | } 123 | } 124 | }); 125 | return outputSet; 126 | } 127 | 128 | /** 129 | * Set the known repositories in the current workspace. 130 | * @param gitRepoSet The set of repositories. 131 | */ 132 | public saveRepos(gitRepoSet: GitRepoSet) { 133 | this.updateWorkspaceState(REPO_STATES, gitRepoSet); 134 | } 135 | 136 | /** 137 | * Transfer state references from one known repository to another. 138 | * @param oldRepo The repository to transfer state from. 139 | * @param newRepo The repository to transfer state to. 140 | */ 141 | public transferRepo(oldRepo: string, newRepo: string) { 142 | if (this.getLastActiveRepo() === oldRepo) { 143 | this.setLastActiveRepo(newRepo); 144 | } 145 | 146 | let reviews = this.getCodeReviews(); 147 | if (typeof reviews[oldRepo] !== 'undefined') { 148 | reviews[newRepo] = reviews[oldRepo]; 149 | delete reviews[oldRepo]; 150 | this.setCodeReviews(reviews); 151 | } 152 | } 153 | 154 | 155 | /* Global View State */ 156 | 157 | /** 158 | * Get the global state of the Git Graph View. 159 | * @returns The global state. 160 | */ 161 | public getGlobalViewState() { 162 | const globalViewState = this.globalState.get(GLOBAL_VIEW_STATE, DEFAULT_GIT_GRAPH_VIEW_GLOBAL_STATE); 163 | return Object.assign({}, DEFAULT_GIT_GRAPH_VIEW_GLOBAL_STATE, globalViewState); 164 | } 165 | 166 | /** 167 | * Set the global state of the Git Graph View. 168 | * @param state The global state. 169 | */ 170 | public setGlobalViewState(state: GitGraphViewGlobalState) { 171 | return this.updateGlobalState(GLOBAL_VIEW_STATE, state); 172 | } 173 | 174 | 175 | /* Workspace View State */ 176 | 177 | /** 178 | * Get the workspace state of the Git Graph View. 179 | * @returns The workspace state. 180 | */ 181 | public getWorkspaceViewState() { 182 | const workspaceViewState = this.workspaceState.get(WORKSPACE_VIEW_STATE, DEFAULT_GIT_GRAPH_VIEW_WORKSPACE_STATE); 183 | return Object.assign({}, DEFAULT_GIT_GRAPH_VIEW_WORKSPACE_STATE, workspaceViewState); 184 | } 185 | 186 | /** 187 | * Set the workspace state of the Git Graph View. 188 | * @param state The workspace state. 189 | */ 190 | public setWorkspaceViewState(state: GitGraphViewWorkspaceState) { 191 | return this.updateWorkspaceState(WORKSPACE_VIEW_STATE, state); 192 | } 193 | 194 | 195 | /* Ignored Repos */ 196 | 197 | /** 198 | * Get the ignored repositories in the current workspace. 199 | * @returns An array of the paths of ignored repositories. 200 | */ 201 | public getIgnoredRepos() { 202 | return this.workspaceState.get(IGNORED_REPOS, []); 203 | } 204 | 205 | /** 206 | * Set the ignored repositories in the current workspace. 207 | * @param ignoredRepos An array of the paths of ignored repositories. 208 | */ 209 | public setIgnoredRepos(ignoredRepos: string[]) { 210 | return this.updateWorkspaceState(IGNORED_REPOS, ignoredRepos); 211 | } 212 | 213 | 214 | /* Last Active Repo */ 215 | 216 | /** 217 | * Get the last active repository in the current workspace. 218 | * @returns The path of the last active repository. 219 | */ 220 | public getLastActiveRepo() { 221 | return this.workspaceState.get(LAST_ACTIVE_REPO, null); 222 | } 223 | 224 | /** 225 | * Set the last active repository in the current workspace. 226 | * @param repo The path of the last active repository. 227 | */ 228 | public setLastActiveRepo(repo: string | null) { 229 | this.updateWorkspaceState(LAST_ACTIVE_REPO, repo); 230 | } 231 | 232 | 233 | /* Last Known Git Path */ 234 | 235 | /** 236 | * Get the last known path of the Git executable used by Git Graph. 237 | * @returns The path of the Git executable. 238 | */ 239 | public getLastKnownGitPath() { 240 | return this.globalState.get(LAST_KNOWN_GIT_PATH, null); 241 | } 242 | 243 | /** 244 | * Set the last known path of the Git executable used by Git Graph. 245 | * @param path The path of the Git executable. 246 | */ 247 | private setLastKnownGitPath(path: string) { 248 | this.updateGlobalState(LAST_KNOWN_GIT_PATH, path); 249 | } 250 | 251 | 252 | /* Avatars */ 253 | 254 | /** 255 | * Checks whether the Avatar Storage Folder is available to store avatars. 256 | * @returns TRUE => Avatar Storage Folder is available, FALSE => Avatar Storage Folder isn't available. 257 | */ 258 | public isAvatarStorageAvailable() { 259 | return this.avatarStorageAvailable; 260 | } 261 | 262 | /** 263 | * Gets the path that is used to store avatars globally in Git Graph. 264 | * @returns The folder path. 265 | */ 266 | public getAvatarStoragePath() { 267 | return this.globalStoragePath + AVATAR_STORAGE_FOLDER; 268 | } 269 | 270 | /** 271 | * Gets the cache of avatars known to Git Graph. 272 | * @returns The avatar cache. 273 | */ 274 | public getAvatarCache() { 275 | return this.globalState.get(AVATAR_CACHE, {}); 276 | } 277 | 278 | /** 279 | * Add a new avatar to the cache of avatars known to Git Graph. 280 | * @param email The email address that the avatar is for. 281 | * @param avatar The details of the avatar. 282 | */ 283 | public saveAvatar(email: string, avatar: Avatar) { 284 | let avatars = this.getAvatarCache(); 285 | avatars[email] = avatar; 286 | this.updateGlobalState(AVATAR_CACHE, avatars); 287 | } 288 | 289 | /** 290 | * Removes an avatar from the cache of avatars known to Git Graph. 291 | * @param email The email address of the avatar to remove. 292 | */ 293 | public removeAvatarFromCache(email: string) { 294 | let avatars = this.getAvatarCache(); 295 | delete avatars[email]; 296 | this.updateGlobalState(AVATAR_CACHE, avatars); 297 | } 298 | 299 | /** 300 | * Clear all avatars from the cache of avatars known to Git Graph. 301 | * @returns A Thenable resolving to the ErrorInfo that resulted from executing this method. 302 | */ 303 | public clearAvatarCache() { 304 | return this.updateGlobalState(AVATAR_CACHE, {}).then((errorInfo) => { 305 | if (errorInfo === null) { 306 | fs.readdir(this.globalStoragePath + AVATAR_STORAGE_FOLDER, (err, files) => { 307 | if (err) return; 308 | for (let i = 0; i < files.length; i++) { 309 | fs.unlink(this.globalStoragePath + AVATAR_STORAGE_FOLDER + '/' + files[i], () => { }); 310 | } 311 | }); 312 | } 313 | return errorInfo; 314 | }); 315 | } 316 | 317 | 318 | /* Code Review */ 319 | 320 | // Note: id => the commit arguments to 'git diff' (either or -) 321 | 322 | /** 323 | * Start a new Code Review. 324 | * @param repo The repository the Code Review is in. 325 | * @param id The ID of the Code Review. 326 | * @param files An array of files that must be reviewed. 327 | * @param lastViewedFile The last file the user reviewed before starting the Code Review. 328 | * @returns The Code Review that was started. 329 | */ 330 | public startCodeReview(repo: string, id: string, files: string[], lastViewedFile: string | null) { 331 | let reviews = this.getCodeReviews(); 332 | if (typeof reviews[repo] === 'undefined') reviews[repo] = {}; 333 | reviews[repo][id] = { lastActive: (new Date()).getTime(), lastViewedFile: lastViewedFile, remainingFiles: files }; 334 | return this.setCodeReviews(reviews).then((err) => ({ 335 | codeReview: Object.assign({ id: id }, reviews[repo][id]), 336 | error: err 337 | })); 338 | } 339 | 340 | /** 341 | * End an existing Code Review. 342 | * @param repo The repository the Code Review is in. 343 | * @param id The ID of the Code Review. 344 | */ 345 | public endCodeReview(repo: string, id: string) { 346 | let reviews = this.getCodeReviews(); 347 | removeCodeReview(reviews, repo, id); 348 | return this.setCodeReviews(reviews); 349 | } 350 | 351 | /** 352 | * Get an existing Code Review. 353 | * @param repo The repository the Code Review is in. 354 | * @param id The ID of the Code Review. 355 | * @returns The Code Review. 356 | */ 357 | public getCodeReview(repo: string, id: string) { 358 | let reviews = this.getCodeReviews(); 359 | if (typeof reviews[repo] !== 'undefined' && typeof reviews[repo][id] !== 'undefined') { 360 | reviews[repo][id].lastActive = (new Date()).getTime(); 361 | this.setCodeReviews(reviews); 362 | return Object.assign({ id: id }, reviews[repo][id]); 363 | } else { 364 | return null; 365 | } 366 | } 367 | 368 | /** 369 | * Update information for a specific Code Review. 370 | * @param repo The repository the Code Review is in. 371 | * @param id The ID of the Code Review. 372 | * @param remainingFiles The files remaining for review. 373 | * @param lastViewedFile The last viewed file. If null, don't change the last viewed file. 374 | * @returns An error message if request can't be completed. 375 | */ 376 | public updateCodeReview(repo: string, id: string, remainingFiles: string[], lastViewedFile: string | null) { 377 | const reviews = this.getCodeReviews(); 378 | 379 | if (typeof reviews[repo] === 'undefined' || typeof reviews[repo][id] === 'undefined') { 380 | return Promise.resolve('The Code Review could not be found.'); 381 | } 382 | 383 | if (remainingFiles.length > 0) { 384 | reviews[repo][id].remainingFiles = remainingFiles; 385 | reviews[repo][id].lastActive = (new Date()).getTime(); 386 | if (lastViewedFile !== null) { 387 | reviews[repo][id].lastViewedFile = lastViewedFile; 388 | } 389 | } else { 390 | removeCodeReview(reviews, repo, id); 391 | } 392 | 393 | return this.setCodeReviews(reviews); 394 | } 395 | 396 | /** 397 | * Delete any Code Reviews that haven't been active during the last 90 days. 398 | */ 399 | public expireOldCodeReviews() { 400 | let reviews = this.getCodeReviews(), change = false, expireReviewsBefore = (new Date()).getTime() - 7776000000; // 90 days x 24 hours x 60 minutes x 60 seconds x 1000 milliseconds 401 | Object.keys(reviews).forEach((repo) => { 402 | Object.keys(reviews[repo]).forEach((id) => { 403 | if (reviews[repo][id].lastActive < expireReviewsBefore) { 404 | delete reviews[repo][id]; 405 | change = true; 406 | } 407 | }); 408 | removeCodeReviewRepoIfEmpty(reviews, repo); 409 | }); 410 | if (change) this.setCodeReviews(reviews); 411 | } 412 | 413 | /** 414 | * End all Code Reviews in the current workspace. 415 | */ 416 | public endAllWorkspaceCodeReviews() { 417 | this.setCodeReviews({}); 418 | } 419 | 420 | /** 421 | * Get all Code Reviews in the current workspace. 422 | * @returns The set of Code Reviews. 423 | */ 424 | public getCodeReviews() { 425 | return this.workspaceState.get(CODE_REVIEWS, {}); 426 | } 427 | 428 | /** 429 | * Set the Code Reviews in the current workspace. 430 | * @param reviews The set of Code Reviews. 431 | */ 432 | private setCodeReviews(reviews: CodeReviews) { 433 | return this.updateWorkspaceState(CODE_REVIEWS, reviews); 434 | } 435 | 436 | 437 | /* Update State Memento's */ 438 | 439 | /** 440 | * Update the Git Graph Global State with a new pair. 441 | * @param key The key. 442 | * @param value The value. 443 | * @returns A Thenable resolving to the ErrorInfo that resulted from updating the Global State. 444 | */ 445 | private updateGlobalState(key: string, value: any): Thenable { 446 | return this.globalState.update(key, value).then( 447 | () => null, 448 | () => 'Visual Studio Code was unable to save the Git Graph Global State Memento.' 449 | ); 450 | } 451 | 452 | /** 453 | * Update the Git Graph Workspace State with a new pair. 454 | * @param key The key. 455 | * @param value The value. 456 | * @returns A Thenable resolving to the ErrorInfo that resulted from updating the Workspace State. 457 | */ 458 | private updateWorkspaceState(key: string, value: any): Thenable { 459 | return this.workspaceState.update(key, value).then( 460 | () => null, 461 | () => 'Visual Studio Code was unable to save the Git Graph Workspace State Memento.' 462 | ); 463 | } 464 | } 465 | 466 | 467 | /* Helper Methods */ 468 | 469 | /** 470 | * Remove a Code Review from a set of Code Reviews. 471 | * @param reviews The set of Code Reviews. 472 | * @param repo The repository the Code Review is in. 473 | * @param id The ID of the Code Review. 474 | */ 475 | function removeCodeReview(reviews: CodeReviews, repo: string, id: string) { 476 | if (typeof reviews[repo] !== 'undefined' && typeof reviews[repo][id] !== 'undefined') { 477 | delete reviews[repo][id]; 478 | removeCodeReviewRepoIfEmpty(reviews, repo); 479 | } 480 | } 481 | 482 | /** 483 | * Remove a repository from a set of Code Reviews if the repository doesn't contain any Code Reviews. 484 | * @param reviews The set of Code Reviews. 485 | * @param repo The repository to perform this action on. 486 | */ 487 | function removeCodeReviewRepoIfEmpty(reviews: CodeReviews, repo: string) { 488 | if (typeof reviews[repo] !== 'undefined' && Object.keys(reviews[repo]).length === 0) { 489 | delete reviews[repo]; 490 | } 491 | } 492 | -------------------------------------------------------------------------------- /src/life-cycle/startup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Git Graph generates an event when it is installed, updated, or uninstalled, that is anonymous, non-personal, and cannot be correlated. 3 | * - Each event only contains the Git Graph and Visual Studio Code version numbers, and a 256 bit cryptographically strong pseudo-random nonce. 4 | * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are 5 | * using Visual Studio Code >= 1.41.0). These insights enable Git Graph to utilise the latest features of Visual Studio Code as soon as 6 | * the majority of users are using a compatible version. The data cannot, and will not, be used for any other purpose. 7 | * - Full details are available at: https://api.mhutchie.com/vscode-git-graph/about 8 | */ 9 | 10 | import * as fs from 'fs'; 11 | import * as path from 'path'; 12 | import * as vscode from 'vscode'; 13 | import { LifeCycleStage, LifeCycleState, generateNonce, getDataDirectory, getLifeCycleStateInDirectory, saveLifeCycleStateInDirectory, sendQueue } from './utils'; 14 | import { getExtensionVersion } from '../utils'; 15 | 16 | /** 17 | * Run on startup to detect if Git Graph has been installed or updated, and if so generate an event. 18 | * @param extensionContext The extension context of Git Graph. 19 | */ 20 | export async function onStartUp(extensionContext: vscode.ExtensionContext) { 21 | if (vscode.env.sessionId === 'someValue.sessionId') { 22 | // Extension is running in the Extension Development Host, don't proceed. 23 | return; 24 | } 25 | 26 | let state = await getLifeCycleStateInDirectory(extensionContext.globalStoragePath); 27 | 28 | if (state !== null && !state.apiAvailable) { 29 | // The API is no longer available, don't proceed. 30 | return; 31 | } 32 | 33 | const versions = { 34 | extension: await getExtensionVersion(extensionContext), 35 | vscode: vscode.version 36 | }; 37 | 38 | if (state === null || state.current.extension !== versions.extension) { 39 | // This is the first startup after installing Git Graph, or Git Graph has been updated since the last startup. 40 | const nonce = await getNonce(); 41 | 42 | if (state === null) { 43 | // Install 44 | state = { 45 | previous: null, 46 | current: versions, 47 | apiAvailable: true, 48 | queue: [{ 49 | stage: LifeCycleStage.Install, 50 | extension: versions.extension, 51 | vscode: versions.vscode, 52 | nonce: nonce 53 | }], 54 | attempts: 1 55 | }; 56 | } else { 57 | // Update 58 | state.previous = state.current; 59 | state.current = versions; 60 | state.queue.push({ 61 | stage: LifeCycleStage.Update, 62 | from: state.previous, 63 | to: state.current, 64 | nonce: nonce 65 | }); 66 | state.attempts = 1; 67 | } 68 | 69 | await saveLifeCycleState(extensionContext, state); 70 | state.apiAvailable = await sendQueue(state.queue); 71 | state.queue = []; 72 | await saveLifeCycleState(extensionContext, state); 73 | 74 | } else if (state.queue.length > 0 && state.attempts < 2) { 75 | // There are one or more events in the queue that previously failed to send, send them 76 | state.attempts++; 77 | await saveLifeCycleState(extensionContext, state); 78 | state.apiAvailable = await sendQueue(state.queue); 79 | state.queue = []; 80 | await saveLifeCycleState(extensionContext, state); 81 | } 82 | } 83 | 84 | /** 85 | * Saves the life cycle state to the extensions global storage directory (for use during future updates), 86 | * and to a directory in this Git Graph installation (for use during future uninstalls). 87 | * @param extensionContext The extension context of Git Graph. 88 | * @param state The state to save. 89 | */ 90 | function saveLifeCycleState(extensionContext: vscode.ExtensionContext, state: LifeCycleState) { 91 | return Promise.all([ 92 | saveLifeCycleStateInDirectory(extensionContext.globalStoragePath, state), 93 | saveLifeCycleStateInDirectory(getDataDirectory(), state) 94 | ]); 95 | } 96 | 97 | /** 98 | * Get a nonce generated for this installation of Git Graph. 99 | * @returns A 256 bit cryptographically strong pseudo-random nonce. 100 | */ 101 | function getNonce() { 102 | return new Promise((resolve, reject) => { 103 | const dir = getDataDirectory(); 104 | const file = path.join(dir, 'lock.json'); 105 | fs.mkdir(dir, (err) => { 106 | if (err) { 107 | if (err.code === 'EEXIST') { 108 | // The directory already exists, attempt to read the previously created data 109 | fs.readFile(file, (err, data) => { 110 | if (err) { 111 | // Unable to read the file, reject 112 | reject(); 113 | } else { 114 | try { 115 | // Resolve to the previously generated nonce 116 | resolve(JSON.parse(data.toString()).nonce); 117 | } catch (_) { 118 | reject(); 119 | } 120 | } 121 | }); 122 | } else { 123 | // An unexpected error occurred, reject 124 | reject(); 125 | } 126 | } else { 127 | // The directory was created, generate a nonce 128 | const nonce = generateNonce(); 129 | fs.writeFile(file, JSON.stringify({ nonce: nonce }), (err) => { 130 | if (err) { 131 | // Unable to save data 132 | reject(); 133 | } else { 134 | // Nonce successfully saved, resolve to it 135 | resolve(nonce); 136 | } 137 | }); 138 | } 139 | }); 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /src/life-cycle/uninstall.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Git Graph generates an event when it is installed, updated, or uninstalled, that is anonymous, non-personal, and cannot be correlated. 3 | * - Each event only contains the Git Graph and Visual Studio Code version numbers, and a 256 bit cryptographically strong pseudo-random nonce. 4 | * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are 5 | * using Visual Studio Code >= 1.41.0). These insights enable Git Graph to utilise the latest features of Visual Studio Code as soon as 6 | * the majority of users are using a compatible version. The data cannot, and will not, be used for any other purpose. 7 | * - Full details are available at: https://api.mhutchie.com/vscode-git-graph/about 8 | */ 9 | 10 | import { LifeCycleStage, generateNonce, getDataDirectory, getLifeCycleStateInDirectory, sendQueue } from './utils'; 11 | 12 | (async function () { 13 | try { 14 | const state = await getLifeCycleStateInDirectory(getDataDirectory()); 15 | if (state !== null) { 16 | if (state.apiAvailable) { 17 | state.queue.push({ 18 | stage: LifeCycleStage.Uninstall, 19 | extension: state.current.extension, 20 | vscode: state.current.vscode, 21 | nonce: generateNonce() 22 | }); 23 | await sendQueue(state.queue); 24 | } 25 | } 26 | } catch (_) { } 27 | })(); 28 | -------------------------------------------------------------------------------- /src/life-cycle/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Git Graph generates an event when it is installed, updated, or uninstalled, that is anonymous, non-personal, and cannot be correlated. 3 | * - Each event only contains the Git Graph and Visual Studio Code version numbers, and a 256 bit cryptographically strong pseudo-random nonce. 4 | * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are 5 | * using Visual Studio Code >= 1.41.0). These insights enable Git Graph to utilise the latest features of Visual Studio Code as soon as 6 | * the majority of users are using a compatible version. The data cannot, and will not, be used for any other purpose. 7 | * - Full details are available at: https://api.mhutchie.com/vscode-git-graph/about 8 | */ 9 | 10 | import * as crypto from 'crypto'; 11 | import * as fs from 'fs'; 12 | import * as https from 'https'; 13 | import * as path from 'path'; 14 | 15 | type LifeCycleEvent = { 16 | stage: LifeCycleStage.Install; 17 | extension: string; 18 | vscode: string; 19 | nonce: string; 20 | } | { 21 | stage: LifeCycleStage.Update; 22 | from: { 23 | extension: string, 24 | vscode: string 25 | }; 26 | to: { 27 | extension: string, 28 | vscode: string 29 | }; 30 | nonce: string; 31 | } | { 32 | stage: LifeCycleStage.Uninstall; 33 | extension: string; 34 | vscode: string; 35 | nonce: string; 36 | }; 37 | 38 | export enum LifeCycleStage { 39 | Install, 40 | Update, 41 | Uninstall 42 | } 43 | 44 | export interface LifeCycleState { 45 | previous: { 46 | extension: string, 47 | vscode: string, 48 | } | null; 49 | current: { 50 | extension: string, 51 | vscode: string 52 | }; 53 | apiAvailable: boolean; 54 | queue: LifeCycleEvent[]; 55 | attempts: number; 56 | } 57 | 58 | /** 59 | * Generate a 256 bit cryptographically strong pseudo-random nonce. 60 | * @returns The nonce. 61 | */ 62 | export function generateNonce() { 63 | return crypto.randomBytes(32).toString('base64'); 64 | } 65 | 66 | /** 67 | * Gets the data directory for files used by the life cycle process. 68 | * @returns The path of the directory. 69 | */ 70 | export function getDataDirectory() { 71 | return path.join(__dirname, 'data'); 72 | } 73 | 74 | /** 75 | * Gets the path of the life cycle file in the specified directory. 76 | * @param directory The path of the directory. 77 | * @returns The path of the life cycle file. 78 | */ 79 | function getLifeCycleFilePathInDirectory(directory: string) { 80 | return path.join(directory, 'life-cycle.json'); 81 | } 82 | 83 | /** 84 | * Gets the life cycle state of Git Graph from the specified directory. 85 | * @param directory The directory that contains the life cycle state. 86 | * @returns The life cycle state. 87 | */ 88 | export function getLifeCycleStateInDirectory(directory: string) { 89 | return new Promise((resolve) => { 90 | fs.readFile(getLifeCycleFilePathInDirectory(directory), (err, data) => { 91 | if (err) { 92 | resolve(null); 93 | } else { 94 | try { 95 | resolve(Object.assign({ attempts: 1 }, JSON.parse(data.toString()))); 96 | } catch (_) { 97 | resolve(null); 98 | } 99 | } 100 | }); 101 | }); 102 | } 103 | 104 | /** 105 | * Saves the life cycle state of Git Graph in the specified directory. 106 | * @param directory The directory to store the life cycle state. 107 | * @param state The state to save. 108 | */ 109 | export function saveLifeCycleStateInDirectory(directory: string, state: LifeCycleState) { 110 | return new Promise((resolve, reject) => { 111 | fs.mkdir(directory, (err) => { 112 | if (!err || err.code === 'EEXIST') { 113 | fs.writeFile(getLifeCycleFilePathInDirectory(directory), JSON.stringify(state), (err) => { 114 | if (err) { 115 | reject(); 116 | } else { 117 | resolve(); 118 | } 119 | }); 120 | } else { 121 | reject(); 122 | } 123 | }); 124 | }); 125 | } 126 | 127 | /** 128 | * Send all events in a specified queue of life cycle events (typically only one event long). 129 | * @param queue The queue containing the events. 130 | * @returns TRUE => Queue was successfully sent & the API is still available, FALSE => The API is no longer available. 131 | */ 132 | export async function sendQueue(queue: LifeCycleEvent[]) { 133 | for (let i = 0; i < queue.length; i++) { 134 | if (!await sendEvent(queue[i])) return false; 135 | } 136 | return true; 137 | } 138 | 139 | /** 140 | * Send an event to the API. 141 | * @param event The event to send. 142 | * @returns TRUE => Event was successfully sent & the API is still available, FALSE => The API is no longer available. 143 | */ 144 | function sendEvent(event: LifeCycleEvent) { 145 | return new Promise((resolve, reject) => { 146 | let completed = false, receivedResponse = false, apiAvailable = false; 147 | const complete = () => { 148 | if (!completed) { 149 | completed = true; 150 | if (receivedResponse) { 151 | resolve(apiAvailable); 152 | } else { 153 | reject(); 154 | } 155 | } 156 | }; 157 | 158 | const sendEvent: Omit & { about: string, stage?: LifeCycleStage } = Object.assign({ 159 | about: 'Information about this API is available at: https://api.mhutchie.com/vscode-git-graph/about' 160 | }, event); 161 | delete sendEvent.stage; 162 | 163 | const content = JSON.stringify(sendEvent); 164 | https.request({ 165 | method: 'POST', 166 | hostname: 'api.mhutchie.com', 167 | path: '/vscode-git-graph/' + (event.stage === LifeCycleStage.Install ? 'install' : event.stage === LifeCycleStage.Update ? 'update' : 'uninstall'), 168 | headers: { 169 | 'Content-Type': 'application/json', 170 | 'Content-Length': content.length 171 | }, 172 | agent: false, 173 | timeout: 15000 174 | }, (res) => { 175 | res.on('data', () => { }); 176 | res.on('end', () => { 177 | if (res.statusCode === 201) { 178 | receivedResponse = true; 179 | apiAvailable = true; 180 | } else if (res.statusCode === 410) { 181 | receivedResponse = true; 182 | } 183 | complete(); 184 | }); 185 | res.on('error', complete); 186 | }).on('error', complete).on('close', complete).end(content); 187 | }); 188 | } 189 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Disposable } from './utils/disposable'; 3 | 4 | const DOUBLE_QUOTE_REGEXP = /"/g; 5 | 6 | /** 7 | * Manages the Git Graph Logger, which writes log information to the Git Graph Output Channel. 8 | */ 9 | export class Logger extends Disposable { 10 | private readonly channel: vscode.OutputChannel; 11 | 12 | /** 13 | * Creates the Git Graph Logger. 14 | */ 15 | constructor() { 16 | super(); 17 | this.channel = vscode.window.createOutputChannel('Git Graph'); 18 | this.registerDisposable(this.channel); 19 | } 20 | 21 | /** 22 | * Log a message to the Output Channel. 23 | * @param message The string to be logged. 24 | */ 25 | public log(message: string) { 26 | const date = new Date(); 27 | const timestamp = date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate()) + ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds()) + '.' + pad3(date.getMilliseconds()); 28 | this.channel.appendLine('[' + timestamp + '] ' + message); 29 | } 30 | 31 | /** 32 | * Log the execution of a spawned command to the Output Channel. 33 | * @param cmd The command being spawned. 34 | * @param args The arguments passed to the command. 35 | */ 36 | public logCmd(cmd: string, args: string[]) { 37 | this.log('> ' + cmd + ' ' + args.map((arg) => arg === '' 38 | ? '""' 39 | : arg.startsWith('--format=') 40 | ? '--format=...' 41 | : arg.includes(' ') 42 | ? '"' + arg.replace(DOUBLE_QUOTE_REGEXP, '\\"') + '"' 43 | : arg 44 | ).join(' ')); 45 | } 46 | 47 | /** 48 | * Log an error message to the Output Channel. 49 | * @param message The string to be logged. 50 | */ 51 | public logError(message: string) { 52 | this.log('ERROR: ' + message); 53 | } 54 | } 55 | 56 | /** 57 | * Pad a number with a leading zero if it is less than two digits long. 58 | * @param n The number to be padded. 59 | * @returns The padded number. 60 | */ 61 | function pad2(n: number) { 62 | return (n > 9 ? '' : '0') + n; 63 | } 64 | 65 | /** 66 | * Pad a number with leading zeros if it is less than three digits long. 67 | * @param n The number to be padded. 68 | * @returns The padded number. 69 | */ 70 | function pad3(n: number) { 71 | return (n > 99 ? '' : n > 9 ? '0' : '00') + n; 72 | } 73 | -------------------------------------------------------------------------------- /src/repoFileWatcher.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Logger } from './logger'; 3 | import { getPathFromUri } from './utils'; 4 | 5 | const FILE_CHANGE_REGEX = /(^\.git\/(config|index|HEAD|refs\/stash|refs\/heads\/.*|refs\/remotes\/.*|refs\/tags\/.*)$)|(^(?!\.git).*$)|(^\.git[^\/]+$)/; 6 | 7 | /** 8 | * Watches a Git repository for file events. 9 | */ 10 | export class RepoFileWatcher { 11 | private readonly logger: Logger; 12 | private readonly repoChangeCallback: () => void; 13 | private repo: string | null = null; 14 | private fsWatcher: vscode.FileSystemWatcher | null = null; 15 | private refreshTimeout: NodeJS.Timer | null = null; 16 | private muted: boolean = false; 17 | private resumeAt: number = 0; 18 | 19 | /** 20 | * Creates a RepoFileWatcher. 21 | * @param logger The Git Graph Logger instance. 22 | * @param repoChangeCallback A callback to be invoked when a file event occurs in the repository. 23 | */ 24 | constructor(logger: Logger, repoChangeCallback: () => void) { 25 | this.logger = logger; 26 | this.repoChangeCallback = repoChangeCallback; 27 | } 28 | 29 | /** 30 | * Start watching a repository for file events. 31 | * @param repo The path of the repository to watch. 32 | */ 33 | public start(repo: string) { 34 | if (this.fsWatcher !== null) { 35 | // If there is an existing File System Watcher, stop it 36 | this.stop(); 37 | } 38 | 39 | this.repo = repo; 40 | // Create a File System Watcher for all events within the specified repository 41 | this.fsWatcher = vscode.workspace.createFileSystemWatcher(repo + '/**'); 42 | this.fsWatcher.onDidCreate(uri => this.refresh(uri)); 43 | this.fsWatcher.onDidChange(uri => this.refresh(uri)); 44 | this.fsWatcher.onDidDelete(uri => this.refresh(uri)); 45 | this.logger.log('Started watching repo: ' + repo); 46 | } 47 | 48 | /** 49 | * Stop watching the repository for file events. 50 | */ 51 | public stop() { 52 | if (this.fsWatcher !== null) { 53 | // If there is an existing File System Watcher, stop it 54 | this.fsWatcher.dispose(); 55 | this.fsWatcher = null; 56 | this.logger.log('Stopped watching repo: ' + this.repo); 57 | } 58 | if (this.refreshTimeout !== null) { 59 | // If a timeout is active, clear it 60 | clearTimeout(this.refreshTimeout); 61 | this.refreshTimeout = null; 62 | } 63 | } 64 | 65 | /** 66 | * Mute file events - Used to prevent many file events from being triggered when a Git action is executed by the Git Graph View. 67 | */ 68 | public mute() { 69 | this.muted = true; 70 | } 71 | 72 | /** 73 | * Unmute file events - Used to resume normal watching after a Git action executed by the Git Graph View has completed. 74 | */ 75 | public unmute() { 76 | this.muted = false; 77 | this.resumeAt = (new Date()).getTime() + 1500; 78 | } 79 | 80 | 81 | /** 82 | * Handle a file event triggered by the File System Watcher. 83 | * @param uri The URI of the file that the event occurred on. 84 | */ 85 | private refresh(uri: vscode.Uri) { 86 | if (this.muted) return; 87 | if (!getPathFromUri(uri).replace(this.repo + '/', '').match(FILE_CHANGE_REGEX)) return; 88 | if ((new Date()).getTime() < this.resumeAt) return; 89 | 90 | if (this.refreshTimeout !== null) { 91 | clearTimeout(this.refreshTimeout); 92 | } 93 | this.refreshTimeout = setTimeout(() => { 94 | this.refreshTimeout = null; 95 | this.repoChangeCallback(); 96 | }, 750); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/statusBarItem.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getConfig } from './config'; 3 | import { Logger } from './logger'; 4 | import { RepoChangeEvent } from './repoManager'; 5 | import { Disposable } from './utils/disposable'; 6 | import { Event } from './utils/event'; 7 | 8 | /** 9 | * Manages the Git Graph Status Bar Item, which allows users to open the Git Graph View from the Visual Studio Code Status Bar. 10 | */ 11 | export class StatusBarItem extends Disposable { 12 | private readonly logger: Logger; 13 | private readonly statusBarItem: vscode.StatusBarItem; 14 | private isVisible: boolean = false; 15 | private numRepos: number = 0; 16 | 17 | /** 18 | * Creates the Git Graph Status Bar Item. 19 | * @param repoManager The Git Graph RepoManager instance. 20 | * @param logger The Git Graph Logger instance. 21 | */ 22 | constructor(initialNumRepos: number, onDidChangeRepos: Event, onDidChangeConfiguration: Event, logger: Logger) { 23 | super(); 24 | this.logger = logger; 25 | 26 | const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1); 27 | statusBarItem.text = 'Git Graph'; 28 | statusBarItem.tooltip = 'View Git Graph'; 29 | statusBarItem.command = 'git-graph.view'; 30 | this.statusBarItem = statusBarItem; 31 | 32 | this.registerDisposables( 33 | onDidChangeRepos((event) => { 34 | this.setNumRepos(event.numRepos); 35 | }), 36 | onDidChangeConfiguration((event) => { 37 | if (event.affectsConfiguration('git-graph.showStatusBarItem')) { 38 | this.refresh(); 39 | } 40 | }), 41 | statusBarItem 42 | ); 43 | 44 | this.setNumRepos(initialNumRepos); 45 | } 46 | 47 | /** 48 | * Sets the number of repositories known to Git Graph, before refreshing the Status Bar Item. 49 | * @param numRepos The number of repositories known to Git Graph. 50 | */ 51 | private setNumRepos(numRepos: number) { 52 | this.numRepos = numRepos; 53 | this.refresh(); 54 | } 55 | 56 | /** 57 | * Show or hide the Status Bar Item according to the configured value of `git-graph.showStatusBarItem`, and the number of repositories known to Git Graph. 58 | */ 59 | private refresh() { 60 | const shouldBeVisible = getConfig().showStatusBarItem && this.numRepos > 0; 61 | if (this.isVisible !== shouldBeVisible) { 62 | if (shouldBeVisible) { 63 | this.statusBarItem.show(); 64 | this.logger.log('Showing "Git Graph" Status Bar Item'); 65 | } else { 66 | this.statusBarItem.hide(); 67 | this.logger.log('Hiding "Git Graph" Status Bar Item'); 68 | } 69 | this.isVisible = shouldBeVisible; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": [ 5 | "es6" 6 | ], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "../out", 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "removeComments": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "target": "es6", 18 | "types": [ 19 | "node", 20 | "vscode", 21 | "./utils/@types/node" 22 | ] 23 | } 24 | } -------------------------------------------------------------------------------- /src/utils/@types/node/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains types for additional methods used by Git Graph, that were added between the 3 | * version of @types/node (8.10.62) and the version of Node.js (10.11.0) used by the minimum 4 | * version of Visual Studio Code that Git Graph supports. Unfortunately @types/node 10.11.0 can't 5 | * be used, as it is not compatible with Typescript >= 3.7.2. Once the minimum version of Visual 6 | * Studio Code that Git Graph supports is increased, such that it's version of @types/node is 7 | * compatible with Typescript >= 3.7.2, @types/node will be updated, and this file will be removed. 8 | */ 9 | declare module 'fs' { 10 | namespace realpath { 11 | function native(path: PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void): void; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/bufferedQueue.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, toDisposable } from './disposable'; 2 | 3 | /** 4 | * Represents a BufferedQueue, which is queue that buffers items for a short period of time before processing them. 5 | */ 6 | export class BufferedQueue extends Disposable { 7 | private readonly queue: T[] = []; 8 | private timeout: NodeJS.Timer | null = null; 9 | private processing: boolean = false; 10 | 11 | private readonly bufferDuration: number; 12 | private onItem: (item: T) => Promise; 13 | private onChanges: () => void; 14 | 15 | /** 16 | * Constructs a BufferedQueue instance. 17 | * @param onItem A callback invoked to process an item in the queue. 18 | * @param onChanges A callback invoked when a change was indicated by onItem. 19 | * @param bufferDuration The number of milliseconds to buffer items in the queue. 20 | * @returns The BufferedQueue instance. 21 | */ 22 | constructor(onItem: (item: T) => Promise, onChanges: () => void, bufferDuration: number = 1000) { 23 | super(); 24 | this.bufferDuration = bufferDuration; 25 | this.onItem = onItem; 26 | this.onChanges = onChanges; 27 | 28 | this.registerDisposable(toDisposable(() => { 29 | if (this.timeout !== null) { 30 | clearTimeout(this.timeout); 31 | this.timeout = null; 32 | } 33 | })); 34 | } 35 | 36 | /** 37 | * Enqueue an item if it doesn't already exist in the queue. 38 | * @param item The item to enqueue. 39 | */ 40 | public enqueue(item: T) { 41 | const itemIndex = this.queue.indexOf(item); 42 | if (itemIndex > -1) { 43 | this.queue.splice(itemIndex, 1); 44 | } 45 | this.queue.push(item); 46 | 47 | if (!this.processing) { 48 | if (this.timeout !== null) { 49 | clearTimeout(this.timeout); 50 | } 51 | this.timeout = setTimeout(() => { 52 | this.timeout = null; 53 | this.run(); 54 | }, this.bufferDuration); 55 | } 56 | } 57 | 58 | /** 59 | * Process all of the items that are currently queued, and call the onChanges callback if any of the items resulted in a change 60 | */ 61 | private async run() { 62 | this.processing = true; 63 | let item, changes = false; 64 | while (item = this.queue.shift()) { 65 | if (await this.onItem(item)) { 66 | changes = true; 67 | } 68 | } 69 | this.processing = false; 70 | if (changes) this.onChanges(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/disposable.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export class Disposable implements vscode.Disposable { 4 | private disposables: vscode.Disposable[] = []; 5 | private disposed: boolean = false; 6 | 7 | /** 8 | * Disposes the resources used by the subclass. 9 | */ 10 | public dispose() { 11 | this.disposed = true; 12 | this.disposables.forEach((disposable) => { 13 | try { 14 | disposable.dispose(); 15 | } catch (_) { } 16 | }); 17 | this.disposables = []; 18 | } 19 | 20 | /** 21 | * Register a single disposable. 22 | */ 23 | protected registerDisposable(disposable: vscode.Disposable) { 24 | this.disposables.push(disposable); 25 | } 26 | 27 | /** 28 | * Register multiple disposables. 29 | */ 30 | protected registerDisposables(...disposables: vscode.Disposable[]) { 31 | this.disposables.push(...disposables); 32 | } 33 | 34 | /** 35 | * Is the Disposable disposed. 36 | * @returns `TRUE` => Disposable has been disposed, `FALSE` => Disposable hasn't been disposed. 37 | */ 38 | protected isDisposed() { 39 | return this.disposed; 40 | } 41 | } 42 | 43 | export function toDisposable(fn: () => void): vscode.Disposable { 44 | return { 45 | dispose: fn 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/event.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | /** 4 | * A function used by a subscriber to process an event emitted from an EventEmitter. 5 | */ 6 | type EventListener = (event: T) => void; 7 | 8 | /** 9 | * A function used to subscribe to an EventEmitter. 10 | */ 11 | export type Event = (listener: EventListener) => vscode.Disposable; 12 | 13 | /** 14 | * Represents an EventEmitter, which is used to automate the delivery of events to subscribers. This applies the observer pattern. 15 | */ 16 | export class EventEmitter implements vscode.Disposable { 17 | private readonly event: Event; 18 | private listeners: EventListener[] = []; 19 | 20 | /** 21 | * Creates an EventEmitter. 22 | */ 23 | constructor() { 24 | this.event = (listener: EventListener) => { 25 | this.listeners.push(listener); 26 | return { 27 | dispose: () => { 28 | const removeListener = this.listeners.indexOf(listener); 29 | if (removeListener > -1) { 30 | this.listeners.splice(removeListener, 1); 31 | } 32 | } 33 | }; 34 | }; 35 | } 36 | 37 | /** 38 | * Disposes the resources used by the EventEmitter. 39 | */ 40 | public dispose() { 41 | this.listeners = []; 42 | } 43 | 44 | /** 45 | * Emit an event to all subscribers of the EventEmitter. 46 | * @param event The event to emit. 47 | */ 48 | public emit(event: T) { 49 | this.listeners.forEach((listener) => { 50 | try { 51 | listener(event); 52 | } catch (_) { } 53 | }); 54 | } 55 | 56 | /** 57 | * Does the EventEmitter have any registered listeners. 58 | * @returns TRUE => There are one or more registered subscribers, FALSE => There are no registered subscribers 59 | */ 60 | public hasSubscribers() { 61 | return this.listeners.length > 0; 62 | } 63 | 64 | /** 65 | * Get the Event of this EventEmitter, which can be used to subscribe to the emitted events. 66 | * @returns The Event. 67 | */ 68 | get subscribe() { 69 | return this.event; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/bufferedQueue.test.ts: -------------------------------------------------------------------------------- 1 | import { BufferedQueue } from '../src/utils/bufferedQueue'; 2 | 3 | import { waitForExpect } from './helpers/expectations'; 4 | 5 | describe('BufferedQueue', () => { 6 | beforeEach(() => { 7 | jest.useFakeTimers(); 8 | }); 9 | 10 | it('Should add items to the queue, and then process them once the buffer has expired', async () => { 11 | // Setup 12 | const onItem = jest.fn(() => Promise.resolve(true)), onChanges = jest.fn(() => { }); 13 | const queue = new BufferedQueue(onItem, onChanges); 14 | 15 | // Run 16 | queue.enqueue('a'); 17 | queue.enqueue('b'); 18 | queue.enqueue('c'); 19 | 20 | // Assert 21 | expect(queue['queue']).toStrictEqual(['a', 'b', 'c']); 22 | expect(queue['processing']).toBe(false); 23 | expect(clearTimeout).toHaveBeenCalledTimes(2); 24 | 25 | // Run 26 | jest.runOnlyPendingTimers(); 27 | jest.useRealTimers(); 28 | 29 | // Assert 30 | await waitForExpect(() => expect(queue['processing']).toBe(false)); 31 | expect(onItem).toHaveBeenCalledTimes(3); 32 | expect(onItem).toHaveBeenCalledWith('a'); 33 | expect(onItem).toHaveBeenCalledWith('b'); 34 | expect(onItem).toHaveBeenCalledWith('c'); 35 | expect(onChanges).toHaveBeenCalledTimes(1); 36 | 37 | // Run 38 | expect(queue['timeout']).toBe(null); 39 | queue.dispose(); 40 | expect(queue['timeout']).toBe(null); 41 | }); 42 | 43 | it('Shouldn\'t add duplicate items to the queue', async () => { 44 | // Setup 45 | const onItem = jest.fn(() => Promise.resolve(true)), onChanges = jest.fn(() => { }); 46 | const queue = new BufferedQueue(onItem, onChanges); 47 | 48 | // Run 49 | queue.enqueue('a'); 50 | queue.enqueue('b'); 51 | queue.enqueue('c'); 52 | queue.enqueue('a'); 53 | 54 | // Assert 55 | expect(queue['queue']).toStrictEqual(['b', 'c', 'a']); 56 | expect(queue['processing']).toBe(false); 57 | 58 | // Run 59 | jest.runOnlyPendingTimers(); 60 | jest.useRealTimers(); 61 | 62 | // Assert 63 | await waitForExpect(() => expect(queue['processing']).toBe(false)); 64 | expect(onItem).toHaveBeenCalledTimes(3); 65 | expect(onItem).toHaveBeenCalledWith('b'); 66 | expect(onItem).toHaveBeenCalledWith('c'); 67 | expect(onItem).toHaveBeenCalledWith('a'); 68 | expect(onChanges).toHaveBeenCalledTimes(1); 69 | }); 70 | 71 | it('Shouldn\'t call onChanges if not items resulted in a change', async () => { 72 | // Setup 73 | const onItem = jest.fn(() => Promise.resolve(false)), onChanges = jest.fn(() => { }); 74 | const queue = new BufferedQueue(onItem, onChanges); 75 | 76 | // Run 77 | queue.enqueue('a'); 78 | queue.enqueue('b'); 79 | queue.enqueue('c'); 80 | 81 | // Assert 82 | expect(queue['queue']).toStrictEqual(['a', 'b', 'c']); 83 | expect(queue['processing']).toBe(false); 84 | 85 | // Run 86 | jest.runOnlyPendingTimers(); 87 | jest.useRealTimers(); 88 | 89 | // Assert 90 | await waitForExpect(() => expect(queue['processing']).toBe(false)); 91 | expect(onItem).toHaveBeenCalledTimes(3); 92 | expect(onItem).toHaveBeenCalledWith('a'); 93 | expect(onItem).toHaveBeenCalledWith('b'); 94 | expect(onItem).toHaveBeenCalledWith('c'); 95 | expect(onChanges).toHaveBeenCalledTimes(0); 96 | }); 97 | 98 | it('Shouldn\'t trigger a new timeout if the queue is already processing events', () => { 99 | // Setup 100 | const onItem = jest.fn(() => Promise.resolve(true)), onChanges = jest.fn(() => { }); 101 | const queue = new BufferedQueue(onItem, onChanges); 102 | queue['processing'] = true; 103 | 104 | // Run 105 | queue.enqueue('a'); 106 | queue.enqueue('b'); 107 | queue.enqueue('c'); 108 | 109 | // Assert 110 | expect(queue['queue']).toStrictEqual(['a', 'b', 'c']); 111 | expect(queue['timeout']).toBe(null); 112 | }); 113 | 114 | it('Should clear the timeout when disposed', async () => { 115 | // Setup 116 | const onItem = jest.fn(() => Promise.resolve(true)), onChanges = jest.fn(() => { }); 117 | const queue = new BufferedQueue(onItem, onChanges); 118 | 119 | // Run 120 | queue.enqueue('a'); 121 | queue.enqueue('b'); 122 | queue.enqueue('c'); 123 | 124 | // Assert 125 | expect(queue['queue']).toStrictEqual(['a', 'b', 'c']); 126 | expect(queue['processing']).toBe(false); 127 | expect(jest.getTimerCount()).toBe(1); 128 | 129 | // Run 130 | queue.dispose(); 131 | 132 | // Assert 133 | expect(jest.getTimerCount()).toBe(0); 134 | expect(queue['timeout']).toBe(null); 135 | }); 136 | 137 | describe('bufferDuration', () => { 138 | it('Should use the default buffer duration of 1000ms', async () => { 139 | // Setup 140 | const onItem = jest.fn(() => Promise.resolve(true)), onChanges = jest.fn(() => { }); 141 | const queue = new BufferedQueue(onItem, onChanges); 142 | 143 | // Run 144 | queue.enqueue('a'); 145 | 146 | // Assert 147 | expect(setTimeout).toHaveBeenCalledWith(expect.anything(), 1000); 148 | 149 | // Run 150 | jest.runOnlyPendingTimers(); 151 | jest.useRealTimers(); 152 | 153 | // Assert 154 | await waitForExpect(() => expect(queue['processing']).toBe(false)); 155 | expect(onItem).toHaveBeenCalledTimes(1); 156 | expect(onItem).toHaveBeenCalledWith('a'); 157 | expect(onChanges).toHaveBeenCalledTimes(1); 158 | }); 159 | 160 | it('Should use the specified buffer duration', async () => { 161 | // Setup 162 | const onItem = jest.fn(() => Promise.resolve(true)), onChanges = jest.fn(() => { }); 163 | const queue = new BufferedQueue(onItem, onChanges, 128); 164 | 165 | // Run 166 | queue.enqueue('a'); 167 | 168 | // Assert 169 | expect(setTimeout).toHaveBeenCalledWith(expect.anything(), 128); 170 | 171 | // Run 172 | jest.runOnlyPendingTimers(); 173 | jest.useRealTimers(); 174 | 175 | // Assert 176 | await waitForExpect(() => expect(queue['processing']).toBe(false)); 177 | expect(onItem).toHaveBeenCalledTimes(1); 178 | expect(onItem).toHaveBeenCalledWith('a'); 179 | expect(onChanges).toHaveBeenCalledTimes(1); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /tests/diffDocProvider.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from './mocks/vscode'; 2 | jest.mock('vscode', () => vscode, { virtual: true }); 3 | jest.mock('../src/dataSource'); 4 | jest.mock('../src/logger'); 5 | 6 | import * as path from 'path'; 7 | import { ConfigurationChangeEvent } from 'vscode'; 8 | import { DataSource } from '../src/dataSource'; 9 | import { DiffDocProvider, DiffSide, decodeDiffDocUri, encodeDiffDocUri } from '../src/diffDocProvider'; 10 | import { Logger } from '../src/logger'; 11 | import { GitFileStatus } from '../src/types'; 12 | import { GitExecutable, UNCOMMITTED } from '../src/utils'; 13 | import { EventEmitter } from '../src/utils/event'; 14 | 15 | let onDidChangeConfiguration: EventEmitter; 16 | let onDidChangeGitExecutable: EventEmitter; 17 | let logger: Logger; 18 | let dataSource: DataSource; 19 | 20 | beforeAll(() => { 21 | onDidChangeConfiguration = new EventEmitter(); 22 | onDidChangeGitExecutable = new EventEmitter(); 23 | logger = new Logger(); 24 | dataSource = new DataSource(null, onDidChangeConfiguration.subscribe, onDidChangeGitExecutable.subscribe, logger); 25 | }); 26 | 27 | afterAll(() => { 28 | dataSource.dispose(); 29 | logger.dispose(); 30 | onDidChangeConfiguration.dispose(); 31 | onDidChangeGitExecutable.dispose(); 32 | }); 33 | 34 | describe('DiffDocProvider', () => { 35 | it('Should construct a DiffDocProvider, provide a document, and be disposed', async () => { 36 | // Setup 37 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New); 38 | jest.spyOn(dataSource, 'getCommitFile').mockResolvedValueOnce('file-contents'); 39 | 40 | // Run 41 | const diffDocProvider = new DiffDocProvider(dataSource); 42 | const disposables = diffDocProvider['disposables']; 43 | const docContents = await diffDocProvider.provideTextDocumentContent(uri); 44 | 45 | // Assert 46 | expect(docContents).toBe('file-contents'); 47 | expect(diffDocProvider['docs'].size).toBe(1); 48 | expect(diffDocProvider.onDidChange).toBeTruthy(); 49 | 50 | // Run 51 | diffDocProvider.dispose(); 52 | 53 | // Assert 54 | expect(disposables[0].dispose).toHaveBeenCalled(); 55 | expect(disposables[1].dispose).toHaveBeenCalled(); 56 | expect(diffDocProvider['docs'].size).toBe(0); 57 | }); 58 | 59 | it('Should remove a cached document once it is closed', async () => { 60 | // Setup 61 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New); 62 | jest.spyOn(dataSource, 'getCommitFile').mockResolvedValueOnce('file-contents'); 63 | 64 | let closeTextDocument: (doc: { uri: vscode.Uri }) => void; 65 | vscode.workspace.onDidCloseTextDocument.mockImplementationOnce((callback: (_: { uri: vscode.Uri }) => void) => { 66 | closeTextDocument = callback; 67 | return { dispose: jest.fn() }; 68 | }); 69 | 70 | // Run 71 | const diffDocProvider = new DiffDocProvider(dataSource); 72 | const docContents = await diffDocProvider.provideTextDocumentContent(uri); 73 | 74 | // Assert 75 | expect(docContents).toBe('file-contents'); 76 | expect(diffDocProvider['docs'].size).toBe(1); 77 | 78 | // Run 79 | closeTextDocument!({ uri: uri }); 80 | 81 | // Assert 82 | expect(diffDocProvider['docs'].size).toBe(0); 83 | 84 | // Teardown 85 | diffDocProvider.dispose(); 86 | }); 87 | 88 | it('Should reuse a cached document if it exists', async () => { 89 | // Setup 90 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New); 91 | const spyOnGetCommitFile = jest.spyOn(dataSource, 'getCommitFile'); 92 | spyOnGetCommitFile.mockResolvedValueOnce('file-contents'); 93 | 94 | // Run 95 | const diffDocProvider = new DiffDocProvider(dataSource); 96 | const docContents1 = await diffDocProvider.provideTextDocumentContent(uri); 97 | const docContents2 = await diffDocProvider.provideTextDocumentContent(uri); 98 | 99 | // Assert 100 | expect(docContents1).toBe('file-contents'); 101 | expect(docContents2).toBe('file-contents'); 102 | expect(spyOnGetCommitFile).toHaveBeenCalledTimes(1); 103 | 104 | // Teardown 105 | diffDocProvider.dispose(); 106 | }); 107 | 108 | it('Should return an empty document if requested', async () => { 109 | // Setup 110 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', UNCOMMITTED, GitFileStatus.Deleted, DiffSide.New); 111 | 112 | // Run 113 | const diffDocProvider = new DiffDocProvider(dataSource); 114 | const docContents = await diffDocProvider.provideTextDocumentContent(uri); 115 | 116 | // Assert 117 | expect(docContents).toBe(''); 118 | 119 | // Teardown 120 | diffDocProvider.dispose(); 121 | }); 122 | 123 | it('Should display an error message if an error occurred when fetching the file contents from the DataSource, and return an empty document', async () => { 124 | // Setup 125 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New); 126 | jest.spyOn(dataSource, 'getCommitFile').mockRejectedValueOnce('error-message'); 127 | vscode.window.showErrorMessage.mockResolvedValue(null); 128 | 129 | // Run 130 | const diffDocProvider = new DiffDocProvider(dataSource); 131 | const docContents = await diffDocProvider.provideTextDocumentContent(uri); 132 | 133 | // Assert 134 | expect(docContents).toBe(''); 135 | expect(vscode.window.showErrorMessage).toBeCalledWith('Unable to retrieve file: error-message'); 136 | 137 | // Teardown 138 | diffDocProvider.dispose(); 139 | }); 140 | }); 141 | 142 | describe('encodeDiffDocUri', () => { 143 | it('Should return a file URI if requested on uncommitted changes and it is not deleted', () => { 144 | // Run 145 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', UNCOMMITTED, GitFileStatus.Added, DiffSide.New); 146 | 147 | // Assert 148 | expect(uri.scheme).toBe('file'); 149 | expect(uri.fsPath).toBe(path.join('/repo', 'path/to/file.txt')); 150 | }); 151 | 152 | it('Should return an empty file URI if requested on a file displayed on the old side of the diff, and it is added', () => { 153 | // Run 154 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Added, DiffSide.Old); 155 | 156 | // Assert 157 | expect(uri.scheme).toBe('git-graph'); 158 | expect(uri.fsPath).toBe('file'); 159 | expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjpmYWxzZX0='); 160 | }); 161 | 162 | it('Should return an empty file URI if requested on a file displayed on the new side of the diff, and it is deleted', () => { 163 | // Run 164 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Deleted, DiffSide.New); 165 | 166 | // Assert 167 | expect(uri.scheme).toBe('git-graph'); 168 | expect(uri.fsPath).toBe('file'); 169 | expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjpmYWxzZX0='); 170 | }); 171 | 172 | it('Should return a git-graph URI with the provided file extension', () => { 173 | // Run 174 | const uri = encodeDiffDocUri('/repo', 'path/to/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New); 175 | 176 | // Assert 177 | expect(uri.scheme).toBe('git-graph'); 178 | expect(uri.fsPath).toBe('file.txt'); 179 | expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjp0cnVlfQ=='); 180 | }); 181 | 182 | it('Should return a git-graph URI with no file extension when it is not provided', () => { 183 | // Run 184 | const uri = encodeDiffDocUri('/repo', 'path/to/file', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New); 185 | 186 | // Assert 187 | expect(uri.scheme).toBe('git-graph'); 188 | expect(uri.fsPath).toBe('file'); 189 | expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZSIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIiLCJyZXBvIjoiL3JlcG8iLCJleGlzdHMiOnRydWV9'); 190 | }); 191 | }); 192 | 193 | describe('decodeDiffDocUri', () => { 194 | it('Should return the parsed DiffDocUriData from the URI', () => { 195 | // Run 196 | const value = decodeDiffDocUri(vscode.Uri.file('file.txt').with({ 197 | scheme: 'git-graph', 198 | query: 'eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjp0cnVlfQ==' 199 | })); 200 | 201 | // Assert 202 | expect(value).toStrictEqual({ 203 | filePath: 'path/to/file.txt', 204 | commit: '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 205 | repo: '/repo', 206 | exists: true 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /tests/disposable.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Disposable, toDisposable } from '../src/utils/disposable'; 3 | 4 | class DisposableTest extends Disposable { 5 | constructor() { 6 | super(); 7 | } 8 | 9 | public registerDisposable(disposable: vscode.Disposable) { 10 | super.registerDisposable(disposable); 11 | } 12 | 13 | public registerDisposables(...disposables: vscode.Disposable[]) { 14 | super.registerDisposables(...disposables); 15 | } 16 | 17 | public isDisposed() { 18 | return super.isDisposed(); 19 | } 20 | } 21 | 22 | describe('Disposable', () => { 23 | it('Should register a disposable', () => { 24 | // Setup 25 | const disposableTest = new DisposableTest(); 26 | const disposable = { dispose: jest.fn() }; 27 | 28 | // Run 29 | disposableTest.registerDisposable(disposable); 30 | 31 | // Assert 32 | expect(disposableTest.isDisposed()).toBe(false); 33 | expect(disposableTest['disposables']).toStrictEqual([disposable]); 34 | }); 35 | 36 | it('Should register multiple disposables', () => { 37 | // Setup 38 | const disposableTest = new DisposableTest(); 39 | const disposable1 = { dispose: jest.fn() }; 40 | const disposable2 = { dispose: jest.fn() }; 41 | 42 | // Run 43 | disposableTest.registerDisposables(disposable1, disposable2); 44 | 45 | // Assert 46 | expect(disposableTest.isDisposed()).toBe(false); 47 | expect(disposableTest['disposables']).toStrictEqual([disposable1, disposable2]); 48 | }); 49 | 50 | it('Should dispose all registered disposables', () => { 51 | // Setup 52 | const disposableTest = new DisposableTest(); 53 | const disposable1 = { dispose: jest.fn() }; 54 | const disposable2 = { dispose: jest.fn() }; 55 | disposableTest.registerDisposables(disposable1, disposable2); 56 | 57 | // Run 58 | disposableTest.dispose(); 59 | 60 | // Assert 61 | expect(disposableTest.isDisposed()).toBe(true); 62 | expect(disposableTest['disposables']).toStrictEqual([]); 63 | expect(disposable1.dispose).toHaveBeenCalled(); 64 | expect(disposable2.dispose).toHaveBeenCalled(); 65 | }); 66 | 67 | it('Should dispose all registered disposables independently, catching any exceptions', () => { 68 | // Setup 69 | const disposableTest = new DisposableTest(); 70 | const disposable1 = { dispose: jest.fn() }; 71 | const disposable2 = { 72 | dispose: jest.fn(() => { 73 | throw new Error(); 74 | }) 75 | }; 76 | const disposable3 = { dispose: jest.fn() }; 77 | disposableTest.registerDisposables(disposable1, disposable2, disposable3); 78 | 79 | // Run 80 | disposableTest.dispose(); 81 | 82 | // Assert 83 | expect(disposableTest.isDisposed()).toBe(true); 84 | expect(disposableTest['disposables']).toStrictEqual([]); 85 | expect(disposable1.dispose).toHaveBeenCalled(); 86 | expect(disposable2.dispose).toHaveBeenCalled(); 87 | expect(disposable3.dispose).toHaveBeenCalled(); 88 | }); 89 | }); 90 | 91 | describe('toDisposable', () => { 92 | it('Should wrap a function with a disposable', () => { 93 | // Setup 94 | const fn = () => { }; 95 | 96 | // Run 97 | const result = toDisposable(fn); 98 | 99 | // Assert 100 | expect(result).toStrictEqual({ dispose: fn }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /tests/event.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '../src/utils/event'; 2 | 3 | describe('Event Emitter', () => { 4 | it('Registers and disposes subscribers', () => { 5 | // Setup 6 | const emitter = new EventEmitter(); 7 | const mockSubscriber1 = jest.fn((x: number) => x); 8 | const mockSubscriber2 = jest.fn((x: number) => x); 9 | 10 | // Run 11 | emitter.subscribe(mockSubscriber1); 12 | emitter.subscribe(mockSubscriber2); 13 | 14 | // Assert 15 | expect(emitter.hasSubscribers()).toBe(true); 16 | expect(emitter['listeners'].length).toBe(2); 17 | 18 | // Run 19 | emitter.dispose(); 20 | 21 | // Assert 22 | expect(emitter.hasSubscribers()).toBe(false); 23 | }); 24 | 25 | it('Disposes a specific subscriber', () => { 26 | // Setup 27 | const emitter = new EventEmitter(); 28 | const mockSubscriber1 = jest.fn((x: number) => x); 29 | const mockSubscriber2 = jest.fn((x: number) => x); 30 | 31 | // Run 32 | const disposable = emitter.subscribe(mockSubscriber1); 33 | emitter.subscribe(mockSubscriber2); 34 | disposable.dispose(); 35 | 36 | // Assert 37 | expect(emitter.hasSubscribers()).toBe(true); 38 | expect(emitter['listeners'].length).toBe(1); 39 | expect(emitter['listeners'][0]).toBe(mockSubscriber2); 40 | 41 | // Teardown 42 | emitter.dispose(); 43 | }); 44 | 45 | it('Handles duplicate disposes of a specific subscriber', () => { 46 | // Setup 47 | const emitter = new EventEmitter(); 48 | const mockSubscriber = jest.fn((x: number) => x); 49 | 50 | // Run 51 | const disposable = emitter.subscribe(mockSubscriber); 52 | disposable.dispose(); 53 | disposable.dispose(); 54 | 55 | // Assert 56 | expect(emitter.hasSubscribers()).toBe(false); 57 | 58 | // Teardown 59 | emitter.dispose(); 60 | }); 61 | 62 | it('Calls subscribers when an event is emitted', () => { 63 | // Setup 64 | const emitter = new EventEmitter(); 65 | const mockSubscriber1 = jest.fn((x: number) => x); 66 | const mockSubscriber2 = jest.fn((x: number) => x); 67 | 68 | // Run 69 | emitter.subscribe(mockSubscriber1); 70 | emitter.subscribe(mockSubscriber2); 71 | emitter.emit(5); 72 | 73 | // Assert 74 | expect(mockSubscriber1).toHaveBeenCalledWith(5); 75 | expect(mockSubscriber2).toHaveBeenCalledWith(5); 76 | 77 | // Teardown 78 | emitter.dispose(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /tests/helpers/expectations.ts: -------------------------------------------------------------------------------- 1 | import { mocks } from '../mocks/vscode'; 2 | 3 | export function expectRenamedExtensionSettingToHaveBeenCalled(newSection: string, oldSection: string) { 4 | expect(mocks.workspaceConfiguration.inspect).toBeCalledWith(newSection); 5 | expect(mocks.workspaceConfiguration.inspect).toBeCalledWith(oldSection); 6 | } 7 | 8 | export function waitForExpect(expect: () => void) { 9 | return new Promise((resolve, reject) => { 10 | let attempts = 0; 11 | const testInterval = setInterval(() => { 12 | try { 13 | attempts++; 14 | expect(); 15 | resolve(); 16 | } catch (e) { 17 | if (attempts === 100) { 18 | clearInterval(testInterval); 19 | reject(e); 20 | } 21 | } 22 | }, 20); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /tests/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_REPO_STATE } from '../../src/extensionState'; 2 | import { GitRepoState } from '../../src/types'; 3 | 4 | export function mockRepoState(custom: Partial = {}): GitRepoState { 5 | return Object.assign({}, DEFAULT_REPO_STATE, custom); 6 | } 7 | -------------------------------------------------------------------------------- /tests/logger.test.ts: -------------------------------------------------------------------------------- 1 | import * as date from './mocks/date'; 2 | import * as vscode from './mocks/vscode'; 3 | jest.mock('vscode', () => vscode, { virtual: true }); 4 | 5 | import { Logger } from '../src/logger'; 6 | 7 | const outputChannel = vscode.mocks.outputChannel; 8 | 9 | describe('Logger', () => { 10 | let logger: Logger; 11 | beforeEach(() => { 12 | logger = new Logger(); 13 | }); 14 | afterEach(() => { 15 | logger.dispose(); 16 | }); 17 | 18 | it('Should create and dispose an output channel', () => { 19 | // Run 20 | logger.dispose(); 21 | 22 | // Assert 23 | expect(vscode.window.createOutputChannel).toHaveBeenCalledWith('Git Graph'); 24 | expect(outputChannel.dispose).toBeCalledTimes(1); 25 | }); 26 | 27 | it('Should log a message to the Output Channel', () => { 28 | // Run 29 | logger.log('Test'); 30 | 31 | // Assert 32 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.000] Test'); 33 | }); 34 | 35 | describe('Should log a command to the Output Channel', () => { 36 | it('Standard arguments are unchanged', () => { 37 | // Run 38 | logger.logCmd('git', ['cmd', '-f', '--arg1']); 39 | 40 | // Assert 41 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.000] > git cmd -f --arg1'); 42 | }); 43 | 44 | it('Format arguments are abbreviated', () => { 45 | // Run 46 | logger.logCmd('git', ['cmd', '--format="format-string"']); 47 | 48 | // Assert 49 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.000] > git cmd --format=...'); 50 | }); 51 | 52 | it('Arguments with spaces are surrounded with double quotes', () => { 53 | // Run 54 | logger.logCmd('git', ['cmd', 'argument with spaces']); 55 | 56 | // Assert 57 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.000] > git cmd "argument with spaces"'); 58 | }); 59 | 60 | it('Arguments with spaces are surrounded with double quotes, and any internal double quotes are escaped', () => { 61 | // Run 62 | logger.logCmd('git', ['cmd', 'argument with "double quotes" and spaces']); 63 | 64 | // Assert 65 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.000] > git cmd "argument with \\"double quotes\\" and spaces"'); 66 | }); 67 | 68 | it('Empty string arguments are shown as two double quotes', () => { 69 | // Run 70 | logger.logCmd('git', ['cmd', '']); 71 | 72 | // Assert 73 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.000] > git cmd ""'); 74 | }); 75 | 76 | it('Should transform all arguments of a command, when logging it to the Output Channel', () => { 77 | // Setup 78 | date.setCurrentTime(1587559258.1); 79 | 80 | // Run 81 | logger.logCmd('git', ['cmd', '--arg1', '--format="format-string"', '', 'argument with spaces', 'argument with "double quotes" and spaces']); 82 | 83 | // Assert 84 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.100] > git cmd --arg1 --format=... "" "argument with spaces" "argument with \\"double quotes\\" and spaces"'); 85 | }); 86 | }); 87 | 88 | it('Should log an error to the Output Channel', () => { 89 | // Setup 90 | date.setCurrentTime(1587559258.01); 91 | 92 | // Run 93 | logger.logError('Test'); 94 | 95 | // Assert 96 | expect(outputChannel.appendLine).toHaveBeenCalledWith('[2020-04-22 12:40:58.010] ERROR: Test'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /tests/mocks/date.ts: -------------------------------------------------------------------------------- 1 | const RealDate = Date; 2 | const InitialNow = 1587559258; 3 | 4 | export let now = InitialNow; 5 | 6 | class MockDate extends RealDate { 7 | constructor(now: number) { 8 | super(now); 9 | } 10 | 11 | public getFullYear() { 12 | return this.getUTCFullYear(); 13 | } 14 | 15 | public getMonth() { 16 | return this.getUTCMonth(); 17 | } 18 | 19 | public getDate() { 20 | return this.getUTCDate(); 21 | } 22 | 23 | public getHours() { 24 | return this.getUTCHours(); 25 | } 26 | 27 | public getMinutes() { 28 | return this.getUTCMinutes(); 29 | } 30 | 31 | public getSeconds() { 32 | return this.getUTCSeconds(); 33 | } 34 | 35 | public getMilliseconds() { 36 | return this.getUTCMilliseconds(); 37 | } 38 | } 39 | 40 | beforeEach(() => { 41 | // Reset now to its initial value 42 | now = InitialNow; 43 | 44 | // Override Date 45 | Date = class extends RealDate { 46 | constructor() { 47 | super(); 48 | return new MockDate(now * 1000); 49 | } 50 | } as DateConstructor; 51 | }); 52 | 53 | afterEach(() => { 54 | Date = RealDate; 55 | }); 56 | 57 | export function setCurrentTime(newNow: number) { 58 | now = newNow; 59 | } 60 | -------------------------------------------------------------------------------- /tests/mocks/spawn.ts: -------------------------------------------------------------------------------- 1 | type OnCallbacks = { [event: string]: (...args: any[]) => void }; 2 | 3 | export function mockSpyOnSpawn(spyOnSpawn: jest.SpyInstance, callback: (onCallbacks: OnCallbacks, stderrOnCallbacks: OnCallbacks, stdoutOnCallbacks: OnCallbacks) => void) { 4 | spyOnSpawn.mockImplementationOnce(() => { 5 | let onCallbacks: OnCallbacks = {}, stderrOnCallbacks: OnCallbacks = {}, stdoutOnCallbacks: OnCallbacks = {}; 6 | setTimeout(() => { 7 | callback(onCallbacks, stderrOnCallbacks, stdoutOnCallbacks); 8 | }, 1); 9 | return { 10 | on: (event: string, callback: (...args: any[]) => void) => onCallbacks[event] = callback, 11 | stderr: { 12 | on: (event: string, callback: (...args: any[]) => void) => stderrOnCallbacks[event] = callback 13 | }, 14 | stdout: { 15 | on: (event: string, callback: (...args: any[]) => void) => stdoutOnCallbacks[event] = callback 16 | } 17 | }; 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /tests/mocks/vscode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { RequestMessage, ResponseMessage, Writeable } from '../../src/types'; 3 | 4 | 5 | /* Mocks */ 6 | 7 | const mockedExtensionSettingValues: { [section: string]: any } = {}; 8 | const mockedCommands: { [command: string]: (...args: any[]) => any } = {}; 9 | 10 | interface WebviewPanelMocks { 11 | messages: ResponseMessage[], 12 | panel: { 13 | onDidChangeViewState: (e: vscode.WebviewPanelOnDidChangeViewStateEvent) => any, 14 | onDidDispose: (e: void) => any, 15 | setVisibility: (visible: boolean) => void, 16 | webview: { 17 | onDidReceiveMessage: (msg: RequestMessage) => void 18 | } 19 | } 20 | } 21 | 22 | let mockedWebviews: { panel: vscode.WebviewPanel, mocks: WebviewPanelMocks }[] = []; 23 | 24 | export const mocks = { 25 | extensionContext: { 26 | asAbsolutePath: jest.fn(), 27 | extensionPath: '/path/to/extension', 28 | globalState: { 29 | get: jest.fn(), 30 | update: jest.fn() 31 | }, 32 | globalStoragePath: '/path/to/globalStorage', 33 | logPath: '/path/to/logs', 34 | storagePath: '/path/to/storage', 35 | subscriptions: [], 36 | workspaceState: { 37 | get: jest.fn(), 38 | update: jest.fn() 39 | } 40 | }, 41 | outputChannel: { 42 | appendLine: jest.fn(), 43 | dispose: jest.fn() 44 | }, 45 | statusBarItem: { 46 | text: '', 47 | tooltip: '', 48 | command: '', 49 | show: jest.fn(), 50 | hide: jest.fn(), 51 | dispose: jest.fn() 52 | }, 53 | terminal: { 54 | sendText: jest.fn(), 55 | show: jest.fn() 56 | }, 57 | workspaceConfiguration: { 58 | get: jest.fn((section: string, defaultValue?: any) => { 59 | return typeof mockedExtensionSettingValues[section] !== 'undefined' 60 | ? mockedExtensionSettingValues[section] 61 | : defaultValue; 62 | }), 63 | inspect: jest.fn((section: string) => ({ 64 | workspaceValue: mockedExtensionSettingValues[section], 65 | globalValue: mockedExtensionSettingValues[section] 66 | })) 67 | } 68 | }; 69 | 70 | 71 | /* Visual Studio Code API Mocks */ 72 | 73 | export const commands = { 74 | executeCommand: jest.fn((command: string, ...rest: any[]) => mockedCommands[command](...rest)), 75 | registerCommand: jest.fn((command: string, callback: (...args: any[]) => any) => { 76 | mockedCommands[command] = callback; 77 | return { 78 | dispose: () => { 79 | delete mockedCommands[command]; 80 | } 81 | }; 82 | }) 83 | }; 84 | 85 | export const env = { 86 | clipboard: { 87 | writeText: jest.fn() 88 | }, 89 | openExternal: jest.fn() 90 | }; 91 | 92 | export const EventEmitter = jest.fn(() => ({ 93 | dispose: jest.fn(), 94 | event: jest.fn() 95 | })); 96 | 97 | export class Uri implements vscode.Uri { 98 | public readonly scheme: string; 99 | public readonly authority: string; 100 | public readonly path: string; 101 | public readonly query: string; 102 | public readonly fragment: string; 103 | 104 | protected constructor(scheme: string, authority?: string, path?: string, query?: string, fragment?: string) { 105 | this.scheme = scheme; 106 | this.authority = authority || ''; 107 | this.path = path || ''; 108 | this.query = query || ''; 109 | this.fragment = fragment || ''; 110 | } 111 | 112 | get fsPath() { 113 | return this.path; 114 | } 115 | 116 | public with(change: { scheme?: string | undefined; authority?: string | undefined; path?: string | undefined; query?: string | undefined; fragment?: string | undefined; }): vscode.Uri { 117 | return new Uri(change.scheme || this.scheme, change.authority || this.authority, change.path || this.path, change.query || this.query, change.fragment || this.fragment); 118 | } 119 | 120 | public toString() { 121 | return this.scheme + '://' + this.path + (this.query ? '?' + this.query : '') + (this.fragment ? '#' + this.fragment : ''); 122 | } 123 | 124 | public toJSON() { 125 | return this; 126 | } 127 | 128 | public static file(path: string) { 129 | return new Uri('file', '', path); 130 | } 131 | 132 | public static parse(path: string) { 133 | const comps = path.match(/([a-z]+):\/\/([^?#]+)(\?([^#]+)|())(#(.+)|())/)!; 134 | return new Uri(comps[1], '', comps[2], comps[4], comps[6]); 135 | } 136 | } 137 | 138 | export enum StatusBarAlignment { 139 | Left = 1, 140 | Right = 2 141 | } 142 | 143 | export let version = '1.51.0'; 144 | 145 | export enum ViewColumn { 146 | Active = -1, 147 | Beside = -2, 148 | One = 1, 149 | Two = 2, 150 | Three = 3, 151 | Four = 4, 152 | Five = 5, 153 | Six = 6, 154 | Seven = 7, 155 | Eight = 8, 156 | Nine = 9 157 | } 158 | 159 | export const window = { 160 | activeTextEditor: undefined as any, 161 | createOutputChannel: jest.fn(() => mocks.outputChannel), 162 | createStatusBarItem: jest.fn(() => mocks.statusBarItem), 163 | createWebviewPanel: jest.fn(createWebviewPanel), 164 | createTerminal: jest.fn(() => mocks.terminal), 165 | showErrorMessage: jest.fn(), 166 | showInformationMessage: jest.fn(), 167 | showOpenDialog: jest.fn(), 168 | showQuickPick: jest.fn(), 169 | showSaveDialog: jest.fn() 170 | }; 171 | 172 | export const workspace = { 173 | createFileSystemWatcher: jest.fn(() => ({ 174 | onDidCreate: jest.fn(), 175 | onDidChange: jest.fn(), 176 | onDidDelete: jest.fn(), 177 | dispose: jest.fn() 178 | })), 179 | getConfiguration: jest.fn(() => mocks.workspaceConfiguration), 180 | onDidChangeWorkspaceFolders: jest.fn((_: () => Promise) => ({ dispose: jest.fn() })), 181 | onDidCloseTextDocument: jest.fn((_: () => void) => ({ dispose: jest.fn() })), 182 | workspaceFolders: <{ uri: Uri, index: number }[] | undefined>undefined 183 | }; 184 | 185 | function createWebviewPanel(viewType: string, title: string, _showOptions: ViewColumn | { viewColumn: ViewColumn, preserveFocus?: boolean }, _options?: vscode.WebviewPanelOptions & vscode.WebviewOptions) { 186 | const mocks: WebviewPanelMocks = { 187 | messages: [], 188 | panel: { 189 | onDidChangeViewState: () => { }, 190 | onDidDispose: () => { }, 191 | setVisibility: (visible) => { 192 | webviewPanel.visible = visible; 193 | mocks.panel.onDidChangeViewState({ webviewPanel: webviewPanel }); 194 | }, 195 | webview: { 196 | onDidReceiveMessage: () => { } 197 | } 198 | } 199 | }; 200 | 201 | const webviewPanel: Writeable = { 202 | active: true, 203 | dispose: jest.fn(), 204 | iconPath: undefined, 205 | onDidChangeViewState: jest.fn((onDidChangeViewState) => { 206 | mocks.panel.onDidChangeViewState = onDidChangeViewState; 207 | return { dispose: jest.fn() }; 208 | }), 209 | onDidDispose: jest.fn((onDidDispose) => { 210 | mocks.panel.onDidDispose = onDidDispose; 211 | return { dispose: jest.fn() }; 212 | }), 213 | options: {}, 214 | reveal: jest.fn((_viewColumn?: ViewColumn, _preserveFocus?: boolean) => { }), 215 | title: title, 216 | visible: true, 217 | viewType: viewType, 218 | webview: { 219 | asWebviewUri: jest.fn((uri: Uri) => uri.with({ scheme: 'vscode-webview-resource', path: 'file//' + uri.path.replace(/\\/g, '/') })), 220 | cspSource: 'vscode-webview-resource:', 221 | html: '', 222 | onDidReceiveMessage: jest.fn((onDidReceiveMessage) => { 223 | mocks.panel.webview.onDidReceiveMessage = onDidReceiveMessage; 224 | return { dispose: jest.fn() }; 225 | }), 226 | options: {}, 227 | postMessage: jest.fn((msg) => { 228 | mocks.messages.push(msg); 229 | return Promise.resolve(true); 230 | }) 231 | } 232 | }; 233 | 234 | mockedWebviews.push({ panel: webviewPanel, mocks: mocks }); 235 | return webviewPanel; 236 | } 237 | 238 | 239 | /* Utilities */ 240 | 241 | beforeEach(() => { 242 | jest.clearAllMocks(); 243 | 244 | window.activeTextEditor = { 245 | document: { 246 | uri: Uri.file('/path/to/workspace-folder/active-file.txt') 247 | }, 248 | viewColumn: ViewColumn.One 249 | }; 250 | 251 | // Clear any mocked extension setting values before each test 252 | Object.keys(mockedExtensionSettingValues).forEach((section) => { 253 | delete mockedExtensionSettingValues[section]; 254 | }); 255 | 256 | mockedWebviews = []; 257 | 258 | version = '1.51.0'; 259 | }); 260 | 261 | export function mockExtensionSettingReturnValue(section: string, value: any) { 262 | mockedExtensionSettingValues[section] = value; 263 | } 264 | 265 | export function mockVscodeVersion(newVersion: string) { 266 | version = newVersion; 267 | } 268 | 269 | export function getMockedWebviewPanel(i: number) { 270 | return mockedWebviews[i]; 271 | } 272 | -------------------------------------------------------------------------------- /tests/repoFileWatcher.test.ts: -------------------------------------------------------------------------------- 1 | import * as date from './mocks/date'; 2 | import * as vscode from './mocks/vscode'; 3 | jest.mock('vscode', () => vscode, { virtual: true }); 4 | jest.mock('../src/logger'); 5 | 6 | import { Logger } from '../src/logger'; 7 | import { RepoFileWatcher } from '../src/repoFileWatcher'; 8 | 9 | let logger: Logger; 10 | let spyOnLog: jest.SpyInstance; 11 | 12 | beforeAll(() => { 13 | logger = new Logger(); 14 | spyOnLog = jest.spyOn(logger, 'log'); 15 | jest.useFakeTimers(); 16 | }); 17 | 18 | afterAll(() => { 19 | logger.dispose(); 20 | }); 21 | 22 | describe('RepoFileWatcher', () => { 23 | let repoFileWatcher: RepoFileWatcher; 24 | let callback: jest.Mock; 25 | beforeEach(() => { 26 | callback = jest.fn(); 27 | repoFileWatcher = new RepoFileWatcher(logger, callback); 28 | }); 29 | 30 | it('Should start and receive file events', () => { 31 | // Setup 32 | repoFileWatcher.start('/path/to/repo'); 33 | const onDidCreate = (>repoFileWatcher['fsWatcher']!.onDidCreate).mock.calls[0][0]; 34 | const onDidChange = (>repoFileWatcher['fsWatcher']!.onDidChange).mock.calls[0][0]; 35 | const onDidDelete = (>repoFileWatcher['fsWatcher']!.onDidDelete).mock.calls[0][0]; 36 | 37 | // Run 38 | onDidCreate(vscode.Uri.file('/path/to/repo/file')); 39 | onDidChange(vscode.Uri.file('/path/to/repo/file')); 40 | onDidDelete(vscode.Uri.file('/path/to/repo/file')); 41 | jest.runOnlyPendingTimers(); 42 | 43 | // Assert 44 | expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/repo/**'); 45 | expect(callback).toHaveBeenCalledTimes(1); 46 | }); 47 | 48 | it('Should stop a previous active File System Watcher before creating a new one', () => { 49 | // Setup 50 | repoFileWatcher.start('/path/to/repo1'); 51 | const watcher = repoFileWatcher['fsWatcher']!; 52 | const onDidCreate = (>watcher.onDidCreate).mock.calls[0][0]; 53 | 54 | // Run 55 | onDidCreate(vscode.Uri.file('/path/to/repo1/file')); 56 | repoFileWatcher.start('/path/to/repo2'); 57 | jest.runOnlyPendingTimers(); 58 | 59 | // Assert 60 | expect(>watcher.dispose).toHaveBeenCalledTimes(1); 61 | expect(callback).toHaveBeenCalledTimes(0); 62 | }); 63 | 64 | it('Should only dispose the active File System Watcher if it exists', () => { 65 | // Run 66 | repoFileWatcher.stop(); 67 | 68 | // Assert 69 | expect(spyOnLog).toHaveBeenCalledTimes(0); 70 | }); 71 | 72 | it('Should ignore file system events while muted', () => { 73 | // Setup 74 | repoFileWatcher.start('/path/to/repo'); 75 | const onDidCreate = (>repoFileWatcher['fsWatcher']!.onDidCreate).mock.calls[0][0]; 76 | 77 | // Run 78 | repoFileWatcher.mute(); 79 | onDidCreate(vscode.Uri.file('/path/to/repo/file')); 80 | jest.runOnlyPendingTimers(); 81 | 82 | // Assert 83 | expect(callback).toHaveBeenCalledTimes(0); 84 | }); 85 | 86 | it('Should resume reporting file events after 1.5 seconds', () => { 87 | // Setup 88 | date.setCurrentTime(1587559258); 89 | repoFileWatcher.start('/path/to/repo'); 90 | const onDidCreate = (>repoFileWatcher['fsWatcher']!.onDidCreate).mock.calls[0][0]; 91 | const onDidChange = (>repoFileWatcher['fsWatcher']!.onDidChange).mock.calls[0][0]; 92 | 93 | // Run 94 | repoFileWatcher.mute(); 95 | repoFileWatcher.unmute(); 96 | onDidCreate(vscode.Uri.file('/path/to/repo/file')); 97 | date.setCurrentTime(1587559260); 98 | onDidChange(vscode.Uri.file('/path/to/repo/file')); 99 | jest.runOnlyPendingTimers(); 100 | 101 | // Assert 102 | expect(callback).toHaveBeenCalledTimes(1); 103 | }); 104 | 105 | it('Should ignore file system events on files ignored within .git directory', () => { 106 | // Setup 107 | repoFileWatcher.start('/path/to/repo'); 108 | const onDidCreate = (>repoFileWatcher['fsWatcher']!.onDidCreate).mock.calls[0][0]; 109 | 110 | // Run 111 | onDidCreate(vscode.Uri.file('/path/to/repo/.git/config-x')); 112 | jest.runOnlyPendingTimers(); 113 | 114 | // Assert 115 | expect(callback).toHaveBeenCalledTimes(0); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/statusBarItem.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from './mocks/vscode'; 2 | jest.mock('vscode', () => vscode, { virtual: true }); 3 | jest.mock('../src/logger'); 4 | 5 | import { ConfigurationChangeEvent } from 'vscode'; 6 | import { Logger } from '../src/logger'; 7 | import { RepoChangeEvent } from '../src/repoManager'; 8 | import { StatusBarItem } from '../src/statusBarItem'; 9 | import { EventEmitter } from '../src/utils/event'; 10 | 11 | const vscodeStatusBarItem = vscode.mocks.statusBarItem; 12 | let onDidChangeRepos: EventEmitter; 13 | let onDidChangeConfiguration: EventEmitter; 14 | let logger: Logger; 15 | 16 | beforeAll(() => { 17 | onDidChangeRepos = new EventEmitter(); 18 | onDidChangeConfiguration = new EventEmitter(); 19 | logger = new Logger(); 20 | }); 21 | 22 | afterAll(() => { 23 | logger.dispose(); 24 | }); 25 | 26 | describe('StatusBarItem', () => { 27 | it('Should show the Status Bar Item on vscode startup', () => { 28 | // Setup 29 | vscode.mockExtensionSettingReturnValue('showStatusBarItem', true); 30 | 31 | // Run 32 | const statusBarItem = new StatusBarItem(1, onDidChangeRepos.subscribe, onDidChangeConfiguration.subscribe, logger); 33 | 34 | // Assert 35 | expect(vscodeStatusBarItem.text).toBe('Git Graph'); 36 | expect(vscodeStatusBarItem.tooltip).toBe('View Git Graph'); 37 | expect(vscodeStatusBarItem.command).toBe('git-graph.view'); 38 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 39 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 40 | 41 | // Teardown 42 | statusBarItem.dispose(); 43 | 44 | // Asset 45 | expect(vscodeStatusBarItem.dispose).toHaveBeenCalledTimes(1); 46 | expect(onDidChangeRepos['listeners']).toHaveLength(0); 47 | expect(onDidChangeConfiguration['listeners']).toHaveLength(0); 48 | }); 49 | 50 | it('Should hide the Status Bar Item after the number of repositories becomes zero', () => { 51 | // Setup 52 | vscode.mockExtensionSettingReturnValue('showStatusBarItem', true); 53 | 54 | // Run 55 | const statusBarItem = new StatusBarItem(1, onDidChangeRepos.subscribe, onDidChangeConfiguration.subscribe, logger); 56 | 57 | // Assert 58 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 59 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 60 | 61 | // Run 62 | onDidChangeRepos.emit({ 63 | repos: {}, 64 | numRepos: 0, 65 | loadRepo: null 66 | }); 67 | 68 | // Assert 69 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 70 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(1); 71 | 72 | // Teardown 73 | statusBarItem.dispose(); 74 | }); 75 | 76 | it('Should show the Status Bar Item after the number of repositories increases above zero', () => { 77 | // Setup 78 | vscode.mockExtensionSettingReturnValue('showStatusBarItem', true); 79 | 80 | // Run 81 | const statusBarItem = new StatusBarItem(0, onDidChangeRepos.subscribe, onDidChangeConfiguration.subscribe, logger); 82 | 83 | // Assert 84 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(0); 85 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 86 | 87 | // Run 88 | onDidChangeRepos.emit({ 89 | repos: {}, 90 | numRepos: 1, 91 | loadRepo: null 92 | }); 93 | 94 | // Assert 95 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 96 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 97 | 98 | // Teardown 99 | statusBarItem.dispose(); 100 | }); 101 | 102 | it('Should hide the Status Bar Item the extension setting git-graph.showStatusBarItem becomes disabled', () => { 103 | // Setup 104 | vscode.mockExtensionSettingReturnValue('showStatusBarItem', true); 105 | 106 | // Run 107 | const statusBarItem = new StatusBarItem(1, onDidChangeRepos.subscribe, onDidChangeConfiguration.subscribe, logger); 108 | 109 | // Assert 110 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 111 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 112 | 113 | // Run 114 | vscode.mockExtensionSettingReturnValue('showStatusBarItem', false); 115 | onDidChangeConfiguration.emit({ 116 | affectsConfiguration: () => true 117 | }); 118 | 119 | // Assert 120 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 121 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(1); 122 | 123 | // Teardown 124 | statusBarItem.dispose(); 125 | }); 126 | 127 | it('Should ignore extension setting changes unrelated to git-graph.showStatusBarItem', () => { 128 | // Setup 129 | vscode.mockExtensionSettingReturnValue('showStatusBarItem', true); 130 | 131 | // Run 132 | const statusBarItem = new StatusBarItem(1, onDidChangeRepos.subscribe, onDidChangeConfiguration.subscribe, logger); 133 | 134 | // Assert 135 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 136 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 137 | 138 | // Run 139 | onDidChangeConfiguration.emit({ 140 | affectsConfiguration: () => false 141 | }); 142 | 143 | // Assert 144 | expect(vscodeStatusBarItem.show).toHaveBeenCalledTimes(1); 145 | expect(vscodeStatusBarItem.hide).toHaveBeenCalledTimes(0); 146 | 147 | // Teardown 148 | statusBarItem.dispose(); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": [ 5 | "es6" 6 | ], 7 | "module": "commonjs", 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitAny": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "removeComments": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "es6", 16 | "types": [ 17 | "jest", 18 | "node", 19 | "vscode", 20 | "../src/utils/@types/node" 21 | ] 22 | } 23 | } -------------------------------------------------------------------------------- /web/contextMenu.ts: -------------------------------------------------------------------------------- 1 | const CLASS_CONTEXT_MENU_ACTIVE = 'contextMenuActive'; 2 | 3 | interface ContextMenuAction { 4 | readonly title: string; 5 | readonly visible: boolean; 6 | readonly onClick: () => void; 7 | readonly checked?: boolean; // Required in checked context menus 8 | } 9 | 10 | type ContextMenuActions = ReadonlyArray>; 11 | 12 | type ContextMenuTarget = { 13 | type: TargetType.Commit | TargetType.Ref | TargetType.CommitDetailsView; 14 | elem: HTMLElement; 15 | hash: string; 16 | index: number; 17 | ref?: string; 18 | } | RepoTarget; 19 | 20 | /** 21 | * Implements the Git Graph View's context menus. 22 | */ 23 | class ContextMenu { 24 | private elem: HTMLElement | null = null; 25 | private onClose: (() => void) | null = null; 26 | private target: ContextMenuTarget | null = null; 27 | 28 | /** 29 | * Construct a new ContextMenu instance. 30 | * @returns The ContextMenu instance. 31 | */ 32 | constructor() { 33 | const listener = () => this.close(); 34 | document.addEventListener('click', listener); 35 | document.addEventListener('contextmenu', listener); 36 | } 37 | 38 | /** 39 | * Show a context menu in the Git Graph View. 40 | * @param actions The collection of actions to display in the context menu. 41 | * @param checked Should the context menu display checks to the left of each action. 42 | * @param target The target that the context menu was triggered on. 43 | * @param event The mouse event that triggered the context menu. 44 | * @param frameElem The HTML Element that the context menu should be rendered within (and be positioned relative to it's content). 45 | * @param onClose An optional callback to be invoked when the context menu is closed. 46 | * @param className An optional class name to add to the context menu. 47 | */ 48 | public show(actions: ContextMenuActions, checked: boolean, target: ContextMenuTarget | null, event: MouseEvent, frameElem: HTMLElement, onClose: (() => void) | null = null, className: string | null = null) { 49 | let html = '', handlers: (() => void)[] = [], handlerId = 0; 50 | this.close(); 51 | 52 | for (let i = 0; i < actions.length; i++) { 53 | let groupHtml = ''; 54 | for (let j = 0; j < actions[i].length; j++) { 55 | if (actions[i][j].visible) { 56 | groupHtml += '
  • ' + (checked ? '' + (actions[i][j].checked ? SVG_ICONS.check : '') + '' : '') + actions[i][j].title + '
  • '; 57 | handlers.push(actions[i][j].onClick); 58 | } 59 | } 60 | 61 | if (groupHtml !== '') { 62 | if (html !== '') html += '
  • '; 63 | html += groupHtml; 64 | } 65 | } 66 | 67 | if (handlers.length === 0) return; // No context menu actions are visible 68 | 69 | const menu = document.createElement('ul'); 70 | menu.className = 'contextMenu' + (checked ? ' checked' : '') + (className !== null ? ' ' + className : ''); 71 | menu.style.opacity = '0'; 72 | menu.innerHTML = html; 73 | frameElem.appendChild(menu); 74 | const menuBounds = menu.getBoundingClientRect(), frameBounds = frameElem.getBoundingClientRect(); 75 | const relativeX = event.pageX + menuBounds.width < frameBounds.right 76 | ? -2 // context menu fits to the right 77 | : event.pageX - menuBounds.width > frameBounds.left 78 | ? 2 - menuBounds.width // context menu fits to the left 79 | : -2 - (menuBounds.width - (frameBounds.width - (event.pageX - frameBounds.left))); // Overlap the context menu horizontally with the cursor 80 | const relativeY = event.pageY + menuBounds.height < frameBounds.bottom 81 | ? -2 // context menu fits below 82 | : event.pageY - menuBounds.height > frameBounds.top 83 | ? 2 - menuBounds.height // context menu fits above 84 | : -2 - (menuBounds.height - (frameBounds.height - (event.pageY - frameBounds.top))); // Overlap the context menu vertically with the cursor 85 | menu.style.left = (frameElem.scrollLeft + Math.max(event.pageX - frameBounds.left + relativeX, 2)) + 'px'; 86 | menu.style.top = (frameElem.scrollTop + Math.max(event.pageY - frameBounds.top + relativeY, 2)) + 'px'; 87 | menu.style.opacity = '1'; 88 | this.elem = menu; 89 | this.onClose = onClose; 90 | 91 | addListenerToClass('contextMenuItem', 'click', (e) => { 92 | // The user clicked on a context menu item => call the corresponding handler 93 | e.stopPropagation(); 94 | this.close(); 95 | handlers[parseInt(((e.target).closest('.contextMenuItem')!).dataset.index!)](); 96 | }); 97 | 98 | menu.addEventListener('click', (e) => { 99 | // The user clicked on the context menu (but not a specific item) => keep the context menu open to allow the user to reattempt clicking on a specific item 100 | e.stopPropagation(); 101 | }); 102 | 103 | this.target = target; 104 | if (this.target !== null && this.target.type !== TargetType.Repo) { 105 | alterClass(this.target.elem, CLASS_CONTEXT_MENU_ACTIVE, true); 106 | } 107 | } 108 | 109 | /** 110 | * Close the context menu (if one is currently open in the Git Graph View). 111 | */ 112 | public close() { 113 | if (this.elem !== null) { 114 | this.elem.remove(); 115 | this.elem = null; 116 | } 117 | alterClassOfCollection(>document.getElementsByClassName(CLASS_CONTEXT_MENU_ACTIVE), CLASS_CONTEXT_MENU_ACTIVE, false); 118 | if (this.onClose !== null) { 119 | this.onClose(); 120 | this.onClose = null; 121 | } 122 | this.target = null; 123 | } 124 | 125 | /** 126 | * Refresh the context menu (if one is currently open in the Git Graph View). If the context menu has a dynamic source, 127 | * re-link it to the newly rendered HTML Element, or close it if the target is no longer visible in the Git Graph View. 128 | * @param commits The new array of commits that is rendered in the Git Graph View. 129 | */ 130 | public refresh(commits: ReadonlyArray) { 131 | if (!this.isOpen() || this.target === null || this.target.type === TargetType.Repo) { 132 | // Don't need to refresh if no context menu is open, or it is not dynamic 133 | return; 134 | } 135 | 136 | if (this.target.index < commits.length && commits[this.target.index].hash === this.target.hash) { 137 | // The commit still exists at the same index 138 | 139 | const commitElem = findCommitElemWithId(getCommitElems(), this.target.index); 140 | if (commitElem !== null) { 141 | if (typeof this.target.ref === 'undefined') { 142 | // ContextMenu is only dependent on the commit itself 143 | if (this.target.type !== TargetType.CommitDetailsView) { 144 | this.target.elem = commitElem; 145 | alterClass(this.target.elem, CLASS_CONTEXT_MENU_ACTIVE, true); 146 | } 147 | return; 148 | } else { 149 | // ContextMenu is dependent on the commit and ref 150 | const elems = >commitElem.querySelectorAll('[data-fullref]'); 151 | for (let i = 0; i < elems.length; i++) { 152 | if (elems[i].dataset.fullref! === this.target.ref) { 153 | this.target.elem = this.target.type === TargetType.Ref ? elems[i] : commitElem; 154 | alterClass(this.target.elem, CLASS_CONTEXT_MENU_ACTIVE, true); 155 | return; 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | this.close(); 163 | } 164 | 165 | /** 166 | * Is a context menu currently open in the Git Graph View. 167 | * @returns TRUE => A context menu is open, FALSE => No context menu is open 168 | */ 169 | public isOpen() { 170 | return this.elem !== null; 171 | } 172 | 173 | /** 174 | * Is the target of the context menu dynamic (i.e. is it tied to a Git object & HTML Element in the Git Graph View). 175 | * @returns TRUE => The context menu is dynamic, FALSE => The context menu is not dynamic 176 | */ 177 | public isTargetDynamicSource() { 178 | return this.isOpen() && this.target !== null; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /web/dropdown.ts: -------------------------------------------------------------------------------- 1 | interface DropdownOption { 2 | readonly name: string; 3 | readonly value: string; 4 | readonly hint?: string; 5 | } 6 | 7 | /** 8 | * Implements the dropdown inputs used in the Git Graph View's top control bar. 9 | */ 10 | class Dropdown { 11 | private readonly showInfo: boolean; 12 | private readonly multipleAllowed: boolean; 13 | private readonly changeCallback: (values: string[]) => void; 14 | 15 | private options: ReadonlyArray = []; 16 | private optionsSelected: boolean[] = []; 17 | private lastSelected: number = 0; // Only used when multipleAllowed === false 18 | private dropdownVisible: boolean = false; 19 | private lastClicked: number = 0; 20 | private doubleClickTimeout: NodeJS.Timer | null = null; 21 | 22 | private readonly elem: HTMLElement; 23 | private readonly currentValueElem: HTMLDivElement; 24 | private readonly menuElem: HTMLDivElement; 25 | private readonly optionsElem: HTMLDivElement; 26 | private readonly noResultsElem: HTMLDivElement; 27 | private readonly filterInput: HTMLInputElement; 28 | 29 | /** 30 | * Constructs a Dropdown instance. 31 | * @param id The ID of the HTML Element that the dropdown should be rendered in. 32 | * @param showInfo Should an information icon be shown on the right of each dropdown item. 33 | * @param multipleAllowed Can multiple items be selected. 34 | * @param dropdownType The type of content the dropdown is being used for. 35 | * @param changeCallback A callback to be invoked when the selected item(s) of the dropdown changes. 36 | * @returns The Dropdown instance. 37 | */ 38 | constructor(id: string, showInfo: boolean, multipleAllowed: boolean, dropdownType: string, changeCallback: (values: string[]) => void) { 39 | this.showInfo = showInfo; 40 | this.multipleAllowed = multipleAllowed; 41 | this.changeCallback = changeCallback; 42 | this.elem = document.getElementById(id)!; 43 | 44 | this.menuElem = document.createElement('div'); 45 | this.menuElem.className = 'dropdownMenu'; 46 | 47 | let filter = this.menuElem.appendChild(document.createElement('div')); 48 | filter.className = 'dropdownFilter'; 49 | 50 | this.filterInput = filter.appendChild(document.createElement('input')); 51 | this.filterInput.className = 'dropdownFilterInput'; 52 | this.filterInput.placeholder = 'Filter ' + dropdownType + '...'; 53 | 54 | this.optionsElem = this.menuElem.appendChild(document.createElement('div')); 55 | this.optionsElem.className = 'dropdownOptions'; 56 | 57 | this.noResultsElem = this.menuElem.appendChild(document.createElement('div')); 58 | this.noResultsElem.className = 'dropdownNoResults'; 59 | this.noResultsElem.innerHTML = 'No results found.'; 60 | 61 | this.currentValueElem = this.elem.appendChild(document.createElement('div')); 62 | this.currentValueElem.className = 'dropdownCurrentValue'; 63 | 64 | alterClass(this.elem, 'multi', multipleAllowed); 65 | this.elem.appendChild(this.menuElem); 66 | 67 | document.addEventListener('click', (e) => { 68 | if (!e.target) return; 69 | if (e.target === this.currentValueElem) { 70 | this.dropdownVisible = !this.dropdownVisible; 71 | if (this.dropdownVisible) { 72 | this.filterInput.value = ''; 73 | this.filter(); 74 | } 75 | this.elem.classList.toggle('dropdownOpen'); 76 | if (this.dropdownVisible) this.filterInput.focus(); 77 | } else if (this.dropdownVisible) { 78 | if ((e.target).closest('.dropdown') !== this.elem) { 79 | this.close(); 80 | } else { 81 | const option = (e.target).closest('.dropdownOption'); 82 | if (option !== null && option.parentNode === this.optionsElem && typeof option.dataset.id !== 'undefined') { 83 | this.onOptionClick(parseInt(option.dataset.id!)); 84 | } 85 | } 86 | } 87 | }, true); 88 | document.addEventListener('contextmenu', () => this.close(), true); 89 | this.filterInput.addEventListener('keyup', () => this.filter()); 90 | } 91 | 92 | /** 93 | * Set the options that should be displayed in the dropdown. 94 | * @param options An array of the options to display in the dropdown. 95 | * @param optionsSelected An array of the selected options in the dropdown. 96 | */ 97 | public setOptions(options: ReadonlyArray, optionsSelected: string[]) { 98 | this.options = options; 99 | this.optionsSelected = []; 100 | let selectedOption = -1, isSelected; 101 | for (let i = 0; i < options.length; i++) { 102 | isSelected = optionsSelected.includes(options[i].value); 103 | this.optionsSelected[i] = isSelected; 104 | if (isSelected) { 105 | selectedOption = i; 106 | } 107 | } 108 | if (selectedOption === -1) { 109 | selectedOption = 0; 110 | this.optionsSelected[selectedOption] = true; 111 | } 112 | this.lastSelected = selectedOption; 113 | if (this.dropdownVisible && options.length <= 1) this.close(); 114 | this.render(); 115 | this.clearDoubleClickTimeout(); 116 | } 117 | 118 | /** 119 | * Is a value selected in the dropdown (respecting "Show All") 120 | * @param value The value to check. 121 | * @returns TRUE => The value is selected, FALSE => The value is not selected. 122 | */ 123 | public isSelected(value: string) { 124 | if (this.options.length > 0) { 125 | if (this.multipleAllowed && this.optionsSelected[0]) { 126 | // Multiple options can be selected, and "Show All" is selected. 127 | return true; 128 | } 129 | const optionIndex = this.options.findIndex((option) => option.value === value); 130 | if (optionIndex > -1 && this.optionsSelected[optionIndex]) { 131 | // The specific option is selected 132 | return true; 133 | } 134 | } 135 | return false; 136 | } 137 | 138 | /** 139 | * Select a specific value in the dropdown. 140 | * @param value The value to select. 141 | */ 142 | public selectOption(value: string) { 143 | const optionIndex = this.options.findIndex((option) => value === option.value); 144 | if (this.multipleAllowed && optionIndex > -1 && !this.optionsSelected[0] && !this.optionsSelected[optionIndex]) { 145 | // Select the option with the specified value 146 | this.optionsSelected[optionIndex] = true; 147 | 148 | // A change has occurred, re-render the dropdown options 149 | const menuScroll = this.menuElem.scrollTop; 150 | this.render(); 151 | if (this.dropdownVisible) { 152 | this.menuElem.scroll(0, menuScroll); 153 | } 154 | this.changeCallback(this.getSelectedOptions(false)); 155 | } 156 | } 157 | 158 | /** 159 | * Unselect a specific value in the dropdown. 160 | * @param value The value to unselect. 161 | */ 162 | public unselectOption(value: string) { 163 | const optionIndex = this.options.findIndex((option) => value === option.value); 164 | if (this.multipleAllowed && optionIndex > -1 && (this.optionsSelected[0] || this.optionsSelected[optionIndex])) { 165 | if (this.optionsSelected[0]) { 166 | // Show All is currently selected, so unselect it, and select all branch options 167 | this.optionsSelected[0] = false; 168 | for (let i = 1; i < this.optionsSelected.length; i++) { 169 | this.optionsSelected[i] = true; 170 | } 171 | } 172 | 173 | // Unselect the option with the specified value 174 | this.optionsSelected[optionIndex] = false; 175 | if (this.optionsSelected.every(selected => !selected)) { 176 | // All items have been unselected, select "Show All" 177 | this.optionsSelected[0] = true; 178 | } 179 | 180 | // A change has occurred, re-render the dropdown options 181 | const menuScroll = this.menuElem.scrollTop; 182 | this.render(); 183 | if (this.dropdownVisible) { 184 | this.menuElem.scroll(0, menuScroll); 185 | } 186 | this.changeCallback(this.getSelectedOptions(false)); 187 | } 188 | } 189 | 190 | /** 191 | * Refresh the rendered dropdown to apply style changes. 192 | */ 193 | public refresh() { 194 | if (this.options.length > 0) this.render(); 195 | } 196 | 197 | /** 198 | * Is the dropdown currently open (i.e. is the list of options visible). 199 | * @returns TRUE => The dropdown is open, FALSE => The dropdown is not open 200 | */ 201 | public isOpen() { 202 | return this.dropdownVisible; 203 | } 204 | 205 | /** 206 | * Close the dropdown. 207 | */ 208 | public close() { 209 | this.elem.classList.remove('dropdownOpen'); 210 | this.dropdownVisible = false; 211 | this.clearDoubleClickTimeout(); 212 | } 213 | 214 | /** 215 | * Render the dropdown. 216 | */ 217 | private render() { 218 | this.elem.classList.add('loaded'); 219 | 220 | const curValueText = formatCommaSeparatedList(this.getSelectedOptions(true)); 221 | this.currentValueElem.title = curValueText; 222 | this.currentValueElem.innerHTML = escapeHtml(curValueText); 223 | 224 | let html = ''; 225 | for (let i = 0; i < this.options.length; i++) { 226 | const escapedName = escapeHtml(this.options[i].name); 227 | html += ''; 232 | } 233 | this.optionsElem.className = 'dropdownOptions' + (this.showInfo ? ' showInfo' : ''); 234 | this.optionsElem.innerHTML = html; 235 | this.filterInput.style.display = 'none'; 236 | this.noResultsElem.style.display = 'none'; 237 | this.menuElem.style.cssText = 'opacity:0; display:block;'; 238 | // Width must be at least 138px for the filter element. 239 | // Don't need to add 12px if showing (info icons or multi checkboxes) and the scrollbar isn't needed. The scrollbar isn't needed if: menuElem height + filter input (25px) < 297px 240 | const menuElemRect = this.menuElem.getBoundingClientRect(); 241 | this.currentValueElem.style.width = Math.max(Math.ceil(menuElemRect.width) + ((this.showInfo || this.multipleAllowed) && menuElemRect.height < 272 ? 0 : 12), 138) + 'px'; 242 | this.menuElem.style.cssText = 'right:0; overflow-y:auto; max-height:297px;'; // Max height for the dropdown is [filter (31px) + 9.5 * dropdown item (28px) = 297px] 243 | if (this.dropdownVisible) this.filter(); 244 | } 245 | 246 | /** 247 | * Filter the options displayed in the dropdown list, based on the filter criteria specified by the user. 248 | */ 249 | private filter() { 250 | let val = this.filterInput.value.toLowerCase(), match, matches = false; 251 | for (let i = 0; i < this.options.length; i++) { 252 | match = this.options[i].name.toLowerCase().indexOf(val) > -1; 253 | (this.optionsElem.children[i]).style.display = match ? 'block' : 'none'; 254 | if (match) matches = true; 255 | } 256 | this.filterInput.style.display = 'block'; 257 | this.noResultsElem.style.display = matches ? 'none' : 'block'; 258 | } 259 | 260 | /** 261 | * Get an array of the selected dropdown options. 262 | * @param names TRUE => Return the names of the selected options, FALSE => Return the values of the selected options. 263 | * @returns The array of the selected options. 264 | */ 265 | private getSelectedOptions(names: boolean) { 266 | let selected = []; 267 | if (this.multipleAllowed && this.optionsSelected[0]) { 268 | // Note: Show All is always the first option (0 index) when multiple selected items are allowed 269 | return [names ? this.options[0].name : this.options[0].value]; 270 | } 271 | for (let i = 0; i < this.options.length; i++) { 272 | if (this.optionsSelected[i]) selected.push(names ? this.options[i].name : this.options[i].value); 273 | } 274 | return selected; 275 | } 276 | 277 | /** 278 | * Select a dropdown option. 279 | * @param option The index of the option to select. 280 | */ 281 | private onOptionClick(option: number) { 282 | // Note: Show All is always the first option (0 index) when multiple selected items are allowed 283 | let change = false; 284 | let doubleClick = this.doubleClickTimeout !== null && this.lastClicked === option; 285 | if (this.doubleClickTimeout !== null) this.clearDoubleClickTimeout(); 286 | 287 | if (doubleClick) { 288 | // Double click 289 | if (this.multipleAllowed && option === 0) { 290 | for (let i = 1; i < this.optionsSelected.length; i++) { 291 | this.optionsSelected[i] = !this.optionsSelected[i]; 292 | } 293 | change = true; 294 | } 295 | } else { 296 | // Single Click 297 | if (this.multipleAllowed) { 298 | // Multiple dropdown options can be selected 299 | if (option === 0) { 300 | // Show All was selected 301 | if (!this.optionsSelected[0]) { 302 | this.optionsSelected[0] = true; 303 | for (let i = 1; i < this.optionsSelected.length; i++) { 304 | this.optionsSelected[i] = false; 305 | } 306 | change = true; 307 | } 308 | } else { 309 | if (this.optionsSelected[0]) { 310 | // Deselect "Show All" if it is enabled 311 | this.optionsSelected[0] = false; 312 | } 313 | 314 | this.optionsSelected[option] = !this.optionsSelected[option]; 315 | 316 | if (this.optionsSelected.every(selected => !selected)) { 317 | // All items have been unselected, select "Show All" 318 | this.optionsSelected[0] = true; 319 | } 320 | change = true; 321 | } 322 | } else { 323 | // Only a single dropdown option can be selected 324 | this.close(); 325 | if (this.lastSelected !== option) { 326 | this.optionsSelected[this.lastSelected] = false; 327 | this.optionsSelected[option] = true; 328 | this.lastSelected = option; 329 | change = true; 330 | } 331 | } 332 | 333 | if (change) { 334 | // If a change has occurred, trigger the callback 335 | this.changeCallback(this.getSelectedOptions(false)); 336 | } 337 | } 338 | 339 | if (change) { 340 | // If a change has occurred, re-render the dropdown elements 341 | let menuScroll = this.menuElem.scrollTop; 342 | this.render(); 343 | if (this.dropdownVisible) this.menuElem.scroll(0, menuScroll); 344 | } 345 | 346 | this.lastClicked = option; 347 | this.doubleClickTimeout = setTimeout(() => { 348 | this.clearDoubleClickTimeout(); 349 | }, 500); 350 | } 351 | 352 | /** 353 | * Clear the timeout used to detect double clicks. 354 | */ 355 | private clearDoubleClickTimeout() { 356 | if (this.doubleClickTimeout !== null) { 357 | clearTimeout(this.doubleClickTimeout); 358 | this.doubleClickTimeout = null; 359 | } 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /web/global.d.ts: -------------------------------------------------------------------------------- 1 | import * as GG from '../out/types'; // Import types from back-end (requires `npm run compile-src`) 2 | 3 | declare global { 4 | 5 | /* Visual Studio Code API Types */ 6 | 7 | function acquireVsCodeApi(): { 8 | getState: () => WebViewState | null, 9 | postMessage: (message: GG.RequestMessage) => void, 10 | setState: (state: WebViewState) => void 11 | }; 12 | 13 | 14 | /* State Types */ 15 | 16 | type Config = GG.GitGraphViewConfig; 17 | 18 | const initialState: GG.GitGraphViewInitialState; 19 | const globalState: GG.DeepReadonly; 20 | const workspaceState: GG.DeepReadonly; 21 | 22 | type AvatarImageCollection = { [email: string]: string }; 23 | 24 | interface ExpandedCommit { 25 | index: number; 26 | commitHash: string; 27 | commitElem: HTMLElement | null; 28 | compareWithHash: string | null; 29 | compareWithElem: HTMLElement | null; 30 | commitDetails: GG.GitCommitDetails | null; 31 | fileChanges: ReadonlyArray | null; 32 | fileTree: FileTreeFolder | null; 33 | avatar: string | null; 34 | codeReview: GG.CodeReview | null; 35 | lastViewedFile: string | null; 36 | loading: boolean; 37 | scrollTop: { 38 | summary: number, 39 | fileView: number 40 | }; 41 | contextMenuOpen: { 42 | summary: boolean, 43 | fileView: number 44 | }; 45 | } 46 | 47 | interface WebViewState { 48 | readonly currentRepo: string; 49 | readonly currentRepoLoading: boolean; 50 | readonly gitRepos: GG.GitRepoSet; 51 | readonly gitBranches: ReadonlyArray; 52 | readonly gitBranchHead: string | null; 53 | readonly gitConfig: GG.GitRepoConfig | null; 54 | readonly gitRemotes: ReadonlyArray; 55 | readonly gitStashes: ReadonlyArray; 56 | readonly gitTags: ReadonlyArray; 57 | readonly commits: GG.GitCommit[]; 58 | readonly commitHead: string | null; 59 | readonly avatars: AvatarImageCollection; 60 | readonly currentBranches: string[] | null; 61 | readonly moreCommitsAvailable: boolean; 62 | readonly maxCommits: number; 63 | readonly onlyFollowFirstParent: boolean; 64 | readonly expandedCommit: ExpandedCommit | null; 65 | readonly scrollTop: number; 66 | readonly findWidget: FindWidgetState; 67 | readonly settingsWidget: SettingsWidgetState; 68 | } 69 | 70 | 71 | /* Commit Details / Comparison View File Tree Types */ 72 | 73 | interface FileTreeFile { 74 | readonly type: 'file'; 75 | readonly name: string; 76 | readonly index: number; 77 | reviewed: boolean; 78 | } 79 | 80 | interface FileTreeRepo { 81 | readonly type: 'repo'; 82 | readonly name: string; 83 | readonly path: string; 84 | } 85 | 86 | interface FileTreeFolder { 87 | readonly type: 'folder'; 88 | readonly name: string; 89 | readonly folderPath: string; 90 | readonly contents: FileTreeFolderContents; 91 | open: boolean; 92 | reviewed: boolean; 93 | } 94 | 95 | type FileTreeLeaf = FileTreeFile | FileTreeRepo; 96 | type FileTreeNode = FileTreeFolder | FileTreeLeaf; 97 | type FileTreeFolderContents = { [name: string]: FileTreeNode }; 98 | 99 | 100 | /* Dialog & ContextMenu shared base Target interfaces */ 101 | 102 | const enum TargetType { 103 | Commit = 'commit', 104 | CommitDetailsView = 'cdv', 105 | Ref = 'ref', 106 | Repo = 'repo' 107 | } 108 | 109 | interface CommitOrRefTarget { 110 | type: TargetType.Commit | TargetType.Ref | TargetType.CommitDetailsView; 111 | elem: HTMLElement; 112 | } 113 | 114 | interface RepoTarget { 115 | type: TargetType.Repo; 116 | } 117 | 118 | interface CommitTarget extends CommitOrRefTarget { 119 | hash: string; 120 | } 121 | 122 | interface RefTarget extends CommitTarget { 123 | ref: string; 124 | } 125 | } 126 | 127 | export as namespace GG; 128 | export = GG; 129 | -------------------------------------------------------------------------------- /web/styles/contextMenu.css: -------------------------------------------------------------------------------- 1 | .contextMenu{ 2 | display:block; 3 | position:absolute; 4 | background-color:var(--vscode-menu-background); 5 | box-shadow:0 1px 4px 1px var(--vscode-widget-shadow); 6 | color:var(--vscode-menu-foreground); 7 | list-style-type:none; 8 | margin:0; 9 | padding:4px 0; 10 | z-index:10; 11 | -webkit-user-select:none; 12 | user-select:none; 13 | } 14 | body.vscode-high-contrast .contextMenu{ 15 | outline:1px solid var(--vscode-menu-border, transparent); 16 | outline-offset:-1px; 17 | } 18 | 19 | .contextMenu li{ 20 | cursor:default; 21 | -webkit-user-select:none; 22 | user-select:none; 23 | } 24 | 25 | .contextMenu li.contextMenuItem{ 26 | padding:6px 20px; 27 | } 28 | 29 | .contextMenu li.contextMenuItem:hover{ 30 | background-color:var(--vscode-menu-selectionBackground, var(--vscode-menu-background)); 31 | color:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); 32 | } 33 | body.vscode-high-contrast .contextMenu li.contextMenuItem:hover{ 34 | outline:1px solid var(--vscode-menu-selectionBorder, transparent); 35 | outline-offset:-2px; 36 | } 37 | 38 | .contextMenu li.contextMenuDivider{ 39 | margin:4px 10px; 40 | border-top:1px solid var(--vscode-menu-separatorBackground); 41 | opacity:0.5; 42 | } 43 | 44 | .contextMenu.checked li.contextMenuItem{ 45 | position:relative; 46 | padding-left:35px; 47 | } 48 | 49 | .contextMenu.checked li.contextMenuItem .contextMenuItemCheck{ 50 | position:absolute; 51 | width:12px; 52 | height:16px; 53 | top:50%; 54 | margin-top:-8px; 55 | left:15px; 56 | -webkit-user-select:none; 57 | user-select:none; 58 | } 59 | 60 | .contextMenu.checked li.contextMenuItem .contextMenuItemCheck svg{ 61 | fill:var(--vscode-menu-foreground); 62 | opacity:0.6; 63 | } 64 | 65 | .contextMenu.checked li.contextMenuItem:hover .contextMenuItemCheck svg{ 66 | fill:var(--vscode-menu-selectionForeground); 67 | opacity:0.8; 68 | } 69 | -------------------------------------------------------------------------------- /web/styles/dialog.css: -------------------------------------------------------------------------------- 1 | .dialog{ 2 | display:block; 3 | position:fixed; 4 | background-color:var(--vscode-menu-background); 5 | color:var(--vscode-menu-foreground); 6 | left:50%; 7 | transform:translateX(-50%); 8 | border:1px solid rgba(128,128,128,0.5); 9 | border-radius:5px; 10 | text-align:center; 11 | font-size:13px; 12 | line-height:17px; 13 | z-index:41; 14 | box-shadow:0 0 30px 5px var(--vscode-widget-shadow); 15 | } 16 | body.vscode-high-contrast .dialog{ 17 | border-color:var(--vscode-menu-border, rgba(128,128,128,0.5)); 18 | } 19 | 20 | .dialogContent{ 21 | display:block; 22 | overflow-y:auto; 23 | padding:10px; 24 | max-width:360px; 25 | } 26 | 27 | .dialogContent > table.dialogForm{ 28 | display:inline-table; 29 | width:360px; 30 | } 31 | 32 | .dialogContent > table.dialogForm td{ 33 | padding-top:8px; 34 | text-align:left; 35 | white-space:nowrap; 36 | -webkit-user-select:none; 37 | user-select:none; 38 | } 39 | 40 | .dialogContent > table.dialogForm tr.mediumField td{ 41 | line-height:22px; 42 | } 43 | .dialogContent > table.dialogForm tr.largeField td{ 44 | line-height:27px; 45 | } 46 | 47 | .dialogContent > table.dialogForm td.inputCol{ 48 | width:100%; 49 | } 50 | 51 | .dialogContent > table.dialogForm.multi td:nth-child(1){ 52 | padding-right:5px; 53 | } 54 | 55 | .dialogContent > table.dialogForm.single .dialogFormCheckbox, .dialogContent > table.dialogForm.single .dialogFormRadio{ 56 | display:inline-block; 57 | width:100%; 58 | } 59 | .dialogContent > table.dialogForm.single .dialogFormCheckbox{ 60 | text-align:center; 61 | } 62 | .dialogContent > table.dialogForm.single .dialogFormRadio{ 63 | text-align:left; 64 | } 65 | 66 | .dialogContent .dialogFormCheckbox > label, .dialogContent .dialogFormRadio > label{ 67 | -webkit-user-select:none; 68 | user-select:none; 69 | cursor:pointer; 70 | } 71 | 72 | .dialogContent > table.dialogForm.single .customCheckbox, .dialogContent > table.dialogForm.multiCheckbox .customCheckbox{ 73 | margin-right:6px; 74 | } 75 | .dialogContent > table.dialogForm .customRadio{ 76 | margin-left:1px; 77 | margin-right:7px; 78 | } 79 | .dialogContent .dialogFormCheckbox > label > input[type=checkbox]:checked ~ .customCheckbox:after{ 80 | border-color:var(--vscode-menu-foreground); 81 | } 82 | .dialogContent .dialogFormRadio > label > input[type=radio]:checked ~ .customRadio:after{ 83 | background-color:var(--vscode-menu-foreground); 84 | } 85 | 86 | .dialogContent > table.dialogForm input[type=text]{ 87 | width:100%; 88 | padding:4px 6px; 89 | box-sizing:border-box; 90 | outline-style:none; 91 | background-color:rgba(128,128,128,0.1); 92 | border:1px solid rgba(128,128,128,0.5); 93 | border-radius:4px; 94 | color:var(--vscode-menu-foreground); 95 | font-family:var(--vscode-font-family); 96 | font-size:13px; 97 | line-height:17px; 98 | } 99 | .dialogContent > table.dialogForm input[type=text]:focus, .dialogContent .dialogFormCheckbox > label > input[type=checkbox]:focus ~ .customCheckbox, .dialogContent .dialogFormRadio > label > input[type=radio]:focus ~ .customRadio{ 100 | border-color:var(--vscode-focusBorder); 101 | } 102 | .dialogContent > table.dialogForm input[type=text]::placeholder{ 103 | color:var(--vscode-menu-foreground); 104 | opacity:0.4; 105 | } 106 | 107 | .dialog .roundedBtn{ 108 | display:inline-block; 109 | line-height:22px; 110 | padding:0 15px; 111 | margin:10px 6px 0 6px; 112 | border-radius:11px; 113 | } 114 | 115 | .dialog .messageContent{ 116 | display:inline-block; 117 | margin-top:10px; 118 | line-height:18px; 119 | text-align:left; 120 | width:100%; 121 | word-wrap:break-word; 122 | } 123 | 124 | .dialog .messageContent.errorContent{ 125 | font-style:italic; 126 | line-height:17px; 127 | } 128 | 129 | .dialog #dialogAction{ 130 | background-color:rgba(128,128,128,0.15); 131 | border-color:rgba(128,128,128,0.8); 132 | box-shadow:0 0 1px 1px rgba(128,128,128,0.2); 133 | } 134 | 135 | .dialog #dialogAction:hover{ 136 | background-color:rgba(128,128,128,0.25); 137 | } 138 | 139 | .dialog.noInput #dialogAction, .dialog.inputInvalid #dialogAction{ 140 | background-color:rgba(128,128,128,0.2); 141 | opacity:0.5; 142 | } 143 | 144 | .dialog.noInput #dialogAction{ 145 | cursor:default; 146 | } 147 | 148 | .dialog.inputInvalid #dialogAction{ 149 | cursor:help; 150 | } 151 | 152 | .dialog svg{ 153 | display:inline; 154 | fill:var(--vscode-menu-foreground); 155 | opacity:0.75; 156 | vertical-align:sub; 157 | } 158 | 159 | .dialog .dialogAlert svg, .dialog .actionRunning svg{ 160 | margin-right:8px; 161 | } 162 | 163 | .dialog .dialogInfo{ 164 | cursor:help; 165 | } 166 | .dialog .dialogInfo svg{ 167 | width:15px !important; 168 | height:15px !important; 169 | margin-left:5px; 170 | margin-top:-2px; 171 | vertical-align:middle; 172 | } 173 | 174 | .dialog .actionRunning{ 175 | margin:0 16px; 176 | line-height:24px; 177 | } 178 | 179 | .dialog .actionRunning svg{ 180 | display:inline-block; 181 | width:15px !important; 182 | height:20px !important; 183 | margin-top:2px; 184 | vertical-align:top; 185 | animation:loadingIconAnimation 2s linear infinite; 186 | } 187 | 188 | .dialogContextMenu{ 189 | z-index:43; 190 | } 191 | 192 | 193 | /* Custom Select Input */ 194 | 195 | .customSelectContainer{ 196 | display:inline-block; 197 | position:relative; 198 | width:100%; 199 | height:27px; 200 | vertical-align:middle; 201 | } 202 | 203 | .customSelectCurrent{ 204 | position:absolute; 205 | left:0; 206 | right:0; 207 | top:0; 208 | bottom:0; 209 | padding:4px 22px 4px 6px; 210 | box-sizing:border-box; 211 | background-color:rgba(128,128,128,0.1); 212 | border:1px solid rgba(128,128,128,0.5); 213 | border-radius:4px; 214 | outline-style:none; 215 | color:var(--vscode-menu-foreground); 216 | line-height:17px; 217 | text-overflow:ellipsis; 218 | white-space:nowrap; 219 | overflow:hidden; 220 | -webkit-user-select:none; 221 | user-select:none; 222 | cursor:pointer; 223 | } 224 | 225 | .customSelectCurrent:after{ 226 | position:absolute; 227 | content:""; 228 | top:11px; 229 | right:7px; 230 | width:0; 231 | height:0; 232 | border:4px solid transparent; 233 | border-color:var(--vscode-menu-foreground) transparent transparent transparent; 234 | } 235 | 236 | .customSelectCurrent:focus, .customSelectContainer.open .customSelectCurrent{ 237 | border-color:var(--vscode-focusBorder); 238 | } 239 | .customSelectContainer.open .customSelectCurrent{ 240 | border-radius:4px 4px 0 0; 241 | } 242 | .customSelectContainer.open .customSelectCurrent:after{ 243 | border-color:transparent transparent var(--vscode-menu-foreground) transparent; 244 | top:6px; 245 | } 246 | 247 | .customSelectOptions{ 248 | display:block; 249 | position:absolute; 250 | box-sizing:border-box; 251 | background-color:var(--vscode-menu-background); 252 | color:var(--vscode-menu-foreground); 253 | border:1px solid var(--vscode-focusBorder); 254 | border-radius:0 0 4px 4px; 255 | z-index:42; 256 | text-align:left; 257 | overflow-y:auto; 258 | } 259 | 260 | .customSelectOption{ 261 | position:relative; 262 | box-sizing:border-box; 263 | width:100%; 264 | padding:5px 6px; 265 | max-height:61px; 266 | background-color:rgba(128,128,128,0.05); 267 | overflow:hidden; 268 | word-wrap:break-word; 269 | -webkit-user-select:none; 270 | user-select:none; 271 | cursor:pointer; 272 | } 273 | 274 | .customSelectOptions > .customSelectOption.selected{ 275 | background-color:rgba(128,128,128,0.2); 276 | } 277 | .customSelectOptions > .customSelectOption.focussed{ 278 | background-color:var(--vscode-menu-selectionBackground, rgba(128,128,128,0.05)); 279 | color:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); 280 | } 281 | body.vscode-high-contrast .customSelectOptions > .customSelectOption.focussed{ 282 | outline:1px dotted var(--vscode-menu-selectionBorder, transparent); 283 | outline-offset:-2px; 284 | } 285 | 286 | .customSelectOptions.multiple > .customSelectOption{ 287 | padding-left:28px; 288 | } 289 | .customSelectOptions.multiple > .customSelectOption > .selectedIcon{ 290 | display:none; 291 | } 292 | .customSelectOptions.multiple > .customSelectOption.selected > .selectedIcon{ 293 | display:block; 294 | position:absolute; 295 | width:12px; 296 | height:16px; 297 | top:50%; 298 | left:9px; 299 | margin-top:-9px; 300 | } 301 | .customSelectOptions.multiple > .customSelectOption > .selectedIcon svg{ 302 | fill:var(--vscode-menu-foreground); 303 | opacity:0.7; 304 | } 305 | .customSelectOptions.multiple > .customSelectOption.focussed > .selectedIcon svg{ 306 | fill:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); 307 | opacity:0.85; 308 | } 309 | -------------------------------------------------------------------------------- /web/styles/dropdown.css: -------------------------------------------------------------------------------- 1 | .dropdown{ 2 | display:none; 3 | position:relative; 4 | font-size:13px; 5 | vertical-align:top; 6 | margin-top:3px; 7 | font-weight:normal; 8 | text-align:left; 9 | } 10 | .dropdown.loaded{ 11 | display:inline-block; 12 | } 13 | .dropdownCurrentValue{ 14 | background-color:rgba(128,128,128,0.1); 15 | border:1px solid rgba(128,128,128,0.5); 16 | border-radius:6px; 17 | box-sizing:border-box; 18 | height:26px; 19 | line-height:24px; 20 | padding-left:10px; 21 | padding-right:20px; 22 | cursor:pointer; 23 | -webkit-user-select:none; 24 | user-select:none; 25 | white-space:nowrap; 26 | overflow-x:hidden; 27 | text-overflow:ellipsis; 28 | } 29 | .dropdownCurrentValue:hover{ 30 | background-color:rgba(128,128,128,0.2); 31 | } 32 | .dropdownCurrentValue:after{ 33 | position:absolute; 34 | content:""; 35 | top:11px; 36 | right:7px; 37 | width:0; 38 | height:0; 39 | border:4px solid transparent; 40 | border-color:var(--vscode-editor-foreground) transparent transparent transparent; 41 | } 42 | .dropdown.dropdownOpen .dropdownCurrentValue:after{ 43 | border-color:transparent transparent var(--vscode-editor-foreground) transparent; 44 | top:7px; 45 | } 46 | 47 | .dropdownMenu{ 48 | display:none; 49 | position:absolute; 50 | background-color:var(--vscode-menu-background); 51 | color:var(--vscode-menu-foreground); 52 | top:100%; 53 | left:0; 54 | z-index:19; 55 | line-height:20px; 56 | border:1px solid rgba(128,128,128,0.5); 57 | border-radius:0 0 6px 6px; 58 | overflow-x:hidden; 59 | margin-top:-1px; 60 | white-space:nowrap; 61 | } 62 | 63 | .dropdownOption{ 64 | position:relative; 65 | cursor:pointer; 66 | padding:4px 10px; 67 | -webkit-user-select:none; 68 | user-select:none; 69 | text-overflow:ellipsis; 70 | white-space:nowrap; 71 | overflow:hidden; 72 | } 73 | .dropdown.multi .dropdownOption{ 74 | padding-left:30px; 75 | } 76 | .dropdownOptions.showInfo .dropdownOption{ 77 | padding-right:30px; 78 | } 79 | .dropdownOption.selected{ 80 | background-color:rgba(128,128,128,0.15); 81 | } 82 | .dropdownOption:hover{ 83 | background-color:var(--vscode-menu-selectionBackground, var(--vscode-menu-background)); 84 | color:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); 85 | } 86 | body.vscode-high-contrast .dropdownOption:hover{ 87 | outline:1px dotted var(--vscode-menu-selectionBorder, transparent); 88 | outline-offset:-2px; 89 | } 90 | .dropdownOptionHint{ 91 | opacity:0.6; 92 | margin-left:8px; 93 | } 94 | .dropdownOptionInfo{ 95 | position:absolute; 96 | width:14px; 97 | height:16px; 98 | top:6px; 99 | right:10px; 100 | } 101 | .dropdownOptionInfo svg{ 102 | fill:var(--vscode-menu-foreground); 103 | opacity:0.35; 104 | } 105 | 106 | .dropdownOptionMultiSelected{ 107 | position:absolute; 108 | width:12px; 109 | height:16px; 110 | top:7px; 111 | left:10px; 112 | } 113 | .dropdownOptionMultiSelected svg{ 114 | fill:var(--vscode-menu-foreground); 115 | opacity:0.7; 116 | } 117 | 118 | .dropdownOption:hover svg{ 119 | fill:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); 120 | opacity:0.85; 121 | } 122 | 123 | .dropdown.dropdownOpen .dropdownCurrentValue{ 124 | border-radius:6px 6px 0 0; 125 | } 126 | .dropdown.dropdownOpen .dropdownMenu{ 127 | display:block; 128 | } 129 | 130 | .dropdownFilter{ 131 | position:relative; 132 | padding:3px 3px; 133 | -webkit-user-select:none; 134 | user-select:none; 135 | } 136 | .dropdownFilterInput{ 137 | width:100%; 138 | box-sizing:border-box; 139 | background:none; 140 | border:1px solid rgba(128,128,128,0.5); 141 | outline:none; 142 | border-radius:4px; 143 | padding:3px 6px; 144 | font-size:13px; 145 | line-height:17px; 146 | color:var(--vscode-menu-foreground); 147 | } 148 | 149 | .dropdownNoResults{ 150 | position:relative; 151 | padding:4px 10px; 152 | -webkit-user-select:none; 153 | user-select:none; 154 | font-style:italic; 155 | } 156 | -------------------------------------------------------------------------------- /web/styles/findWidget.css: -------------------------------------------------------------------------------- 1 | .findWidget{ 2 | display:block; 3 | position:fixed; 4 | top:-42px; 5 | right:28px; 6 | height:34px; 7 | padding:0 10px; 8 | z-index:21; 9 | background-color:var(--vscode-editorWidget-background); 10 | box-shadow:0 2px 8px var(--vscode-widget-shadow); 11 | line-height:34px; 12 | font-size:13px; 13 | color:var(--vscode-editorSuggestWidget-foreground); 14 | fill:var(--vscode-editorSuggestWidget-foreground); 15 | fill-opacity:0.7; 16 | white-space:nowrap; 17 | } 18 | .findWidget.active{ 19 | top:0; 20 | } 21 | .findWidget.transition{ 22 | transition:top .2s linear; 23 | } 24 | .findWidget[data-error] #findInput, .findWidget[data-error]:after{ 25 | outline:1px solid var(--vscode-inputValidation-errorBorder) !important; 26 | } 27 | .findWidget[data-error]:after{ 28 | content:attr(data-error); 29 | position:relative; 30 | display:block; 31 | top:-5px; 32 | left:0; 33 | width:201px; 34 | padding:5px; 35 | font-size:12px; 36 | line-height:16px; 37 | background-color:var(--vscode-inputValidation-errorBackground); 38 | outline-offset:-1px !important; 39 | white-space:normal; 40 | } 41 | 42 | #findInput{ 43 | display:inline-block; 44 | vertical-align:top; 45 | margin-top:4px; 46 | padding:5px 47px 5px 5px; 47 | border:0; 48 | height:16px; 49 | width:159px; 50 | background-color:var(--vscode-input-background); 51 | color:var(--vscode-input-foreground); 52 | font-size:13px; 53 | outline-offset:-1px !important; 54 | } 55 | #findInput:focus{ 56 | outline:1px solid var(--vscode-focusBorder); 57 | } 58 | #findInput::placeholder{ 59 | color:var(--vscode-input-placeholderForeground); 60 | } 61 | 62 | .findModifier{ 63 | display:block; 64 | position:absolute; 65 | top:7px; 66 | height:20px; 67 | width:20px; 68 | line-height:20px; 69 | font-size:11px; 70 | cursor:pointer; 71 | -webkit-user-select:none; 72 | user-select:none; 73 | text-align:center; 74 | color:var(--vscode-input-foreground); 75 | opacity:0.7; 76 | } 77 | .findModifier.active, #findOpenCdv.active{ 78 | outline:1px solid var(--vscode-inputOption-activeBorder); 79 | outline-offset:-1px; 80 | background-color:var(--vscode-inputOption-activeBackground); 81 | } 82 | .findModifier:hover, .findModifier.active{ 83 | opacity:1; 84 | } 85 | #findCaseSensitive{ 86 | left:176px; 87 | } 88 | #findRegex{ 89 | left:198px; 90 | font-size:12px; 91 | } 92 | 93 | #findPosition, #findPrev, #findNext, #findOpenCdv, #findClose{ 94 | display:inline-block; 95 | -webkit-user-select:none; 96 | user-select:none; 97 | } 98 | #findPosition{ 99 | min-width:75px; 100 | margin-left:10px; 101 | } 102 | #findPrev, #findNext, #findOpenCdv, #findClose{ 103 | position:relative; 104 | width:20px; 105 | height:20px; 106 | vertical-align:top; 107 | margin:7px 0 0 6px; 108 | cursor:pointer; 109 | } 110 | #findPrev svg, #findNext svg, #findOpenCdv svg{ 111 | position:absolute; 112 | left:3px; 113 | top:3px; 114 | } 115 | #findClose svg{ 116 | position:absolute; 117 | left:2px; 118 | top:2px; 119 | width:16px !important; 120 | height:16px !important; 121 | } 122 | #findPrev:hover, #findNext:hover, #findOpenCdv:hover, #findOpenCdv.active, #findClose:hover{ 123 | fill-opacity:1; 124 | } 125 | #findPrev.disabled, #findNext.disabled{ 126 | fill-opacity:0.3; 127 | cursor:default; 128 | } 129 | 130 | .findMatch{ 131 | background-color:var(--git-graph-findMatch, transparent); 132 | outline:1px solid var(--vscode-editor-findMatchHighlightBorder, transparent); 133 | outline-offset:-1px; 134 | } 135 | body.vscode-high-contrast .findMatch{ 136 | outline-style:dotted; 137 | } 138 | -------------------------------------------------------------------------------- /web/styles/settingsWidget.css: -------------------------------------------------------------------------------- 1 | #settingsWidget{ 2 | display:block; 3 | position:fixed; 4 | top:-158px; 5 | right:28px; 6 | width:400px; 7 | min-height:146px; 8 | padding:0 10px 4px 10px; 9 | z-index:31; 10 | background-color:var(--vscode-editorWidget-background); 11 | box-shadow:0 2px 8px var(--vscode-widget-shadow); 12 | line-height:17px; 13 | font-size:13px; 14 | color:var(--vscode-editorSuggestWidget-foreground); 15 | fill:var(--vscode-editorSuggestWidget-foreground); 16 | fill-opacity:0.7; 17 | -webkit-user-select:none; 18 | user-select:none; 19 | overflow-x:hidden; 20 | overflow-y:auto; 21 | max-height:90vh; 22 | } 23 | #settingsWidget.active{ 24 | top:0; 25 | } 26 | #settingsWidget.transition{ 27 | transition:top .2s linear; 28 | } 29 | 30 | #settingsWidget h2{ 31 | margin:8px 10px; 32 | padding:0; 33 | font-size:16px; 34 | line-height:24px; 35 | text-align:center; 36 | } 37 | 38 | .settingsSection{ 39 | position:relative; 40 | padding:6px; 41 | } 42 | .settingsSection.centered{ 43 | text-align:center; 44 | } 45 | .settingsSection.general{ 46 | padding-left:11px; 47 | padding-right:11px; 48 | } 49 | 50 | .settingsSection h3{ 51 | position:relative; 52 | margin:5px 0; 53 | padding:0; 54 | font-size:15px; 55 | line-height:20px; 56 | text-align:center; 57 | } 58 | 59 | .settingsSection > table{ 60 | width:100%; 61 | border-collapse:collapse; 62 | } 63 | 64 | .settingsSection:before, .settingsSection > table tr.lineAbove td:before, .settingsSection > table tr.lineBelow td:after, .settingsSection .settingsSectionButtons.lineAbove:before{ 65 | content:''; 66 | position:absolute; 67 | display:block; 68 | left:0; 69 | right:0; 70 | height:1px; 71 | background-color:var(--vscode-editorSuggestWidget-foreground); 72 | opacity:0.3; 73 | } 74 | .settingsSection:before, .settingsSection > table tr.lineAbove td:before, .settingsSection .settingsSectionButtons.lineAbove:before{ 75 | top:0; 76 | } 77 | .settingsSection > table tr.lineBelow td:after{ 78 | bottom:0; 79 | } 80 | .settingsSection > table tr.lineAbove td:before, .settingsSection > table tr.lineBelow td:after, .settingsSection .settingsSectionButtons.lineAbove:before{ 81 | opacity:0.15; 82 | } 83 | 84 | .settingsSection > table th, .settingsSection > table td{ 85 | position:relative; 86 | text-align:center; 87 | white-space:nowrap; 88 | } 89 | .settingsSection > table th{ 90 | font-weight:bold; 91 | padding:3px 5px; 92 | } 93 | .settingsSection > table td{ 94 | padding:0 5px 3px 5px; 95 | } 96 | .settingsSection > table tr.lineAbove td{ 97 | padding-top:3px; 98 | } 99 | .settingsSection > table td.left{ 100 | text-align:left; 101 | } 102 | .settingsSection > table td.leftWithEllipsis{ 103 | width:100%; 104 | max-width:0; 105 | overflow:hidden; 106 | text-overflow:ellipsis; 107 | text-align:left; 108 | } 109 | .settingsSection > table td.right{ 110 | text-align:right; 111 | } 112 | 113 | .settingsSection.general > table{ 114 | margin-bottom:4px; 115 | } 116 | .settingsSection.general > table td{ 117 | padding:5px !important; 118 | } 119 | 120 | .settingsSection .hideRemoteBtn{ 121 | position:relative; 122 | display:inline-block; 123 | width:13px; 124 | height:13px; 125 | margin-right:5px; 126 | vertical-align:middle; 127 | cursor:pointer; 128 | } 129 | .settingsSection .hideRemoteBtn svg{ 130 | position:absolute; 131 | top:0; 132 | left:0; 133 | } 134 | 135 | .settingsSection > table td.btns div{ 136 | display:inline-block; 137 | position:relative; 138 | width:17px; 139 | height:17px; 140 | vertical-align:top; 141 | cursor:pointer; 142 | } 143 | .settingsSection > table td.btns.remoteBtns div{ 144 | vertical-align:middle; 145 | } 146 | .settingsSection > table #editRepoName svg, .settingsSection > table #editInitialBranches svg, .settingsSection > table .editRemote svg{ 147 | position:absolute; 148 | left:1.5px; 149 | top:1.5px; 150 | width:14px !important; 151 | height:14px !important; 152 | } 153 | .settingsSection > table #deleteRepoName svg, .settingsSection > table #clearInitialBranches svg, .settingsSection > table .deleteRemote svg, .settingsSection > table .fetchRemote svg, .settingsSection > table .pruneRemote svg{ 154 | position:absolute; 155 | left:0.5px; 156 | top:0.5px; 157 | width:16px !important; 158 | height:16px !important; 159 | } 160 | 161 | .settingsSectionButtons{ 162 | position:relative; 163 | width:100%; 164 | padding:2px 0; 165 | text-align:center; 166 | } 167 | 168 | .settingsSectionButtons > div{ 169 | display:inline-block; 170 | line-height:24px; 171 | cursor:pointer; 172 | padding-right:6px; 173 | } 174 | 175 | .settingsSection label{ 176 | display:inline-block; 177 | margin:4px 0; 178 | cursor:pointer; 179 | } 180 | 181 | .settingsSection .customCheckbox{ 182 | margin-right:6px; 183 | } 184 | 185 | .settingsSection label > input[type=checkbox]:checked ~ .customCheckbox:after{ 186 | border-color:var(--vscode-editorSuggestWidget-foreground); 187 | } 188 | 189 | .settingsSectionButtons > div.editBtn svg{ 190 | vertical-align:top; 191 | margin:6px; 192 | width:12px !important; 193 | height:12px !important; 194 | } 195 | .settingsSectionButtons > div.addBtn svg, .settingsSectionButtons > div.removeBtn svg, #openExtensionSettings svg, #exportRepositoryConfig svg{ 196 | vertical-align:top; 197 | width:14px !important; 198 | height:14px !important; 199 | margin:5px; 200 | } 201 | #openExtensionSettings svg, #exportRepositoryConfig svg{ 202 | margin-top:6px; 203 | } 204 | #exportRepositoryConfig svg{ 205 | margin-right:7px; 206 | } 207 | 208 | #settingsLoading{ 209 | display:none; 210 | position:absolute; 211 | top:0; 212 | left:0; 213 | right:0; 214 | height:24px; 215 | z-index:32; 216 | line-height:24px; 217 | text-align:center; 218 | } 219 | #settingsLoading svg{ 220 | display:inline-block; 221 | width:15px !important; 222 | height:20px !important; 223 | margin-top:2px; 224 | margin-right:8px; 225 | vertical-align:top; 226 | animation:loadingIconAnimation 2s linear infinite; 227 | } 228 | 229 | #settingsClose{ 230 | position:absolute; 231 | width:24px; 232 | height:24px; 233 | top:8px; 234 | right:10px; 235 | cursor:pointer; 236 | z-index:33; 237 | } 238 | #settingsClose svg{ 239 | position:absolute; 240 | left:4px; 241 | top:4px; 242 | width:16px !important; 243 | height:16px !important; 244 | } 245 | 246 | .settingsSectionButtons > div:hover svg, .settingsSection .hideRemoteBtn:hover svg, .settingsSection > table td.btns div:hover svg, #settingsClose:hover{ 247 | fill-opacity:1; 248 | } 249 | #settingsWidget.loading #settingsContent{ 250 | opacity:0.1; 251 | } 252 | #settingsWidget.loading #settingsLoading{ 253 | display:block; 254 | } 255 | 256 | #settingsWidget .settingsWidgetInfo{ 257 | cursor:help; 258 | } 259 | #settingsWidget .settingsWidgetInfo > svg{ 260 | width:15px !important; 261 | height:15px !important; 262 | margin-top:-2px; 263 | margin-left:5px; 264 | vertical-align:middle; 265 | } 266 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es6", 5 | "dom" 6 | ], 7 | "module": "none", 8 | "moduleResolution": "node", 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "../media", 14 | "removeComments": true, 15 | "strict": true, 16 | "target": "es5" 17 | } 18 | } --------------------------------------------------------------------------------