├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ └── pr-auditor.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── icon ├── LICENSE └── git_logo.svg ├── mocha.opts ├── package.json ├── prettier.config.js ├── renovate.json ├── src ├── blame.test.ts ├── blame.ts ├── extension.test.ts ├── extension.ts ├── uri.ts └── util │ ├── memoizeAsync.ts │ └── stubs.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Test plan 2 | 3 | 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 3 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '14.7.0' 17 | - run: yarn --frozen-lockfile 18 | - run: yarn prettier-check 19 | - run: yarn tslint 20 | - run: yarn typecheck 21 | - run: yarn build 22 | - run: yarn cover 23 | - run: yarn nyc report --reporter json 24 | - run: 'bash <(curl -s https://codecov.io/bash)' 25 | -------------------------------------------------------------------------------- /.github/workflows/pr-auditor.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.sourcegraph.com/dev/background-information/ci#pr-auditor 2 | name: pr-auditor 3 | on: 4 | pull_request_target: 5 | types: [ closed, edited, opened, synchronize, ready_for_review ] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | check-pr: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | repository: 'sourcegraph/devx-service' 15 | token: ${{ secrets.PR_AUDITOR_TOKEN }} 16 | - uses: actions/setup-go@v4 17 | with: { go-version: '1.22' } 18 | 19 | - run: 'go run ./cmd/pr-auditor' 20 | env: 21 | GITHUB_EVENT_PATH: ${{ env.GITHUB_EVENT_PATH }} 22 | GITHUB_TOKEN: ${{ secrets.PR_AUDITOR_TOKEN }} 23 | GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 24 | report_failure: 25 | needs: check-pr 26 | if: ${{ failure() }} 27 | uses: sourcegraph/workflows/.github/workflows/report-job-failure.yml@main 28 | secrets: inherit 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | .cache/ 5 | .nyc_output/ 6 | yarn-error.log 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | dist/ 4 | .cache/ 5 | coverage/ 6 | .nyc_output/ 7 | .github 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sourcegraph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sourcegraph-git-extras 2 | 3 | [![master build status](https://img.shields.io/github/workflow/status/sourcegraph/sourcegraph-git-extras/build/master?logo=github)](https://github.com/sourcegraph/sourcegraph-git-extras/actions?query=branch%3Amaster) 4 | [![codecov](https://codecov.io/gh/sourcegraph/sourcegraph-git-extras/branch/master/graph/badge.svg?token=c3KpMf1MaY)](https://codecov.io/gh/sourcegraph/sourcegraph-git-extras) 5 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 6 | 7 | ## ⚠️ Deprecation notice 8 | 9 | **Sourcegraph extensions have been deprecated with the September 2022 Sourcegraph 10 | release. [Learn more](https://docs.sourcegraph.com/extensions/deprecation).** 11 | 12 | Starting with the September 2022 release of Sourcegraph, git extras is a core part of the product. 13 | 14 | The repo and the docs below are kept to support older Sourcegraph versions. 15 | 16 | ## Description 17 | 18 | A [Sourcegraph extension](https://docs.sourcegraph.com/extensions) that adds useful features when viewing files in a Git repository on [Sourcegraph](https://sourcegraph.com), GitHub, GitLab, and other [supported code hosts](https://docs.sourcegraph.com/extensions): 19 | 20 | - **Git: Show/hide blame**: toggles Git blame annotations with each line's last commit, author, date, etc. 21 | 22 | [**🗃️ Source code**](https://github.com/sourcegraph/sourcegraph-git-extras) 23 | 24 | [**➕ Add to Sourcegraph**](https://sourcegraph.com/extensions/sourcegraph/git-extras) 25 | 26 | ![screenshot from 2018-10-28 17-55-20](https://user-images.githubusercontent.com/1976/47624533-f3a1e800-dada-11e8-81d9-3d4bd67fc08a.png) 27 | ![screenshot from 2018-10-28 17-55-02](https://user-images.githubusercontent.com/1976/47624534-f3a1e800-dada-11e8-9c08-9ce307653b20.png) 28 | -------------------------------------------------------------------------------- /icon/LICENSE: -------------------------------------------------------------------------------- 1 | Git Logo by Jason Long is licensed under the Creative Commons Attribution 3.0 Unported License. 2 | https://git-scm.com/downloads/logos 3 | -------------------------------------------------------------------------------- /icon/git_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | --watch-extensions ts 3 | --timeout 200 4 | src/**/*.test.ts -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/sourcegraph/sourcegraph/main/client/shared/src/schema/extension.schema.json", 3 | "name": "git-extras", 4 | "publisher": "sourcegraph", 5 | "title": "Git extras", 6 | "description": "A Sourcegraph extension that adds useful features when viewing files in a Git repository on Sourcegraph, GitHub, GitLab, and other supported code hosts.", 7 | "icon": "data:image/png;base64,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", 8 | "activationEvents": [ 9 | "*" 10 | ], 11 | "tags": [ 12 | "blame", 13 | "github", 14 | "gitlab", 15 | "code host", 16 | "author" 17 | ], 18 | "categories": [ 19 | "Code analysis" 20 | ], 21 | "contributes": { 22 | "actions": [ 23 | { 24 | "id": "git.blame.toggle", 25 | "command": "updateConfiguration", 26 | "commandArguments": [ 27 | [ 28 | "git.blame.decorations" 29 | ], 30 | "${(config.git.blame.decorations === 'line' && 'file') || (config.git.blame.decorations === 'file' && 'none') || 'line'}", 31 | null 32 | ], 33 | "category": "Git", 34 | "title": "${(config.git.blame.decorations === 'line' && 'Show blame for the whole file') || (config.git.blame.decorations === 'file' && 'Hide blame') || 'Show blame for selected lines'}", 35 | "actionItem": { 36 | "label": "Blame", 37 | "description": "${(config.git.blame.decorations === 'line' && 'Show Git blame line annotations for the whole file') || (config.git.blame.decorations === 'file' && 'Hide Git blame line annotations') || 'Show Git blame line annotations on selected lines'}", 38 | "pressed": "(config.git.blame.decorations === 'line') || (config.git.blame.decorations === 'file')", 39 | "iconURL": "https://raw.githubusercontent.com/sourcegraph/sourcegraph-git-extras/63dd95962c43b95b3f3a9ea2aa0165d6b38a958c/icon/git_logo.svg?sanitize=true" 40 | } 41 | } 42 | ], 43 | "menus": { 44 | "editor/title": [ 45 | { 46 | "action": "git.blame.toggle", 47 | "when": "resource && clientApplication.isSourcegraph" 48 | } 49 | ], 50 | "commandPalette": [ 51 | { 52 | "action": "git.blame.toggle", 53 | "when": "resource" 54 | } 55 | ] 56 | }, 57 | "configuration": { 58 | "title": "Git extras", 59 | "properties": { 60 | "git.blame.decorations": { 61 | "description": "Whether to decorate all lines in a file, only selected lines, or none at all.", 62 | "type": "string", 63 | "enum": [ 64 | "none", 65 | "line", 66 | "file" 67 | ], 68 | "default": "none" 69 | }, 70 | "git.blame.showPreciseDate": { 71 | "description": "Whether to show precise dates (e.g. \"Mar 17, 2021\"). By default, distance from current date is shown (e.g. \"3 months ago\")", 72 | "type": "boolean", 73 | "default": false 74 | } 75 | } 76 | } 77 | }, 78 | "version": "0.0.0-DEVELOPMENT", 79 | "license": "MIT", 80 | "repository": { 81 | "type": "git", 82 | "url": "https://github.com/sourcegraph/sourcegraph-git-extras.git" 83 | }, 84 | "files": [ 85 | "dist" 86 | ], 87 | "main": "dist/sourcegraph-git-extras.js", 88 | "scripts": { 89 | "prettier-check": "npm run prettier -- --write=false", 90 | "prettier": "prettier '**/*.{js?(on),ts?(x),scss,yml,md}' --write --list-different", 91 | "tslint": "tslint -c tslint.json -p tsconfig.json './src/*.ts?(x)' './*.ts?(x)'", 92 | "typecheck": "tsc -p .", 93 | "test": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' mocha --require ts-node/register --require source-map-support/register --opts mocha.opts", 94 | "cover": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --require ts-node/register --require source-map-support/register --all mocha --opts mocha.opts --timeout 10000", 95 | "build": "parcel build --no-minify --out-file sourcegraph-git-extras.js src/extension.ts", 96 | "symlink-package": "mkdirp dist && lnfs ./package.json ./dist/package.json", 97 | "serve": "npm run symlink-package && parcel serve --no-hmr --out-file sourcegraph-git-extras.js src/extension.ts", 98 | "sourcegraph:prepublish": "yarn typecheck && yarn test && yarn build" 99 | }, 100 | "commitlint": { 101 | "extends": [ 102 | "@commitlint/config-conventional" 103 | ] 104 | }, 105 | "nyc": { 106 | "include": [ 107 | "src/**/*.ts?(x)" 108 | ], 109 | "exclude": [ 110 | "**/*.test.ts?(x)", 111 | "src/util/stubs.ts" 112 | ], 113 | "extension": [ 114 | ".tsx", 115 | ".ts" 116 | ] 117 | }, 118 | "browserslist": [ 119 | "last 1 Chrome versions", 120 | "last 1 Firefox versions", 121 | "last 1 Edge versions", 122 | "last 1 Safari versions" 123 | ], 124 | "devDependencies": { 125 | "@commitlint/cli": "^17.3.0", 126 | "@commitlint/config-conventional": "^17.3.0", 127 | "@sourcegraph/prettierrc": "^2.2.0", 128 | "@sourcegraph/tsconfig": "^4.0.1", 129 | "@sourcegraph/tslint-config": "^13.4.0", 130 | "@types/expect": "1.20.4", 131 | "@types/lodash": "4.17.13", 132 | "@types/mocha": "9.1.1", 133 | "@types/node": "22.10.1", 134 | "@types/sinon": "17.0.3", 135 | "expect": "^24.1.0", 136 | "graphql": "^15.4.0", 137 | "husky": "^4.3.5", 138 | "lnfs-cli": "^2.1.0", 139 | "lodash": "^4.17.20", 140 | "mkdirp": "^0.5.1", 141 | "mocha": "^6.1.4", 142 | "nyc": "^13.3.0", 143 | "parcel-bundler": "^1.12.4", 144 | "prettier": "^1.19.1", 145 | "sinon": "^17.0.1", 146 | "source-map-support": "^0.5.12", 147 | "sourcegraph": "^25.7.0", 148 | "ts-node": "^8.10.2", 149 | "tslint": "^5.11.0", 150 | "typescript": "^4.1.2", 151 | "vscode-languageserver-types": "^3.14.0" 152 | }, 153 | "dependencies": { 154 | "date-fns": "^2.0.0-alpha.24", 155 | "rxjs": "^6.6.3", 156 | "tagged-template-noop": "^2.1.0" 157 | }, 158 | "husky": { 159 | "hooks": { 160 | "commit-msg": "commitlint -e $HUSKY_GIT_PARAMS" 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@sourcegraph/prettierrc') 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/renovate", 3 | "extends": ["github>sourcegraph/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /src/blame.test.ts: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import { 3 | getAllBlameDecorations, 4 | getBlameDecorations, 5 | getBlameDecorationsForSelections, 6 | getBlameStatusBarItem, 7 | getDecorationFromHunk, 8 | Hunk, 9 | } from './blame' 10 | import { createMockSourcegraphAPI } from './util/stubs' 11 | 12 | const FIXTURE_HUNK_1: Hunk = { 13 | startLine: 1, 14 | endLine: 2, 15 | author: { 16 | person: { 17 | email: 'email@email.email', 18 | displayName: 'a', 19 | user: null, 20 | }, 21 | date: '2018-09-10T21:52:45Z', 22 | }, 23 | rev: 'b', 24 | message: 'c', 25 | commit: { 26 | url: 'd', 27 | }, 28 | } 29 | 30 | const FIXTURE_HUNK_2: Hunk = { 31 | startLine: 2, 32 | endLine: 3, 33 | author: { 34 | person: { 35 | email: 'email@email.email', 36 | displayName: 'e', 37 | user: null, 38 | }, 39 | date: '2018-11-10T21:52:45Z', 40 | }, 41 | rev: 'f', 42 | message: 'g', 43 | commit: { 44 | url: 'h', 45 | }, 46 | } 47 | 48 | const FIXTURE_HUNK_3: Hunk = { 49 | startLine: 3, 50 | endLine: 4, 51 | author: { 52 | person: { 53 | email: 'email@email.email', 54 | displayName: 'i', 55 | user: null, 56 | }, 57 | date: '2018-10-10T21:52:45Z', 58 | }, 59 | rev: 'j', 60 | message: 'k', 61 | commit: { 62 | url: 'l', 63 | }, 64 | } 65 | 66 | const FIXTURE_HUNK_4: Hunk = { 67 | startLine: 4, 68 | endLine: 5, 69 | author: { 70 | person: { 71 | email: 'email@email.email', 72 | displayName: 'i', 73 | user: { 74 | username: 'testUserName', 75 | }, 76 | }, 77 | date: '2018-10-10T21:52:45Z', 78 | }, 79 | rev: 'j', 80 | message: 'k', 81 | commit: { 82 | url: 'l', 83 | }, 84 | } 85 | 86 | const NOW = +new Date('2018-12-01T21:52:45Z') 87 | 88 | const SOURCEGRAPH = createMockSourcegraphAPI() 89 | 90 | describe('getDecorationsFromHunk()', () => { 91 | it('creates a TextDocumentDecoration from a Hunk', () => { 92 | expect( 93 | getDecorationFromHunk(FIXTURE_HUNK_1, NOW, 0, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any) 94 | ).toEqual({ 95 | after: { 96 | contentText: '3 months ago • a [c]', 97 | dark: { 98 | backgroundColor: 'rgba(15, 43, 89, 0.65)', 99 | color: 'rgba(235, 235, 255, 0.55)', 100 | }, 101 | hoverMessage: `${FIXTURE_HUNK_1.author.person.email} • ${FIXTURE_HUNK_1.message}`, 102 | light: { 103 | backgroundColor: 'rgba(193, 217, 255, 0.65)', 104 | color: 'rgba(0, 0, 25, 0.55)', 105 | }, 106 | linkURL: 'https://sourcegraph.test/d', 107 | }, 108 | isWholeLine: true, 109 | range: { 110 | end: 0, 111 | start: 0, 112 | }, 113 | }) 114 | }) 115 | 116 | it('truncates long commit messsages', () => { 117 | const decoration = getDecorationFromHunk( 118 | { 119 | ...FIXTURE_HUNK_1, 120 | message: 'asdgjdsag asdklgbasdghladg asdgjlhbasdgjlhabsdg asdgilbadsgiobasgd', 121 | }, 122 | NOW, 123 | 0, 124 | { 'git.blame.showPreciseDate': false }, 125 | SOURCEGRAPH as any 126 | ) 127 | expect(decoration.after && decoration.after.contentText).toEqual( 128 | '3 months ago • a [asdgjdsag asdklgbasdghladg asdgjlhbasdgjlhabs…]' 129 | ) 130 | }) 131 | 132 | it('truncates long display names', () => { 133 | const decoration = getDecorationFromHunk( 134 | { 135 | ...FIXTURE_HUNK_1, 136 | author: { 137 | person: { 138 | email: 'email@email.email', 139 | displayName: 'asdgjdsag asdklgbasdghladg asdgjlhbasdgjlhabsdg asdgilbadsgiobasgd', 140 | user: null, 141 | }, 142 | date: '2018-09-10T21:52:45Z', 143 | }, 144 | }, 145 | NOW, 146 | 0, 147 | { 'git.blame.showPreciseDate': false }, 148 | SOURCEGRAPH as any 149 | ) 150 | expect(decoration.after && decoration.after.contentText).toEqual( 151 | '3 months ago • asdgjdsag asdklgbasdghlad… [c]' 152 | ) 153 | }) 154 | }) 155 | 156 | describe('getBlameDecorationsForSelections()', () => { 157 | it('adds decorations only for hunks that are within the selections', () => { 158 | const decorations = getBlameDecorationsForSelections( 159 | [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 160 | [new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(1, 0), new SOURCEGRAPH.Position(1, 0)) as any], 161 | NOW, 162 | { 'git.blame.showPreciseDate': false }, 163 | SOURCEGRAPH as any 164 | ) 165 | expect(decorations).toEqual([ 166 | getDecorationFromHunk(FIXTURE_HUNK_2, NOW, 1, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 167 | ]) 168 | }) 169 | 170 | it('handles multiple selections', () => { 171 | const decorations = getBlameDecorationsForSelections( 172 | [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 173 | [ 174 | new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(1, 0), new SOURCEGRAPH.Position(1, 0)) as any, 175 | new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(2, 0), new SOURCEGRAPH.Position(5, 0)) as any, 176 | new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(6, 0), new SOURCEGRAPH.Position(10, 0)) as any, 177 | ], 178 | NOW, 179 | { 'git.blame.showPreciseDate': false }, 180 | SOURCEGRAPH as any 181 | ) 182 | expect(decorations).toEqual([ 183 | getDecorationFromHunk(FIXTURE_HUNK_2, NOW, 1, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 184 | getDecorationFromHunk(FIXTURE_HUNK_3, NOW, 2, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 185 | getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 186 | ]) 187 | }) 188 | 189 | it('handles multiple hunks per selection', () => { 190 | const decorations = getBlameDecorationsForSelections( 191 | [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 192 | [new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(0, 0), new SOURCEGRAPH.Position(5, 0)) as any], 193 | NOW, 194 | { 'git.blame.showPreciseDate': false }, 195 | SOURCEGRAPH as any 196 | ) 197 | expect(decorations).toEqual([ 198 | getDecorationFromHunk(FIXTURE_HUNK_1, NOW, 0, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 199 | getDecorationFromHunk(FIXTURE_HUNK_2, NOW, 1, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 200 | getDecorationFromHunk(FIXTURE_HUNK_3, NOW, 2, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 201 | getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 202 | ]) 203 | }) 204 | 205 | it('decorates the start line of the selection if the start line of the hunk is outside of the selection boundaries', () => { 206 | const decorations = getBlameDecorationsForSelections( 207 | [ 208 | { 209 | ...FIXTURE_HUNK_1, 210 | startLine: 1, 211 | endLine: 10, 212 | }, 213 | ], 214 | [new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(2, 0), new SOURCEGRAPH.Position(2, 0)) as any], 215 | NOW, 216 | { 'git.blame.showPreciseDate': false }, 217 | SOURCEGRAPH as any 218 | ) 219 | expect(decorations).toEqual([ 220 | getDecorationFromHunk(FIXTURE_HUNK_1, NOW, 2, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 221 | ]) 222 | }) 223 | }) 224 | 225 | describe('getAllBlameDecorations()', () => { 226 | it('adds decorations for all hunks', () => { 227 | expect( 228 | getAllBlameDecorations( 229 | [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 230 | NOW, 231 | { 'git.blame.showPreciseDate': false }, 232 | SOURCEGRAPH as any 233 | ) 234 | ).toEqual([ 235 | getDecorationFromHunk(FIXTURE_HUNK_1, NOW, 0, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 236 | getDecorationFromHunk(FIXTURE_HUNK_2, NOW, 1, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 237 | getDecorationFromHunk(FIXTURE_HUNK_3, NOW, 2, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 238 | getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 239 | ]) 240 | }) 241 | }) 242 | 243 | describe('getBlameDecorations()', () => { 244 | it('gets decorations for all hunks if no selections are passed', async () => { 245 | expect( 246 | getBlameDecorations({ 247 | settings: { 248 | 'git.blame.decorations': 'line', 249 | 'git.blame.showPreciseDate': false, 250 | }, 251 | now: NOW, 252 | selections: null, 253 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 254 | sourcegraph: SOURCEGRAPH as any, 255 | }) 256 | ).toEqual([ 257 | getDecorationFromHunk(FIXTURE_HUNK_1, NOW, 0, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 258 | getDecorationFromHunk(FIXTURE_HUNK_2, NOW, 1, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 259 | getDecorationFromHunk(FIXTURE_HUNK_3, NOW, 2, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 260 | getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 261 | ]) 262 | }) 263 | 264 | it('gets decorations for the selections if selections are passed', async () => { 265 | expect( 266 | getBlameDecorations({ 267 | settings: { 268 | 'git.blame.decorations': 'line', 269 | }, 270 | now: NOW, 271 | selections: [ 272 | new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(3, 0), new SOURCEGRAPH.Position(3, 0)) as any, 273 | ], 274 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 275 | sourcegraph: SOURCEGRAPH as any, 276 | }) 277 | ).toEqual([ 278 | getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 279 | ]) 280 | }) 281 | 282 | it('gets no decorations if git.blame.decorations is "none"', async () => { 283 | expect( 284 | getBlameDecorations({ 285 | settings: { 286 | 'git.blame.decorations': 'none', 287 | }, 288 | now: NOW, 289 | selections: null, 290 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 291 | sourcegraph: SOURCEGRAPH as any, 292 | }) 293 | ).toEqual([]) 294 | }) 295 | 296 | it('gets decorations for all hunks if git.blame.decorations is "file"', async () => { 297 | expect( 298 | getBlameDecorations({ 299 | settings: { 300 | 'git.blame.decorations': 'file', 301 | }, 302 | now: NOW, 303 | selections: [ 304 | new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(3, 0), new SOURCEGRAPH.Position(3, 0)) as any, 305 | ], 306 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 307 | sourcegraph: SOURCEGRAPH as any, 308 | }) 309 | ).toEqual([ 310 | getDecorationFromHunk(FIXTURE_HUNK_1, NOW, 0, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 311 | getDecorationFromHunk(FIXTURE_HUNK_2, NOW, 1, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 312 | getDecorationFromHunk(FIXTURE_HUNK_3, NOW, 2, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 313 | getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, { 'git.blame.showPreciseDate': false }, SOURCEGRAPH as any), 314 | ]) 315 | }) 316 | 317 | it('renders username in decoration content message', async () => { 318 | expect( 319 | getDecorationFromHunk( 320 | FIXTURE_HUNK_4, 321 | NOW, 322 | 3, 323 | { 'git.blame.showPreciseDate': false }, 324 | SOURCEGRAPH as any 325 | ).after!.contentText!.includes( 326 | `(${FIXTURE_HUNK_4.author.person.user!.username}) ${FIXTURE_HUNK_4.author.person.displayName}` 327 | ) 328 | ).toBe(true) 329 | expect( 330 | getDecorationFromHunk( 331 | FIXTURE_HUNK_3, 332 | NOW, 333 | 2, 334 | { 'git.blame.showPreciseDate': false }, 335 | SOURCEGRAPH as any 336 | ).after!.contentText!.includes(`${FIXTURE_HUNK_3.author.person.displayName}`) 337 | ).toBe(true) 338 | }) 339 | }) 340 | 341 | describe('getBlameStatusBarItem()', () => { 342 | it('displays the hunk for the first selected line', () => { 343 | expect( 344 | getBlameStatusBarItem({ 345 | selections: [ 346 | new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(3, 0), new SOURCEGRAPH.Position(3, 0)) as any, 347 | ], 348 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 349 | sourcegraph: SOURCEGRAPH as any, 350 | settings: { 'git.blame.showPreciseDate': false }, 351 | now: NOW, 352 | }).text 353 | ).toBe('Author: (testUserName) i, 2 months ago') 354 | }) 355 | 356 | it('displays the most recent hunk if there are no selections', () => { 357 | expect( 358 | getBlameStatusBarItem({ 359 | selections: [], 360 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 361 | sourcegraph: SOURCEGRAPH as any, 362 | settings: { 'git.blame.showPreciseDate': false }, 363 | now: NOW, 364 | }).text 365 | ).toBe('Author: e, 21 days ago') 366 | 367 | expect( 368 | getBlameStatusBarItem({ 369 | selections: null, 370 | hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4], 371 | sourcegraph: SOURCEGRAPH as any, 372 | settings: { 'git.blame.showPreciseDate': false }, 373 | now: NOW, 374 | }).text 375 | ).toBe('Author: e, 21 days ago') 376 | }) 377 | }) 378 | -------------------------------------------------------------------------------- /src/blame.ts: -------------------------------------------------------------------------------- 1 | import compareDesc from 'date-fns/compareDesc' 2 | import format from 'date-fns/format' 3 | import formatDistanceStrict from 'date-fns/formatDistanceStrict' 4 | import { Selection, StatusBarItem, TextDocumentDecoration } from 'sourcegraph' 5 | import gql from 'tagged-template-noop' 6 | import { Settings } from './extension' 7 | import { resolveURI } from './uri' 8 | import { memoizeAsync } from './util/memoizeAsync' 9 | 10 | /** 11 | * Get display info shared between status bar items and text document decorations. 12 | */ 13 | const getDisplayInfoFromHunk = ({ 14 | hunk: { author, commit, message }, 15 | now, 16 | settings, 17 | sourcegraph, 18 | }: { 19 | hunk: Pick 20 | now: number 21 | settings: Pick 22 | sourcegraph: typeof import('sourcegraph') 23 | }): { displayName: string; username: string; dateString: string; linkURL: string; hoverMessage: string } => { 24 | const displayName = truncate(author.person.displayName, 25) 25 | const username = author.person.user ? `(${author.person.user.username}) ` : '' 26 | const dateString = settings['git.blame.showPreciseDate'] 27 | ? format(author.date, 'MMM dd, y') 28 | : formatDistanceStrict(author.date, now, { addSuffix: true }) 29 | const linkURL = new URL(commit.url, sourcegraph.internal.sourcegraphURL.toString()).href 30 | const hoverMessage = `${author.person.email} • ${truncate(message, 1000)}` 31 | 32 | return { 33 | displayName, 34 | username, 35 | dateString, 36 | linkURL, 37 | hoverMessage, 38 | } 39 | } 40 | 41 | /** 42 | * Get hunks and 0-indexed start lines for the given selections. 43 | * 44 | * @param selections If null, returns all hunks 45 | */ 46 | export const getHunksForSelections = ( 47 | hunks: Hunk[], 48 | selections: Selection[] | null 49 | ): { selectionStartLine: number; hunk: Hunk }[] => { 50 | const hunksForSelections: { selectionStartLine: number; hunk: Hunk }[] = [] 51 | 52 | if (!selections) { 53 | return hunks.map(hunk => ({ hunk, selectionStartLine: hunk.startLine - 1 })) 54 | } 55 | 56 | for (const hunk of hunks) { 57 | // Hunk start and end lines are 1-indexed, but selection lines are zero-indexed 58 | const hunkStartLineZeroBased = hunk.startLine - 1 59 | // A Hunk's end line overlaps with the next hunk's start line. 60 | // -2 here to avoid decorating the same line twice. 61 | const hunkEndLineZeroBased = hunk.endLine - 2 62 | for (const selection of selections) { 63 | if (selection.end.line < hunkStartLineZeroBased || selection.start.line > hunkEndLineZeroBased) { 64 | continue 65 | } 66 | 67 | // Decorate the hunk's start line or, if the hunk's start line is 68 | // outside of the selection's boundaries, the start line of the selection. 69 | const selectionStartLine = 70 | hunkStartLineZeroBased < selection.start.line ? selection.start.line : hunkStartLineZeroBased 71 | hunksForSelections.push({ selectionStartLine, hunk }) 72 | } 73 | } 74 | 75 | return hunksForSelections 76 | } 77 | 78 | export const getDecorationFromHunk = ( 79 | hunk: Hunk, 80 | now: number, 81 | decoratedLine: number, 82 | settings: Pick, 83 | sourcegraph: typeof import('sourcegraph') 84 | ): TextDocumentDecoration => { 85 | const { displayName, username, dateString, linkURL, hoverMessage } = getDisplayInfoFromHunk({ 86 | hunk, 87 | now, 88 | settings, 89 | sourcegraph, 90 | }) 91 | 92 | return { 93 | range: new sourcegraph.Range(decoratedLine, 0, decoratedLine, 0), 94 | isWholeLine: true, 95 | after: { 96 | light: { 97 | color: 'rgba(0, 0, 25, 0.55)', 98 | backgroundColor: 'rgba(193, 217, 255, 0.65)', 99 | }, 100 | dark: { 101 | color: 'rgba(235, 235, 255, 0.55)', 102 | backgroundColor: 'rgba(15, 43, 89, 0.65)', 103 | }, 104 | contentText: `${dateString} • ${username}${displayName} [${truncate(hunk.message, 45)}]`, 105 | hoverMessage, 106 | linkURL, 107 | }, 108 | } 109 | } 110 | 111 | export const getBlameDecorationsForSelections = ( 112 | hunks: Hunk[], 113 | selections: Selection[], 114 | now: number, 115 | settings: Pick, 116 | sourcegraph: typeof import('sourcegraph') 117 | ) => 118 | getHunksForSelections(hunks, selections).map(({ hunk, selectionStartLine }) => 119 | getDecorationFromHunk(hunk, now, selectionStartLine, settings, sourcegraph) 120 | ) 121 | 122 | export const getAllBlameDecorations = ( 123 | hunks: Hunk[], 124 | now: number, 125 | settings: Pick, 126 | sourcegraph: typeof import('sourcegraph') 127 | ) => hunks.map(hunk => getDecorationFromHunk(hunk, now, hunk.startLine - 1, settings, sourcegraph)) 128 | 129 | export const queryBlameHunks = memoizeAsync( 130 | async ({ 131 | uri, 132 | sourcegraph, 133 | selections, 134 | }: { 135 | uri: string 136 | sourcegraph: typeof import('sourcegraph') 137 | selections: Selection[] | null 138 | }): Promise => { 139 | const { repo, rev, path } = resolveURI(uri) 140 | const { data, errors } = await sourcegraph.commands.executeCommand( 141 | 'queryGraphQL', 142 | gql` 143 | query GitBlame($repo: String!, $rev: String!, $path: String!, $startLine: Int!, $endLine: Int!) { 144 | repository(name: $repo) { 145 | commit(rev: $rev) { 146 | blob(path: $path) { 147 | blame(startLine: $startLine, endLine: $endLine) { 148 | startLine 149 | endLine 150 | author { 151 | person { 152 | email 153 | displayName 154 | user { 155 | username 156 | } 157 | } 158 | date 159 | } 160 | message 161 | rev 162 | commit { 163 | url 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | `, 171 | { 172 | repo, 173 | rev, 174 | path, 175 | startLine: selections ? selections[0].start.line + 1 : 0, 176 | endLine: selections ? selections[0].end.line + 1 : 0, 177 | } 178 | ) 179 | if (errors && errors.length > 0) { 180 | throw new Error(errors.join('\n')) 181 | } 182 | if (!data || !data.repository || !data.repository.commit || !data.repository.commit.blob) { 183 | throw new Error('no blame data is available (repository, commit, or path not found)') 184 | } 185 | return data.repository.commit.blob.blame 186 | }, 187 | ({ uri, selections }) => { 188 | if (selections) { 189 | return [uri, selections[0].start.line, selections[0].end.line].join(':') 190 | } 191 | return uri 192 | } 193 | ) 194 | 195 | /** 196 | * Returns blame decorations for all provided selections, 197 | * or for all hunks if `selections` is `null`. 198 | */ 199 | export const getBlameDecorations = ({ 200 | settings, 201 | selections, 202 | now, 203 | hunks, 204 | sourcegraph, 205 | }: { 206 | settings: Settings 207 | selections: Selection[] | null 208 | now: number 209 | hunks: Hunk[] 210 | sourcegraph: typeof import('sourcegraph') 211 | }): TextDocumentDecoration[] => { 212 | const decorations = settings['git.blame.decorations'] || 'none' 213 | 214 | if (decorations === 'none') { 215 | return [] 216 | } 217 | if (selections !== null && decorations === 'line') { 218 | return getBlameDecorationsForSelections(hunks, selections, now, settings, sourcegraph) 219 | } else { 220 | return getAllBlameDecorations(hunks, now, settings, sourcegraph) 221 | } 222 | } 223 | 224 | export const getBlameStatusBarItem = ({ 225 | selections, 226 | hunks, 227 | now, 228 | settings, 229 | sourcegraph, 230 | }: { 231 | selections: Selection[] | null 232 | hunks: Hunk[] 233 | now: number 234 | settings: Pick 235 | sourcegraph: typeof import('sourcegraph') 236 | }): StatusBarItem => { 237 | if (selections && selections.length > 0) { 238 | const hunksForSelections = getHunksForSelections(hunks, selections) 239 | if (hunksForSelections[0]) { 240 | // Display the commit for the first selected hunk in the status bar. 241 | const { displayName, username, dateString, linkURL, hoverMessage } = getDisplayInfoFromHunk({ 242 | hunk: hunksForSelections[0].hunk, 243 | now, 244 | settings, 245 | sourcegraph, 246 | }) 247 | 248 | return { 249 | text: `Author: ${username}${displayName}, ${dateString}`, 250 | command: { id: 'open', args: [linkURL] }, 251 | tooltip: hoverMessage, 252 | } 253 | } 254 | } 255 | 256 | // Since there are no selections, we want to determine the most 257 | // recent change to this file to display in the status bar. 258 | 259 | // Get all hunks 260 | const hunksForSelections = getHunksForSelections(hunks, null) 261 | const mostRecentHunk = hunksForSelections.sort((a, b) => compareDesc(a.hunk.author.date, b.hunk.author.date))[0] 262 | if (!mostRecentHunk) { 263 | // Probably a network error 264 | return { 265 | text: 'Author: not found', 266 | } 267 | } 268 | const { displayName, username, dateString, linkURL, hoverMessage } = getDisplayInfoFromHunk({ 269 | hunk: mostRecentHunk.hunk, 270 | now, 271 | settings, 272 | sourcegraph, 273 | }) 274 | 275 | return { 276 | text: `Author: ${username}${displayName}, ${dateString}`, 277 | command: { id: 'open', args: [linkURL] }, 278 | tooltip: hoverMessage, 279 | } 280 | } 281 | 282 | export interface HunkForSelection { 283 | hunk: Hunk 284 | selectionStartLine: number 285 | } 286 | 287 | export interface Hunk { 288 | startLine: number 289 | endLine: number 290 | author: { 291 | person: { 292 | email: string 293 | displayName: string 294 | user: { 295 | username: string 296 | } | null 297 | } 298 | date: string 299 | } 300 | rev: string 301 | message: string 302 | commit: { 303 | url: string 304 | } 305 | } 306 | 307 | function truncate(s: string, max: number, omission = '…'): string { 308 | if (s.length <= max) { 309 | return s 310 | } 311 | return `${s.slice(0, max)}${omission}` 312 | } 313 | -------------------------------------------------------------------------------- /src/extension.test.ts: -------------------------------------------------------------------------------- 1 | describe('extension', () => { 2 | it('works', () => void 0) 3 | }) 4 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, combineLatest, from } from 'rxjs' 2 | import { filter, map, switchMap } from 'rxjs/operators' 3 | import * as sourcegraph from 'sourcegraph' 4 | import { getBlameDecorations, getBlameStatusBarItem, queryBlameHunks } from './blame' 5 | 6 | export interface Settings { 7 | ['git.blame.decorations']?: 'none' | 'line' | 'file' 8 | // The following two settings are deprecated, but we will still look for them 9 | // to 'onboard' users to new setting 10 | ['git.blame.lineDecorations']?: boolean 11 | ['git.blame.decorateWholeFile']?: boolean 12 | ['git.blame.showPreciseDate']?: boolean 13 | } 14 | 15 | const decorationType = sourcegraph.app.createDecorationType && sourcegraph.app.createDecorationType() 16 | 17 | const statusBarItemType = sourcegraph.app.createStatusBarItemType && sourcegraph.app.createStatusBarItemType() 18 | 19 | export function activate(context: sourcegraph.ExtensionContext): void { 20 | // TODO(lguychard) sourcegraph.configuration is currently not rxjs-compatible. 21 | // Fix this once it has been made compatible. 22 | const configurationChanges = new BehaviorSubject(undefined) 23 | context.subscriptions.add(sourcegraph.configuration.subscribe(() => configurationChanges.next(undefined))) 24 | 25 | if (sourcegraph.app.activeWindowChanges) { 26 | const selectionChanges = from(sourcegraph.app.activeWindowChanges).pipe( 27 | filter((window): window is Exclude => window !== undefined), 28 | switchMap(window => window.activeViewComponentChanges), 29 | filter((editor): editor is sourcegraph.CodeEditor => !!editor && editor.type === 'CodeEditor'), 30 | switchMap(editor => from(editor.selectionsChanges).pipe(map(selections => ({ editor, selections })))) 31 | ) 32 | // When the configuration or current file changes, publish new decorations. 33 | context.subscriptions.add( 34 | combineLatest(configurationChanges, selectionChanges).subscribe(([, { editor, selections }]) => 35 | decorate(editor, selections) 36 | ) 37 | ) 38 | } else { 39 | // Backcompat: the extension host does not support activeWindowChanges or CodeEditor.selectionsChanges. 40 | // When configuration changes or onDidOpenTextDocument fires, add decorations for all blame hunks. 41 | const activeEditor = () => sourcegraph.app.activeWindow && sourcegraph.app.activeWindow.activeViewComponent 42 | context.subscriptions.add( 43 | combineLatest(configurationChanges, from(sourcegraph.workspace.openedTextDocuments)).subscribe(async () => { 44 | const editor = activeEditor() 45 | if (editor && editor.type === 'CodeEditor') { 46 | await decorate(editor, null) 47 | } 48 | }) 49 | ) 50 | } 51 | 52 | // TODO: Unpublish decorations on previously (but not currently) open files when settings changes, to avoid a 53 | // brief flicker of the old state when the file is reopened. 54 | async function decorate(editor: sourcegraph.CodeEditor, selections: sourcegraph.Selection[] | null): Promise { 55 | const settings = sourcegraph.configuration.get().value 56 | const decorations = settings['git.blame.decorations'] || 'none' 57 | const shouldQueryBlameHunks = Boolean(decorations === 'file' || (decorations === 'line' && selections?.length)) 58 | 59 | try { 60 | const hunks = shouldQueryBlameHunks 61 | ? await queryBlameHunks({ 62 | uri: editor.document.uri, 63 | sourcegraph, 64 | selections: decorations === 'line' ? selections : null, 65 | }) 66 | : [] 67 | const now = Date.now() 68 | 69 | // Check if the extension host supports status bar items (Introduced in Sourcegraph version 3.26.0). 70 | // If so, display blame info for the first selected line in the status bar. 71 | if ('setStatusBarItem' in editor) { 72 | editor.setStatusBarItem( 73 | statusBarItemType, 74 | getBlameStatusBarItem({ selections, hunks, now, settings, sourcegraph }) 75 | ) 76 | } 77 | 78 | editor.setDecorations( 79 | decorationType, 80 | getBlameDecorations({ 81 | hunks, 82 | now, 83 | settings, 84 | selections, 85 | sourcegraph, 86 | }) 87 | ) 88 | } catch (err) { 89 | console.error('Decoration/status bar error:', err) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/uri.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Resolve a URI of the forms git://github.com/owner/repo?rev#path and file:///path to an absolute reference, using 3 | * the given base (root) URI. 4 | */ 5 | export function resolveURI(uri: string): { repo: string; rev: string; path: string } { 6 | const url = new URL(uri) 7 | if (url.protocol === 'git:') { 8 | return { 9 | repo: (url.host + decodeURIComponent(url.pathname)).replace(/^\/*/, ''), 10 | rev: decodeURIComponent(url.search.slice(1)), 11 | path: decodeURIComponent(url.hash.slice(1)), 12 | } 13 | } 14 | throw new Error(`unrecognized URI: ${JSON.stringify(uri)} (supported URI schemes: git)`) 15 | } 16 | -------------------------------------------------------------------------------- /src/util/memoizeAsync.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a function that memoizes the async result of func. If the Promise is rejected, the result will not be 3 | * cached. 4 | * 5 | * @param toKey etermines the cache key for storing the result based on the first argument provided to the memoized 6 | * function 7 | */ 8 | export function memoizeAsync( 9 | func: (params: P) => Promise, 10 | toKey: (params: P) => string 11 | ): (params: P) => Promise { 12 | const cache = new Map>() 13 | return (params: P) => { 14 | const key = toKey(params) 15 | const hit = cache.get(key) 16 | if (hit) { 17 | return hit 18 | } 19 | const p = func(params) 20 | p.then(null, () => cache.delete(key)) 21 | cache.set(key, p) 22 | return p 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/util/stubs.ts: -------------------------------------------------------------------------------- 1 | import { uniqueId } from 'lodash' 2 | import { Subject, Subscription } from 'rxjs' 3 | import * as sinon from 'sinon' 4 | import * as sourcegraph from 'sourcegraph' 5 | import { MarkupKind } from 'vscode-languageserver-types' 6 | 7 | const URI = URL 8 | type URI = URL 9 | class Position { 10 | constructor(public line: number, public character: number) {} 11 | } 12 | class Range { 13 | constructor(public start: Position, public end: Position) {} 14 | } 15 | class Location { 16 | constructor(public uri: URI, public range: Range) {} 17 | } 18 | class Selection extends Range { 19 | constructor(public anchor: Position, public active: Position) { 20 | super(anchor, active) 21 | } 22 | } 23 | 24 | /** 25 | * Creates an object that (mostly) implements the Sourcegraph API, 26 | * with all methods being Sinon spys and all Subscribables being Subjects. 27 | */ 28 | export const createMockSourcegraphAPI = (sourcegraphURL?: string) => { 29 | const rootChanges = new Subject() 30 | // const shims: typeof import('sourcegraph') = { 31 | const openedTextDocuments = new Subject() 32 | return { 33 | internal: { 34 | sourcegraphURL: sourcegraphURL || 'https://sourcegraph.test', 35 | }, 36 | URI, 37 | Position, 38 | Range, 39 | Location, 40 | Selection, 41 | MarkupKind, 42 | workspace: { 43 | onDidOpenTextDocument: openedTextDocuments, 44 | openedTextDocuments, 45 | textDocuments: [] as sourcegraph.TextDocument[], 46 | onDidChangeRoots: rootChanges, 47 | rootChanges, 48 | roots: [] as sourcegraph.WorkspaceRoot[], 49 | }, 50 | languages: { 51 | registerHoverProvider: sinon.spy( 52 | ( 53 | selector: sourcegraph.DocumentSelector, 54 | provider: { 55 | provideHover: ( 56 | textDocument: sourcegraph.TextDocument, 57 | position: Position 58 | ) => Promise 59 | } 60 | ) => new Subscription() 61 | ), 62 | registerDefinitionProvider: sinon.spy( 63 | ( 64 | selector: sourcegraph.DocumentSelector, 65 | provider: { 66 | provideDefinition: ( 67 | textDocument: sourcegraph.TextDocument, 68 | position: Position 69 | ) => Promise 70 | } 71 | ) => new Subscription() 72 | ), 73 | registerLocationProvider: sinon.spy( 74 | ( 75 | selector: sourcegraph.DocumentSelector, 76 | provider: { 77 | provideLocations: ( 78 | textDocument: sourcegraph.TextDocument, 79 | position: Position 80 | ) => Promise 81 | } 82 | ) => new Subscription() 83 | ), 84 | registerReferenceProvider: sinon.spy( 85 | ( 86 | selector: sourcegraph.DocumentSelector, 87 | provider: { 88 | provideReferences: ( 89 | textDocument: sourcegraph.TextDocument, 90 | position: Position, 91 | context: sourcegraph.ReferenceContext 92 | ) => Promise 93 | } 94 | ) => new Subscription() 95 | ), 96 | registerTypeDefinitionProvider: sinon.spy( 97 | ( 98 | selector: sourcegraph.DocumentSelector, 99 | provider: { 100 | provideTypeDefinition: ( 101 | textDocument: sourcegraph.TextDocument, 102 | position: Position 103 | ) => Promise 104 | } 105 | ) => new Subscription() 106 | ), 107 | registerImplementationProvider: sinon.spy( 108 | ( 109 | selector: sourcegraph.DocumentSelector, 110 | provider: { 111 | provideImplementation: ( 112 | textDocument: sourcegraph.TextDocument, 113 | position: Position 114 | ) => Promise 115 | } 116 | ) => new Subscription() 117 | ), 118 | }, 119 | app: { 120 | createDecorationType: () => ({ key: uniqueId('decorationType') }), 121 | }, 122 | configuration: {}, 123 | search: {}, 124 | commands: {}, 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@sourcegraph/tsconfig/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2018", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "lib": ["es2018", "webworker"], 8 | "inlineSources": true, 9 | "inlineSourceMap": true, 10 | "declaration": false, 11 | "outDir": "dist", 12 | "noEmit": true, 13 | "rootDir": "src", 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@sourcegraph/tslint-config"] 3 | } 4 | --------------------------------------------------------------------------------