├── .gitattributes ├── .github └── workflows │ ├── build.yml │ ├── release-git-urls.yml │ └── release-vscode-extension.yml ├── .gitignore ├── README.md ├── gitlink.code-workspace ├── images └── how_to_use_it.gif ├── package-lock.json ├── package.json └── packages ├── eslint-config ├── index.js └── package.json ├── git-urls ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── package.json ├── preprocessor.js ├── src │ ├── error.ts │ ├── helper.ts │ ├── host │ │ ├── basicHost.ts │ │ ├── bitbucket.ts │ │ ├── devops.ts │ │ ├── github.ts │ │ ├── gitlab.ts │ │ ├── host.ts │ │ ├── hostBuilder.ts │ │ └── vsts.ts │ ├── index.ts │ ├── info.ts │ └── result.ts ├── test │ ├── bitbucket.test.ts │ ├── devops.test.ts │ ├── github.test.ts │ ├── gitlab.test.ts │ ├── helper.test.ts │ ├── host.test.ts │ └── vsts.test.ts └── tsconfig.json ├── prettier-config ├── index.js └── package.json └── vscode-gitlink ├── .eslintrc.js ├── .prettierrc.js ├── .vscode └── launch.json ├── .vscodeignore ├── LICENSE ├── README.md ├── images └── logo.png ├── package.json ├── src ├── extension.ts └── logger.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js eol=lf 3 | *.json eol=lf 4 | *.md eol=lf 5 | *.ts eol=lf 6 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Project 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest, macos-latest] 17 | node-version: [">=18"] 18 | 19 | runs-on: ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Setup node environment 23 | uses: actions/setup-node@v2.1.2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Checkout code 28 | uses: actions/checkout@v2 29 | 30 | - name: Install 31 | run: npm install 32 | 33 | - name: Build 34 | run: npm run build 35 | 36 | - name: Lint 37 | run: npm run lint 38 | 39 | - name: Test 40 | run: npm run test -------------------------------------------------------------------------------- /.github/workflows/release-git-urls.yml: -------------------------------------------------------------------------------- 1 | name: Publish Git Urls to npmjs.org 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | environment: production 10 | steps: 11 | - name: Setup node environment 12 | uses: actions/setup-node@v2.1.2 13 | with: 14 | node-version: ">=18" 15 | registry-url: https://registry.npmjs.org/ 16 | 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Install 21 | run: | 22 | npm install 23 | working-directory: ./packages/git-urls 24 | 25 | - name: Build 26 | run: npm run build 27 | working-directory: ./packages/git-urls 28 | 29 | - name: Test 30 | run: npm run test 31 | working-directory: ./packages/git-urls 32 | 33 | - name: Publish 34 | run: npm publish --access public 35 | working-directory: ./packages/git-urls 36 | env: 37 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 38 | -------------------------------------------------------------------------------- /.github/workflows/release-vscode-extension.yml: -------------------------------------------------------------------------------- 1 | name: Publish VS Code Extension to marketplace 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | environment: production 10 | steps: 11 | - name: Setup node environment 12 | uses: actions/setup-node@v2.1.2 13 | with: 14 | node-version: ">=18" 15 | registry-url: https://registry.npmjs.org/ 16 | 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Install 21 | run: | 22 | npm install 23 | npm install -g vsce 24 | working-directory: ./packages/vscode-gitlink 25 | 26 | - name: Build 27 | run: npm run build 28 | working-directory: ./packages/vscode-gitlink 29 | 30 | - name: Test 31 | run: npm run test 32 | working-directory: ./packages/vscode-gitlink 33 | 34 | - name: Publish 35 | run: vsce publish -p ${{secrets.VSCODE_TOKEN}} 36 | working-directory: ./packages/vscode-gitlink 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | node_modules/ 3 | .vscode-test/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitLink 2 | 3 | [![Current Version](https://vsmarketplacebadges.dev/version/qezhu.gitlink.svg)](https://marketplace.visualstudio.com/items?itemName=qezhu.gitlink) 4 | [![Installs](https://vsmarketplacebadges.dev/installs-short/qezhu.gitlink.svg)](https://marketplace.visualstudio.com/items?itemName=qezhu.gitlink) 5 | [![Rating](https://vsmarketplacebadges.dev/rating/qezhu.gitlink.svg)](https://marketplace.visualstudio.com/items?itemName=qezhu.gitlink) 6 | [![AppVeyor](https://img.shields.io/appveyor/ci/qinezh/vscode-gitlink.svg)](https://ci.appveyor.com/project/qinezh/vscode-gitlink) 7 | 8 | Inspired by GitHub extension for Visual Studio, this extension provide features that **Go To** current file's online link in browser and **Copy** the link in clipboard. 9 | 10 | ## Features 11 | 12 | 1. Go to the online link of current file. 13 | 2. Copy the online link of current file. 14 | 3. Supported git repo platforms: 15 | 1. GitHub 16 | 2. GitLab 17 | 3. Azure DevOps 18 | 4. BitBucket 19 | 5. VSTS 20 | 21 | ## Usage 22 | 23 | 24 | 25 | ### Set default remote source 26 | 27 | When your project has multiple git remotes, you need to choose the git remote before the git link is generated. If you don't want to choose it every time, you could set the default remote source: 28 | 29 | **Workspace Level**: add `gitlink.defaultRemote: ""` in `.vscode/settings.json` under the root of your workspace. 30 | 31 | **Global Level**: toggle the preference of vscode, and add `gitlink.defaultRemote: ""` in User Settings. 32 | 33 | Please note, you could get the name of your remote sources by the command: `git remote -v`: 34 | 35 | ```bash 36 | # example 37 | $ git remote -v 38 | origin git@github.com:qinezh/vscode-gitlink (fetch) 39 | origin git@github.com:qinezh/vscode-gitlink (push) 40 | upstream git@github.com:upstream/vscode-gitlink.git (fetch) 41 | upstream git@github.com:upstream/vscode-gitlink.git (push) 42 | ``` 43 | 44 | And the sample `settings.json` could be like: 45 | ```json 46 | { 47 | "gitlink.defaultRemote": "upsteam" 48 | } 49 | ``` 50 | 51 | **Enjoy!** 52 | -------------------------------------------------------------------------------- /gitlink.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".github" 5 | }, 6 | { 7 | "path": "packages/vscode-gitlink" 8 | }, 9 | { 10 | "path": "packages/git-urls" 11 | }, 12 | { 13 | "path": "packages/eslint-config" 14 | }, 15 | { 16 | "path": "packages/prettier-config" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /images/how_to_use_it.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qinezh/vscode-gitlink/c4490f5636969b2a264a7d3d6d64ab2739c6b98a/images/how_to_use_it.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlink", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/git-urls", 6 | "packages/vscode-gitlink", 7 | "packages/eslint-config" 8 | ], 9 | "scripts": { 10 | "build": "npm run build --workspaces", 11 | "test": "npm run test --workspaces", 12 | "lint": "npm run lint --workspaces" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/eslint-config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | parserOptions: { 8 | ecmaVersion: 2018, 9 | sourceType: "module", 10 | }, 11 | extends: [ 12 | "plugin:@typescript-eslint/recommended", 13 | "prettier", 14 | "plugin:import/errors", 15 | "plugin:import/warnings", 16 | "plugin:import/typescript" 17 | ], 18 | plugins: [ 19 | "@typescript-eslint/eslint-plugin", 20 | "prettier" 21 | ], 22 | rules: { 23 | 'prettier/prettier': 'error', 24 | quotes: ["error", "double", { "allowTemplateLiterals": true, "avoidEscape": true }], 25 | semi: ["error", "always"], 26 | '@typescript-eslint/no-var-requires': 0, 27 | '@typescript-eslint/no-empty-function': 0, 28 | "import/no-cycle": [ 29 | "error", 30 | { 31 | "maxDepth": Infinity, 32 | "ignoreExternal": true 33 | } 34 | ], 35 | "no-unused-vars": "off", 36 | "import/no-unresolved": "off", 37 | "@typescript-eslint/no-unused-vars": ["error", { "vars": "local", "args": "none", "ignoreRestSiblings": true, "varsIgnorePattern": "^_" }] 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shared/eslint-config", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Shared ESLint config", 6 | "main": "index.js", 7 | "devDependencies": { 8 | "@typescript-eslint/eslint-plugin": "^5.0.0", 9 | "@typescript-eslint/parser": "^5.0.0", 10 | "eslint-config-prettier": "^8.5.0" 11 | }, 12 | "scripts": { 13 | "build": "", 14 | "test": "", 15 | "lint": "" 16 | } 17 | } -------------------------------------------------------------------------------- /packages/git-urls/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["../eslint-config"], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/git-urls/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | shrinkwrap.yaml 3 | .vscode/ 4 | dist/ -------------------------------------------------------------------------------- /packages/git-urls/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | test/ 3 | dist/test/ 4 | .github -------------------------------------------------------------------------------- /packages/git-urls/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("../prettier-config"), 3 | }; 4 | -------------------------------------------------------------------------------- /packages/git-urls/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Qinen Zhu 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 | -------------------------------------------------------------------------------- /packages/git-urls/README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/dt/git-urls.svg)](https://www.npmjs.com/package/git-urls) 2 | [![AppVeyor](https://img.shields.io/appveyor/ci/qinezh/git-urls.svg)](https://ci.appveyor.com/project/qinezh/git-urls) 3 | 4 | # git-urls 5 | 6 | Get online link of file with git remote URL(support Github, GitLab, Bitbucket, VSTS, DevOps) 7 | 8 | ## Install 9 | 10 | ```bash 11 | npm install git-urls 12 | ``` 13 | 14 | ## Usage example 15 | 16 | ```javascript 17 | import GitUrls from "git-urls"; 18 | 19 | const f = async () => { 20 | return await GitUrls.getUrlsAsync(__filename); 21 | }; 22 | 23 | f().then(linkMap => { 24 | for (const [remoteName, link] of linkMap) { 25 | console.log(`${remoteName}: ${link}`); 26 | } 27 | }); 28 | ``` 29 | -------------------------------------------------------------------------------- /packages/git-urls/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-urls", 3 | "version": "2.0.2", 4 | "description": "Get online URL of file (Github, GitLab, Bitbucket...)", 5 | "main": "out/src/index.js", 6 | "types": "out/src/index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/qinezh/git-urls.git" 10 | }, 11 | "keywords": [ 12 | "git" 13 | ], 14 | "author": "qinezh", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/qinezh/git-urls/issues" 18 | }, 19 | "homepage": "https://github.com/qinezh/git-urls#readme", 20 | "contributors": [ 21 | "Junle Li", 22 | "Scott Bonebrake", 23 | "Tyler Kindy" 24 | ], 25 | "dependencies": { 26 | "fs-extra": "^7.0.1" 27 | }, 28 | "devDependencies": { 29 | "@types/fs-extra": "^5.1.0", 30 | "@types/jest": "^27.4.1", 31 | "@types/node": "^7.10.9", 32 | "@typescript-eslint/eslint-plugin": "^5.21.0", 33 | "@typescript-eslint/parser": "^5.21.0", 34 | "eslint": "^8.14.0", 35 | "eslint-config-prettier": "^8.5.0", 36 | "eslint-plugin-import": "^2.25.2", 37 | "eslint-plugin-prettier": "^4.0.0", 38 | "jest": "^27.5.1", 39 | "jest-cli": "^27.5.1", 40 | "prettier": "^2.6.2", 41 | "ts-jest": "^27.1.4", 42 | "typescript": "^4.6.4" 43 | }, 44 | "scripts": { 45 | "build": "tsc", 46 | "test": "jest", 47 | "lint": "npx eslint \"src/**/*.ts\" \"test/**/*.ts\"", 48 | "lint:fix": "npx eslint --fix \"src/**/*.ts\" \"test/**/*.ts\"" 49 | }, 50 | "jest": { 51 | "moduleFileExtensions": [ 52 | "ts", 53 | "js" 54 | ], 55 | "transform": { 56 | "^.+\\.(ts|tsx)$": "/preprocessor.js" 57 | }, 58 | "testMatch": [ 59 | "/test/*.test.ts" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/git-urls/preprocessor.js: -------------------------------------------------------------------------------- 1 | const tsc = require('typescript'); 2 | const tsConfig = require('./tsconfig.json'); 3 | 4 | module.exports = { 5 | process(src, path) { 6 | if (path.endsWith('.ts') || path.endsWith('.tsx')) { 7 | return tsc.transpile(src, tsConfig.compilerOptions, path, []); 8 | } 9 | return src; 10 | }, 11 | }; -------------------------------------------------------------------------------- /packages/git-urls/src/error.ts: -------------------------------------------------------------------------------- 1 | export class GitUrlError extends Error { 2 | constructor(public message: string) { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/git-urls/src/helper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs-extra"; 2 | import * as path from "path"; 3 | 4 | export default class Helper { 5 | public static async getRepoRoot(filePath: string): Promise { 6 | let currentFolder = this.normalize(path.dirname(filePath)); 7 | while (true) { 8 | const configFolder = path.join(currentFolder, ".git"); 9 | if (await fs.pathExists(configFolder)) { 10 | return currentFolder; 11 | } 12 | 13 | const index = currentFolder.lastIndexOf("/"); 14 | if (index < 0) { 15 | break; 16 | } 17 | 18 | currentFolder = currentFolder.substring(0, index); 19 | } 20 | return null; 21 | } 22 | 23 | public static normalize(filePath: string): string { 24 | return filePath.replace(/\\/g, "/"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/basicHost.ts: -------------------------------------------------------------------------------- 1 | import Host from "./host"; 2 | import { GitConfigInfo, GitUrlInfo } from "../info"; 3 | import { GitUrlError } from "../error"; 4 | 5 | export default abstract class BasicHost implements Host { 6 | private readonly httpRegex = /(https?:\/\/)(?:[^:@]+:[^:@]+@)?([^\/:]+)(?:\/)([^\/:]+)(?:\/)([^\/:\n]+)/; 7 | private readonly gitRegex = /(git@)([^\/:]+)(?::)(?:\d+\/)?([^\/:]+)(?:\/)([^\/:\n]+)/; 8 | protected abstract get separateFolder(): string | undefined; 9 | 10 | public abstract get name(): string; 11 | public abstract match(url: string): boolean; 12 | 13 | public parse(configInfo: GitConfigInfo): GitUrlInfo { 14 | const matches = this.httpRegex.exec(configInfo.remoteUrl) ?? this.gitRegex.exec(configInfo.remoteUrl); 15 | if (!matches) { 16 | throw new GitUrlError(`Can't parse ${configInfo.remoteUrl} with the rule of ${this.constructor.name}.`); 17 | } 18 | 19 | const schema = matches[1]; 20 | let isHttp = false; 21 | if (schema.startsWith("http://")) { 22 | isHttp = true; 23 | } 24 | 25 | let repoName = matches[4]; 26 | if (repoName.endsWith(".git")) { 27 | repoName = repoName.substring(0, repoName.lastIndexOf(".git")); 28 | } 29 | 30 | let hostName = matches[2]; 31 | const index = hostName.indexOf("@"); 32 | if (index !== -1) { 33 | hostName = hostName.substring(index + 1); 34 | } 35 | 36 | const gitInfo: GitUrlInfo = { 37 | hostName: hostName, 38 | repoName: repoName, 39 | ref: { ...configInfo.ref, value: encodeURIComponent(configInfo.ref.value) }, 40 | userName: matches[3], 41 | section: configInfo.section, 42 | metadata: { isHttp: isHttp }, 43 | }; 44 | 45 | if (configInfo.relativeFilePath) { 46 | let parts = configInfo.relativeFilePath.split("/"); 47 | parts = parts.map(p => encodeURIComponent(p)); 48 | gitInfo.relativeFilePath = parts.join("/"); 49 | } 50 | 51 | return gitInfo; 52 | } 53 | 54 | public assemble(info: GitUrlInfo): string { 55 | return this.assembleLink(info); 56 | } 57 | 58 | protected assembleLink(info: GitUrlInfo): string { 59 | let prefix = "https://"; 60 | if (info.metadata && info.metadata["isHttp"]) { 61 | prefix = "http://"; 62 | } 63 | 64 | return `${prefix}${info.hostName}/${info.userName}/${info.repoName}/${this.separateFolder}/${info.ref.value}/${info.relativeFilePath}`; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/bitbucket.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import BasicHost from "./basicHost"; 4 | import { GitUrlInfo } from "../info"; 5 | 6 | export default class BitBucket extends BasicHost { 7 | protected override get separateFolder(): string | undefined { 8 | return "src"; 9 | } 10 | 11 | public override get name(): string { 12 | return "BitBucket"; 13 | } 14 | 15 | public override match(url: string): boolean { 16 | return url.indexOf("bitbucket") >= 0; 17 | } 18 | 19 | public assemble(info: GitUrlInfo): string { 20 | const link = this.assembleLink(info); 21 | 22 | let fileName: string | null = null; 23 | if (info.relativeFilePath) { 24 | fileName = path.basename(info.relativeFilePath); 25 | } 26 | 27 | if ( 28 | info.section && 29 | info.section.startLine && 30 | info.section.endLine && 31 | info.section.startLine !== info.section.endLine && 32 | fileName 33 | ) { 34 | return `${link}#${fileName}-${info.section.startLine}:${info.section.endLine}`; 35 | } else if (info.section && info.section.startLine && fileName) { 36 | return `${link}#${fileName}-${info.section.startLine}`; 37 | } else { 38 | return link; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/devops.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo, GitUrlInfo } from "../info"; 2 | import BasicHost from "./basicHost"; 3 | 4 | export default class DevOps extends BasicHost { 5 | /** 6 | * The regular expression to match the DevOps Git URL. 7 | * @example https://my-tenant@dev.azure.com/my-org/my-project/_git/my-repo 8 | * @example https://dev.azure.com/my-org/my-project/_git/my-repo 9 | * @example my-tenant@ssh.dev.azure.com:22/my-org/my-project/my-repo 10 | */ 11 | private static urlRegex = 12 | /(?:https:\/\/)?(?:[\w-]+@)?(?:ssh.)?dev\.azure\.com(?::[v\d]+)?\/([^\/]+\/[^\/]+)\/(?:_git\/)?([^/]+)/i; 13 | 14 | protected override get separateFolder(): string | undefined { 15 | return undefined; 16 | } 17 | 18 | public override get name(): string { 19 | return "DevOps"; 20 | } 21 | 22 | public override match(url: string): boolean { 23 | return DevOps.urlRegex.test(url); 24 | } 25 | 26 | public override parse(configInfo: GitConfigInfo): GitUrlInfo { 27 | const gitInfo: GitUrlInfo = { 28 | repoName: configInfo.remoteUrl, 29 | ref: configInfo.ref, 30 | userName: "", 31 | section: configInfo.section, 32 | }; 33 | 34 | if (configInfo.relativeFilePath) { 35 | let parts = configInfo.relativeFilePath.split("/"); 36 | parts = parts.map(p => encodeURIComponent(p)); 37 | gitInfo.relativeFilePath = parts.join("/"); 38 | } 39 | 40 | return gitInfo; 41 | } 42 | 43 | public override assemble(info: GitUrlInfo): string { 44 | const baseUrl = info.repoName.replace(DevOps.urlRegex, "https://dev.azure.com/$1/_git/$2"); 45 | const path: string = encodeURIComponent(`/${info.relativeFilePath}`); 46 | 47 | let version: string; 48 | switch (info.ref.type) { 49 | case "branch": 50 | version = `GB${info.ref.value}`; 51 | break; 52 | case "commit": 53 | version = `GC${info.ref.value}`; 54 | break; 55 | } 56 | 57 | let url = `${baseUrl}?path=${path}&version=${version}&_a=contents`; 58 | 59 | if (info.section && info.section.startLine && info.section.endLine) { 60 | url += `&lineStyle=plain&line=${info.section.startLine}&lineEnd=${info.section.endLine}`; 61 | 62 | if (info.section.startColumn && info.section.endColumn) { 63 | url += `&lineStartColumn=${info.section.startColumn}&lineEndColumn=${info.section.endColumn}`; 64 | } else { 65 | url += "&lineStartColumn=1&lineEndColumn=1"; 66 | } 67 | } 68 | 69 | return url; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/github.ts: -------------------------------------------------------------------------------- 1 | import BasicHost from "./basicHost"; 2 | import { GitUrlInfo } from "../info"; 3 | 4 | export default class GitHub extends BasicHost { 5 | protected override get separateFolder(): string | undefined { 6 | return "blob"; 7 | } 8 | 9 | public override get name(): string { 10 | return "GitHub"; 11 | } 12 | 13 | public override match(url: string): boolean { 14 | return url.indexOf("github") >= 0; 15 | } 16 | 17 | public override assemble(info: GitUrlInfo): string { 18 | const link = this.assembleLink(info); 19 | 20 | if ( 21 | info.section && 22 | info.section.startLine && 23 | info.section.endLine && 24 | info.section.startLine !== info.section.endLine 25 | ) { 26 | return `${link}#L${info.section.startLine}-L${info.section.endLine}`; 27 | } else if (info.section && info.section.startLine) { 28 | return `${link}#L${info.section.startLine}`; 29 | } else { 30 | return link; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/gitlab.ts: -------------------------------------------------------------------------------- 1 | import BasicHost from "./basicHost"; 2 | import { GitUrlInfo } from "../info"; 3 | 4 | export default class GitLab extends BasicHost { 5 | public override get name(): string { 6 | return "GitLab"; 7 | } 8 | 9 | public override match(url: string): boolean { 10 | return url.indexOf("gitlab") >= 0; 11 | } 12 | 13 | protected override get separateFolder(): string | undefined { 14 | return "blob"; 15 | } 16 | 17 | assemble(info: GitUrlInfo): string { 18 | const link = this.assembleLink(info); 19 | 20 | if ( 21 | info.section && 22 | info.section.startLine && 23 | info.section.endLine && 24 | info.section.startLine !== info.section.endLine 25 | ) { 26 | return `${link}#L${info.section.startLine}-${info.section.endLine}`; 27 | } else if (info.section && info.section.startLine) { 28 | return `${link}#L${info.section.startLine}`; 29 | } else { 30 | return link; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/host.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo, GitUrlInfo } from "../info"; 2 | 3 | interface Host { 4 | get name(): string; 5 | match(url: string): boolean; 6 | parse(info: GitConfigInfo): GitUrlInfo; 7 | assemble(info: GitUrlInfo): string; 8 | } 9 | 10 | export default Host; 11 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/hostBuilder.ts: -------------------------------------------------------------------------------- 1 | import Host from "./host"; 2 | import GitHub from "./github"; 3 | import GitLab from "./gitlab"; 4 | import BitBucket from "./bitbucket"; 5 | import Vsts from "./vsts"; 6 | import DevOps from "./devops"; 7 | import { GitConfigInfo } from "../info"; 8 | 9 | class HostBuilder { 10 | constructor(readonly hosts: Host[]) { 11 | this.hosts = hosts.length > 0 ? hosts : [new GitHub(), new GitLab(), new DevOps(), new Vsts(), new BitBucket()]; 12 | } 13 | 14 | create(info: GitConfigInfo, hostType?: string): Host { 15 | const url = info.remoteUrl; 16 | for (const host of this.hosts) { 17 | if (hostType?.toLowerCase() === host.name.toLowerCase()) { 18 | return host; 19 | } 20 | } 21 | 22 | for (const host of this.hosts) { 23 | if (host.match(url)) { 24 | return host; 25 | } 26 | } 27 | 28 | // if no host matched, use GitHub as default 29 | return new GitHub(); 30 | } 31 | } 32 | 33 | export const hostBuilder = new HostBuilder([]); 34 | -------------------------------------------------------------------------------- /packages/git-urls/src/host/vsts.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo, GitUrlInfo } from "../info"; 2 | import BasicHost from "./basicHost"; 3 | 4 | export default class Vsts extends BasicHost { 5 | /** 6 | * The regular expression to match the VSTS Git URL. 7 | * @example https://my-tenant.visualstudio.com/DefaultCollection/MyCollection/_git/my-repo 8 | * @example ssh://my-tenant@my-tenant.visualstudio.com:22/DefaultCollection/MyCollection/_ssh/my-repo 9 | */ 10 | private static urlRegex = 11 | /(?:https:\/\/|ssh:\/\/)([\w-]+)@?.*\.visualstudio\.com(?:\:\d+)?\/(.+)\/(?:_git|_ssh)\/([^/]+)/i; 12 | protected override get separateFolder(): string | undefined { 13 | return undefined; 14 | } 15 | 16 | public override get name(): string { 17 | return "VSTS"; 18 | } 19 | 20 | public override match(url: string): boolean { 21 | return Vsts.urlRegex.test(url); 22 | } 23 | 24 | public override parse(configInfo: GitConfigInfo): GitUrlInfo { 25 | const gitInfo: GitUrlInfo = { 26 | repoName: configInfo.remoteUrl, 27 | ref: configInfo.ref, 28 | userName: "", 29 | section: configInfo.section, 30 | }; 31 | 32 | if (configInfo.relativeFilePath) { 33 | let parts = configInfo.relativeFilePath.split("/"); 34 | parts = parts.map(p => encodeURIComponent(p)); 35 | gitInfo.relativeFilePath = parts.join("/"); 36 | } 37 | 38 | return gitInfo; 39 | } 40 | 41 | public override assemble(info: GitUrlInfo): string { 42 | const baseUrl = info.repoName.replace(Vsts.urlRegex, "https://$1.visualstudio.com/$2/_git/$3"); 43 | const path: string = encodeURIComponent(`/${info.relativeFilePath}`); 44 | 45 | let version: string; 46 | switch (info.ref.type) { 47 | case "branch": 48 | version = `GB${info.ref.value}`; 49 | break; 50 | case "commit": 51 | version = `GC${info.ref.value}`; 52 | break; 53 | } 54 | 55 | let url = `${baseUrl}?path=${path}&version=${version}&_a=contents`; 56 | 57 | if (info.section && info.section.startLine && info.section.endLine) { 58 | url += `&lineStyle=plain&line=${info.section.startLine}&lineEnd=${info.section.endLine}`; 59 | 60 | if (info.section.startColumn && info.section.endColumn) { 61 | url += `&lineStartColumn=${info.section.startColumn}&lineEndColumn=${info.section.endColumn}`; 62 | } else { 63 | url += "&lineStartColumn=1&lineEndColumn=1"; 64 | } 65 | } 66 | 67 | return url; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/git-urls/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs-extra"; 3 | 4 | import { GitConfigInfo, Section, GitReference } from "./info"; 5 | import { hostBuilder } from "./host/hostBuilder"; 6 | import Helper from "./helper"; 7 | import { GitUrlError } from "./error"; 8 | import { err, GitRemote, GitUrlResult, ok } from "./result"; 9 | 10 | export { GitUrlResult } from "./result"; 11 | 12 | export default class GitUrls { 13 | public static async getUrls( 14 | filePath: string, 15 | section?: Section, 16 | hostType?: string 17 | ): Promise> { 18 | const repoRoot = await Helper.getRepoRoot(filePath); 19 | if (!repoRoot) { 20 | throw new GitUrlError(`Can't find repo root for ${filePath}.`); 21 | } 22 | 23 | const configMap = await this.parseConfigAsync(repoRoot); 24 | const results = new Map(); 25 | 26 | for (const [key, configInfo] of configMap) { 27 | configInfo.relativeFilePath = Helper.normalize(path.relative(repoRoot, filePath)); 28 | 29 | if (section) { 30 | configInfo.section = section; 31 | } 32 | 33 | const result = await this.getUrl(configInfo, hostType); 34 | results.set(key, result); 35 | } 36 | 37 | return results; 38 | } 39 | 40 | public static async getUrl(configInfo: GitConfigInfo, hostType?: string): Promise { 41 | try { 42 | const host = hostBuilder.create(configInfo, hostType); 43 | const gitInfo = host.parse(configInfo); 44 | const result: GitRemote = { 45 | name: configInfo.remoteName, 46 | url: host.assemble(gitInfo), 47 | }; 48 | 49 | return ok(result); 50 | } catch (error) { 51 | if (error instanceof GitUrlError) { 52 | return err(error); 53 | } 54 | 55 | throw error; 56 | } 57 | } 58 | 59 | private static async parseConfigAsync(repoRoot: string): Promise> { 60 | const configPath = path.join(repoRoot, ".git/config"); 61 | const headPath = path.join(repoRoot, ".git/HEAD"); 62 | 63 | const existConfig = await fs.pathExists(configPath); 64 | const existHead = await fs.pathExists(headPath); 65 | 66 | if (!existConfig || !existHead) { 67 | throw new GitUrlError(`No git config files found in ${repoRoot}.`); 68 | } 69 | 70 | const configContent = await fs.readFile(configPath, "utf8"); 71 | const headContent = await fs.readFile(headPath, "utf8"); 72 | 73 | const remoteMap = this.parseRemoteUrl(configContent); 74 | const ref = this.parseRef(headContent); 75 | 76 | if (!remoteMap) { 77 | throw new GitUrlError(`Can't get remote name & url from ${configPath}.`); 78 | } 79 | 80 | const configMap = new Map(); 81 | for (const [remoteName, remoteUrl] of remoteMap) { 82 | const info: GitConfigInfo = { 83 | remoteName, 84 | remoteUrl, 85 | ref, 86 | }; 87 | 88 | configMap.set(remoteName, info); 89 | } 90 | 91 | return configMap; 92 | } 93 | 94 | private static parseRemoteUrl(content: string): Map | null { 95 | const regex = /\n\[remote \"(.+)\"\]\s+url\s*=\s*(.+)\n/gi; 96 | const result = new Map(); 97 | 98 | let matches = regex.exec(content); 99 | while (matches !== null) { 100 | if (matches.index === regex.lastIndex) { 101 | regex.lastIndex++; 102 | } 103 | 104 | result.set(matches[1], matches[2]); 105 | matches = regex.exec(content); 106 | } 107 | 108 | if (result.size > 0) { 109 | return result; 110 | } 111 | 112 | return null; 113 | } 114 | 115 | private static parseRef(content: string): GitReference { 116 | const branchRegex = /ref:\s+refs\/heads\/(\S+)/; 117 | const branchMatches = branchRegex.exec(content); 118 | if (branchMatches) { 119 | return { type: "branch", value: branchMatches[1] }; 120 | } 121 | 122 | return { type: "commit", value: content.trim() }; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/git-urls/src/info.ts: -------------------------------------------------------------------------------- 1 | export interface GitReference { 2 | type: "branch" | "commit"; 3 | value: string; 4 | } 5 | 6 | export interface Section { 7 | startLine: number; 8 | endLine?: number; 9 | startColumn?: number; 10 | endColumn?: number; 11 | } 12 | 13 | export interface SelectedFileInfo { 14 | relativeFilePath?: string; 15 | section?: Section; 16 | ref: GitReference; 17 | } 18 | 19 | export interface GitConfigInfo extends SelectedFileInfo { 20 | remoteName: string; 21 | remoteUrl: string; 22 | } 23 | 24 | export interface GitUrlInfo extends SelectedFileInfo { 25 | repoName: string; 26 | userName: string; 27 | hostName?: string; 28 | metadata?: Record; 29 | } 30 | -------------------------------------------------------------------------------- /packages/git-urls/src/result.ts: -------------------------------------------------------------------------------- 1 | import { GitUrlError } from "./error"; 2 | 3 | export interface GitRemote { 4 | name: string; 5 | url: string; 6 | } 7 | 8 | export type GitUrlResult = Ok | Err; 9 | export const ok = (value: T): Ok => new Ok(value); 10 | export const err = (error: E): Err => new Err(error); 11 | 12 | // Copied from https://github.com/supermacro/neverthrow/blob/master/src/result.ts 13 | interface IResult { 14 | isOk(): this is Ok; 15 | isErr(): this is Err; 16 | _unsafeUnwrap(): T; 17 | } 18 | 19 | class Ok implements IResult { 20 | constructor(readonly value: T) {} 21 | 22 | isOk(): this is Ok { 23 | return true; 24 | } 25 | 26 | isErr(): this is Err { 27 | return false; 28 | } 29 | 30 | _unsafeUnwrap(): T { 31 | return this.value; 32 | } 33 | } 34 | 35 | class Err implements IResult { 36 | constructor(readonly error: E) {} 37 | 38 | isOk(): this is Ok { 39 | return false; 40 | } 41 | 42 | isErr(): this is Err { 43 | return true; 44 | } 45 | 46 | _unsafeUnwrap(): T { 47 | throw this.error; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/git-urls/test/bitbucket.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import GitUrls from "../src/index"; 3 | 4 | const remoteName = "origin"; 5 | test("Get HTTPS url in BitBucket", async () => { 6 | const configInfo: GitConfigInfo = { 7 | remoteName, 8 | remoteUrl: "https://bitbucket.org/qinezh/git-urls.git", 9 | ref: { type: "branch", value: "master" }, 10 | relativeFilePath: "test/a.md", 11 | }; 12 | const result = await GitUrls.getUrl(configInfo); 13 | 14 | expect(result._unsafeUnwrap().url).toBe("https://bitbucket.org/qinezh/git-urls/src/master/test/a.md"); 15 | }); 16 | 17 | test("Get SSH URL in BitBucket", async () => { 18 | const configInfo: GitConfigInfo = { 19 | remoteName, 20 | remoteUrl: "git@bitbucket.org:qinezh/git-urls.git", 21 | ref: { type: "branch", value: "master" }, 22 | section: { 23 | startLine: 2, 24 | endLine: 3, 25 | startColumn: 4, 26 | endColumn: 5, 27 | }, 28 | relativeFilePath: "test/a.md", 29 | }; 30 | const result = await GitUrls.getUrl(configInfo); 31 | 32 | expect(result._unsafeUnwrap().url).toBe("https://bitbucket.org/qinezh/git-urls/src/master/test/a.md#a.md-2:3"); 33 | }); 34 | 35 | test("Get URL with commit SHA in BitBucket", async () => { 36 | const configInfo: GitConfigInfo = { 37 | remoteName, 38 | remoteUrl: "https://bitbucket.org/qinezh/git-urls.git", 39 | ref: { type: "commit", value: "59f76230dd5829a10aab717265b66c6b5849365e" }, 40 | relativeFilePath: "test/a.md", 41 | }; 42 | const result = await GitUrls.getUrl(configInfo); 43 | 44 | expect(result._unsafeUnwrap().url).toBe( 45 | "https://bitbucket.org/qinezh/git-urls/src/59f76230dd5829a10aab717265b66c6b5849365e/test/a.md" 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/git-urls/test/devops.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import GitUrls from "../src/index"; 3 | 4 | const remoteName = "origin"; 5 | test("Get file URL in DevOps", async () => { 6 | const configInfo: GitConfigInfo = { 7 | remoteName, 8 | remoteUrl: "https://dev.azure.com/my-org/my-project/_git/repo", 9 | ref: { type: "branch", value: "master" }, 10 | relativeFilePath: "test/file", 11 | }; 12 | const result = await GitUrls.getUrl(configInfo); 13 | 14 | expect(result._unsafeUnwrap().url).toBe( 15 | "https://dev.azure.com/my-org/my-project/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents" 16 | ); 17 | }); 18 | 19 | test("Get selection block URL in DevOps", async () => { 20 | const configInfo: GitConfigInfo = { 21 | remoteName, 22 | remoteUrl: "https://dev.azure.com/my-org/my-project/_git/repo", 23 | ref: { type: "branch", value: "master" }, 24 | section: { 25 | startLine: 12, 26 | endLine: 23, 27 | }, 28 | relativeFilePath: "test/file", 29 | }; 30 | const result = await GitUrls.getUrl(configInfo); 31 | 32 | expect(result._unsafeUnwrap().url).toBe( 33 | "https://dev.azure.com/my-org/my-project/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents&lineStyle=plain&line=12&lineEnd=23&lineStartColumn=1&lineEndColumn=1" 34 | ); 35 | }); 36 | 37 | test("Get file URL in DevOps with SSH", async () => { 38 | const configInfo: GitConfigInfo = { 39 | remoteName, 40 | remoteUrl: "my-tenant@ssh.dev.azure.com:22/my-org/my-project/repo", 41 | ref: { type: "branch", value: "master" }, 42 | relativeFilePath: "test/file", 43 | }; 44 | 45 | const result = await GitUrls.getUrl(configInfo); 46 | expect(result._unsafeUnwrap().url).toBe( 47 | "https://dev.azure.com/my-org/my-project/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents" 48 | ); 49 | }); 50 | 51 | test("Get selection block URL with column in DevOps", async () => { 52 | const configInfo: GitConfigInfo = { 53 | remoteName, 54 | remoteUrl: "https://dev.azure.com/my-org/my-project/_git/repo", 55 | ref: { type: "branch", value: "master" }, 56 | section: { 57 | startLine: 12, 58 | endLine: 23, 59 | startColumn: 8, 60 | endColumn: 9, 61 | }, 62 | relativeFilePath: "test/file", 63 | }; 64 | const result = await GitUrls.getUrl(configInfo); 65 | 66 | expect(result._unsafeUnwrap().url).toBe( 67 | "https://dev.azure.com/my-org/my-project/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents&lineStyle=plain&line=12&lineEnd=23&lineStartColumn=8&lineEndColumn=9" 68 | ); 69 | }); 70 | 71 | test("Get URL with commit SHA in DevOps", async () => { 72 | const configInfo: GitConfigInfo = { 73 | remoteName, 74 | remoteUrl: "https://dev.azure.com/my-org/my-project/_git/repo", 75 | ref: { type: "commit", value: "59f76230dd5829a10aab717265b66c6b5849365e" }, 76 | relativeFilePath: "test/file", 77 | }; 78 | const result = await GitUrls.getUrl(configInfo); 79 | 80 | expect(result._unsafeUnwrap().url).toBe( 81 | "https://dev.azure.com/my-org/my-project/_git/repo?path=%2Ftest%2Ffile&version=GC59f76230dd5829a10aab717265b66c6b5849365e&_a=contents" 82 | ); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/git-urls/test/github.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import GitUrls from "../src/index"; 3 | 4 | // test("Get current project's git online link", async () => { 5 | // const link = await GitUrl.getOnlineLinkAsync(path.resolve("package.json")); 6 | // expect(link).toMatch(/https:\/\/github.com\/.+\/git-urls\/blob\/.+\/package.json/); 7 | // }); 8 | 9 | const remoteName = "origin"; 10 | 11 | test("Get HTTPS URL in GitHub", async () => { 12 | const configInfo: GitConfigInfo = { 13 | remoteName, 14 | remoteUrl: "https://github.com/build/git-urls.git", 15 | ref: { type: "branch", value: "master" }, 16 | relativeFilePath: "test/a.md", 17 | }; 18 | const result = await GitUrls.getUrl(configInfo); 19 | expect(result._unsafeUnwrap().url).toBe("https://github.com/build/git-urls/blob/master/test/a.md"); 20 | }); 21 | 22 | test("Get SSH URL in GitHub", async () => { 23 | const configInfo: GitConfigInfo = { 24 | remoteName, 25 | remoteUrl: "git@github.com:qinezh/git-urls", 26 | ref: { type: "branch", value: "master" }, 27 | relativeFilePath: "test/a.md", 28 | }; 29 | const result = await GitUrls.getUrl(configInfo); 30 | expect(result._unsafeUnwrap().url).toBe("https://github.com/qinezh/git-urls/blob/master/test/a.md"); 31 | }); 32 | 33 | test("Get HTTP URL in GitHub", async () => { 34 | const configInfo: GitConfigInfo = { 35 | remoteName, 36 | remoteUrl: "http://github.com/qinezh/git-urls.git", 37 | ref: { type: "branch", value: "master" }, 38 | relativeFilePath: "test/a.md", 39 | }; 40 | const result = await GitUrls.getUrl(configInfo); 41 | expect(result._unsafeUnwrap().url).toBe("http://github.com/qinezh/git-urls/blob/master/test/a.md"); 42 | }); 43 | 44 | test("Get HTTPS URL with username in GitHub", async () => { 45 | const configInfo: GitConfigInfo = { 46 | remoteName, 47 | remoteUrl: "https://qinezh@github.com/build/git-urls.git", 48 | ref: { type: "branch", value: "master" }, 49 | relativeFilePath: "test/a.md", 50 | }; 51 | const result = await GitUrls.getUrl(configInfo); 52 | expect(result._unsafeUnwrap().url).toBe("https://github.com/build/git-urls/blob/master/test/a.md"); 53 | }); 54 | 55 | test("Get URL with space in file path in GitHub", async () => { 56 | const configInfo: GitConfigInfo = { 57 | remoteName, 58 | remoteUrl: "https://qinezh@github.com/build/git-urls.git", 59 | ref: { type: "branch", value: "master" }, 60 | relativeFilePath: "test space in path/a.md", 61 | }; 62 | const result = await GitUrls.getUrl(configInfo); 63 | expect(result._unsafeUnwrap().url).toBe( 64 | "https://github.com/build/git-urls/blob/master/test%20space%20in%20path/a.md" 65 | ); 66 | }); 67 | 68 | test("Get URL with section in GitHub", async () => { 69 | const configInfo: GitConfigInfo = { 70 | remoteName, 71 | remoteUrl: "https://qinezh@github.com/build/git-urls.git", 72 | ref: { type: "branch", value: "master" }, 73 | section: { 74 | startLine: 2, 75 | endLine: 3, 76 | startColumn: 4, 77 | endColumn: 5, 78 | }, 79 | relativeFilePath: "test space in path/a.md", 80 | }; 81 | const result = await GitUrls.getUrl(configInfo); 82 | expect(result._unsafeUnwrap().url).toBe( 83 | "https://github.com/build/git-urls/blob/master/test%20space%20in%20path/a.md#L2-L3" 84 | ); 85 | }); 86 | 87 | test("Get URL with special branch in GitHub", async () => { 88 | const configInfo: GitConfigInfo = { 89 | remoteName, 90 | remoteUrl: "https://qinezh@github.com/build/git-urls.git", 91 | ref: { type: "branch", value: "#test" }, 92 | relativeFilePath: "a.md", 93 | }; 94 | const result = await GitUrls.getUrl(configInfo); 95 | expect(result._unsafeUnwrap().url).toBe("https://github.com/build/git-urls/blob/%23test/a.md"); 96 | }); 97 | 98 | test("Get URL with commit SHA in GitHub", async () => { 99 | const configInfo: GitConfigInfo = { 100 | remoteName, 101 | remoteUrl: "https://github.com/build/git-urls.git", 102 | ref: { type: "commit", value: "59f76230dd5829a10aab717265b66c6b5849365e" }, 103 | relativeFilePath: "test/a.md", 104 | }; 105 | const result = await GitUrls.getUrl(configInfo); 106 | 107 | expect(result._unsafeUnwrap().url).toBe( 108 | "https://github.com/build/git-urls/blob/59f76230dd5829a10aab717265b66c6b5849365e/test/a.md" 109 | ); 110 | }); 111 | 112 | test("Get URL with basic auth", async () => { 113 | const configInfo: GitConfigInfo = { 114 | remoteName, 115 | remoteUrl: "https://username:ghp_REDACTED@github.com/build/git-urls.git", 116 | ref: { type: "branch", value: "main" }, 117 | relativeFilePath: "a.md", 118 | }; 119 | const result = await GitUrls.getUrl(configInfo); 120 | expect(result._unsafeUnwrap().url).toBe("https://github.com/build/git-urls/blob/main/a.md"); 121 | }); 122 | -------------------------------------------------------------------------------- /packages/git-urls/test/gitlab.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import GitUrls from "../src/index"; 3 | 4 | const remoteName = "origin"; 5 | test("Get HTTPS url in GitLab", async () => { 6 | const configInfo: GitConfigInfo = { 7 | remoteName, 8 | remoteUrl: "https://gitlab.com/build/git-urls.git", 9 | ref: { type: "branch", value: "master" }, 10 | relativeFilePath: "test/a.md", 11 | }; 12 | const result = await GitUrls.getUrl(configInfo); 13 | 14 | expect(result._unsafeUnwrap().url).toBe("https://gitlab.com/build/git-urls/blob/master/test/a.md"); 15 | }); 16 | 17 | test("Get SSH URL in GitLab", async () => { 18 | const configInfo: GitConfigInfo = { 19 | remoteName, 20 | remoteUrl: "git@gitlab.com:qinezh/git-urls", 21 | ref: { type: "branch", value: "master" }, 22 | relativeFilePath: "test/a.md", 23 | }; 24 | const result = await GitUrls.getUrl(configInfo); 25 | 26 | expect(result._unsafeUnwrap().url).toBe("https://gitlab.com/qinezh/git-urls/blob/master/test/a.md"); 27 | }); 28 | 29 | test("Get HTTPS url in GitLab with company name", async () => { 30 | const configInfo: GitConfigInfo = { 31 | remoteName, 32 | remoteUrl: "https://gitlab.xyz.com/build/git-urls.git", 33 | ref: { type: "branch", value: "master" }, 34 | relativeFilePath: "test/a.md", 35 | }; 36 | const result = await GitUrls.getUrl(configInfo); 37 | 38 | expect(result._unsafeUnwrap().url).toBe("https://gitlab.xyz.com/build/git-urls/blob/master/test/a.md"); 39 | }); 40 | 41 | test("Get SSH URL in GitLab with company name", async () => { 42 | const configInfo: GitConfigInfo = { 43 | remoteName, 44 | remoteUrl: "git@gitlab.xyz.com:qinezh/git-urls", 45 | ref: { type: "branch", value: "master" }, 46 | relativeFilePath: "test/a.md", 47 | }; 48 | const result = await GitUrls.getUrl(configInfo); 49 | 50 | expect(result._unsafeUnwrap().url).toBe("https://gitlab.xyz.com/qinezh/git-urls/blob/master/test/a.md"); 51 | }); 52 | 53 | test("Get URL with section in GitLab", async () => { 54 | const configInfo: GitConfigInfo = { 55 | remoteName, 56 | remoteUrl: "https://qinezh@gitlab.com/build/git-urls.git", 57 | ref: { type: "branch", value: "master" }, 58 | section: { 59 | startLine: 2, 60 | endLine: 3, 61 | startColumn: 4, 62 | endColumn: 5, 63 | }, 64 | relativeFilePath: "a.md", 65 | }; 66 | const result = await GitUrls.getUrl(configInfo); 67 | 68 | expect(result._unsafeUnwrap().url).toBe("https://gitlab.com/build/git-urls/blob/master/a.md#L2-3"); 69 | }); 70 | 71 | test("Get URL with commit SHA in GitLab", async () => { 72 | const configInfo: GitConfigInfo = { 73 | remoteName, 74 | remoteUrl: "https://gitlab.com/build/git-urls.git", 75 | ref: { type: "commit", value: "59f76230dd5829a10aab717265b66c6b5849365e" }, 76 | relativeFilePath: "test/a.md", 77 | }; 78 | const result = await GitUrls.getUrl(configInfo); 79 | 80 | expect(result._unsafeUnwrap().url).toBe( 81 | "https://gitlab.com/build/git-urls/blob/59f76230dd5829a10aab717265b66c6b5849365e/test/a.md" 82 | ); 83 | }); 84 | 85 | test("Get SSH URL with port number in GitLab", async () => { 86 | const configInfo: GitConfigInfo = { 87 | remoteName, 88 | remoteUrl: "git@gitlab.com:1024/qinezh/git-urls", 89 | ref: { type: "branch", value: "master" }, 90 | relativeFilePath: "test/a.md", 91 | }; 92 | const result = await GitUrls.getUrl(configInfo); 93 | 94 | expect(result._unsafeUnwrap().url).toBe("https://gitlab.com/qinezh/git-urls/blob/master/test/a.md"); 95 | }); 96 | 97 | test("Get unsupported SSH URL", async () => { 98 | const configInfo: GitConfigInfo = { 99 | remoteName, 100 | remoteUrl: "git@contoso.com:1024/qinezh/git-urls", 101 | ref: { type: "branch", value: "master" }, 102 | relativeFilePath: "test/a.md", 103 | }; 104 | const result = await GitUrls.getUrl(configInfo); 105 | 106 | expect(result._unsafeUnwrap().url).toBe("https://contoso.com/qinezh/git-urls/blob/master/test/a.md"); 107 | }); 108 | 109 | test("Get unsupported SSH URL with customized host type", async () => { 110 | const configInfo: GitConfigInfo = { 111 | remoteName, 112 | remoteUrl: "git@contoso.com:1024/qinezh/git-urls", 113 | ref: { type: "branch", value: "master" }, 114 | relativeFilePath: "test/a.md", 115 | }; 116 | const result = await GitUrls.getUrl(configInfo, "GitLab"); 117 | 118 | expect(result._unsafeUnwrap().url).toBe("https://contoso.com/qinezh/git-urls/blob/master/test/a.md"); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/git-urls/test/helper.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import * as fs from "fs-extra"; 3 | import * as path from "path"; 4 | import GitUrls from "../src"; 5 | 6 | const testDir = fs.mkdtempSync("test"); 7 | const configPath = path.resolve(testDir, ".git/config"); 8 | const headPath = path.resolve(testDir, ".git/HEAD"); 9 | 10 | describe("valid config", () => { 11 | const configContent = ` 12 | [remote "ssh"] 13 | url = git@github.com:qinezh/vscode-gitlink 14 | fetch = +refs/heads/*:refs/remotes/origin/* 15 | [branch "main"] 16 | remote = origin 17 | merge = refs/heads/main 18 | [remote "https"] 19 | url = https://github.com/qinezh/vscode-gitlink.git 20 | fetch = +refs/heads/*:refs/remotes/https/*`; 21 | 22 | const headContent = `ref: refs/heads/main`; 23 | beforeAll(async () => { 24 | await fs.ensureDir(path.resolve(testDir, ".git")); 25 | await fs.writeFile(configPath, configContent); 26 | await fs.writeFile(headPath, headContent); 27 | }); 28 | 29 | afterAll(async () => { 30 | await fs.remove(testDir); 31 | }); 32 | 33 | test("parse git config", async () => { 34 | const mapping = await GitUrls["parseConfigAsync"](testDir); 35 | const sshInfo = mapping.get("ssh"); 36 | const sshExpected: GitConfigInfo = { 37 | remoteName: "ssh", 38 | remoteUrl: "git@github.com:qinezh/vscode-gitlink", 39 | ref: { type: "branch", value: "main" }, 40 | }; 41 | expect(sshInfo).toEqual(sshExpected); 42 | 43 | const httpsInfo = mapping.get("https"); 44 | const httpsExpected: GitConfigInfo = { 45 | remoteName: "https", 46 | remoteUrl: "https://github.com/qinezh/vscode-gitlink.git", 47 | ref: { type: "branch", value: "main" }, 48 | }; 49 | expect(httpsInfo).toEqual(httpsExpected); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/git-urls/test/host.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import { GitUrlError } from "../src/error"; 3 | import { hostBuilder } from "../src/host/hostBuilder"; 4 | 5 | test("parse valid config info", async () => { 6 | const configInfo: GitConfigInfo = { 7 | remoteName: "origin", 8 | remoteUrl: "https://github.com/qinezh/vscode-gitlink.git", 9 | ref: { type: "branch", value: "main" }, 10 | relativeFilePath: "test/a.md", 11 | }; 12 | const host = hostBuilder.create(configInfo); 13 | 14 | const gitInfo = host.parse(configInfo); 15 | expect(gitInfo.hostName).toEqual("github.com"); 16 | expect(gitInfo.userName).toEqual("qinezh"); 17 | 18 | const url = host.assemble(gitInfo); 19 | expect(url).toEqual("https://github.com/qinezh/vscode-gitlink/blob/main/test/a.md"); 20 | }); 21 | 22 | test("parse invalid config info", async () => { 23 | const configInfo: GitConfigInfo = { 24 | remoteName: "origin", 25 | remoteUrl: "https:/git.heroku.com/vscode-gitlink.git", 26 | ref: { type: "branch", value: "main" }, 27 | relativeFilePath: "test/a.md", 28 | }; 29 | 30 | const host = hostBuilder.create(configInfo); 31 | expect(() => host.parse(configInfo)).toThrow(GitUrlError); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/git-urls/test/vsts.test.ts: -------------------------------------------------------------------------------- 1 | import { GitConfigInfo } from "../src/info"; 2 | import GitUrls from "../src/index"; 3 | 4 | const remoteName = "origin"; 5 | test("Get file URL in VSTS", async () => { 6 | const configInfo: GitConfigInfo = { 7 | remoteName, 8 | remoteUrl: "https://vsts.visualstudio.com/Collection/_git/repo", 9 | ref: { type: "branch", value: "master" }, 10 | relativeFilePath: "test/file", 11 | }; 12 | const result = await GitUrls.getUrl(configInfo); 13 | 14 | expect(result._unsafeUnwrap().url).toBe( 15 | "https://vsts.visualstudio.com/Collection/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents" 16 | ); 17 | }); 18 | 19 | test("Get selection block URL in VSTS", async () => { 20 | const configInfo: GitConfigInfo = { 21 | remoteName, 22 | remoteUrl: "https://vsts.visualstudio.com/Collection/_git/repo", 23 | ref: { type: "branch", value: "master" }, 24 | section: { 25 | startLine: 12, 26 | endLine: 23, 27 | }, 28 | relativeFilePath: "test/file", 29 | }; 30 | const result = await GitUrls.getUrl(configInfo); 31 | 32 | expect(result._unsafeUnwrap().url).toBe( 33 | "https://vsts.visualstudio.com/Collection/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents&lineStyle=plain&line=12&lineEnd=23&lineStartColumn=1&lineEndColumn=1" 34 | ); 35 | }); 36 | 37 | test("Get file URL in VSTS with SSH", async () => { 38 | const configInfo: GitConfigInfo = { 39 | remoteName, 40 | remoteUrl: "ssh://my-tenant@vs-ssh.visualstudio.com:22/Collection/_ssh/repo", 41 | ref: { type: "branch", value: "master" }, 42 | relativeFilePath: "test/file", 43 | }; 44 | 45 | const result = await GitUrls.getUrl(configInfo); 46 | expect(result._unsafeUnwrap().url).toBe( 47 | "https://my-tenant.visualstudio.com/Collection/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents" 48 | ); 49 | }); 50 | 51 | test("Get selection block URL with column in VSTS", async () => { 52 | const configInfo: GitConfigInfo = { 53 | remoteName, 54 | remoteUrl: "https://vsts.visualstudio.com/Collection/_git/repo", 55 | ref: { type: "branch", value: "master" }, 56 | section: { 57 | startLine: 12, 58 | endLine: 23, 59 | startColumn: 8, 60 | endColumn: 9, 61 | }, 62 | relativeFilePath: "test/file", 63 | }; 64 | const result = await GitUrls.getUrl(configInfo); 65 | 66 | expect(result._unsafeUnwrap().url).toBe( 67 | "https://vsts.visualstudio.com/Collection/_git/repo?path=%2Ftest%2Ffile&version=GBmaster&_a=contents&lineStyle=plain&line=12&lineEnd=23&lineStartColumn=8&lineEndColumn=9" 68 | ); 69 | }); 70 | 71 | test("Get URL with commit SHA in VSTS", async () => { 72 | const configInfo: GitConfigInfo = { 73 | remoteName, 74 | remoteUrl: "https://vsts.visualstudio.com/Collection/_git/repo", 75 | ref: { type: "commit", value: "59f76230dd5829a10aab717265b66c6b5849365e" }, 76 | relativeFilePath: "test/file", 77 | }; 78 | const result = await GitUrls.getUrl(configInfo); 79 | 80 | expect(result._unsafeUnwrap().url).toBe( 81 | "https://vsts.visualstudio.com/Collection/_git/repo?path=%2Ftest%2Ffile&version=GC59f76230dd5829a10aab717265b66c6b5849365e&_a=contents" 82 | ); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/git-urls/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "lib": ["es2015", "es2017"], 6 | "noImplicitAny": false, 7 | "sourceMap": true, 8 | "outDir": "out", 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "strict": true 12 | }, 13 | "exclude": ["node_modules", "out"] 14 | } -------------------------------------------------------------------------------- /packages/prettier-config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: "avoid", 3 | bracketSpacing: true, 4 | endOfLine: "lf", 5 | printWidth: 120, 6 | semi: true, 7 | singleQuote: false, 8 | tabWidth: 4 9 | }; 10 | -------------------------------------------------------------------------------- /packages/prettier-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shared/prettier-config", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Shared Prettier config", 6 | "main": "index.js" 7 | } -------------------------------------------------------------------------------- /packages/vscode-gitlink/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["../eslint-config"], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("../prettier-config"), 3 | }; 4 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Extension", 9 | "type": "extensionHost", 10 | "request": "launch", 11 | "runtimeExecutable": "${execPath}", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceRoot}" 14 | ], 15 | "preLaunchTask": "npm: esbuild", 16 | "stopOnEntry": false, 17 | "sourceMaps": true, 18 | "outFiles": [ 19 | "${workspaceFolder}/out/**/*.js" 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .vscode-test/ 3 | .github/ 4 | test/ 5 | node_modules/ 6 | src/ 7 | .gitignore 8 | tsconfig.json 9 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Qinen Zhu 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 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/README.md: -------------------------------------------------------------------------------- 1 | # GitLink (Support GitHub/GitLab/BitBucket/VSTS/DevOps) 2 | 3 | [![Current Version](https://vsmarketplacebadge.apphb.com/version/qezhu.gitlink.svg)](https://marketplace.visualstudio.com/items?itemName=qezhu.gitlink) 4 | [![Installs](https://vsmarketplacebadge.apphb.com/installs-short/qezhu.gitlink.svg)](https://marketplace.visualstudio.com/items?itemName=qezhu.gitlink) 5 | [![Rating](https://vsmarketplacebadge.apphb.com/rating/qezhu.gitlink.svg)](https://marketplace.visualstudio.com/items?itemName=qezhu.gitlink) 6 | [![AppVeyor](https://img.shields.io/appveyor/ci/qinezh/vscode-gitlink.svg)](https://ci.appveyor.com/project/qinezh/vscode-gitlink) 7 | 8 | Inspired by GitHub extension for Visual Studio, this extension provide the feature that **Go To** current file's online link in browser and **Copy** the link in clipboard. 9 | 10 | ## Features 11 | 12 | 1. Go to the online link of current file. 13 | 2. Copy the online link of current file. 14 | 15 | ## Usage 16 | 17 | 18 | 19 | ### Set default remote source 20 | 21 | When your project has multiple git remotes, you need to choose the git remote before the git link is generated. If you don't want to choose it every time, you could set the default remote source: 22 | 23 | **Workspace Level**: add `gitlink.defaultRemote: ""` in `.vscode/settings.json` under the root of your workspace. 24 | 25 | **Global Level**: toggle the preference of vscode, and add `gitlink.defaultRemote: ""` in User Settings. 26 | 27 | Please note, you could get the name of your remote sources by the command: `git remote -v`: 28 | 29 | ```bash 30 | # example 31 | $ git remote -v 32 | origin git@github.com:qinezh/vscode-gitlink (fetch) 33 | origin git@github.com:qinezh/vscode-gitlink (push) 34 | upstream git@github.com:upstream/vscode-gitlink.git (fetch) 35 | upstream git@github.com:upstream/vscode-gitlink.git (push) 36 | ``` 37 | 38 | And the sample `settings.json` could be like: 39 | ```json 40 | { 41 | "gitlink.defaultRemote": "upsteam" 42 | } 43 | ``` 44 | 45 | **Enjoy!** 46 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qinezh/vscode-gitlink/c4490f5636969b2a264a7d3d6d64ab2739c6b98a/packages/vscode-gitlink/images/logo.png -------------------------------------------------------------------------------- /packages/vscode-gitlink/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlink", 3 | "displayName": "GitLink", 4 | "description": "Goto/Copy current file's online link, supports multiple remote sources in GitHub/GitLab/BitBucket/VSTS/DevOps", 5 | "version": "1.2.4", 6 | "publisher": "qezhu", 7 | "license": "MIT", 8 | "author": { 9 | "name": "Qinen Zhu", 10 | "email": "qezhu@outlook.com" 11 | }, 12 | "icon": "images/logo.png", 13 | "bugs": { 14 | "url": "https://github.com/qinezh/vscode-gitlink/issues", 15 | "email": "qezhu@outlook.com" 16 | }, 17 | "homepage": "https://github.com/qinezh/vscode-gitlink/blob/master/README.md", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/qinezh/vscode-gitlink.git" 21 | }, 22 | "engines": { 23 | "vscode": "^1.42.0" 24 | }, 25 | "categories": [ 26 | "Other" 27 | ], 28 | "activationEvents": [ 29 | "onCommand:extension.gotoOnlineLink", 30 | "onCommand:extension.copyOnlineLink" 31 | ], 32 | "main": "./out/main.js", 33 | "contributes": { 34 | "commands": [ 35 | { 36 | "command": "extension.gotoOnlineLink", 37 | "title": "GitLink: Goto Online Link" 38 | }, 39 | { 40 | "command": "extension.copyOnlineLink", 41 | "title": "GitLink: Copy Online Link" 42 | } 43 | ], 44 | "menus": { 45 | "editor/context": [ 46 | { 47 | "when": "editorTextFocus", 48 | "command": "extension.gotoOnlineLink", 49 | "group": "GitLink@1" 50 | }, 51 | { 52 | "when": "editorTextFocus", 53 | "command": "extension.copyOnlineLink", 54 | "group": "GitLink@2" 55 | } 56 | ] 57 | }, 58 | "configuration": { 59 | "properties": { 60 | "GitLink.defaultRemote": { 61 | "type": "string", 62 | "default": "", 63 | "description": "The default remote source name used by GitLink extension, while the repo contains multiple remote sources." 64 | }, 65 | "GitLink.hostType": { 66 | "type": "string", 67 | "default": "auto", 68 | "enum": [ 69 | "auto", 70 | "github", 71 | "gitlab", 72 | "bitbucket", 73 | "vsts", 74 | "devops" 75 | ], 76 | "enumDescriptions": [ 77 | "Detect the host type automatically", 78 | "Specify the host type to GitHub", 79 | "Specify the host type to GitLab", 80 | "Specify the host type to BitBucket", 81 | "Specify the host type to VSTS", 82 | "Specify the host type to DevOps" 83 | ], 84 | "description": "The host type to match the git url." 85 | } 86 | } 87 | } 88 | }, 89 | "scripts": { 90 | "build": "npm run esbuild", 91 | "vscode:prepublish": "npm run esbuild-base -- --minify", 92 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/main.js --external:vscode --format=cjs --platform=node", 93 | "esbuild": "npm run esbuild-base -- --sourcemap", 94 | "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch", 95 | "compile": "tsc -watch -p ./", 96 | "test": "", 97 | "lint": "npx eslint \"src/**/*.ts\"", 98 | "lint:fix": "npx eslint --fix \"src/**/*.ts\"" 99 | }, 100 | "devDependencies": { 101 | "@types/node": "^6.0.40", 102 | "@types/vscode": "1.42.0", 103 | "@typescript-eslint/eslint-plugin": "^5.21.0", 104 | "@typescript-eslint/parser": "^5.21.0", 105 | "esbuild": "^0.13.15", 106 | "eslint": "^8.14.0", 107 | "eslint-config-prettier": "^8.5.0", 108 | "eslint-plugin-import": "^2.25.2", 109 | "eslint-plugin-prettier": "^4.0.0", 110 | "prettier": "^2.6.2", 111 | "typescript": "^4.6.4" 112 | }, 113 | "dependencies": { 114 | "git-urls": "^2.0.1" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/src/extension.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import * as vscode from "vscode"; 4 | import GitUrl, { GitUrlResult } from "git-urls"; 5 | import { logger } from "./logger"; 6 | 7 | export function activate(context: vscode.ExtensionContext) { 8 | const gotoDisposable = vscode.commands.registerCommand("extension.gotoOnlineLink", gotoCommandAsync); 9 | const copyDisposable = vscode.commands.registerCommand("extension.copyOnlineLink", copyCommandAsync); 10 | 11 | context.subscriptions.push(gotoDisposable, copyDisposable); 12 | } 13 | 14 | async function getGitLink(): Promise { 15 | const position = vscode.window.activeTextEditor.selection; 16 | const filePath = vscode.window.activeTextEditor.document.fileName; 17 | const obsoleteGitLinkConfig = vscode.workspace.getConfiguration("gitlink"); 18 | const gitLinkConfig = vscode.workspace.getConfiguration("GitLink"); 19 | 20 | const customizedHostType = gitLinkConfig["hostType"]; 21 | logger.info(`customized host type: ${customizedHostType}`); 22 | 23 | const linkMap = await GitUrl.getUrls( 24 | filePath, 25 | { 26 | startLine: position.start.line + 1, 27 | endLine: position.end.line + 1, 28 | startColumn: position.start.character + 1, 29 | endColumn: position.end.character + 1, 30 | }, 31 | customizedHostType 32 | ); 33 | 34 | // single result. 35 | if (linkMap.size === 1) { 36 | const result = [...linkMap.values()][0]; 37 | return await transform(result); 38 | } 39 | 40 | // multiple results chosen by default git remote set. 41 | const defaultRemote = gitLinkConfig["defaultRemote"] || obsoleteGitLinkConfig["defaultRemote"]; 42 | logger.info(`default remote source: ${defaultRemote ?? "not set"}`); 43 | 44 | if (defaultRemote && linkMap.get(defaultRemote)) { 45 | const result = linkMap.get(defaultRemote); 46 | return await transform(result); 47 | } 48 | 49 | // multiple results chosen by user input. 50 | const itemPickList: vscode.QuickPickItem[] = []; 51 | for (const [remoteName, _] of linkMap) { 52 | itemPickList.push({ label: remoteName, description: "" }); 53 | } 54 | 55 | const choice = await vscode.window.showQuickPick(itemPickList, { 56 | placeHolder: "Select the git remote source", 57 | }); 58 | if (choice === undefined) { 59 | // cancel 60 | return null; 61 | } 62 | 63 | const result = linkMap.get(choice.label); 64 | return await transform(result); 65 | } 66 | 67 | async function gotoCommandAsync(): Promise { 68 | const gitLink = await getGitLink(); 69 | if (gitLink === null) { 70 | return; 71 | } 72 | 73 | let uri: vscode.Uri; 74 | try { 75 | uri = vscode.Uri.parse(gitLink); 76 | } catch (ex) { 77 | const message = `Fail to parse ${gitLink}, error: ${ex.message}`; 78 | await vscode.window.showErrorMessage(message); 79 | logger.error(message); 80 | 81 | return; 82 | } 83 | 84 | try { 85 | await vscode.commands.executeCommand("vscode.open", uri); 86 | return; 87 | } catch (ex) { 88 | const message = `Fail to open ${gitLink}, error: ${ex.message}`; 89 | await vscode.window.showErrorMessage(message); 90 | logger.error(message); 91 | 92 | return; 93 | } 94 | } 95 | 96 | async function copyCommandAsync(): Promise { 97 | const gitLink = await getGitLink(); 98 | if (gitLink === null) { 99 | return; 100 | } 101 | 102 | try { 103 | await vscode.env.clipboard.writeText(gitLink); 104 | await vscode.window.showInformationMessage(`The link has been copied to the clipboard.`); 105 | return; 106 | } catch (ex) { 107 | const message = `Fail to copy the link ${gitLink}, error: ${ex.message}`; 108 | await vscode.window.showErrorMessage(message); 109 | logger.error(message); 110 | 111 | return; 112 | } 113 | } 114 | 115 | async function transform(result: GitUrlResult): Promise { 116 | if (result.isErr()) { 117 | await vscode.window.showErrorMessage(result.error.message); 118 | return null; 119 | } 120 | 121 | return result.value.url; 122 | } 123 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | enum LogLevel { 4 | Debug = 0, 5 | Info = 1, 6 | Warning = 2, 7 | Error = 3, 8 | } 9 | 10 | class Logger { 11 | outputChannel: vscode.OutputChannel; 12 | 13 | constructor() { 14 | const channelName = "GitLink"; 15 | this.outputChannel = vscode.window.createOutputChannel(channelName); 16 | } 17 | 18 | public debug(message: string): void { 19 | this.log(LogLevel.Debug, message); 20 | } 21 | 22 | public info(message: string): void { 23 | this.log(LogLevel.Info, message); 24 | } 25 | 26 | public warning(message: string): void { 27 | this.log(LogLevel.Warning, message); 28 | } 29 | 30 | public error(message: string): void { 31 | this.log(LogLevel.Error, message); 32 | } 33 | 34 | private log(logLevel: LogLevel, message: string) { 35 | if (logLevel > LogLevel.Info) { 36 | this.outputChannel.show(); 37 | } 38 | 39 | const dateString = new Date().toJSON(); 40 | const line = `[${dateString}] [${LogLevel[logLevel]}] - ${message}`; 41 | this.outputChannel.appendLine(line); 42 | } 43 | } 44 | 45 | export const logger = new Logger(); 46 | -------------------------------------------------------------------------------- /packages/vscode-gitlink/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "." 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "out", 15 | ".vscode-test" 16 | ] 17 | } --------------------------------------------------------------------------------