├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.yml │ ├── test.yml │ └── update_docs.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── analysis-engine.yml │ ├── ci.yml │ └── e2eTests.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── doc ├── getting-started-ko.md └── getting-started-ko │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 7.png │ ├── 8.png │ └── login-popup.png ├── package-lock.json ├── package.json └── packages ├── analysis-engine ├── .env.sample ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── CONTRIBUTING.md ├── README.md ├── jest.config.ts ├── package.json ├── rollup.config.js ├── src │ ├── commit.util.ts │ ├── csm.spec.ts │ ├── csm.ts │ ├── index.ts │ ├── parser.spec.ts │ ├── parser.ts │ ├── pluginOctokit.ts │ ├── pullRequest.ts │ ├── queue.ts │ ├── stem.spec.ts │ ├── stem.ts │ ├── summary.ts │ └── types │ │ ├── CSM.ts │ │ ├── CommitMessageType.ts │ │ ├── CommitNode.ts │ │ ├── CommitRaw.ts │ │ ├── Github.ts │ │ ├── Stem.ts │ │ └── index.ts └── tsconfig.json ├── view ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .vscode │ └── launch.json ├── CONTRIBUTING.md ├── README.md ├── jest.config.ts ├── package.json ├── playwright.config.ts ├── public │ ├── fake-assets │ │ └── sampleClusterNodeList.json │ └── index.html ├── src │ ├── App.scss │ ├── App.tsx │ ├── assets │ │ └── monoLogo.svg │ ├── components │ │ ├── @common │ │ │ └── Author │ │ │ │ ├── Author.tsx │ │ │ │ └── index.ts │ │ ├── BranchSelector │ │ │ ├── BranchSelector.const.ts │ │ │ ├── BranchSelector.scss │ │ │ ├── BranchSelector.tsx │ │ │ └── index.ts │ │ ├── Detail │ │ │ ├── Detail.const.ts │ │ │ ├── Detail.hook.tsx │ │ │ ├── Detail.scss │ │ │ ├── Detail.tsx │ │ │ ├── Detail.type.ts │ │ │ ├── Detail.util.test.ts │ │ │ ├── Detail.util.ts │ │ │ └── index.ts │ │ ├── FilteredAuthors │ │ │ ├── FilteredAuthors.scss │ │ │ ├── FilteredAuthors.tsx │ │ │ └── index.ts │ │ ├── RefreshButton │ │ │ ├── RefreshButton.scss │ │ │ ├── RefreshButton.tsx │ │ │ └── index.ts │ │ ├── SelectedClusterGroup │ │ │ ├── SelectedClusterGroup.scss │ │ │ ├── SelectedClusterGroup.tsx │ │ │ └── index.ts │ │ ├── Statistics │ │ │ ├── AuthorBarChart │ │ │ │ ├── AuthorBarChart.const.ts │ │ │ │ ├── AuthorBarChart.scss │ │ │ │ ├── AuthorBarChart.tsx │ │ │ │ ├── AuthorBarChart.type.ts │ │ │ │ ├── AuthorBarChart.util.test.ts │ │ │ │ ├── AuthorBarChart.util.ts │ │ │ │ └── index.ts │ │ │ ├── FileIcicleSummary │ │ │ │ ├── FileIcicleSummary.const.ts │ │ │ │ ├── FileIcicleSummary.scss │ │ │ │ ├── FileIcicleSummary.tsx │ │ │ │ ├── FileIcicleSummary.type.ts │ │ │ │ ├── FileIcicleSummary.util.test.ts │ │ │ │ ├── FileIcicleSummary.util.ts │ │ │ │ └── index.ts │ │ │ ├── Statistics.hook.tsx │ │ │ ├── Statistics.scss │ │ │ ├── Statistics.tsx │ │ │ └── index.ts │ │ ├── TemporalFilter │ │ │ ├── LineChart.const.ts │ │ │ ├── LineChart.scss │ │ │ ├── LineChart.ts │ │ │ ├── LineChartBrush.ts │ │ │ ├── TemporalFilter.hook.tsx │ │ │ ├── TemporalFilter.scss │ │ │ ├── TemporalFilter.tsx │ │ │ ├── TemporalFilter.util.test.ts │ │ │ ├── TemporalFilter.util.ts │ │ │ └── index.ts │ │ ├── ThemeSelector │ │ │ ├── ThemeSelector.const.ts │ │ │ ├── ThemeSelector.scss │ │ │ ├── ThemeSelector.tsx │ │ │ ├── ThemeSelector.type.ts │ │ │ └── index.ts │ │ ├── VerticalClusterList │ │ │ ├── ClusterGraph │ │ │ │ ├── ClusterGraph.const.ts │ │ │ │ ├── ClusterGraph.hook.tsx │ │ │ │ ├── ClusterGraph.scss │ │ │ │ ├── ClusterGraph.tsx │ │ │ │ ├── ClusterGraph.type.ts │ │ │ │ ├── ClusterGraph.util.test.ts │ │ │ │ ├── ClusterGraph.util.ts │ │ │ │ ├── Draws │ │ │ │ │ ├── destroyClusterGraph.ts │ │ │ │ │ ├── drawClusterBox.ts │ │ │ │ │ ├── drawCommitAmountCluster.ts │ │ │ │ │ ├── drawSubGraph.ts │ │ │ │ │ ├── drawTotalLine.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── Summary │ │ │ │ ├── Content │ │ │ │ │ ├── Content.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Summary.hook.tsx │ │ │ │ ├── Summary.scss │ │ │ │ ├── Summary.tsx │ │ │ │ ├── Summary.type.ts │ │ │ │ ├── Summary.util.test.ts │ │ │ │ ├── Summary.util.ts │ │ │ │ └── index.ts │ │ │ ├── VerticalClusterList.hook.ts │ │ │ ├── VerticalClusterList.scss │ │ │ ├── VerticalClusterList.tsx │ │ │ ├── VerticalClusterList.util.test.ts │ │ │ ├── VerticalClusterList.util.ts │ │ │ └── index.ts │ │ └── index.ts │ ├── constants │ │ └── constants.tsx │ ├── fake-assets │ │ ├── branch-list.json │ │ └── cluster-nodes.json │ ├── hooks │ │ ├── index.ts │ │ └── useAnalayzedData.ts │ ├── ide │ │ ├── FakeIDEAdapter.ts │ │ ├── IDEPort.ts │ │ ├── VSCodeAPIWrapper.ts │ │ └── VSCodeIDEAdapter.ts │ ├── index.common.tsx │ ├── index.prod.tsx │ ├── index.tsx │ ├── services │ │ └── index.ts │ ├── setupTest.ts │ ├── store │ │ ├── branch.ts │ │ ├── data.ts │ │ ├── filteredRange.ts │ │ ├── githubInfo.ts │ │ ├── index.ts │ │ └── loading.ts │ ├── styles │ │ ├── _colors.scss │ │ ├── _font.scss │ │ ├── _reset.scss │ │ ├── _utils.scss │ │ └── app.scss │ ├── types │ │ ├── Author.ts │ │ ├── ClusterGraphProps.ts │ │ ├── Commit.ts │ │ ├── CommitMessageType.ts │ │ ├── DiffStatistics.ts │ │ ├── GitHubUser.ts │ │ ├── IDEMessage.ts │ │ ├── IDESentEvents.ts │ │ ├── Nodes.ts │ │ ├── custom.d.ts │ │ ├── global.ts │ │ └── index.ts │ └── utils │ │ ├── author.ts │ │ ├── debounce.test.ts │ │ ├── debounce.ts │ │ ├── index.ts │ │ ├── pxToRem.ts │ │ ├── throttle.test.ts │ │ └── throttle.ts ├── tests │ ├── fakeAsset.ts │ └── home.spec.ts ├── tsconfig.json ├── webpack.dev.config.js └── webpack.prod.config.js └── vscode ├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENSE ├── README.md ├── images ├── githru_logo_temp.png ├── githru_logo_v0.2.png └── logo.png ├── jest.config.mjs ├── package.json ├── src ├── commands.ts ├── credentials.ts ├── errors │ └── ExtensionError.ts ├── extension.ts ├── setting-repository.ts ├── test │ ├── runTest.ts │ ├── suite │ │ ├── extension.ts │ │ └── index.ts │ └── utils │ │ └── getGitLog.spec.ts ├── types │ ├── CSMDictionary.ts │ ├── Commit.ts │ ├── CommitMessageType.ts │ ├── DiffStatistics.ts │ ├── DifferenceStatistic.ts │ ├── GitHubUser.ts │ ├── Node.ts │ └── StemCommitNode.ts ├── utils │ ├── csm.mapper.ts │ ├── git.util.ts │ └── git.worker.ts └── webview-loader.ts ├── tsconfig.json ├── vsc-extension-quickstart.md └── webpack.config.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @githru/contributors @githru/oss-2024 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: fix a bug 🐞 2 | description: >- 3 | 발견한 버그에 대한 정보를 입력해주세요. 4 | title: '[fix]: ' 5 | labels: 6 | - fix 7 | body: 8 | - type: textarea 9 | id: related-issue 10 | attributes: 11 | label: 관련 이슈 12 | description: 관련 있는 이슈 번호 기재 (#이슈번호) 13 | placeholder: >- 14 | #77 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: 버그 설명 19 | description: 버그에 대해 명확하게 설명하여 주세요 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: reference 24 | attributes: 25 | label: 참고 26 | description: 추가 논의사항 혹은 참고사항에 대해 기재해주세요 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | 3 | contact_links: 4 | - name: Contributing Guide 5 | url: https://github.com/githru/githru-vscode-ext/blob/main/CONTRIBUTING.md 6 | about: 기여를 하시기 전에 본 문서를 참고해주세요. 7 | - name: Documentation 8 | url: https://github.com/githru/githru-vscode-ext/wiki 9 | about: 참고 자료를 확인할 수 있습니다. 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: feature request 🚀 2 | description: >- 3 | 추가하고 싶은 신규 기능에 대해 알려주세요. 4 | title: '[new feature]: ' 5 | labels: 6 | - new feature 7 | body: 8 | - type: textarea 9 | id: related-issue 10 | attributes: 11 | label: 관련 이슈 12 | description: 관련 있는 이슈 번호 기재 (#이슈번호) 13 | placeholder: >- 14 | #77 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: 기능 설명 19 | description: 기능에 대해 명확하게 설명하여 주세요 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: task 24 | attributes: 25 | label: 기능 목록 26 | description: 기능 목록을 기재해주세요. 각 항목별로 새로운 줄에 입력하세요. (체크박스를 이용하면 좋아요) 27 | placeholder: >- 28 | - [ ] 기능 1 29 | - type: textarea 30 | id: reference 31 | attributes: 32 | label: 참고 33 | description: 추가 논의사항 혹은 참고사항에 대해 기재해주세요 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/test.yml: -------------------------------------------------------------------------------- 1 | name: test 🧪 2 | description: >- 3 | 테스트 사항에 대한 내용을 입력해주세요. 4 | title: '[test]: ' 5 | labels: 6 | - test 7 | body: 8 | - type: textarea 9 | id: related-issue 10 | attributes: 11 | label: 관련 이슈 12 | description: 관련 있는 이슈 번호 기재 (#이슈번호) 13 | placeholder: >- 14 | #77 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: 테스트 내용 설명 19 | description: 테스트 내용에 대해 명확하게 설명하여 주세요 (서술 혹은 스크린샷 첨부) 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: reference 24 | attributes: 25 | label: 참고 26 | description: 추가 논의사항 혹은 참고사항에 대해 기재해주세요 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/update_docs.yml: -------------------------------------------------------------------------------- 1 | name: Update docs ✍️ 2 | description: >- 3 | 문서 업데이트가 필요한 경우 본 이슈를 사용하세요 (.md 파일 수정 등) 4 | 5 | title: "[docs]: " 6 | labels: 7 | - docs 8 | body: 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Related issue 13 | description: 관련 있는 이슈 번호 기재 (#이슈번호) 14 | - type: textarea 15 | id: context 16 | attributes: 17 | label: Content 18 | description: 본 이슈에 대한 내용을 작성해주세요. 19 | validations: 20 | required: true -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Related issue 2 | 3 | ## Result 4 | 5 | ## Work list 6 | 7 | ## Discussion 8 | -------------------------------------------------------------------------------- /.github/workflows/analysis-engine.yml: -------------------------------------------------------------------------------- 1 | name: analysis-engine PR checker 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | pull_request_test: 9 | runs-on: ubuntu-latest 10 | 11 | defaults: 12 | run: 13 | working-directory: ./packages/analysis-engine 14 | 15 | strategy: 16 | matrix: 17 | node-version: [20.x] 18 | 19 | steps: 20 | - name: download codes 21 | uses: actions/checkout@v3 22 | 23 | - name: install node 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | 29 | - name: install node_modules from package.json 30 | run: npm install 31 | 32 | - name: check lint and prettier 33 | run: npm run check:eslint 34 | 35 | # - name: check version vulnerabilities 36 | # run: npm run check:vulnerabilities 37 | 38 | - name: test analysis-engine module 39 | run: npm run test 40 | 41 | - name: test analysis-engine log and return its coverage 42 | run: npm run test:log 43 | 44 | - name: test analysis-engine DAG and return its coverage 45 | run: npm run test:DAG 46 | 47 | - name: test analysis-engine stem and return its coverage 48 | run: npm run test:stem 49 | 50 | - name: test analysis-engine CSM and return its coverage 51 | run: npm run test:CSM 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build:all --if-present 31 | - run: npm run test:all --if-present 32 | -------------------------------------------------------------------------------- /.github/workflows/e2eTests.yml: -------------------------------------------------------------------------------- 1 | # name: E2E Tests - Playwright 2 | # on: 3 | # pull_request: 4 | # branches: [main, master] 5 | # jobs: 6 | # tests: 7 | # timeout-minutes: 600 8 | # runs-on: ubuntu-latest 9 | # defaults: 10 | # run: 11 | # working-directory: ./packages/view 12 | # steps: 13 | # - uses: actions/checkout@v2 # v3 is not available as of my last update in January 2022 14 | # - uses: actions/setup-node@v3 15 | # with: 16 | # node-version: 18.16.0 17 | 18 | # # Add Playwright GitHub Action 19 | # - name: Setup Playwright 20 | # uses: microsoft/playwright-github-action@v1 21 | 22 | # - name: Install dependencies 23 | # run: npm i 24 | 25 | # - name: npx playwright install 26 | # run: npx playwright install 27 | 28 | # Temporarily Disabled until UI renewal 29 | # - name: Run Playwright tests 30 | # run: npm run test:e2e 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .idea 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "printWidth": 120, 5 | "semi": true, 6 | "singleQuote": false, 7 | "singleAttributePerLine": true, 8 | "bracketSameLine": false 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [{ "pattern": "./packages/*/" }] 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Githru 2 | 3 | TODO: Add support 수정 필요! 4 | 5 | ## Develop Environment 6 | 7 | - [node](https://nodejs.org/ko/download/) -v v14.17.3 8 | - [vscode](https://code.visualstudio.com/) 9 | - [vscode plugin - ESlint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 10 | 11 | ## Installation 12 | 13 | 1. 이 저장소를 [Fork](https://help.github.com/articles/fork-a-repo/) 한 후 14 | 로컬 기기에 [clone](https://help.github.com/articles/cloning-a-repository/) 합니다. 15 | 2. VSCode를 사용한다면 아래 과정을 통해 custom TS setting을 활성화해야 합니다. 16 | 17 | 1. TypeScript 파일이 열려 있는 상태로 `ctrl(cmd) + shift + p`를 입력합니다. 18 | 2. "Select TypeScript Version"을 선택합니다. 19 | 3. "Use Workspace Version"을 선택합니다. 20 | 21 | 3. 브랜치 생성: 22 | ```shell 23 | git checkout -b MY_BRANCH_NAME 24 | ``` 25 | 4. 의존성 설치: 26 | ```shell 27 | # can skip if you already installed 28 | npm i -g yarn 29 | yarn install 30 | ``` 31 | 5. 라이브러리 개발 서버 띄우기: 32 | ```shell 33 | yarn serve 34 | ``` 35 | 6. 그 외 여러 가지 명령어들을 사용해 볼 수 있습니다. 36 | 37 | ```shell 38 | # 빌드 39 | yarn build 40 | 41 | # 테스트 42 | yarn test 43 | ``` 44 | 45 | 7. [demo README](https://github.com/EveryAnalytics/react-analytics-provider/tree/main/demo)를 참고해 demo 앱도 실행해 보세요. 46 | 47 | ## Debugging 48 | 49 | 1. root 폴더에서 build 합니다. 50 | 51 | ``` 52 | yarn build:all 53 | ``` 54 | 55 | 2. vscode 폴더로 이동합니다. 56 | 57 | 3. Run 탭에서 Start Debugging을 누르거나 F5를 눌러 디버깅으로 진입합니다. 58 | 59 | 4. palette를 띄어 Open Githru View를 입력합니다. 60 | 61 | ## Commit message 62 | 63 | 커밋 메시지는 제목과 본문을 포함해야 합니다. 64 | 65 | 제목은 해당 커밋에 대한 주요 내용을 간략하게 기록합니다. 66 | 형식은 https://www.conventionalcommits.org/en/v1.0.0/ 를 따릅니다. 67 | 68 | - optional scope을 사용하며, `engine`, `vscode`, `view` 3가지 scope만을 사용합니다. 69 | - ex) feat(view): Add File Icicle Tree view. 70 | 71 | 본문은 커밋에서 수정된 상세내용을 작성합니다. 생략 가능하며, `어떻게`보단 `무엇을`, `왜` 해결했는지 적어주시는 것이 좋습니다. 72 | 73 | 상황에 따라 연관된 이슈 트래킹 번호를 포함합니다. 74 | 75 | ## Issue 76 | 77 | - 각 이슈는 1개의 주제만 포함해야 합니다. 78 | - 문제상황이나 제안을 포함해 주세요. 79 | - 최대한 구체적이고 명확하게 작성해 주세요. \*필요에 따라 스크린샷도 첨부해 주세요. 80 | 81 | ## Pull request(PR) 82 | 83 | `main` 브랜치에 PR을 열어주세요. 84 | 85 | 각 PR은 1개의 주제만 포함해야 합니다. 1개의 주제는 여러 부분의 코드를 수정할 수도 있습니다. 예를 들어, `새로운 ga 연동 함수를 추가`는 라이브러리 구현, demo에 예시 추가, 문서 내용 추가 등을 포함합니다. 86 | 87 | PR의 제목 형식은 commit과 동일하게 맞추면 됩니다. 88 | 89 | ## Coding Guidelines 90 | 91 | `vscode`의 `ESlint` 플러그인을 통해 미리 설정된 코드 컨벤션을 적용하고 검사해 볼 수 있습니다. 92 | 93 | ## Add yourself as a contributor 94 | 95 | 기여자 목록에 자신을 추가하려면 [All Contributors 봇 설명서](https://allcontributors.org/docs/en/bot/usage)를 참고하세요 :) 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # githru-vscode-ext 2 | Lightweight but robust Githru for VSCode Extension 3 | 4 | ## Getting Started 5 | 6 | https://github.com/githru/githru-vscode-ext/blob/main/doc/getting-started-ko.md 7 | 8 | ## Documentation 9 | 10 | https://github.com/githru/githru-vscode-ext/wiki 11 | 12 | ## Contributors 13 | 14 | This project exists thanks to all the people who contribute. [[Contributing](https://github.com/githru/githru-vscode-ext/blob/main/CONTRIBUTING.md)] 15 | 16 | 17 | 18 | 19 | 20 | Made with [contrib.rocks](https://contrib.rocks). 21 | -------------------------------------------------------------------------------- /doc/getting-started-ko.md: -------------------------------------------------------------------------------- 1 | # Getting Started (KO) 2 | 3 | 로컬 개발환경을 위한 프로젝트 실행방법을 설명합니다. 4 | 5 | ## install dependencies 6 | 7 | ``` 8 | cd <프로젝트 루트> 9 | npm install 10 | ``` 11 | 12 | ## build 13 | 14 | ``` 15 | npm run build:all 16 | ``` 17 | 18 | view → engine → vscode 순서대로 빌드를 시작합니다. 19 | 20 | ## run on debug mode 21 | 22 | 1. vscode IDE 를 통해 `<프로젝트 루트>/packages/vscode` 를 불러옵니다. 23 | ![](./getting-started-ko/1.png) 24 | 25 | 2. `<프로젝트 루트>/packages/vscode` 를 실행한 vscode IDE 내에서 `F5` 버튼을 통해 debug mode 로 진입합니다. 26 | ![](./getting-started-ko/2.png) 27 | 28 | 3. debug mode 로 실행된 vscode(githru의 vscode패키지) 에서 다른 git 기반 프로젝트를 불러옵니다. 29 | (주의 : debug mode 로 실행된 vscode 로 불러올 경로가 다른 vscode 에서 이미 열려있는 경로라면, 제대로 동작하지 않습니다.) 30 | (ex. `<프로젝트 루트>/packages/view/tsconfig.json`) 31 | ![](./getting-started-ko/3.png) 32 | ![](./getting-started-ko/4.png) 33 | 34 | 4. debug mode 로 실행된 vscode(githru의 vscode패키지) 의 하단 상태표시줄에 "githru" 텍스트 버튼을 통해 githru 화면에 진입합니다. 35 | ![](./getting-started-ko/7.png) 36 | 37 | 5. "확장 'githru-vscode-ext'은(는) GitHub을(를) 사용하여 로그인하려고 합니다."라는 메시지가 뜨면, "허용" 버튼을 클릭하고 외부 페이지에서 GitHub 로그인합니다. 38 | ![](./getting-started-ko/login-popup.png) 39 | 40 | 6. 로그인이 완료되면, vscode(githru의 vscode패키지) 에서 githru 화면이 뜹니다. 41 | ![](./getting-started-ko/8.png) 42 | -------------------------------------------------------------------------------- /doc/getting-started-ko/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/1.png -------------------------------------------------------------------------------- /doc/getting-started-ko/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/2.png -------------------------------------------------------------------------------- /doc/getting-started-ko/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/3.png -------------------------------------------------------------------------------- /doc/getting-started-ko/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/4.png -------------------------------------------------------------------------------- /doc/getting-started-ko/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/7.png -------------------------------------------------------------------------------- /doc/getting-started-ko/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/8.png -------------------------------------------------------------------------------- /doc/getting-started-ko/login-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/doc/getting-started-ko/login-popup.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "githru-vscode-ext", 3 | "version": "0.7.2", 4 | "description": "githru-vscode-ext root package.json", 5 | "scripts": { 6 | "build:all": "npm run build --workspaces", 7 | "deploy:all": "npm run deploy --workspaces", 8 | "test:all": "npm run test --workspaces", 9 | "lint": "eslint '**/*.{js,ts,tsx}'", 10 | "lint:fix": "npm run lint --fix", 11 | "prettier": "prettier '**/*.{ts,tsx,json,yaml,md}' --check", 12 | "prettier:fix": "prettier '**/*.{ts,tsx,json,yaml,md}' --write" 13 | }, 14 | "author": "githru team", 15 | "license": "MIT", 16 | "workspaces": [ 17 | "./packages/*" 18 | ], 19 | "dependencies": { 20 | "generator-code": "^1.6.10", 21 | "yo": "^1.4.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/analysis-engine/.env.sample: -------------------------------------------------------------------------------- 1 | GEMENI_API_KEY= -------------------------------------------------------------------------------- /packages/analysis-engine/.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/analysis-engine/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["@typescript-eslint", "simple-import-sort"], 14 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 15 | "rules": { 16 | "no-unused-vars": "off", 17 | "no-console": "off", 18 | "func-names": "off", 19 | "no-process-exit": "off", 20 | "object-shorthand": "off", 21 | "class-methods-use-this": "off", 22 | "import/extensions": "off", 23 | "import/no-unresolved": "off", 24 | "no-continue": "off", 25 | "no-underscore-dangle": ["error", { "allowAfterThis": true }], 26 | "sort-imports": "off", 27 | "simple-import-sort/imports": [ 28 | "error", 29 | { 30 | "groups": [["^\\u0000"], ["^@?\\w"], ["^."]] 31 | } 32 | ], 33 | "simple-import-sort/exports": "error", 34 | "no-duplicate-imports": "error" 35 | }, 36 | "overrides": [ 37 | { 38 | "files": ["**/*.ts?(x)"], 39 | "settings": { 40 | "import/resolver": { 41 | "typescript": true 42 | } 43 | }, 44 | "parserOptions": { 45 | "project": ["./tsconfig.json"] 46 | }, 47 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 48 | "rules": { 49 | "no-empty": "warn", 50 | "@typescript-eslint/no-unused-vars": "warn", 51 | "@typescript-eslint/ban-types": "off", 52 | "@typescript-eslint/no-redeclare": "warn", 53 | "@typescript-eslint/no-empty-function": "off", 54 | "@typescript-eslint/no-non-null-assertion": "off", 55 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off", 56 | "@typescript-eslint/no-unsafe-return": "off", 57 | "@typescript-eslint/consistent-type-assertions": "warn", 58 | "@typescript-eslint/consistent-type-exports": ["error", { "fixMixedExportsWithInlineTypeSpecifier": true }], 59 | "@typescript-eslint/consistent-type-imports": [ 60 | "error", 61 | { 62 | "prefer": "type-imports", 63 | "disallowTypeAnnotations": true, 64 | "fixStyle": "inline-type-imports" 65 | } 66 | ], 67 | "@typescript-eslint/no-import-type-side-effects": "error" 68 | } 69 | } 70 | ], 71 | "ignorePatterns": ["node_modules", "*.config.ts", "*.config.js"] 72 | } 73 | -------------------------------------------------------------------------------- /packages/analysis-engine/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .env 5 | -------------------------------------------------------------------------------- /packages/analysis-engine/README.md: -------------------------------------------------------------------------------- 1 | # @githru-vscode-ext/analysis-engine 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/githru/githru-vscode-ext/blob/main/LICENSE) [![GitHub actions Status](https://github.com/githru/githru-vscode-ext/actions/workflows/analysis-engine.yml/badge.svg)](https://github.com/githru/githru-vscode-ext/actions/workflows/analysis-engine.yml) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/githru/githru-vscode-ext/blob/main/packages/analysis-engine/CONTRIBUTING.md) 4 | 5 | Githru Analysis Engine is module for preprocessing Git logs. 6 | 7 | - **Convincing:** Design a visual encoding idiom to represent a large Git graph in a scalable manner while preserving the topological structures in the Git graph. 8 | - **Novel-Techniques:** Use graph reconstruction, clustering, and Context-Preserving Squash Merge methods to enable scalable exploration of a large Git commit graph. 9 | 10 | ## Installation 11 | 12 | The easiest way to get started with Githru Analysis Engine is to run the npm command. 13 | 14 | ```Shell 15 | # Run from your project's root directory 16 | npm install @githru-vscode-ext/analysis-engine 17 | ``` 18 | 19 | ## Documentation 20 | 21 | ### PARSER 22 | 23 | ### STEM 24 | 25 | ### CSM 26 | 27 | You can improve it by sending pull requests to [this repository](https://github.com/githru/githru-vscode-ext/blob/main/packages/analysis-engine). 28 | 29 | ## Examples 30 | 31 | Here is the first one to get you started: 32 | 33 | ```ts 34 | import { analyzeGit } from "@githru-vscode-ext/analysis-engine"; 35 | 36 | async function getAnalyzedGit(gitLog: string) { 37 | const analyzedGitInformation = await analyzeGit({ gitLog }); 38 | 39 | // Add your codes 40 | return analyzedGitInformation; 41 | } 42 | ``` 43 | 44 | ## Contributing 45 | 46 | The main purpose of this repository is to continue evolving procedure for analyzing Git logs, making it faster and easier to understand. Development of Githru Analysis Engine happens in the open on GitHub, and we are grateful to the community for contributing bugfixes and improvements. Read below to learn how you can take part in. 47 | 48 | ### [Contributing Guide](https://github.com/githru/githru-vscode-ext/blob/main/packages/analysis-engine/CONTRIBUTING.md) 49 | 50 | Read our [contributing guide](https://github.com/githru/githru-vscode-ext/blob/main/packages/analysis-engine/CONTRIBUTING.md) to learn about our development process, how to propose bugfixes and improvements, and how to build and test your changes to Githru Analysis Engine. 51 | 52 | ### Analysis Engine Label 53 | 54 | To help you get your feet wet and get you familiar with our contribution process, we have a list of [analysis engine](https://github.com/githru/githru-vscode-ext/labels/%F0%9F%94%8D%20analysis%20engine) that contain information that have a relatively limited scope. This is a great place to get started. 55 | 56 | ### License 57 | 58 | Githru Analysis Engine is Apache License 2.0 licensed. 59 | -------------------------------------------------------------------------------- /packages/analysis-engine/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | verbose: true, 7 | transform: { 8 | "^.+\\.ts?$": "ts-jest", 9 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/analysis-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@githru-vscode-ext/analysis-engine", 3 | "version": "0.7.2", 4 | "description": "analysis-engine module for githru", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "types": "dist/index.d.ts", 8 | "jsnext:main": "dist/index.es.js", 9 | "engines": { 10 | "node": ">=16", 11 | "npm": ">=8" 12 | }, 13 | "scripts": { 14 | "build": "rollup -c && tsc -d --emitDeclarationOnly --noEmit false --declarationDir dist", 15 | "watch": "rollup -c -w", 16 | "start": "sh -c 'node -e \"require(\\\"./dist/index.js\\\").getMockData(\\\"$0\\\")\"'", 17 | "lint": "eslint src --ext ts", 18 | "lint:fix": "eslint src --ext ts --fix", 19 | "check:eslint": "eslint --ignore-path .gitignore --max-warnings 0 \"**/*.{js,ts}\"", 20 | "check:vulnerabilities": "npm audit --omit=dev", 21 | "test": "jest", 22 | "test:log": "echo \"jest --verbose --coverage ./src/log\"", 23 | "test:DAG": "echo \"jest --verbose --coverage ./src/DAG\"", 24 | "test:stem": "jest --verbose --coverage ./src/stem", 25 | "test:CSM": "echo \"jest --verbose --coverage ./src/CSM\"" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/githru/githru-vscode-ext.git" 30 | }, 31 | "author": "githru team", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/githru/githru-vscode-ext/issues" 35 | }, 36 | "homepage": "https://github.com/githru/githru-vscode-ext#readme", 37 | "devDependencies": { 38 | "@babel/core": "^7.18.10", 39 | "@babel/runtime": "^7.18.9", 40 | "@jest/types": "^28.1.3", 41 | "@rollup/plugin-babel": "^5.3.1", 42 | "@rollup/plugin-commonjs": "^22.0.2", 43 | "@rollup/plugin-json": "^4.1.0", 44 | "@rollup/plugin-node-resolve": "^13.3.0", 45 | "@rollup/plugin-replace": "^5.0.7", 46 | "@rollup/plugin-typescript": "^8.3.4", 47 | "@rollup/plugin-url": "^7.0.0", 48 | "@types/jest": "^28.1.7", 49 | "@typescript-eslint/eslint-plugin": "^6.2.1", 50 | "@typescript-eslint/parser": "^6.2.1", 51 | "dotenv": "^16.4.5", 52 | "eslint": "^8.22.0", 53 | "eslint-config-node": "^4.1.0", 54 | "eslint-config-prettier": "^8.5.0", 55 | "eslint-plugin-import": "^2.28.0", 56 | "eslint-plugin-node": "^11.1.0", 57 | "eslint-plugin-prettier": "^5.0.0", 58 | "eslint-plugin-simple-import-sort": "^10.0.0", 59 | "eslint-plugin-unused-imports": "^3.0.0", 60 | "jest": "^28.1.3", 61 | "prettier": "^3.0.1", 62 | "react": "^18.2.0", 63 | "rollup": "^2.78.0", 64 | "rollup-plugin-peer-deps-external": "^2.2.4", 65 | "ts-jest": "^28.0.8", 66 | "ts-node": "^10.9.1", 67 | "tslib": "^2.4.0", 68 | "typescript": "^4.7.4" 69 | }, 70 | "dependencies": { 71 | "@octokit/core": "^4.0.4", 72 | "@octokit/plugin-retry": "^3.0.9", 73 | "@octokit/plugin-throttling": "^4.1.0", 74 | "@octokit/rest": "^19.0.3", 75 | "@octokit/types": "^13.5.0", 76 | "reflect-metadata": "^0.1.13", 77 | "tsyringe": "^4.7.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/analysis-engine/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import resolve from "@rollup/plugin-node-resolve"; 4 | import external from "rollup-plugin-peer-deps-external"; 5 | import url from "@rollup/plugin-url"; 6 | import json from "@rollup/plugin-json"; 7 | import replace from '@rollup/plugin-replace'; 8 | import dotenv from 'dotenv'; 9 | import pkg from "./package.json"; 10 | 11 | dotenv.config(); 12 | 13 | export default { 14 | input: "src/index.ts", 15 | output: [ 16 | { 17 | file: pkg.main, 18 | format: "cjs", 19 | exports: "named", 20 | sourcemap: true, 21 | }, 22 | { 23 | file: pkg.module, 24 | format: "es", 25 | exports: "named", 26 | sourcemap: true, 27 | }, 28 | ], 29 | plugins: [ 30 | external(), 31 | json(), 32 | url({ exclude: ["**/*.svg"] }), 33 | resolve({ preferBuiltins: false }), 34 | typescript(), 35 | commonjs({ extensions: [".js", ".ts"] }), 36 | replace({ 37 | preventAssignment: true, 38 | values: { 39 | 'process.env.GEMENI_API_KEY': JSON.stringify(process.env.GEMENI_API_KEY), 40 | } 41 | }) 42 | ], 43 | }; 44 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/commit.util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CommitDict, 3 | type CommitMessageType, 4 | CommitMessageTypeList, 5 | type CommitNode, 6 | type CommitRaw, 7 | } from "./types"; 8 | 9 | export function buildCommitDict(commits: CommitRaw[]): CommitDict { 10 | return new Map(commits.map((commit) => [commit.id, { commit } as CommitNode])); 11 | } 12 | 13 | export function getLeafNodes(commitDict: CommitDict): CommitNode[] { 14 | const leafNodes: CommitNode[] = []; 15 | commitDict.forEach((node) => node.commit.branches.length && leafNodes.push(node)); 16 | return leafNodes; 17 | } 18 | 19 | export function getCommitMessageType(message: string): CommitMessageType { 20 | const lowerCaseMessage = message.toLowerCase(); 21 | let type = ""; 22 | 23 | CommitMessageTypeList.forEach((commitMessageType) => { 24 | const classifiedCommitMessageIndex = lowerCaseMessage.indexOf(commitMessageType); 25 | 26 | if (classifiedCommitMessageIndex >= 0) { 27 | if (!type.length) type = commitMessageType; 28 | else if (lowerCaseMessage.indexOf(type) > classifiedCommitMessageIndex) type = commitMessageType; 29 | } 30 | }); 31 | 32 | return type; 33 | } 34 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/index.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import { container } from "tsyringe"; 4 | 5 | import { buildCommitDict } from "./commit.util"; 6 | import { buildCSMDict } from "./csm"; 7 | import getCommitRaws from "./parser"; 8 | import { PluginOctokit } from "./pluginOctokit"; 9 | import { buildStemDict } from "./stem"; 10 | import { getSummary } from "./summary"; 11 | 12 | type AnalysisEngineArgs = { 13 | isDebugMode?: boolean; 14 | gitLog: string; 15 | owner: string; 16 | repo: string; 17 | baseBranchName: string; 18 | auth?: string; 19 | }; 20 | 21 | export class AnalysisEngine { 22 | private gitLog!: string; 23 | 24 | private isDebugMode?: boolean; 25 | 26 | private octokit!: PluginOctokit; 27 | 28 | private baseBranchName!: string; 29 | 30 | constructor(args: AnalysisEngineArgs) { 31 | this.insertArgs(args); 32 | } 33 | 34 | private insertArgs = (args: AnalysisEngineArgs) => { 35 | const { isDebugMode, gitLog, owner, repo, auth, baseBranchName } = args; 36 | this.gitLog = gitLog; 37 | this.baseBranchName = baseBranchName; 38 | this.isDebugMode = isDebugMode; 39 | container.register("OctokitOptions", { 40 | useValue: { 41 | owner, 42 | repo, 43 | options: { 44 | auth, 45 | }, 46 | }, 47 | }); 48 | this.octokit = container.resolve(PluginOctokit); 49 | }; 50 | 51 | public analyzeGit = async () => { 52 | let isPRSuccess = true; 53 | if (this.isDebugMode) console.log("baseBranchName: ", this.baseBranchName); 54 | 55 | const commitRaws = getCommitRaws(this.gitLog); 56 | if (this.isDebugMode){ 57 | console.log("commitRaws: ", commitRaws); 58 | } 59 | 60 | const commitDict = buildCommitDict(commitRaws); 61 | if (this.isDebugMode) console.log("commitDict: ", commitDict); 62 | 63 | const pullRequests = await this.octokit 64 | .getPullRequests() 65 | .catch((err) => { 66 | console.error(err); 67 | isPRSuccess = false; 68 | return []; 69 | }) 70 | .then((pullRequests) => { 71 | console.log("success, pr = ", pullRequests); 72 | return pullRequests; 73 | }); 74 | if (this.isDebugMode) console.log("pullRequests: ", pullRequests); 75 | 76 | const stemDict = buildStemDict(commitDict, this.baseBranchName); 77 | if (this.isDebugMode) console.log("stemDict: ", stemDict); 78 | const csmDict = buildCSMDict(commitDict, stemDict, this.baseBranchName, pullRequests); 79 | if (this.isDebugMode) console.log("csmDict: ", csmDict); 80 | const nodes = stemDict.get(this.baseBranchName)?.nodes?.map(({ commit }) => commit); 81 | const geminiCommitSummary = await getSummary(nodes ? nodes?.slice(-10) : []); 82 | if (this.isDebugMode) console.log("GeminiCommitSummary: ", geminiCommitSummary); 83 | 84 | return { 85 | isPRSuccess, 86 | csmDict, 87 | }; 88 | }; 89 | 90 | public updateArgs = (args: AnalysisEngineArgs) => { 91 | if (container.isRegistered("OctokitOptions")) container.clearInstances(); 92 | this.insertArgs(args); 93 | }; 94 | } 95 | 96 | export default AnalysisEngine; 97 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/parser.ts: -------------------------------------------------------------------------------- 1 | import { getCommitMessageType } from "./commit.util"; 2 | import type { CommitRaw, DifferenceStatistic } from "./types"; 3 | 4 | export default function getCommitRaws(log: string) { 5 | if (!log) return []; 6 | const EOL_REGEX = /\r?\n/; 7 | const COMMIT_SEPARATOR = new RegExp(`${EOL_REGEX.source}{4}`); 8 | const INDENTATION = " "; 9 | 10 | // step 0: Split log into commits 11 | const commits = log.substring(2).split(COMMIT_SEPARATOR); 12 | const commitRaws: CommitRaw[] = []; 13 | for (let commitIdx = 0; commitIdx < commits.length; commitIdx += 1) { 14 | // step 1: Extract commitData 15 | const commitData = commits[commitIdx].split(EOL_REGEX); 16 | const [ 17 | id, 18 | parents, 19 | refs, 20 | authorName, 21 | authorEmail, 22 | authorDate, 23 | committerName, 24 | committerEmail, 25 | committerDate, 26 | ...messageAndDiffStats 27 | ] = commitData; 28 | // step 2: Extract branch and tag data from refs 29 | const refsArray = refs.replace(" -> ", ", ").split(", "); 30 | const [branches, tags]: string[][] = refsArray.reduce( 31 | ([branches, tags], ref) => { 32 | if (ref === "") return [branches, tags]; 33 | if (ref.startsWith("tag: ")) { 34 | tags.push(ref.replace("tag: ", "")); 35 | } else { 36 | branches.push(ref); 37 | } 38 | return [branches, tags]; 39 | }, 40 | [new Array(), new Array()] 41 | ); 42 | 43 | // step 3: Extract message and diffStats 44 | let messageSubject = ""; 45 | let messageBody = ""; 46 | const diffStats: DifferenceStatistic = { 47 | totalInsertionCount: 0, 48 | totalDeletionCount: 0, 49 | fileDictionary: {}, 50 | }; 51 | for (let idx = 0; idx < messageAndDiffStats.length; idx++) { 52 | const line = messageAndDiffStats[idx]; 53 | if (idx === 0) 54 | // message subject 55 | messageSubject = line; 56 | else if (line.startsWith(INDENTATION)) { 57 | // message body (add newline if not first line) 58 | messageBody += idx === 1 ? line.trim() : `\n${line.trim()}`; 59 | } else if (line === "") 60 | // pass empty line 61 | continue; 62 | else { 63 | // diffStats 64 | const [insertions, deletions, path] = line.split("\t"); 65 | const numberedInsertions = insertions === "-" ? 0 : Number(insertions); 66 | const numberedDeletions = deletions === "-" ? 0 : Number(deletions); 67 | diffStats.totalInsertionCount += numberedInsertions; 68 | diffStats.totalDeletionCount += numberedDeletions; 69 | diffStats.fileDictionary[path] = { 70 | insertionCount: numberedInsertions, 71 | deletionCount: numberedDeletions, 72 | }; 73 | } 74 | } 75 | 76 | const message = messageBody === "" ? messageSubject : `${messageSubject}\n${messageBody}`; 77 | // step 4: Construct commitRaw 78 | const commitRaw: CommitRaw = { 79 | sequence: commitIdx, 80 | id, 81 | parents: parents.length === 0 ? [] : parents.split(" "), 82 | branches, 83 | tags, 84 | author: { 85 | name: authorName, 86 | email: authorEmail, 87 | }, 88 | authorDate: new Date(authorDate), 89 | committer: { 90 | name: committerName, 91 | email: committerEmail, 92 | }, 93 | committerDate: new Date(committerDate), 94 | message, 95 | commitMessageType: getCommitMessageType(message), 96 | differenceStatistic: diffStats, 97 | }; 98 | commitRaws.push(commitRaw); 99 | } 100 | 101 | return commitRaws; 102 | } 103 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/pluginOctokit.ts: -------------------------------------------------------------------------------- 1 | import type { OctokitOptions } from "@octokit/core/dist-types/types"; 2 | import { throttling } from "@octokit/plugin-throttling"; 3 | import type { ThrottlingOptions } from "@octokit/plugin-throttling/dist-types/types"; 4 | import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest"; 5 | import { inject, singleton } from "tsyringe"; 6 | 7 | type PullsListResponseData = RestEndpointMethodTypes["pulls"]["get"]["response"]; 8 | type PullsListCommitsResponseData = RestEndpointMethodTypes["pulls"]["listCommits"]["response"]; 9 | 10 | @singleton() 11 | export class PluginOctokit extends Octokit.plugin(throttling) { 12 | private owner: string; 13 | 14 | private repo: string; 15 | 16 | constructor( 17 | @inject("OctokitOptions") 18 | props: { 19 | owner: string; 20 | repo: string; 21 | options: Partial; 22 | } 23 | ) { 24 | super({ 25 | ...props.options, 26 | throttle: { 27 | onRateLimit: (retryAfter, options) => { 28 | const { 29 | method, 30 | url, 31 | request: { retryCount }, 32 | } = options as { 33 | method: string; 34 | url: string; 35 | request: { retryCount: number }; 36 | }; 37 | console.log(`[L] - request quota exhausted for request ${method} ${url}`); 38 | 39 | if (retryCount <= 1) { 40 | console.log(`[L] - retrying after ${retryAfter} seconds!`); 41 | return true; 42 | } 43 | return false; 44 | }, 45 | onAbuseLimit: (retryAfter, options) => { 46 | const { method, url } = options as { method: string; url: string }; 47 | throw new Error(`[E] - abuse detected for request ${method} ${url} ${retryAfter}`); 48 | }, 49 | }, 50 | }); 51 | this.owner = props.owner; 52 | this.repo = props.repo; 53 | } 54 | 55 | private _getPullRequest = async (pullNumber: number) => { 56 | const { owner, repo } = this; 57 | 58 | const pullRequestDetail:PullsListResponseData = await this.rest.pulls.get({ 59 | owner, 60 | repo, 61 | pull_number: pullNumber, 62 | }); 63 | 64 | const pullRequestCommits:PullsListCommitsResponseData = await this.rest.pulls.listCommits({ 65 | owner, 66 | repo, 67 | pull_number: pullNumber, 68 | }); 69 | 70 | return { 71 | detail: pullRequestDetail, 72 | commitDetails: pullRequestCommits, 73 | }; 74 | }; 75 | 76 | 77 | public getPullRequests = async (): Promise<{ 78 | detail: PullsListResponseData, 79 | commitDetails: PullsListCommitsResponseData 80 | }[]> => { 81 | const { owner, repo } = this; 82 | 83 | const { data } = await this.rest.pulls.list({ 84 | owner, 85 | repo, 86 | state: "all", 87 | per_page: 100, 88 | }); 89 | 90 | const pullNumbers = data.map((item) => item.number); 91 | 92 | const pullRequests = await Promise.all(pullNumbers.map((pullNumber) => this._getPullRequest(pullNumber))); 93 | 94 | return pullRequests; 95 | }; 96 | } 97 | 98 | export default PluginOctokit; 99 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/pullRequest.ts: -------------------------------------------------------------------------------- 1 | import type { CommitNode, CommitRaw, PullRequest } from "./types"; 2 | 3 | export const convertPRCommitsToCommitNodes = (baseCommit: CommitRaw, pr: PullRequest): CommitNode[] => 4 | pr.commitDetails.data.map((commitDetail) => { 5 | const { 6 | sha, 7 | parents, 8 | commit: { author, committer, message }, 9 | files, 10 | } = commitDetail; 11 | 12 | let totalInsertionCount = 0; 13 | let totalDeletionCount = 0; 14 | const fileDictionary = 15 | files?.reduce((dict, f) => { 16 | totalInsertionCount += f.additions; 17 | totalDeletionCount += f.deletions; 18 | return { 19 | ...dict, 20 | [f.filename]: { 21 | insertionCount: f.additions, 22 | deletionCount: f.deletions, 23 | }, 24 | }; 25 | }, {}) ?? {}; 26 | 27 | const prCommitRaw: CommitRaw = { 28 | sequence: -1, // ignore 29 | id: sha, 30 | parents: parents.map((p) => p.sha), 31 | branches: [], // ignore 32 | tags: [], // ignore 33 | author: { 34 | name: author?.name ?? "", 35 | email: author?.email ?? "", 36 | }, 37 | authorDate: author?.date ? new Date(author.date) : baseCommit.authorDate, 38 | committer: { 39 | name: committer?.name ?? "", 40 | email: committer?.email ?? "", 41 | }, 42 | committerDate: committer?.date ? new Date(committer.date) : baseCommit.committerDate, 43 | message, 44 | differenceStatistic: { 45 | fileDictionary, 46 | totalInsertionCount, 47 | totalDeletionCount, 48 | }, 49 | commitMessageType: "", 50 | }; 51 | 52 | return { commit: prCommitRaw } as CommitNode; 53 | }); 54 | 55 | export const convertPRDetailToCommitRaw = (baseCommit: CommitRaw, pr: PullRequest): CommitRaw => { 56 | const { 57 | data: { title, body, additions, deletions }, 58 | } = pr.detail; 59 | 60 | return { 61 | ...baseCommit, 62 | message: `${title}\n\n${body}`, 63 | differenceStatistic: { 64 | fileDictionary: {}, 65 | totalInsertionCount: additions, 66 | totalDeletionCount: deletions, 67 | }, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/queue.ts: -------------------------------------------------------------------------------- 1 | export default class Queue { 2 | private queue: Array = []; 3 | 4 | private readonly compareFn: (a: T, b: T) => number; 5 | 6 | constructor(compareFn: (a: T, b: T) => number) { 7 | this.compareFn = compareFn; 8 | } 9 | 10 | push(node: T): void { 11 | this.queue.push(node); 12 | this.queue.sort(this.compareFn); 13 | } 14 | 15 | pop(): T | undefined { 16 | return this.queue.shift(); 17 | } 18 | 19 | isEmpty(): boolean { 20 | return this.queue.length === 0; 21 | } 22 | 23 | pushFront(node: T): void { 24 | this.queue.unshift(node); 25 | } 26 | 27 | pushBack(node: T): void { 28 | this.queue.push(node); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/stem.ts: -------------------------------------------------------------------------------- 1 | import { getLeafNodes } from "./commit.util"; 2 | import Queue from "./queue"; 3 | import type { CommitDict, CommitNode, Stem, StemDict } from "./types"; 4 | 5 | export function getStemNodes( 6 | tailId: string, 7 | commitDict: CommitDict, 8 | q: Queue, 9 | stemId: string 10 | ): CommitNode[] { 11 | let now = commitDict.get(tailId); 12 | if (!now) return []; 13 | 14 | const nodes: CommitNode[] = []; 15 | while (now && !now.stemId) { 16 | now.stemId = stemId; 17 | if (now.commit.parents.length > 1) { 18 | now.commit.parents.forEach((parent, idx) => { 19 | if (idx === 0) return; 20 | const parentNode = commitDict.get(parent); 21 | if (parentNode) { 22 | q.push(parentNode); 23 | } 24 | }, q); 25 | } 26 | nodes.push(now); 27 | now = commitDict.get(now.commit.parents?.[0]); 28 | } 29 | return nodes; 30 | } 31 | 32 | function compareCommitPriority(a: CommitNode, b: CommitNode): number { 33 | // branches 값 존재하는 노드 => leaf / main / HEAD 노드. 34 | // 이 노드는 큐에 들어올 때 순서가 정해져 있기 때문에 순서를 바꾸지 않음. 35 | if (a.commit.branches.length || b.commit.branches.length) { 36 | return 0; 37 | } 38 | // 나중에 커밋된 것을 먼저 담기 39 | return new Date(b.commit.committerDate).getTime() - new Date(a.commit.committerDate).getTime(); 40 | } 41 | 42 | function buildGetStemId() { 43 | let implicitBranchNumber = 0; 44 | return function ( 45 | id: string, 46 | branches: string[], 47 | baseBranchName: string, 48 | mainNode?: CommitNode, 49 | headNode?: CommitNode 50 | ) { 51 | if (branches.length === 0) { 52 | implicitBranchNumber += 1; 53 | return `implicit-${implicitBranchNumber}`; 54 | } 55 | if (id === mainNode?.commit.id) { 56 | return baseBranchName; 57 | } 58 | if (id === headNode?.commit.id) { 59 | return "HEAD"; 60 | } 61 | return branches[0]; 62 | }; 63 | } 64 | 65 | /** 66 | * Stem 생성 67 | * @param commitDict 68 | * @param baseBranchName 69 | */ 70 | export function buildStemDict(commitDict: CommitDict, baseBranchName: string): StemDict { 71 | const q = new Queue(compareCommitPriority); 72 | 73 | /** 74 | * 처음 큐에 담기는 순서 75 | * 1. main 76 | * 2. sub-branches 77 | * 3. HEAD 78 | */ 79 | const stemDict = new Map(); 80 | const leafNodes = getLeafNodes(commitDict); 81 | const mainNode = leafNodes.find((node) => node.commit.branches.includes(baseBranchName)); 82 | const headNode = leafNodes.find((node) => node.commit.branches.includes("HEAD")); 83 | leafNodes 84 | .filter((node) => node.commit.id !== mainNode?.commit.id && node.commit.id !== headNode?.commit.id) 85 | .forEach((node) => q.push(node), q); 86 | if (mainNode) q.pushFront(mainNode); 87 | if (headNode) q.pushBack(headNode); 88 | 89 | const getStemId = buildGetStemId(); 90 | 91 | while (!q.isEmpty()) { 92 | const tail = q.pop(); 93 | if (!tail) continue; 94 | 95 | const stemId = getStemId(tail.commit.id, tail.commit.branches, baseBranchName, mainNode, headNode); 96 | 97 | const nodes = getStemNodes(tail.commit.id, commitDict, q, stemId); 98 | if (nodes.length === 0) continue; 99 | 100 | const stem: Stem = { nodes }; 101 | stemDict.set(stemId, stem); 102 | } 103 | 104 | return stemDict; 105 | } 106 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/summary.ts: -------------------------------------------------------------------------------- 1 | import type { CommitRaw } from "./types"; 2 | 3 | const apiKey = process.env.GEMENI_API_KEY || ''; 4 | const apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key="; 5 | 6 | export async function getSummary(csmNodes: CommitRaw[]) { 7 | const commitMessages = csmNodes.map((csmNode) => csmNode.message.split('\n')[0]).join(', '); 8 | 9 | try { 10 | const response = await fetch(apiUrl + apiKey, { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | contents: [{parts: [{text: `${prompt} \n${commitMessages}`}]}], 17 | }), 18 | }); 19 | 20 | if (!response.ok) { 21 | throw new Error(`Error: ${response.status} ${response.statusText}`); 22 | } 23 | 24 | const data = await response.json(); 25 | return data.candidates[0].content.parts[0].text.trim(); 26 | } catch (error) { 27 | console.error("Error fetching summary:", error); 28 | return undefined; 29 | } 30 | } 31 | 32 | const prompt = `Proceed with the task of summarising the contents of the commit message provided. 33 | 34 | Procedure: 35 | 1. Separate the commits based on , . 36 | 2. Extract only the commits given, excluding the merge commits. 37 | 3. Summarise the commits based on the most common words. Keep the shape of your commit message. 38 | 39 | Example Merge commits: 40 | - Merge pull request #633 from HIITMEMARIO/main 41 | - Merge branch ‘githru:main’ into main 42 | 43 | Rules: 44 | - Summarize in 3 to 5 lines. 45 | - Combine similar or overlapping content. (e.g. feat: add button, feat: add button to header -> feat: add button) 46 | - Include prefixes if present (e.g. feat, fix, refactor) 47 | - Please preserve the stylistic style of the commit. 48 | 49 | Output format: 50 | ‘’ 51 | - {prefix (if any)}:{Commit summary1} 52 | - {prefix (if any)}:{Commit summary2} 53 | - {prefix (if any)}:{commit summary3} 54 | ‘’ 55 | 56 | Commits:` 57 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/CSM.ts: -------------------------------------------------------------------------------- 1 | import type { CommitNode } from "./CommitNode"; 2 | 3 | export interface CSMNode { 4 | base: CommitNode; 5 | source: CommitNode[]; 6 | } 7 | 8 | export interface CSMDictionary { 9 | [branch: string]: CSMNode[]; 10 | } 11 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/CommitMessageType.ts: -------------------------------------------------------------------------------- 1 | export const CommitMessageTypeList = [ 2 | "build", 3 | "chore", 4 | "ci", 5 | "docs", 6 | "feat", 7 | "fix", 8 | "pert", 9 | "refactor", 10 | "revert", 11 | "style", 12 | "test", 13 | ]; 14 | 15 | const COMMIT_MESSAGE_TYPE = [...CommitMessageTypeList] as const; 16 | 17 | export type CommitMessageType = (typeof COMMIT_MESSAGE_TYPE)[number]; 18 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/CommitNode.ts: -------------------------------------------------------------------------------- 1 | import type { CommitRaw } from "./CommitRaw"; 2 | 3 | export interface CommitNode { 4 | // 순회 이전에는 stemId가 존재하지 않음. 5 | stemId?: string; 6 | commit: CommitRaw; 7 | } 8 | 9 | export type CommitDict = Map; 10 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/CommitRaw.ts: -------------------------------------------------------------------------------- 1 | import type { CommitMessageType } from "./CommitMessageType"; 2 | 3 | export interface FileChanged { 4 | [path: string]: { 5 | insertionCount: number; 6 | deletionCount: number; 7 | }; 8 | } 9 | 10 | export interface DifferenceStatistic { 11 | totalInsertionCount: number; 12 | totalDeletionCount: number; 13 | fileDictionary: FileChanged; 14 | } 15 | 16 | export interface GitUser { 17 | name: string; 18 | email: string; 19 | } 20 | 21 | export interface CommitRaw { 22 | sequence: number; 23 | id: string; 24 | parents: string[]; 25 | branches: string[]; 26 | tags: string[]; 27 | author: GitUser; 28 | authorDate: Date; 29 | committer: GitUser; 30 | committerDate: Date; 31 | message: string; 32 | differenceStatistic: DifferenceStatistic; 33 | commitMessageType: CommitMessageType; 34 | } 35 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/Github.ts: -------------------------------------------------------------------------------- 1 | import type { RestEndpointMethodTypes } from "@octokit/rest"; 2 | 3 | export interface PullRequest { 4 | detail: RestEndpointMethodTypes["pulls"]["get"]["response"]; 5 | commitDetails: RestEndpointMethodTypes["pulls"]["listCommits"]["response"]; 6 | } 7 | 8 | export type PullRequestDict = Map; 9 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/Stem.ts: -------------------------------------------------------------------------------- 1 | import type { CommitNode } from "./CommitNode"; 2 | 3 | export interface Stem { 4 | nodes: CommitNode[]; 5 | } 6 | 7 | export type StemDict = Map; 8 | -------------------------------------------------------------------------------- /packages/analysis-engine/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CommitMessageType"; 2 | export * from "./CommitNode"; 3 | export * from "./CommitRaw"; 4 | export * from "./CSM"; 5 | export * from "./Github"; 6 | export * from "./Stem"; 7 | -------------------------------------------------------------------------------- /packages/view/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | 5 | *.test.js 6 | *.config.js 7 | 8 | test.* 9 | 10 | *.css 11 | *.scss 12 | *.d.ts -------------------------------------------------------------------------------- /packages/view/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended", 11 | "plugin:react-hooks/recommended", 12 | "airbnb", 13 | "prettier" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": "latest", 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["@typescript-eslint", "prettier", "react-hooks"], 21 | "rules": { 22 | "@typescript-eslint/ban-types": "off", 23 | "@typescript-eslint/consistent-type-imports": ["error"], 24 | "@typescript-eslint/no-unused-vars": "warn", 25 | "@typescript-eslint/no-use-before-define": ["error"], 26 | "@typescript-eslint/no-var-requires": "off", 27 | "arrow-body-style": "off", 28 | "class-methods-use-this": "off", 29 | "func-names": "off", 30 | "import/extensions": [ 31 | "error", 32 | "ignorePackages", 33 | { 34 | "js": "never", 35 | "jsx": "never", 36 | "ts": "never", 37 | "tsx": "never" 38 | } 39 | ], 40 | "import/no-extraneous-dependencies": "off", 41 | "import/no-useless-path-segments": [ 42 | "error", 43 | { 44 | "noUselessIndex": true 45 | } 46 | ], 47 | "import/order": [ 48 | "warn", 49 | { 50 | "newlines-between": "always", 51 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object"] 52 | } 53 | ], 54 | "import/prefer-default-export": "off", 55 | "no-console": "off", 56 | "no-unused-vars": "off", 57 | "no-process-exit": "off", 58 | "no-use-before-define": "off", 59 | "object-shorthand": "off", 60 | "prettier/prettier": [ 61 | "error", 62 | { 63 | "endOfLine": "auto" 64 | } 65 | ], 66 | "react/function-component-definition": [ 67 | "error", 68 | { 69 | "namedComponents": ["arrow-function"], 70 | "unnamedComponents": ["arrow-function", "function-expression"] 71 | } 72 | ], 73 | "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx", ".ts", ".tsx"] }], 74 | "react/jsx-props-no-spreading": "off", 75 | "react/no-unstable-nested-components": ["error", { "allowAsProps": true }], 76 | "react/react-in-jsx-scope": "off", 77 | "react/require-default-props": "off", 78 | "consistent-return": "off" 79 | }, 80 | "settings": { 81 | "import/resolver": { 82 | "node": { 83 | "extensions": [".js", ".jsx", ".ts", ".tsx"], 84 | "moduleDirectory": ["node_modules", "src/"] 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/view/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | /dist 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | /test-results/ 27 | /playwright-report/ 28 | /playwright/.cache/ 29 | -------------------------------------------------------------------------------- /packages/view/.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.5.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-chrome", 9 | "request": "launch", 10 | "name": "githru-vscode-ext/view", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/view/README.md: -------------------------------------------------------------------------------- 1 | # githru-vscode-ext/view 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/githru/githru-vscode-ext/blob/main/LICENSE) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/githru/githru-vscode-ext/blob/main/packages/view/CONTRIBUTING.md) 4 | 5 | Githru view에서는 Githru vscode extension에서 Githru analysis engine의 결과물을 넘겨받아 시각화된 자료를 인터랙션과 함께 보여줍니다. 6 | 7 | ## 기능 8 | 9 | - Temporal Filter 10 | 11 | - 저장소의 전체 commit과 cloc를 라인차트로 보여주고, 날짜 구간을 선택하여 확인할 수 있습니다. 12 | 13 | - 구간을 선택하면 해당 구간의 데이터를 기반으로 Vertical Cluster List, Statistics에서 데이터가 보여집니다. 14 | 15 | - Vertical Cluster List 16 | 17 | - cluster별로 얼마나 많은 commit을 가지고 있는지 그래프로 확인할 수 있고, 해당 cluster에 어떤 author가 있고, 어떤 내용의 변화가 있는지 간접적으로 알 수 있습니다. 클릭하면 자세한 commit list를 확인할 수 있습니다. 18 | 19 | - Detail 20 | 21 | - 선택한 cluster의 commit list와 cluster에서의 몇 명의 author가 작업했는지, 몇 개의 commit이 등록되었는지, 총 몇 개의 file이 change되었는지, code의 additions과 deletions는 얼마나 되는지 보여줍니다. 22 | 23 | - Statistics 24 | 25 | - author별로 commit을 얼마나 했는지, insertions은 얼마나 되었는지, deletions는 얼마나 되었는지 bar chart로 보여줍니다. 26 | 27 | - 파일 경로마다 어느 정도의 변화가 있었는지 인터랙션과 함께 icicle chart에서 확인할 수 있습니다. 28 | 29 | ## 문서 30 | 31 | ### Temporal Filter 32 | 33 | ### Vertical Cluster list 34 | 35 | ### Detail 36 | 37 | ### Statistics 38 | 39 | [이 저장소](https://github.com/githru/githru-vscode-ext/blob/main/packages/view)로 Pull Request를 제출하여 개선할 수 있습니다. 40 | 41 | ## 기여 42 | 43 | 이 저장소의 주된 목적은 Git 로그를 분석한 데이터를 시각화하여 사용자가 더 쉽게 이해하기 위함입니다. Githru View의 개발은 GitHub에서 개방적으로 이루어지며, 우리는 버그 수정과 개선에 기여한 커뮤니티에 감사를 표합니다. 참여 방법에 대해 알아보려면 아래를 읽어주세요. 44 | 45 | ### [기여 가이드라인](https://github.com/githru/githru-vscode-ext/blob/main/packages/view/CONTRIBUTING.md) 46 | 47 | 우리의 개발 프로세스, 버그 수정 및 개선 제안 방법, Githru View에 대한 변경 내용을 빌드하는 방법에 대해 알아보려면 [기여 가이드라인](https://github.com/githru/githru-vscode-ext/blob/main/packages/view/CONTRIBUTING.md)을 읽어주시기 바랍니다. 48 | -------------------------------------------------------------------------------- /packages/view/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | verbose: true, 7 | transform: { 8 | "^.+\\.ts?$": "ts-jest", 9 | }, 10 | testPathIgnorePatterns: ["tests/"], 11 | moduleFileExtensions: ["ts", "tsx", "js", "mjs", "cjs", "jsx", "json", "node"], 12 | moduleNameMapper: { 13 | "^utils/(.*)$": "/src/utils/$1", 14 | d3: "/../../node_modules/d3/dist/d3.min.js", 15 | }, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /packages/view/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: "./tests", 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: "html", 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: "http://127.0.0.1:3000", 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: "on-first-retry", 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: "chromium", 37 | use: { ...devices["Desktop Chrome"] }, 38 | }, 39 | 40 | // { 41 | // name: "firefox", 42 | // use: { ...devices["Desktop Firefox"] }, 43 | // }, 44 | 45 | { 46 | name: "webkit", 47 | use: { ...devices["Desktop Safari"] }, 48 | }, 49 | 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: 'Mobile Safari', 57 | // use: { ...devices['iPhone 12'] }, 58 | // }, 59 | 60 | /* Test against branded browsers. */ 61 | // { 62 | // name: 'Microsoft Edge', 63 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 64 | // }, 65 | // { 66 | // name: 'Google Chrome', 67 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 68 | // }, 69 | ], 70 | 71 | /* Run your local dev server before starting the tests */ 72 | webServer: { 73 | command: "npm start", 74 | url: "http://127.0.0.1:3000", 75 | reuseExistingServer: !process.env.CI, 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /packages/view/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | Githru 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/view/src/App.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | body { 4 | background: $color-background; 5 | color: $color-white; 6 | } 7 | 8 | .header-container { 9 | position: relative; 10 | display: flex; 11 | height: 3.75rem; 12 | justify-content: space-between; 13 | align-items: center; 14 | padding: 0 0.75rem 0 1.25rem; 15 | } 16 | 17 | .top-container { 18 | height: 13.75rem; 19 | } 20 | 21 | .middle-container { 22 | display: flex; 23 | height: calc(100vh - 12.5rem); 24 | padding: 1.25rem; 25 | } 26 | 27 | .no-commits-container { 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: center; 31 | align-items: center; 32 | height: calc(100vh - 21.875rem); 33 | 34 | h1 { 35 | margin: 2.5rem 0 0 0; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/view/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { container } from "tsyringe"; 3 | import { useEffect, useRef } from "react"; 4 | import BounceLoader from "react-spinners/BounceLoader"; 5 | 6 | import MonoLogo from "assets/monoLogo.svg"; 7 | import { BranchSelector, Statistics, TemporalFilter, ThemeSelector, VerticalClusterList } from "components"; 8 | import "./App.scss"; 9 | import type IDEPort from "ide/IDEPort"; 10 | import { useAnalayzedData } from "hooks"; 11 | import { RefreshButton } from "components/RefreshButton"; 12 | import type { IDESentEvents } from "types/IDESentEvents"; 13 | import { useBranchStore, useDataStore, useGithubInfo, useLoadingStore } from "store"; 14 | import { THEME_INFO } from "components/ThemeSelector/ThemeSelector.const"; 15 | 16 | const App = () => { 17 | const initRef = useRef(false); 18 | const { handleChangeAnalyzedData } = useAnalayzedData(); 19 | const filteredData = useDataStore((state) => state.filteredData); 20 | const { handleChangeBranchList } = useBranchStore(); 21 | const { handleGithubInfo } = useGithubInfo(); 22 | const { loading, setLoading } = useLoadingStore(); 23 | const ideAdapter = container.resolve("IDEAdapter"); 24 | 25 | useEffect(() => { 26 | if (initRef.current === false) { 27 | const callbacks: IDESentEvents = { 28 | handleChangeAnalyzedData, 29 | handleChangeBranchList, 30 | handleGithubInfo, 31 | }; 32 | setLoading(true); 33 | ideAdapter.addIDESentEventListener(callbacks); 34 | ideAdapter.sendFetchAnalyzedDataMessage(); 35 | ideAdapter.sendFetchBranchListMessage(); 36 | ideAdapter.sendFetchGithubInfo(); 37 | initRef.current = true; 38 | } 39 | }, [handleChangeAnalyzedData, handleChangeBranchList, handleGithubInfo, ideAdapter, setLoading]); 40 | 41 | if (loading) { 42 | return ( 43 | 53 | ); 54 | } 55 | 56 | return ( 57 | <> 58 |
59 | 60 | 61 | 62 |
63 |
64 | 65 |
66 |
67 | {filteredData.length !== 0 ? ( 68 |
69 | 70 | 71 |
72 | ) : ( 73 |
74 | 75 |

No Commits Found.

76 |

Make at least one commit to proceed.

77 |
78 | )} 79 |
80 | 81 | ); 82 | }; 83 | 84 | export default App; 85 | -------------------------------------------------------------------------------- /packages/view/src/components/@common/Author/Author.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip, Avatar } from "@mui/material"; 2 | 3 | import type { AuthorInfo } from "types"; 4 | 5 | import { GITHUB_URL } from "../../../constants/constants"; 6 | 7 | const Author = ({ name, src }: AuthorInfo) => { 8 | const isUser = src.includes(GITHUB_URL); 9 | return ( 10 | 15 | {isUser ? ( 16 | 22 | 27 | 28 | ) : ( 29 | 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default Author; 40 | -------------------------------------------------------------------------------- /packages/view/src/components/@common/Author/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Author } from "./Author"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/BranchSelector/BranchSelector.const.ts: -------------------------------------------------------------------------------- 1 | export const SLICE_LENGTH = 15; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/BranchSelector/BranchSelector.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .branch-selector { 4 | display: flex; 5 | flex-direction: row; 6 | gap: 1rem; 7 | align-items: center; 8 | font-weight: $font-weight-semibold; 9 | 10 | & &__select-box { 11 | border: 0.0625rem solid $color-white; 12 | color: $color-white; 13 | height: 1.875rem; 14 | 15 | & .MuiSvgIcon-root { 16 | color: $color-white; 17 | } 18 | 19 | &.Mui-focused .MuiOutlinedInput-notchedOutline { 20 | border: none; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/view/src/components/BranchSelector/BranchSelector.tsx: -------------------------------------------------------------------------------- 1 | import MenuItem from "@mui/material/MenuItem"; 2 | import FormControl from "@mui/material/FormControl"; 3 | import type { SelectChangeEvent } from "@mui/material/Select"; 4 | import Select from "@mui/material/Select"; 5 | 6 | import { sendFetchAnalyzedDataCommand } from "services"; 7 | import "./BranchSelector.scss"; 8 | import { useBranchStore, useLoadingStore } from "store"; 9 | 10 | import { SLICE_LENGTH } from "./BranchSelector.const"; 11 | 12 | const BranchSelector = () => { 13 | const { branchList, selectedBranch, setSelectedBranch } = useBranchStore(); 14 | const { setLoading } = useLoadingStore(); 15 | 16 | const handleChangeSelect = (event: SelectChangeEvent) => { 17 | setSelectedBranch(event.target.value); 18 | setLoading(true); 19 | sendFetchAnalyzedDataCommand(event.target.value); 20 | }; 21 | 22 | return ( 23 |
24 |

Branches:

25 | 29 | 63 | 64 |
65 | ); 66 | }; 67 | 68 | export default BranchSelector; 69 | -------------------------------------------------------------------------------- /packages/view/src/components/BranchSelector/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BranchSelector } from "./BranchSelector"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/Detail/Detail.const.ts: -------------------------------------------------------------------------------- 1 | export const FIRST_SHOW_NUM = 5; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/Detail/Detail.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import type { CommitNode } from "types"; 4 | 5 | import { getSummaryCommitList } from "./Detail.util"; 6 | 7 | type UseToggleHook = [boolean, () => void]; 8 | const useToggleHook = (init = false): UseToggleHook => { 9 | const [toggle, setToggle] = useState(init); 10 | const handleToggle = () => setToggle((prev) => !prev); 11 | return [toggle, handleToggle]; 12 | }; 13 | 14 | export const useCommitListHide = (commitNodeListInCluster: CommitNode[]) => { 15 | const list = getSummaryCommitList(commitNodeListInCluster); 16 | const stretch = commitNodeListInCluster; 17 | const [toggle, handleToggle] = useToggleHook(); 18 | const commitNodeList = toggle ? stretch : list; 19 | 20 | return { 21 | toggle, 22 | handleToggle, 23 | commitNodeList, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/view/src/components/Detail/Detail.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .detail__summary { 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | font-size: $font-size-caption; 8 | 9 | .detail__summary-divider { 10 | margin-right: 1rem; 11 | flex-grow: 10; 12 | height: 0; 13 | border: 0.0625rem solid $color-white; 14 | } 15 | 16 | .detail__summary-list { 17 | flex-grow: 0.1; 18 | text-align: right; 19 | 20 | .detail__summary-item { 21 | position: relative; 22 | margin-right: 0.625rem; 23 | display: inline-flex; 24 | align-items: center; 25 | gap: 0.25rem; 26 | 27 | &:last-child { 28 | margin-right: 0; 29 | } 30 | } 31 | } 32 | 33 | .additions { 34 | color: var(--color-success); 35 | } 36 | 37 | .deletions { 38 | color: var(--color-failed); 39 | } 40 | } 41 | 42 | @media (max-width: 1080px) { 43 | .detail__summary-item-name { 44 | display: none; 45 | } 46 | } 47 | 48 | .detail__commit-list { 49 | display: flex; 50 | flex-direction: column; 51 | row-gap: 0.4375rem; 52 | padding: 1.25rem; 53 | font-size: $font-size-body; 54 | 55 | .detail__commit-item { 56 | width: 100%; 57 | display: flex; 58 | justify-content: space-between; 59 | color: $color-medium-gray; 60 | 61 | .commit-item__detail { 62 | color: $color-white; 63 | display: flex; 64 | justify-content: space-between; 65 | align-items: center; 66 | width: 100%; 67 | padding-left: 0.3125rem; 68 | 69 | .commit-item__avatar-message { 70 | display: flex; 71 | align-items: center; 72 | width: 100%; 73 | 74 | .commit-item__message-container { 75 | position: relative; 76 | overflow: visible; 77 | flex-grow: 1; 78 | padding-right: 3.125rem; 79 | width: auto; 80 | 81 | .commit-item__message { 82 | display: -webkit-box; 83 | -webkit-box-orient: vertical; 84 | -webkit-line-clamp: 1; 85 | overflow: hidden; 86 | color: $color-white; 87 | margin: 0 0.25rem 0 0.9375rem; 88 | 89 | &:hover { 90 | display: block; 91 | cursor: pointer; 92 | overflow: visible; 93 | position: absolute; 94 | z-index: 10; 95 | background-color: $color-dark-gray; 96 | padding-bottom: 0.3125rem; 97 | top: -0.4375rem; 98 | } 99 | } 100 | } 101 | } 102 | 103 | .commit-item__author-date { 104 | display: block; 105 | position: relative; 106 | white-space: nowrap; 107 | } 108 | } 109 | 110 | .commit-item__commit-id { 111 | display: flex; 112 | justify-content: center; 113 | align-items: center; 114 | width: 6em; 115 | position: relative; 116 | 117 | .commit-id__link { 118 | text-decoration: none; 119 | color: $color-light-gray; 120 | 121 | &:visited { 122 | color: $color-light-gray; 123 | } 124 | 125 | &:hover { 126 | color: var(--color-primary); 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | .detail__toggle-button { 134 | display: flex; 135 | margin: 0 auto; 136 | border: none; 137 | background: none; 138 | color: var(--color-tertiary); 139 | cursor: pointer; 140 | 141 | &:hover { 142 | color: $color-light-gray; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /packages/view/src/components/Detail/Detail.type.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | import type { ClusterNode, SelectedDataProps } from "types"; 4 | import type { AuthSrcMap } from "components/VerticalClusterList/Summary/Summary.type"; 5 | 6 | export type DetailProps = { 7 | selectedData: SelectedDataProps; 8 | clusterId: number; 9 | authSrcMap: AuthSrcMap | null; 10 | }; 11 | export type DetailSummaryProps = { 12 | commitNodeListInCluster: ClusterNode["commitNodeList"]; 13 | }; 14 | 15 | export interface DetailSummaryItem { 16 | name: string; 17 | count: number; 18 | icon?: ReactNode; 19 | } 20 | -------------------------------------------------------------------------------- /packages/view/src/components/Detail/Detail.util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | import type { CommitNode } from "types/"; 3 | 4 | import { FIRST_SHOW_NUM } from "./Detail.const"; 5 | 6 | // type GetCommitListInCluster = GlobalProps & { clusterId: number }; 7 | // export const getCommitListInCluster = ({ data, clusterId }: GetCommitListInCluster) => 8 | // data 9 | // .map((clusterNode) => clusterNode.commitNodeList) 10 | // .flat() 11 | // .filter((commitNode) => commitNode.clusterId === clusterId); 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | const getDataSetSize = (arr: T) => { 15 | const set = new Set(arr); 16 | return set.size; 17 | }; 18 | 19 | const getCommitListAuthorLength = (commitNodes: CommitNode[]) => { 20 | return getDataSetSize(commitNodes.map((d) => d.commit.author.names).flat()); 21 | }; 22 | 23 | const getChangeFileLength = (commitNodes: CommitNode[]) => { 24 | return getDataSetSize(commitNodes.map((d) => Object.keys(d.commit.diffStatistics.files)).flat()); 25 | }; 26 | 27 | type GetCommitListDetail = { commitNodeListInCluster: CommitNode[] }; 28 | export const getCommitListDetail = ({ commitNodeListInCluster }: GetCommitListDetail) => { 29 | const authorLength = getCommitListAuthorLength(commitNodeListInCluster); 30 | const fileLength = getChangeFileLength(commitNodeListInCluster); 31 | const diffStatistics = commitNodeListInCluster.reduce( 32 | (acc, { commit: { diffStatistics: cur } }) => ({ 33 | insertions: acc.insertions + cur.insertions, 34 | deletions: acc.deletions + cur.deletions, 35 | }), 36 | { 37 | insertions: 0, 38 | deletions: 0, 39 | } 40 | ); 41 | 42 | return { 43 | authorLength, 44 | fileLength, 45 | commitLength: commitNodeListInCluster.length, 46 | insertions: diffStatistics.insertions, 47 | deletions: diffStatistics.deletions, 48 | }; 49 | }; 50 | 51 | export const getSummaryCommitList = (arr: CommitNode[]) => { 52 | return arr.length > FIRST_SHOW_NUM ? arr.slice(0, FIRST_SHOW_NUM) : [...arr]; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/view/src/components/Detail/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Detail } from "./Detail"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/FilteredAuthors/FilteredAuthors.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .filtered-authors { 4 | display: flex; 5 | align-items: center; 6 | column-gap: 0.625rem; 7 | 8 | .filtered-authors__label { 9 | font-size: $font-size-title; 10 | font-weight: $font-weight-semibold; 11 | } 12 | 13 | .filtered-authors__author { 14 | display: flex; 15 | align-items: center; 16 | flex-wrap: wrap; 17 | gap: 0.3125rem; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/view/src/components/FilteredAuthors/FilteredAuthors.tsx: -------------------------------------------------------------------------------- 1 | import { Author } from "components/@common/Author"; 2 | import { usePreLoadAuthorImg } from "components/VerticalClusterList/Summary/Summary.hook"; 3 | import { getInitData } from "components/VerticalClusterList/Summary/Summary.util"; 4 | import { useDataStore } from "store"; 5 | 6 | import "./FilteredAuthors.scss"; 7 | 8 | const FilteredAuthors = () => { 9 | const selectedData = useDataStore((state) => state.selectedData); 10 | const authSrcMap = usePreLoadAuthorImg(); 11 | const selectedClusters = getInitData(selectedData); 12 | 13 | // 이미 선택된 사용자를 관리 14 | const addedAuthors = new Set(); 15 | 16 | return ( 17 |
18 |

Authors:

19 |
20 | {authSrcMap && 21 | selectedClusters.map((selectedCluster) => { 22 | return selectedCluster.summary.authorNames.map((authorArray: string[]) => { 23 | return authorArray.map((authorName: string) => { 24 | // 이미 추가된 사용자인지 확인 후 추가되지 않은 경우에만 추가하고 Set에 이름을 저장 25 | if (!addedAuthors.has(authorName)) { 26 | addedAuthors.add(authorName); 27 | return ( 28 | 33 | ); 34 | } 35 | // 이미 추가된 사용자인 경우 null 반환 36 | return null; 37 | }); 38 | }); 39 | })} 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default FilteredAuthors; 46 | -------------------------------------------------------------------------------- /packages/view/src/components/FilteredAuthors/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FilteredAuthors } from "./FilteredAuthors"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/RefreshButton/RefreshButton.scss: -------------------------------------------------------------------------------- 1 | .refresh-button { 2 | width: 1.875rem; 3 | height: 1.875rem; 4 | } 5 | 6 | .refresh-button__icon { 7 | &--loading { 8 | animation: rotate 2s infinite; 9 | } 10 | } 11 | 12 | @keyframes rotate { 13 | from { 14 | transform: rotate(0deg); 15 | } 16 | to { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/view/src/components/RefreshButton/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import cn from "classnames"; 3 | import ReplayCircleFilledRoundedIcon from "@mui/icons-material/ReplayCircleFilledRounded"; 4 | import { IconButton } from "@mui/material"; 5 | 6 | import { throttle } from "utils"; 7 | import "./RefreshButton.scss"; 8 | import { sendRefreshDataCommand } from "services"; 9 | import { useBranchStore, useLoadingStore } from "store"; 10 | 11 | const RefreshButton = () => { 12 | const { selectedBranch } = useBranchStore(); 13 | const { loading, setLoading } = useLoadingStore(); 14 | 15 | const refreshHandler = throttle(() => { 16 | setLoading(true); 17 | sendRefreshDataCommand(selectedBranch); 18 | }, 3000); 19 | 20 | return ( 21 | 27 | 30 | 31 | ); 32 | }; 33 | 34 | export default RefreshButton; 35 | -------------------------------------------------------------------------------- /packages/view/src/components/RefreshButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RefreshButton } from "./RefreshButton"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/SelectedClusterGroup/SelectedClusterGroup.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .selected-clusters { 4 | position: relative; 5 | display: flex; 6 | align-items: center; 7 | column-gap: 0.625rem; 8 | 9 | &__label { 10 | display: flex; 11 | align-items: center; 12 | border: 0; 13 | background-color: transparent; 14 | color: $color-white; 15 | font-size: $font-size-title; 16 | font-weight: $font-weight-semibold; 17 | cursor: pointer; 18 | } 19 | 20 | &__list { 21 | position: absolute; 22 | top: 2rem; 23 | right: -0.625rem; 24 | max-height: 15rem; 25 | overflow-y: auto; 26 | padding: 0.3125rem; 27 | background-color: $color-dark-gray; 28 | border-radius: 0.5rem; 29 | box-shadow: 0rem 0rem 0.625rem $color-background; 30 | z-index: 10; 31 | 32 | &::-webkit-scrollbar { 33 | width: 0; 34 | } 35 | 36 | .selected-clusters__item { 37 | width: 12.5rem; 38 | text-overflow: ellipsis; 39 | justify-content: space-between; 40 | margin: 0.3125rem; 41 | color: $color-white; 42 | background-color: #4f5662; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/view/src/components/SelectedClusterGroup/SelectedClusterGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useShallow } from "zustand/react/shallow"; 3 | import Chip from "@mui/material/Chip"; 4 | import ArrowDropDownRoundedIcon from "@mui/icons-material/ArrowDropDownRounded"; 5 | 6 | import { selectedDataUpdater } from "components/VerticalClusterList/VerticalClusterList.util"; 7 | import { getInitData, getClusterById } from "components/VerticalClusterList/Summary/Summary.util"; 8 | import "./SelectedClusterGroup.scss"; 9 | import { useDataStore } from "store"; 10 | 11 | const SelectedClusterGroup = () => { 12 | const [selectedData, setSelectedData] = useDataStore( 13 | useShallow((state) => [state.selectedData, state.setSelectedData]) 14 | ); 15 | const selectedClusters = getInitData(selectedData); 16 | 17 | const [isOpen, setIsOpen] = useState(false); 18 | 19 | const deselectCluster = (clusterId: number) => () => { 20 | const selected = getClusterById(selectedData, clusterId); 21 | setSelectedData(selectedDataUpdater(selected, clusterId)); 22 | }; 23 | 24 | return ( 25 |
26 | 34 | {isOpen && ( 35 |
    36 | {selectedClusters.map((selectedCluster) => ( 37 |
  • 38 | 44 |
  • 45 | ))} 46 |
47 | )} 48 |
49 | ); 50 | }; 51 | 52 | export default SelectedClusterGroup; 53 | -------------------------------------------------------------------------------- /packages/view/src/components/SelectedClusterGroup/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SelectedClusterGroup } from "./SelectedClusterGroup"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/AuthorBarChart/AuthorBarChart.const.ts: -------------------------------------------------------------------------------- 1 | export const DIMENSIONS = { 2 | width: 250, 3 | height: 200, 4 | margins: 60, 5 | }; 6 | 7 | export const METRIC_TYPE = ["commit", "insertion", "deletion"] as const; 8 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/AuthorBarChart/AuthorBarChart.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .author-bar-chart { 4 | width: fit-content; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: flex-start; 8 | 9 | &__title { 10 | font-size: $font-size-title; 11 | font-weight: $font-weight-semibold; 12 | } 13 | 14 | &__header { 15 | width: 100%; 16 | text-align: right; 17 | } 18 | 19 | & &__select-box { 20 | border: 0.0625rem solid $color-white; 21 | color: $color-white; 22 | height: 1.5625rem; 23 | width: 6.25rem; 24 | font-size: $font-size-caption; 25 | 26 | & .MuiSvgIcon-root { 27 | color: $color-white; 28 | } 29 | 30 | &.Mui-focused .MuiOutlinedInput-notchedOutline { 31 | border: none; 32 | } 33 | } 34 | 35 | &__chart { 36 | overflow: visible; 37 | margin: 1.25rem 2.5rem 2.5rem; 38 | } 39 | 40 | &__axis { 41 | color: $color-white; 42 | 43 | &.x-axis { 44 | .tick { 45 | display: none; 46 | } 47 | 48 | .x-axis__label { 49 | fill: $color-white; 50 | } 51 | } 52 | } 53 | 54 | &__container { 55 | cursor: pointer; 56 | 57 | .author-bar-chart__bar { 58 | &:hover { 59 | rect { 60 | fill: var(--color-tertiary); 61 | } 62 | } 63 | 64 | rect { 65 | fill: var(--color-secondary); 66 | } 67 | 68 | .author-bar-chart__name { 69 | fill: $color-white; 70 | stroke: none; 71 | font-size: $font-size-caption; 72 | text-anchor: start; 73 | font-weight: $font-weight-semibold; 74 | } 75 | } 76 | } 77 | 78 | &__tooltip { 79 | display: none; 80 | position: absolute; 81 | padding: 0.5rem 1rem; 82 | border-radius: 0.3125rem; 83 | font-size: $font-size-caption; 84 | text-align: center; 85 | line-height: 1.5; 86 | background-color: rgba($color-dark-gray, 0.9); 87 | color: $color-white; 88 | pointer-events: none; 89 | 90 | .author-bar-chart__name { 91 | font-weight: $font-weight-semibold; 92 | } 93 | 94 | .author-bar-chart__count { 95 | font-weight: $font-weight-semibold; 96 | color: var(--color-primary); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/AuthorBarChart/AuthorBarChart.type.ts: -------------------------------------------------------------------------------- 1 | import type { METRIC_TYPE } from "./AuthorBarChart.const"; 2 | 3 | export type AuthorDataType = { 4 | name: string; 5 | commit: number; 6 | insertion: number; 7 | deletion: number; 8 | names?: string[]; 9 | }; 10 | 11 | export type MetricType = (typeof METRIC_TYPE)[number]; 12 | 13 | export type AuthorDataObj = { 14 | [key: string]: { 15 | name: string; 16 | commit: number; 17 | insertion: number; 18 | deletion: number; 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/AuthorBarChart/AuthorBarChart.util.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | 3 | import type { ClusterNode, CommitNode } from "types"; 4 | 5 | import type { AuthorDataObj, AuthorDataType } from "./AuthorBarChart.type"; 6 | 7 | export const getDataByAuthor = (data: ClusterNode[]): AuthorDataType[] => { 8 | if (!data.length) return []; 9 | 10 | const authorDataObj: AuthorDataObj = {}; 11 | 12 | data.forEach(({ commitNodeList }) => { 13 | commitNodeList.forEach(({ commit }) => { 14 | const author = commit.author.names[0]; 15 | const { insertions, deletions } = commit.diffStatistics; 16 | 17 | if (!authorDataObj[author]) { 18 | authorDataObj[author] = { 19 | name: author, 20 | commit: 1, 21 | insertion: insertions, 22 | deletion: deletions, 23 | }; 24 | } else { 25 | authorDataObj[author] = { 26 | ...authorDataObj[author], 27 | commit: (authorDataObj[author].commit || 0) + 1, 28 | insertion: (authorDataObj[author].insertion || 0) + insertions, 29 | deletion: (authorDataObj[author].deletion || 0) + deletions, 30 | }; 31 | } 32 | }); 33 | }); 34 | 35 | return Object.values(authorDataObj); 36 | }; 37 | 38 | export const sortDataByName = (a: string, b: string) => { 39 | const nameA = a.toUpperCase(); 40 | const nameB = b.toUpperCase(); 41 | 42 | if (nameA < nameB) return 1; 43 | if (nameA > nameB) return -1; 44 | return 0; 45 | }; 46 | 47 | export const convertNumberFormat = (d: number | { valueOf(): number }): string => { 48 | if (typeof d === "number" && d < 1 && d >= 0) { 49 | return `${d}`; 50 | } 51 | return d3.format("~s")(d); 52 | }; 53 | 54 | export const sortDataByAuthor = (data: ClusterNode[], names: string[]): ClusterNode[] => { 55 | return data.reduce((acc: ClusterNode[], cluster: ClusterNode) => { 56 | const checkedCluster = cluster.commitNodeList.filter((commitNode: CommitNode) => 57 | names.some((name) => commitNode.commit.author.names.includes(name)) 58 | ); 59 | 60 | if (checkedCluster.length > 0) { 61 | acc.push({ nodeTypeName: "CLUSTER", commitNodeList: checkedCluster }); 62 | } 63 | 64 | return acc; 65 | }, []); 66 | }; 67 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/AuthorBarChart/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthorBarChart } from "./AuthorBarChart"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/FileIcicleSummary/FileIcicleSummary.const.ts: -------------------------------------------------------------------------------- 1 | export const WIDTH = 600; 2 | export const HEIGHT = 750; 3 | export const FONT_SIZE = 24; 4 | export const MAX_DEPTH = 4; 5 | export const SINGLE_RECT_WIDTH = WIDTH / MAX_DEPTH; 6 | export const LABEL_VISIBLE_HEIGHT = 20; 7 | export const OPACITY_CODE = { 8 | dir: 1, 9 | file: 0.5, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/FileIcicleSummary/FileIcicleSummary.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .file-icicle-summary { 4 | width: 16rem; 5 | 6 | &__title { 7 | font-size: $font-size-title; 8 | font-weight: $font-weight-semibold; 9 | margin-bottom: 1rem; 10 | } 11 | 12 | &__label { 13 | color: $color-black; 14 | font-weight: $font-weight-semibold; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/FileIcicleSummary/FileIcicleSummary.type.ts: -------------------------------------------------------------------------------- 1 | export type FileChanges = { 2 | insertions: number; 3 | deletions: number; 4 | commits: number; 5 | }; 6 | 7 | export type FileChangesMap = { 8 | [path: string]: FileChanges; 9 | }; 10 | 11 | export type FileScoresMap = { 12 | [path: string]: number; 13 | }; 14 | 15 | export type FileChangesNode = { 16 | name: string; // Name of file/directory. 17 | children: FileChangesNode[]; 18 | value?: number; // Count of changed lines. 19 | } & Partial; 20 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/FileIcicleSummary/FileIcicleSummary.util.ts: -------------------------------------------------------------------------------- 1 | import type { ClusterNode } from "types"; 2 | 3 | import type { FileChanges, FileChangesMap, FileChangesNode, FileScoresMap } from "./FileIcicleSummary.type"; 4 | 5 | export const getFileChangesMap = (data: ClusterNode[]): FileChangesMap => { 6 | if (!data.length) return {}; 7 | 8 | return data 9 | .flatMap(({ commitNodeList }) => commitNodeList) 10 | .reduce( 11 | (map, { commit: { diffStatistics } }) => 12 | Object.entries(diffStatistics.files).reduce((acc, [path, { insertions, deletions }]) => { 13 | acc[path] = { 14 | insertions: (acc[path]?.insertions ?? 0) + insertions, 15 | deletions: (acc[path]?.deletions ?? 0) + deletions, 16 | commits: (acc[path]?.commits ?? 0) + 1, 17 | }; 18 | return acc; 19 | }, map), 20 | {} as FileChangesMap 21 | ); 22 | }; 23 | 24 | export const getFileScoresMap = (data: ClusterNode[]) => { 25 | if (!data.length) return {}; 26 | 27 | return data 28 | .flatMap(({ commitNodeList }) => commitNodeList) 29 | .reduce( 30 | (map, { commit: { diffStatistics } }) => 31 | Object.keys(diffStatistics.files).reduce((acc, path) => { 32 | acc[path] = 1; 33 | return acc; 34 | }, map), 35 | {} as FileScoresMap 36 | ); 37 | }; 38 | 39 | const visitFileNode = (node: FileChangesNode[], path: string, fileChanges: FileChanges, score: number) => { 40 | // If file is in the root directory 41 | if (!path.includes("/")) { 42 | node.push({ 43 | name: path, 44 | value: score, 45 | children: [], 46 | ...fileChanges, 47 | }); 48 | return; 49 | } 50 | 51 | const [currentPath] = path.split("/"); 52 | const subPath = path.substring(currentPath.length + 1); 53 | const currentNode = { 54 | name: currentPath, 55 | children: [], 56 | }; 57 | 58 | // Use for-of loop to return early 59 | // eslint-disable-next-line no-restricted-syntax 60 | for (const { name, children } of node) { 61 | if (name === currentPath && children !== undefined) { 62 | visitFileNode(children, subPath, fileChanges, score); 63 | return; 64 | } 65 | } 66 | 67 | node.push(currentNode); 68 | visitFileNode(currentNode.children, subPath, fileChanges, score); 69 | }; 70 | 71 | export const getFileChangesTree = (data: ClusterNode[]): FileChangesNode => { 72 | const fileChangesMap = getFileChangesMap(data); 73 | const fileScoresMap = getFileScoresMap(data); 74 | const root: FileChangesNode = { 75 | name: "root", 76 | children: [], 77 | }; 78 | 79 | Object.entries(fileChangesMap).forEach(([path, fileChanges]) => 80 | visitFileNode(root.children, path, fileChanges, fileScoresMap[path]) 81 | ); 82 | 83 | return root; 84 | }; 85 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/FileIcicleSummary/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FileIcicleSummary } from "./FileIcicleSummary"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/Statistics.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useShallow } from "zustand/react/shallow"; 2 | 3 | import { useDataStore } from "store"; 4 | 5 | export const useGetSelectedData = () => { 6 | const [filteredData, selectedData] = useDataStore(useShallow((state) => [state.filteredData, state.selectedData])); 7 | return selectedData.length ? selectedData : filteredData; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/Statistics.scss: -------------------------------------------------------------------------------- 1 | .statistics { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 5vh; 6 | padding: 0 1.25rem 1.25rem; 7 | overflow-y: scroll; 8 | } 9 | 10 | @media (max-width: 850px) { 11 | .statistics { 12 | display: none; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/Statistics.tsx: -------------------------------------------------------------------------------- 1 | import { AuthorBarChart } from "./AuthorBarChart"; 2 | import { FileIcicleSummary } from "./FileIcicleSummary"; 3 | import "./Statistics.scss"; 4 | 5 | const Statistics = () => { 6 | return ( 7 |
8 | 9 | 10 |
11 | ); 12 | }; 13 | 14 | export default Statistics; 15 | -------------------------------------------------------------------------------- /packages/view/src/components/Statistics/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Statistics } from "./Statistics"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/LineChart.const.ts: -------------------------------------------------------------------------------- 1 | import type { Margin } from "./LineChart"; 2 | 3 | export const WIDTH = 600; 4 | export const BRUSH_MARGIN: Margin = { 5 | bottom: 0, 6 | left: 10, 7 | right: 10, 8 | top: 0, 9 | }; 10 | export const COMMIT_CHART_HEIGHT = 50; 11 | export const TEMPORAL_FILTER_LINE_CHART_STYLES = { 12 | // NOTE: Histograms are easier to read when they are wider than they are tall 13 | width: WIDTH, 14 | height: WIDTH * 0.9, 15 | padding: { 16 | top: 0.1, 17 | right: 0.3, 18 | bottom: 0.1, 19 | left: 0.5, 20 | }, 21 | margin: { 22 | top: 50, 23 | right: 15, 24 | bottom: 15, 25 | left: 10, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/LineChart.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .cloc-line-chart { 4 | height: 50%; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | font-size: 1.25rem; 9 | margin: auto 0; 10 | 11 | &__chart { 12 | overflow: visible; 13 | fill: var(--color-primary); 14 | } 15 | 16 | &__label { 17 | font-size: $font-size-title; 18 | font-weight: $font-weight-semibold; 19 | fill: white; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/LineChart.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import dayjs from "dayjs"; 3 | import { type DateFilterRange } from "store"; 4 | 5 | import "./LineChart.scss"; 6 | 7 | export type LineChartDatum = { 8 | dateString: string; 9 | value: number; 10 | }; 11 | 12 | export type Margin = { 13 | top: number; 14 | right: number; 15 | bottom: number; 16 | left: number; 17 | }; 18 | 19 | const getMinMaxDate = (lineChartData: LineChartDatum[], dateRange: DateFilterRange) => { 20 | if (lineChartData.length === 0 && dateRange !== undefined) return [dateRange.fromDate, dateRange.toDate]; 21 | 22 | const minDate = dayjs(lineChartData[0].dateString).format("YYYY-MM-DD"); 23 | const maxDate = dayjs(lineChartData[lineChartData.length - 1].dateString).format("YYYY-MM-DD"); 24 | 25 | if (dateRange === undefined) return [minDate, maxDate]; 26 | 27 | const { fromDate, toDate } = dateRange; 28 | 29 | return [minDate < fromDate ? minDate : fromDate, maxDate > toDate ? maxDate : toDate]; 30 | }; 31 | 32 | const drawLineChart = ( 33 | refTarget: SVGSVGElement, 34 | lineChartData: LineChartDatum[], 35 | dateRange: DateFilterRange, 36 | margin: Margin, 37 | chartWidth: number, 38 | chartHeight: number, 39 | startHeight: number, 40 | showXAxis: boolean, 41 | chartTitle: string 42 | ) => { 43 | const width = chartWidth - margin.left - margin.right; 44 | const svg = d3 45 | .select(refTarget) 46 | .append("g") 47 | .attr("transform", `translate(${margin.left}, ${startHeight})`) 48 | .attr("class", "cloc-line-chart"); 49 | 50 | // TODO cleanup으로 옮기기 51 | svg.selectAll("*").remove(); 52 | 53 | const [xMin, xMax] = getMinMaxDate(lineChartData, dateRange); 54 | const [yMin, yMax] = d3.extent(lineChartData, (d) => d.value) as [number, number]; 55 | 56 | const xScale = d3 57 | .scaleTime() 58 | .domain([new Date(xMin), new Date(xMax)]) 59 | .range([0, width]); 60 | 61 | const yScale = d3.scaleLinear().domain([yMin, yMax]).range([chartHeight, 0]); 62 | 63 | const area = d3 64 | .area() 65 | .curve(d3.curveBasis) 66 | .x((d) => xScale(new Date(d.dateString))) 67 | .y0(yScale(1)) 68 | .y1((d) => yScale(d.value)); 69 | 70 | if (showXAxis) { 71 | const tickCount = Math.min(Math.round(width / 75), lineChartData.length); 72 | 73 | const xAxis = d3 74 | .axisBottom(xScale) 75 | .tickFormat(d3.timeFormat("%y-%m-%d")) 76 | .tickValues(d3.timeTicks(new Date(xMin), new Date(xMax), tickCount)) 77 | .tickSizeOuter(-5); 78 | 79 | d3.select(refTarget) 80 | .append("g") 81 | .attr("transform", `translate(${margin.left / 2}, ${startHeight + chartHeight})`) 82 | .call(xAxis); 83 | } 84 | 85 | svg.append("path").datum(lineChartData).attr("class", "cloc-line-chart__chart").attr("d", area); 86 | 87 | svg.append("text").text(chartTitle).attr("class", "cloc-line-chart__label").attr("x", 0).attr("y", 15); 88 | 89 | return xScale; 90 | }; 91 | 92 | export default drawLineChart; 93 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/LineChartBrush.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import type { D3BrushEvent } from "d3"; 3 | 4 | import type { Margin } from "./LineChart"; 5 | 6 | export type BrushXSelection = [number, number] | null; 7 | 8 | export const createBrush = ( 9 | margin: Margin, 10 | chartWidth: number, 11 | chartHeight: number, 12 | brushHandler: (selection: BrushXSelection) => void 13 | ) => { 14 | const width = chartWidth - margin.left - margin.right; 15 | 16 | const brushed = (event: D3BrushEvent) => { 17 | console.log("brush sel ", event.selection); 18 | brushHandler(event.selection as BrushXSelection); 19 | }; 20 | 21 | const brush = d3 22 | .brushX() 23 | .extent([ 24 | [0, 0], 25 | [width - margin.left / 2, chartHeight - margin.bottom], 26 | ]) 27 | // .handleSize(5) 28 | .on("end", brushed); 29 | 30 | return brush; 31 | }; 32 | 33 | export const drawBrush = (refTarget: SVGSVGElement, margin: Margin, brush: d3.BrushBehavior) => { 34 | const svg = d3.select(refTarget); 35 | 36 | const brushGroup = svg 37 | .append("g") 38 | .call(brush) 39 | .attr("transform", `translate(${margin.left / 2}, 0)`); 40 | 41 | return brushGroup; 42 | }; 43 | 44 | export const resetBrush = (brushGroup: SVGGElement, brush: d3.BrushBehavior) => { 45 | d3.select(brushGroup).call(brush.move, null); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/TemporalFilter.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export const useWindowResize = () => { 4 | const [windowSize, setWindowSize] = useState({ 5 | width: window.innerWidth, 6 | height: window.innerHeight, 7 | }); 8 | const handleResizeWindow = () => { 9 | setWindowSize({ 10 | width: window.innerWidth, 11 | height: window.innerHeight, 12 | }); 13 | }; 14 | 15 | useEffect(() => { 16 | window.addEventListener("resize", handleResizeWindow); 17 | return () => { 18 | window.removeEventListener("resize", handleResizeWindow); 19 | }; 20 | }, []); 21 | 22 | return windowSize; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/TemporalFilter.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .temporal-filter { 4 | display: flex; 5 | flex-direction: column; 6 | padding: 1rem; 7 | 8 | &__container { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: flex-end; 12 | gap: 1rem; 13 | position: relative; 14 | 15 | .temporal-filter__reset-button { 16 | width: 3rem; 17 | text-transform: none; 18 | background-color: var(--color-tertiary); 19 | position: absolute; 20 | top: 0; 21 | 22 | &:hover { 23 | background-color: var(--color-secondary); 24 | } 25 | 26 | & .temporal-filter__button-text { 27 | color: $color-black; 28 | font-weight: $font-weight-semibold; 29 | } 30 | } 31 | } 32 | 33 | &__chart { 34 | height: 100%; 35 | width: 100%; 36 | > g > text { 37 | fill: var(--color-tertiary); 38 | } 39 | } 40 | } 41 | 42 | .filter { 43 | font-weight: $font-weight-semibold; 44 | font-size: $font-size-caption; 45 | 46 | .date-from, 47 | .date-to { 48 | margin: 0 0.75rem 0 0.5rem; 49 | padding: 0.125rem; 50 | border: solid 0.0625rem rgba(255, 255, 255, 0.22); 51 | border-radius: 0.1875rem; 52 | color: $color-white; 53 | background: rgba(255, 255, 255, 0.09); 54 | font-weight: $font-weight-light; 55 | font-size: $font-size-caption; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/TemporalFilter.util.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { timeFormat } from "d3"; 3 | 4 | import type { ClusterNode, CommitNode } from "types/Nodes"; 5 | 6 | import { NODE_TYPES } from "../../constants/constants"; 7 | 8 | /** 9 | * Note: Line Chart를 위한 시간순 CommitNode 정렬 10 | */ 11 | export function sortBasedOnCommitNode(data: ClusterNode[]): CommitNode[] { 12 | const sortedData: CommitNode[] = []; 13 | data.forEach((cluster) => { 14 | cluster.commitNodeList.map((commitNode: CommitNode) => sortedData.push(commitNode)); 15 | }); 16 | 17 | return sortedData.sort((a, b) => Number(new Date(a.commit.commitDate)) - Number(new Date(b.commit.commitDate))); 18 | } 19 | 20 | type FilterDataByDateProps = { 21 | data: ClusterNode[]; 22 | fromDate: string; 23 | toDate: string; 24 | }; 25 | 26 | export function filterDataByDate({ data, fromDate, toDate }: FilterDataByDateProps): ClusterNode[] { 27 | const filteredData = data 28 | .map((clusterNode) => { 29 | return clusterNode.commitNodeList.filter((commitNode: CommitNode) => { 30 | return ( 31 | new Date(commitNode.commit.commitDate) >= new Date(`${fromDate} 00:00:00`) && 32 | new Date(commitNode.commit.commitDate) <= new Date(`${toDate} 23:59:59`) 33 | ); 34 | }); 35 | }) 36 | .filter((commitNodeList) => commitNodeList.length > 0) 37 | .map( 38 | (commitNodeList): ClusterNode => ({ 39 | nodeTypeName: NODE_TYPES[1], 40 | commitNodeList, 41 | }) 42 | ); 43 | 44 | return filteredData.reverse(); 45 | } 46 | 47 | export const getCloc = (d: CommitNode) => d.commit.diffStatistics.insertions + d.commit.diffStatistics.deletions; 48 | 49 | export const getMinMaxDate = (data: CommitNode[]) => { 50 | const minMaxDateFormat = "YYYY-MM-DD"; 51 | 52 | return { 53 | fromDate: dayjs(data[0]?.commit.commitDate).format(minMaxDateFormat), 54 | toDate: dayjs(data[data.length - 1]?.commit.commitDate).format(minMaxDateFormat), 55 | }; 56 | }; 57 | 58 | export const lineChartTimeFormatter = timeFormat("%Y %m %d"); 59 | -------------------------------------------------------------------------------- /packages/view/src/components/TemporalFilter/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TemporalFilter } from "./TemporalFilter"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/ThemeSelector/ThemeSelector.const.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeInfo } from "./ThemeSelector.type"; 2 | 3 | export const THEME_INFO: ThemeInfo = { 4 | githru: { 5 | title: "Githru", 6 | value: "githru", 7 | colors: { 8 | primary: "#e06091", 9 | secondary: "#8840bb", 10 | tertiary: "#ffd08a", 11 | }, 12 | }, 13 | "hacker-blue": { 14 | title: "Hacker Blue", 15 | value: "hacker-blue", 16 | colors: { 17 | primary: "#456cf7", 18 | secondary: "#3f4c73", 19 | tertiary: "#6c60f0", 20 | }, 21 | }, 22 | aqua: { 23 | title: "Aqua", 24 | value: "aqua", 25 | colors: { 26 | primary: "#51decd", 27 | secondary: "#0687a3", 28 | tertiary: "#a7ffff", 29 | }, 30 | }, 31 | "cotton-candy": { 32 | title: "Cotton Candy", 33 | value: "cotton-candy", 34 | colors: { 35 | primary: "#ffcccb", 36 | secondary: "#feffd1", 37 | tertiary: "#a39aeb", 38 | }, 39 | }, 40 | mono: { 41 | title: "Mono", 42 | value: "mono", 43 | colors: { 44 | primary: "#68788f", 45 | secondary: "#3a4776", 46 | tertiary: "#9aaed1", 47 | }, 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /packages/view/src/components/ThemeSelector/ThemeSelector.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .theme-selector { 4 | position: relative; 5 | 6 | svg { 7 | cursor: pointer; 8 | } 9 | 10 | &__container { 11 | position: absolute; 12 | top: 2.5rem; 13 | left: 0; 14 | padding: 1.25rem; 15 | background-color: $color-dark-gray; 16 | border-radius: 0.3rem 1.5rem 1.5rem 1.5rem; 17 | z-index: 10; 18 | 19 | display: flex; 20 | flex-direction: column; 21 | gap: 0.625rem; 22 | } 23 | 24 | &__list { 25 | display: flex; 26 | justify-content: space-between; 27 | gap: 0.125rem; 28 | } 29 | 30 | &__header { 31 | @extend .theme-selector__list; 32 | font-size: $font-size-title; 33 | font-weight: $font-weight-semibold; 34 | } 35 | } 36 | 37 | .theme-icon { 38 | display: flex; 39 | flex-direction: column; 40 | align-items: center; 41 | justify-items: center; 42 | gap: 1rem; 43 | padding: 0.625rem; 44 | box-sizing: border-box; 45 | 46 | &:hover, 47 | &--selected { 48 | @extend .theme-icon; 49 | background-color: #4f5662; 50 | border-radius: 0.625rem; 51 | cursor: pointer; 52 | } 53 | 54 | &__container { 55 | position: relative; 56 | margin: auto; 57 | 58 | display: flex; 59 | justify-items: center; 60 | align-items: center; 61 | } 62 | 63 | &__color { 64 | width: 2.625rem; 65 | height: 2.625rem; 66 | border-radius: 100%; 67 | margin-left: -0.75rem; 68 | &:nth-child(1) { 69 | margin-left: 0; 70 | } 71 | } 72 | 73 | &__title { 74 | font-size: $font-size-body; 75 | font-weight: $font-weight-regular; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/view/src/components/ThemeSelector/ThemeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import "./ThemeSelector.scss"; 3 | import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; 4 | import CloseIcon from "@mui/icons-material/Close"; 5 | 6 | import { sendUpdateThemeCommand } from "services"; 7 | 8 | import { THEME_INFO } from "./ThemeSelector.const"; 9 | import type { ThemeInfo } from "./ThemeSelector.type"; 10 | 11 | type ThemeIconsProps = ThemeInfo[keyof ThemeInfo] & { 12 | onClick: () => void; 13 | }; 14 | 15 | const ThemeIcons = ({ title, value, colors, onClick }: ThemeIconsProps) => { 16 | return ( 17 |
22 |
23 | {Object.values(colors).map((color, index) => ( 24 |
29 | ))} 30 |
31 |

{title}

32 |
33 | ); 34 | }; 35 | 36 | const ThemeSelector = () => { 37 | const [isOpen, setIsOpen] = useState(false); 38 | const themeSelectorRef = useRef(null); 39 | 40 | const handleTheme = (value: string) => { 41 | sendUpdateThemeCommand(value); 42 | window.theme = value; 43 | document.documentElement.setAttribute("theme", value); 44 | }; 45 | 46 | useEffect(() => { 47 | const handleClickOutside = (event: MouseEvent) => { 48 | if (themeSelectorRef.current && !themeSelectorRef.current.contains(event.target as Node)) { 49 | setIsOpen(false); 50 | } 51 | }; 52 | document.addEventListener("mousedown", handleClickOutside); 53 | return () => { 54 | document.removeEventListener("mousedown", handleClickOutside); 55 | }; 56 | }, []); 57 | 58 | useEffect(() => { 59 | document.documentElement.setAttribute("theme", window.theme); 60 | }, []); 61 | 62 | return ( 63 |
67 | setIsOpen(true)} /> 68 | {isOpen && ( 69 |
70 |
71 |

Theme

72 | setIsOpen(false)} 75 | /> 76 |
77 |
78 | {Object.entries(THEME_INFO).map(([_, theme]) => ( 79 | { 83 | handleTheme(theme.value); 84 | setIsOpen(false); 85 | }} 86 | /> 87 | ))} 88 |
89 |
90 | )} 91 |
92 | ); 93 | }; 94 | 95 | export default ThemeSelector; 96 | -------------------------------------------------------------------------------- /packages/view/src/components/ThemeSelector/ThemeSelector.type.ts: -------------------------------------------------------------------------------- 1 | export type ThemeInfo = { 2 | [key in "githru" | "hacker-blue" | "aqua" | "cotton-candy" | "mono"]: { 3 | title: string; 4 | value: key; 5 | colors: { 6 | primary: string; 7 | secondary: string; 8 | tertiary: string; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/view/src/components/ThemeSelector/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ThemeSelector } from "./ThemeSelector"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.const.ts: -------------------------------------------------------------------------------- 1 | import type { SVGMargin } from "./ClusterGraph.type"; 2 | 3 | export const NODE_GAP = 10; 4 | export const CLUSTER_HEIGHT = 40; 5 | export const DETAIL_HEIGHT = 220; 6 | export const GRAPH_WIDTH = 80; 7 | export const SVG_WIDTH = GRAPH_WIDTH + 4; 8 | export const SVG_MARGIN: SVGMargin = { 9 | right: 2, 10 | left: 2, 11 | top: 10, 12 | bottom: 10, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.hook.tsx: -------------------------------------------------------------------------------- 1 | import type { Dispatch, RefObject } from "react"; 2 | import type React from "react"; 3 | import { useCallback, useEffect, useRef } from "react"; 4 | import * as d3 from "d3"; 5 | 6 | import type { ClusterNode } from "types"; 7 | 8 | import { selectedDataUpdater } from "../VerticalClusterList.util"; 9 | 10 | import { CLUSTER_HEIGHT, DETAIL_HEIGHT, GRAPH_WIDTH, NODE_GAP, SVG_MARGIN } from "./ClusterGraph.const"; 11 | import type { ClusterGraphElement } from "./ClusterGraph.type"; 12 | import { destroyClusterGraph, drawClusterBox, drawCommitAmountCluster, drawSubGraph, drawTotalLine } from "./Draws"; 13 | import { getTranslateAfterSelect } from "./ClusterGraph.util"; 14 | 15 | const drawClusterGraph = ( 16 | svgRef: RefObject, 17 | data: ClusterGraphElement[], 18 | detailElementHeight: number, 19 | onClickCluster: (_: PointerEvent, d: ClusterGraphElement) => void 20 | ) => { 21 | const group = d3 22 | .select(svgRef.current) 23 | .selectAll(".cluster-graph__container") 24 | .data(data) 25 | .join("g") 26 | .on("click", onClickCluster) 27 | .attr("class", "cluster-graph__container") 28 | .attr("transform", (d, i) => getTranslateAfterSelect(d, i, detailElementHeight, true)); 29 | 30 | group.append("title").text((_, i) => `${i + 1}번째 container`); 31 | 32 | group 33 | .transition() 34 | .duration(0) 35 | .attr("transform", (d, i) => getTranslateAfterSelect(d, i, detailElementHeight)); 36 | 37 | drawClusterBox(group, GRAPH_WIDTH, CLUSTER_HEIGHT); 38 | drawCommitAmountCluster(group, GRAPH_WIDTH, CLUSTER_HEIGHT); 39 | drawSubGraph(svgRef, data, detailElementHeight); 40 | drawTotalLine(svgRef, data, detailElementHeight, SVG_MARGIN, CLUSTER_HEIGHT, NODE_GAP, GRAPH_WIDTH); 41 | }; 42 | 43 | export const useHandleClusterGraph = ({ 44 | data, 45 | clusterSizes, 46 | selectedIndex, 47 | setSelectedData, 48 | }: { 49 | selectedIndex: number[]; 50 | clusterSizes: number[]; 51 | data: ClusterNode[]; 52 | setSelectedData: Dispatch>; 53 | }) => { 54 | const svgRef = useRef(null); 55 | const prevSelected = useRef([-1]); 56 | 57 | const clusterGraphElements = data.map((cluster, i) => ({ 58 | cluster, 59 | clusterSize: clusterSizes[i], 60 | selected: { 61 | prev: prevSelected.current, 62 | current: selectedIndex, 63 | }, 64 | })); 65 | 66 | const handleClickCluster = useCallback( 67 | (_: PointerEvent, d: ClusterGraphElement) => { 68 | const targetIndex = d.cluster.commitNodeList[0].clusterId; 69 | setSelectedData(selectedDataUpdater(d.cluster, targetIndex)); 70 | }, 71 | [setSelectedData] 72 | ); 73 | useEffect(() => { 74 | drawClusterGraph(svgRef, clusterGraphElements, DETAIL_HEIGHT, handleClickCluster); 75 | 76 | prevSelected.current = selectedIndex; 77 | return () => { 78 | destroyClusterGraph(svgRef); 79 | }; 80 | }, [handleClickCluster, clusterGraphElements, selectedIndex]); 81 | return svgRef; 82 | }; 83 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .cluster-graph { 4 | display: block; 5 | 6 | &__container { 7 | cursor: pointer; 8 | min-width: 5.25rem; 9 | 10 | &:hover { 11 | .cluster-graph__cluster-box { 12 | stroke-width: 3; 13 | } 14 | } 15 | } 16 | 17 | &__cluster-box { 18 | rx: 5; 19 | stroke-width: 1; 20 | stroke: var(--color-primary); 21 | fill: transparent; 22 | } 23 | 24 | &__commit-amount { 25 | rx: 5; 26 | fill: var(--color-primary); 27 | } 28 | 29 | &__connector-line { 30 | stroke: var(--color-primary); 31 | stroke-width: 1; 32 | } 33 | 34 | &__circle { 35 | fill: var(--color-primary); 36 | } 37 | 38 | &__tooltip { 39 | position: absolute; 40 | z-index: 10; 41 | background: $color-white; 42 | padding: 0.5rem 1rem; 43 | font-size: $font-size-caption; 44 | line-height: 1.5; 45 | border-radius: 0.3125rem; 46 | color: $color-dark-gray; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useShallow } from "zustand/react/shallow"; 3 | 4 | import { useDataStore } from "store"; 5 | import type { ClusterGraphProps } from "types/ClusterGraphProps"; 6 | 7 | import { getGraphHeight, getSelectedIndex } from "./ClusterGraph.util"; 8 | import { DETAIL_HEIGHT, SVG_WIDTH } from "./ClusterGraph.const"; 9 | import { useHandleClusterGraph } from "./ClusterGraph.hook"; 10 | 11 | import "./ClusterGraph.scss"; 12 | 13 | const ClusterGraph: React.FC = ({ data, clusterSizes }) => { 14 | const [selectedData, setSelectedData] = useDataStore( 15 | useShallow((state) => [state.selectedData, state.setSelectedData]) 16 | ); 17 | const selectedIndex = getSelectedIndex(data, selectedData); 18 | const graphHeight = getGraphHeight(clusterSizes) + selectedIndex.length * DETAIL_HEIGHT; 19 | 20 | const svgRef = useHandleClusterGraph({ 21 | data, 22 | clusterSizes, 23 | selectedIndex, 24 | setSelectedData, 25 | }); 26 | 27 | return ( 28 | 34 | ); 35 | }; 36 | 37 | export default ClusterGraph; 38 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.type.ts: -------------------------------------------------------------------------------- 1 | import type { BaseType, Selection } from "d3"; 2 | 3 | import type { ClusterNode } from "types"; 4 | 5 | export type ClusterGraphElement = { 6 | cluster: ClusterNode; 7 | clusterSize: number; 8 | selected: { 9 | prev: number[]; 10 | current: number[]; 11 | }; 12 | }; 13 | 14 | export type SVGElementSelection = Selection< 15 | T | BaseType, 16 | ClusterGraphElement, 17 | SVGSVGElement | null, 18 | unknown 19 | >; 20 | 21 | export type SVGMargin = { [key: string]: number }; 22 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.util.test.ts: -------------------------------------------------------------------------------- 1 | import { fakeFirstClusterNode, fakePrev, fakePrev2, fakePrev3, fakePrev4 } from "../../../../tests/fakeAsset"; 2 | 3 | import { getClusterSizes, getGraphHeight, getTranslateAfterSelect, getSelectedIndex } from "./ClusterGraph.util"; 4 | import type { ClusterGraphElement } from "./ClusterGraph.type"; 5 | 6 | const getClusterSizesResult = getClusterSizes(fakePrev); 7 | 8 | test.each(getClusterSizesResult)("getClusterSizes", (Cluster) => { 9 | expect(Cluster).toBe(1); 10 | }); 11 | 12 | test("getGraphHeight", () => { 13 | const resultLength1 = getGraphHeight([1]); 14 | const resultLength2 = getGraphHeight([1, 1]); 15 | const resultLength3 = getGraphHeight([1, 1, 1]); 16 | const resultLength4 = getGraphHeight([1, 1, 1, 1]); 17 | const resultLength5 = getGraphHeight([1, 1, 1, 1, 1]); 18 | expect(resultLength1).toBe(60); 19 | expect(resultLength2).toBe(110); 20 | expect(resultLength3).toBe(160); 21 | expect(resultLength4).toBe(210); 22 | expect(resultLength5).toBe(260); 23 | }); 24 | 25 | test("getSelectedIndex", () => { 26 | const resultSameEle = getSelectedIndex(fakePrev, fakePrev2); 27 | const resultEmptyArray = getSelectedIndex(fakePrev2, fakePrev3); 28 | const resultDiffEle = getSelectedIndex(fakePrev, fakePrev4); 29 | expect(resultSameEle.length).toBe(1); 30 | expect(resultEmptyArray.length).toBe(0); 31 | expect(resultDiffEle.length).toBe(0); 32 | }); 33 | 34 | const fakeClusterGraphElement: ClusterGraphElement = { 35 | cluster: fakeFirstClusterNode, 36 | clusterSize: 1, 37 | selected: { 38 | prev: [0, 1, 2, 3], 39 | current: [5, 6, 7, 8], 40 | }, 41 | }; 42 | 43 | test("getClusterPosition", () => { 44 | const resultPrev = getTranslateAfterSelect(fakeClusterGraphElement, 1, 1, true); 45 | const resultCurrent = getTranslateAfterSelect(fakeClusterGraphElement, 1, 6, false); 46 | const resultDiffI = getTranslateAfterSelect(fakeClusterGraphElement, 2, 3, true); 47 | expect(resultPrev).toBe("translate(2, 61)"); 48 | expect(resultCurrent).toBe("translate(2, 60)"); 49 | expect(resultDiffI).toBe("translate(2, 116)"); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/ClusterGraph.util.ts: -------------------------------------------------------------------------------- 1 | import type { ClusterNode, SelectedDataProps } from "types"; 2 | 3 | import { CLUSTER_HEIGHT, NODE_GAP, SVG_MARGIN } from "./ClusterGraph.const"; 4 | import type { ClusterGraphElement } from "./ClusterGraph.type"; 5 | 6 | export function getClusterSizes(data: ClusterNode[]) { 7 | return data.map((node) => node.commitNodeList.length); 8 | } 9 | 10 | export function getGraphHeight(clusterSizes: number[]) { 11 | return clusterSizes.length * CLUSTER_HEIGHT + clusterSizes.length * NODE_GAP + NODE_GAP; 12 | } 13 | 14 | export function getStartYEndY(d: ClusterGraphElement, a: number, detailElementHeight: number) { 15 | const selected = d.selected.current; 16 | const selectedLength = selected.filter((selectedIdx) => selectedIdx < a).length; 17 | const selectedLongerHeight = selectedLength * detailElementHeight; 18 | const startY = SVG_MARGIN.top + 20 + (a + 1) * (CLUSTER_HEIGHT + NODE_GAP) + selectedLongerHeight; 19 | const endY = startY + detailElementHeight - 50; 20 | return { startY, endY }; 21 | } 22 | 23 | export function getTranslateAfterSelect( 24 | d: ClusterGraphElement, 25 | i: number, 26 | detailElementHeight: number, 27 | isPrev = false // TODO - this param can be removed 28 | ) { 29 | const selected = isPrev ? d.selected.prev : d.selected.current; 30 | const selectedLength = selected.filter((selectedIdx) => selectedIdx < i).length; 31 | const margin = selectedLength * detailElementHeight; 32 | const x = SVG_MARGIN.left; 33 | const y = SVG_MARGIN.top + i * (CLUSTER_HEIGHT + NODE_GAP) + margin; 34 | return `translate(${x}, ${y})`; 35 | } 36 | 37 | export function getSelectedIndex(data: ClusterNode[], selectedData: SelectedDataProps) { 38 | return selectedData 39 | .map((selected) => selected.commitNodeList[0].clusterId) 40 | .map((clusterId) => data.findIndex((item) => item.commitNodeList[0].clusterId === clusterId)) 41 | .filter((idx) => idx !== -1); 42 | } 43 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/Draws/destroyClusterGraph.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from "react"; 2 | import * as d3 from "d3"; 3 | 4 | export const destroyClusterGraph = (target: RefObject) => d3.select(target.current).selectAll("*").remove(); 5 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/Draws/drawClusterBox.ts: -------------------------------------------------------------------------------- 1 | import type { SVGElementSelection } from "../ClusterGraph.type"; 2 | 3 | export const drawClusterBox = ( 4 | container: SVGElementSelection, 5 | graphWidth: number, 6 | clusterHeight: number 7 | ) => { 8 | container 9 | .append("rect") 10 | .attr("class", "cluster-graph__cluster-box") 11 | .attr("width", graphWidth) 12 | .attr("height", clusterHeight); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/Draws/drawCommitAmountCluster.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | 3 | import type { SVGElementSelection } from "../ClusterGraph.type"; 4 | 5 | export const drawCommitAmountCluster = ( 6 | container: SVGElementSelection, 7 | graphHeight: number, 8 | clusterHeight: number 9 | ) => { 10 | const widthScale = d3.scaleLinear().range([0, graphHeight]).domain([0, 10]); 11 | container 12 | .append("rect") 13 | .attr("class", "cluster-graph__commit-amount") 14 | .attr("width", (d) => widthScale(Math.min(d.clusterSize, 10))) 15 | .attr("height", clusterHeight) 16 | .attr("x", (d) => (graphHeight - widthScale(Math.min(d.clusterSize, 10))) / 2); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/Draws/drawSubGraph.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import type { RefObject } from "react"; 3 | 4 | import { pxToRem } from "utils"; 5 | 6 | import type { ClusterGraphElement } from "../ClusterGraph.type"; 7 | import { getStartYEndY } from "../ClusterGraph.util"; 8 | import { GRAPH_WIDTH } from "../ClusterGraph.const"; 9 | 10 | // create tootip (HTML) 11 | const tooltip = d3 12 | .select("body") 13 | .append("div") 14 | .attr("class", "cluster-graph__tooltip") 15 | .style("visibility", "hidden") 16 | .text("Tooltip"); 17 | 18 | const calculateCirclePositions = (numOfCircles: number, startY: number, endY: number, gap: number) => { 19 | const positionStrategies = new Map number[]>([ 20 | [1, (start, end) => [(start + end) / 2]], 21 | [2, (start, end) => [(3 * start + end) / 4, (start + 3 * end) / 4]], 22 | ]); 23 | 24 | const strategy = positionStrategies.get(numOfCircles); 25 | 26 | return strategy ? strategy(startY, endY) : Array.from({ length: numOfCircles }, (_, i) => startY + i * gap); 27 | }; 28 | 29 | export const drawSubGraph = ( 30 | svgRef: RefObject, 31 | data: ClusterGraphElement[], 32 | detailElementHeight: number 33 | ) => { 34 | const allCirclePositions = data.reduce( 35 | (acc, clusterData, index) => { 36 | if (clusterData.selected.current.includes(index)) { 37 | const { startY, endY } = getStartYEndY(clusterData, index, detailElementHeight); 38 | const numOfCircles = clusterData.cluster.commitNodeList.length; 39 | const gap = (endY - startY) / (numOfCircles - 1); 40 | const circlePositions = calculateCirclePositions(numOfCircles, startY, endY, gap); 41 | 42 | const enrichedPositions = circlePositions.map((y, circleIndex) => ({ y, clusterData, circleIndex })); 43 | return acc.concat(enrichedPositions); 44 | } 45 | return acc; 46 | }, 47 | [] as Array<{ y: number; clusterData: ClusterGraphElement; circleIndex: number }> 48 | ); 49 | 50 | const circleRadius = 5; 51 | 52 | d3.select(svgRef.current) 53 | .selectAll(".cluster-graph__circle") 54 | .data(allCirclePositions) 55 | .join("circle") 56 | .attr("class", "cluster-graph__circle") 57 | .attr("cx", GRAPH_WIDTH / 2 + 2) 58 | .attr("cy", (d) => d.y) 59 | .attr("r", circleRadius) 60 | .on("mouseover", (_, { clusterData, circleIndex }) => { 61 | const { commitNodeList } = clusterData.cluster; 62 | const info = commitNodeList[circleIndex].commit.message; 63 | tooltip.text(info); 64 | return tooltip.style("visibility", "visible"); 65 | }) 66 | .on("mousemove", (event) => { 67 | return tooltip.style("top", pxToRem(event.pageY - 10)).style("left", pxToRem(event.pageX + 10)); 68 | }) 69 | .on("mouseout", () => { 70 | return tooltip.style("visibility", "hidden"); 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/Draws/drawTotalLine.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from "react"; 2 | import * as d3 from "d3"; 3 | 4 | import type { ClusterGraphElement, SVGMargin } from "../ClusterGraph.type"; 5 | 6 | export const drawTotalLine = ( 7 | svgRef: RefObject, 8 | data: ClusterGraphElement[], 9 | detailElementHeight: number, 10 | svgMargin: SVGMargin, 11 | clusterHeight: number, 12 | nodeGap: number, 13 | graphWidth: number 14 | ) => { 15 | const lineData = [ 16 | { 17 | start: 0, 18 | end: clusterHeight + nodeGap * 2, 19 | selected: { 20 | prev: data[0].selected.prev, 21 | current: data[0].selected.current, 22 | }, 23 | }, 24 | ]; 25 | 26 | d3.select(svgRef.current) 27 | .selectAll(".cluster-graph__connector-line") 28 | .data(lineData) 29 | .join("line") 30 | .attr("class", "cluster-graph__connector-line") 31 | .attr("x1", svgMargin.left + graphWidth / 2) 32 | .attr("y1", (d) => d.start) 33 | .attr("x2", svgMargin.left + graphWidth / 2) 34 | .attr("y2", (d) => d.end + d.selected.prev.length * detailElementHeight) 35 | .transition() 36 | .attr("y2", (d) => d.end + d.selected.current.length * detailElementHeight) 37 | .attr("pointer-events", "none"); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/Draws/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./drawClusterBox"; 2 | export * from "./drawCommitAmountCluster"; 3 | export * from "./drawTotalLine"; 4 | export * from "./destroyClusterGraph"; 5 | export * from "./drawSubGraph"; 6 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/ClusterGraph/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ClusterGraph } from "./ClusterGraph"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/Summary/Content/Content.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import ArrowDropDownCircleRoundedIcon from "@mui/icons-material/ArrowDropDownCircleRounded"; 3 | 4 | import { useGithubInfo } from "store"; 5 | 6 | import type { ContentProps } from "../Summary.type"; 7 | 8 | const Content = ({ content, clusterId, selectedClusterId }: ContentProps) => { 9 | const { owner, repo } = useGithubInfo(); 10 | const [linkedStr, setLinkedStr] = useState([]); 11 | 12 | useEffect(() => { 13 | const str: string = content.message; 14 | const regex = /^(\(#[0-9]+\)|#[0-9]+)/g; 15 | const tobeStr: string[] = str.split(" "); 16 | 17 | const newLinkedStr = tobeStr.reduce((acc: React.ReactNode[], tokenStr: string) => { 18 | const matches = tokenStr.match(regex); // #num 으로 결과가 나옴 ()가 결과에 포함되지 않음 19 | if (matches) { 20 | const matchedStr = matches[0]; 21 | const matchedStrNum: string = matchedStr.substring(1); 22 | const linkIssues = `https://github.com/${owner}/${repo}/issues/${matchedStrNum}`; 23 | acc.push( 24 | 29 | {matchedStr} 30 | 31 | ); 32 | acc.push(" "); 33 | } else { 34 | acc.push(tokenStr); 35 | acc.push(" "); 36 | } 37 | return acc; 38 | }, []); 39 | setLinkedStr(newLinkedStr); 40 | }, [content]); 41 | 42 | return ( 43 | <> 44 |
45 |
{linkedStr}
46 | {content.count > 0 && + {content.count} more} 47 |
48 |
49 | 50 |
51 | 52 | ); 53 | }; 54 | 55 | export default Content; 56 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/Summary/Content/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Content } from "./Content"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/Summary/Summary.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { useDataStore } from "store"; 4 | 5 | import { getAuthSrcMap } from "./Summary.util"; 6 | import type { AuthSrcMap } from "./Summary.type"; 7 | 8 | export const usePreLoadAuthorImg = () => { 9 | const data = useDataStore((state) => state.data); 10 | const [authSrcMap, setAuthSrcMap] = useState(null); 11 | 12 | useEffect(() => { 13 | getAuthSrcMap(data) 14 | .then(setAuthSrcMap) 15 | .catch(() => setAuthSrcMap(null)); 16 | }, [data]); 17 | 18 | return authSrcMap; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/Summary/Summary.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .vertical-cluster-list__body { 4 | display: flex; 5 | flex-direction: column; 6 | flex-grow: 1; 7 | padding-left: 0.625rem;; 8 | padding-right: 10px; 9 | width: 100%; 10 | height: 100%; 11 | overflow-x: hidden; 12 | overflow-y: scroll; 13 | } 14 | 15 | .cluster-summary { 16 | &__item { 17 | display: flex; 18 | align-items: center; 19 | column-gap: 0.625rem; 20 | } 21 | 22 | &__info { 23 | display: flex; 24 | flex-direction: column; 25 | width: 100%; 26 | 27 | &--expanded { 28 | @extend .cluster-summary__info; 29 | border-radius: 1.5625rem; 30 | background-color: $color-dark-gray; 31 | } 32 | } 33 | } 34 | 35 | .summary { 36 | display: flex; 37 | align-items: center; 38 | padding: 0.625rem 0.9375rem; 39 | border: none; 40 | background-color: transparent; 41 | cursor: pointer; 42 | color: $color-white; 43 | width: 100%; 44 | 45 | &:hover { 46 | border-radius: 2.5rem; 47 | background-color: $color-dark-gray; 48 | 49 | .summary__toggle { 50 | visibility: visible; 51 | } 52 | } 53 | 54 | &__author { 55 | display: flex; 56 | justify-content: center; 57 | } 58 | 59 | &__content { 60 | flex-grow: 1; 61 | display: flex; 62 | align-items: center; 63 | justify-content: space-between; 64 | font-size: $font-size-body; 65 | gap: 0.625rem; 66 | } 67 | 68 | &__commit-message { 69 | flex-grow: 1; 70 | padding-left: 0.9375rem; 71 | text-align: left; 72 | cursor: pointer; 73 | // "width: 0" makes the "commit-message" ellipsis reponsive 74 | width: 0; 75 | white-space: nowrap; 76 | overflow: hidden; 77 | text-overflow: ellipsis; 78 | } 79 | 80 | &__commit-link { 81 | text-decoration: none; 82 | color: $color-light-gray; 83 | 84 | &:visited { 85 | color: $color-light-gray; 86 | } 87 | 88 | &:hover { 89 | color: var(--color-primary); 90 | } 91 | } 92 | 93 | &__more-commit { 94 | text-align: right; 95 | font-size: $font-size-caption; 96 | } 97 | 98 | &__toggle { 99 | display: flex; 100 | align-items: center; 101 | justify-content: center; 102 | margin-left: 0.5rem; 103 | background-color: transparent; 104 | border: none; 105 | font-size: 1.5rem; 106 | color: var(--color-primary); 107 | visibility: hidden; 108 | cursor: pointer; 109 | 110 | &--visible { 111 | @extend .summary__toggle; 112 | visibility: visible; 113 | transform: rotate(180deg); 114 | } 115 | } 116 | } 117 | 118 | .detail { 119 | overflow: auto; 120 | height: 13.75rem; 121 | max-height: 17.5rem; 122 | padding: 0.625rem 1.875rem; 123 | } 124 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/Summary/Summary.type.ts: -------------------------------------------------------------------------------- 1 | import type { AuthorInfo } from "types"; 2 | 3 | export type Content = { 4 | message: string; 5 | count: number; 6 | }; 7 | 8 | export type ContentProps = { 9 | content: Content; 10 | clusterId: number; 11 | selectedClusterId: number[]; 12 | }; 13 | 14 | export type Summary = { 15 | authorNames: Array>; 16 | content: Content; 17 | }; 18 | 19 | export type Cluster = { 20 | clusterId: number; 21 | summary: Summary; 22 | clusterTags: string[]; 23 | }; 24 | 25 | export type AuthSrcMap = Record; 26 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/Summary/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Summary } from "./Summary"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/VerticalClusterList.hook.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from "react"; 2 | import { useEffect, useState } from "react"; 3 | 4 | import type { SelectedDataProps } from "types"; 5 | 6 | export const useResizeObserver = (ref: RefObject, selectedData: SelectedDataProps) => { 7 | const [height, setHeight] = useState(0); 8 | 9 | useEffect(() => { 10 | let RO: ResizeObserver | null = new ResizeObserver((entries) => { 11 | if (!Array.isArray(entries)) { 12 | return; 13 | } 14 | const { contentRect } = entries[0]; 15 | setHeight(contentRect.height); 16 | }); 17 | 18 | if (ref.current) { 19 | RO.observe(ref.current); 20 | } 21 | 22 | return () => { 23 | if (RO) { 24 | RO.disconnect(); 25 | RO = null; 26 | } 27 | }; 28 | }, [ref, selectedData]); 29 | 30 | return [height]; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/VerticalClusterList.scss: -------------------------------------------------------------------------------- 1 | @import "styles/app"; 2 | 3 | .vertical-cluster-list { 4 | display: flex; 5 | flex: 1; 6 | flex-direction: column; 7 | 8 | &__header { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | column-gap: 1.25rem; 13 | padding: 0 0.625rem 1.25rem; 14 | width: 100%; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/VerticalClusterList.tsx: -------------------------------------------------------------------------------- 1 | import { useDataStore } from "store"; 2 | import { FilteredAuthors } from "components/FilteredAuthors"; 3 | import { SelectedClusterGroup } from "components/SelectedClusterGroup"; 4 | 5 | import { Summary } from "./Summary"; 6 | 7 | import "./VerticalClusterList.scss"; 8 | 9 | const VerticalClusterList = () => { 10 | const selectedData = useDataStore((state) => state.selectedData); 11 | 12 | return ( 13 |
14 | {selectedData.length > 0 && ( 15 |
16 | 17 | 18 |
19 | )} 20 | 21 |
22 | ); 23 | }; 24 | 25 | export default VerticalClusterList; 26 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/VerticalClusterList.util.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fakeFirstClusterNode, 3 | fakeSecondClusterNode, 4 | fakePrev, 5 | } from "../../../tests/fakeAsset"; 6 | 7 | import { selectedDataUpdater } from "./VerticalClusterList.util"; 8 | 9 | const EmptyArrayAddSelectedDataUpdater = selectedDataUpdater( 10 | fakeFirstClusterNode, 11 | 0, 12 | ); 13 | const PrevAddSelectedDataUpdater = selectedDataUpdater(fakeFirstClusterNode, 5); 14 | const RemoveSelectedDataUpdater = selectedDataUpdater(fakeSecondClusterNode, 1); 15 | 16 | const EmptyArrayAddSelectedresult = EmptyArrayAddSelectedDataUpdater([]); 17 | const PrevAddSelectedresult = PrevAddSelectedDataUpdater(fakePrev); 18 | const RemoveSelectedresult = RemoveSelectedDataUpdater(fakePrev); 19 | 20 | test("EmptyArrayAddSelectedDataUpdater", () => { 21 | expect(EmptyArrayAddSelectedDataUpdater).not.toBeUndefined(); 22 | expect(typeof EmptyArrayAddSelectedDataUpdater).toBe("function"); 23 | expect(EmptyArrayAddSelectedresult).not.toBeUndefined(); 24 | expect(EmptyArrayAddSelectedresult.length).toBe(1); 25 | }); 26 | 27 | test("PrevAddSelectedDataUpdater", () => { 28 | expect(PrevAddSelectedDataUpdater).not.toBeUndefined(); 29 | expect(typeof PrevAddSelectedDataUpdater).toBe("function"); 30 | expect(PrevAddSelectedresult).not.toBeUndefined(); 31 | expect(PrevAddSelectedresult.length).toBe(3); 32 | }); 33 | 34 | test("RemoveSelectedDataUpdater", () => { 35 | expect(RemoveSelectedDataUpdater).not.toBeUndefined(); 36 | expect(typeof RemoveSelectedDataUpdater).toBe("function"); 37 | expect(RemoveSelectedresult).not.toBeUndefined(); 38 | expect(RemoveSelectedresult.length).toBe(1); 39 | }); 40 | 41 | test.each(EmptyArrayAddSelectedresult)("EmptyArrayAddSelected", (Cluster) => { 42 | expect(Cluster).not.toBeUndefined(); 43 | expect(Cluster.nodeTypeName).toBe("CLUSTER"); 44 | }); 45 | 46 | test.each(PrevAddSelectedresult)("prevAddSelected", (Cluster) => { 47 | expect(Cluster).not.toBeUndefined(); 48 | expect(Cluster.nodeTypeName).toBe("CLUSTER"); 49 | }); 50 | 51 | test.each(RemoveSelectedresult)("RemoveSelectedSelected", (Cluster) => { 52 | expect(Cluster).not.toBeUndefined(); 53 | expect(Cluster.nodeTypeName).toBe("CLUSTER"); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/VerticalClusterList.util.ts: -------------------------------------------------------------------------------- 1 | import type { ClusterNode } from "types"; 2 | 3 | export const selectedDataUpdater = (selected: ClusterNode, clusterId: number) => (prev: ClusterNode[]) => { 4 | if (prev.length === 0) return [selected]; 5 | const prevClusterIds = prev.map((prevSelected) => prevSelected.commitNodeList[0].clusterId); 6 | const clusterInPrev = prevClusterIds.includes(clusterId); 7 | if (clusterInPrev) { 8 | return prev.filter((prevSelected) => prevSelected.commitNodeList[0].clusterId !== clusterId); 9 | } 10 | return [...prev, selected]; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/view/src/components/VerticalClusterList/index.ts: -------------------------------------------------------------------------------- 1 | export { default as VerticalClusterList } from "./VerticalClusterList"; 2 | -------------------------------------------------------------------------------- /packages/view/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Detail"; 2 | export * from "./Statistics"; 3 | export * from "./TemporalFilter"; 4 | export * from "./ThemeSelector"; 5 | export * from "./VerticalClusterList"; 6 | export * from "./BranchSelector"; 7 | export * from "./FilteredAuthors"; 8 | -------------------------------------------------------------------------------- /packages/view/src/constants/constants.tsx: -------------------------------------------------------------------------------- 1 | export const GITHUB_URL = "https://github.com"; 2 | export const GRAVATA_URL = "https://www.gravatar.com/avatar"; 3 | export const PRIMARY_COLOR_VARIABLE_NAME = "--color-primary"; 4 | export const NODE_TYPES = ["COMMIT", "CLUSTER"] as const; 5 | -------------------------------------------------------------------------------- /packages/view/src/fake-assets/branch-list.json: -------------------------------------------------------------------------------- 1 | { 2 | "branchList": ["main", "develop", "release", "origin/HEAD", "feature/SelectedClusterGroup"], 3 | "head": "main" 4 | } 5 | -------------------------------------------------------------------------------- /packages/view/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useAnalayzedData"; 2 | -------------------------------------------------------------------------------- /packages/view/src/hooks/useAnalayzedData.ts: -------------------------------------------------------------------------------- 1 | import { useShallow } from "zustand/react/shallow"; 2 | 3 | import { useDataStore, useLoadingStore } from "store"; 4 | import type { ClusterNode } from "types"; 5 | 6 | export const useAnalayzedData = () => { 7 | const [setData, setFilteredData, setSelectedData] = useDataStore( 8 | useShallow((state) => [state.setData, state.setFilteredData, state.setSelectedData]) 9 | ); 10 | const { setLoading } = useLoadingStore(); 11 | 12 | const handleChangeAnalyzedData = (analyzedData: ClusterNode[]) => { 13 | setData(analyzedData); 14 | setFilteredData([...analyzedData.reverse()]); 15 | setSelectedData([]); 16 | setLoading(false); 17 | }; 18 | 19 | return { handleChangeAnalyzedData }; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/view/src/ide/FakeIDEAdapter.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "tsyringe"; 2 | 3 | import type { IDEMessage, IDEMessageEvent } from "types"; 4 | import type { IDESentEvents } from "types/IDESentEvents"; 5 | 6 | import fakeData from "../fake-assets/cluster-nodes.json"; 7 | import fakeBranchList from "../fake-assets/branch-list.json"; 8 | 9 | import type IDEPort from "./IDEPort"; 10 | 11 | @injectable() 12 | export default class FakeIDEAdapter implements IDEPort { 13 | public addIDESentEventListener(events: IDESentEvents) { 14 | const onReceiveMessage = (e: IDEMessageEvent): void => { 15 | const responseMessage = e.data; 16 | const { command, payload } = responseMessage; 17 | 18 | switch (command) { 19 | case "fetchAnalyzedData": 20 | return events.handleChangeAnalyzedData(payload ? JSON.parse(payload) : undefined); 21 | case "fetchBranchList": 22 | return events.handleChangeBranchList(payload ? JSON.parse(payload) : undefined); 23 | case "fetchGithubInfo": 24 | return events.handleGithubInfo(payload ? JSON.parse(payload) : undefined); 25 | default: 26 | console.log("Unknown Message"); 27 | } 28 | }; 29 | 30 | window.addEventListener("message", onReceiveMessage); 31 | } 32 | 33 | public sendRefreshDataMessage(payload?: string) { 34 | const message: IDEMessage = { 35 | command: "refresh", 36 | payload, 37 | }; 38 | this.sendMessageToMe(message); 39 | } 40 | 41 | public sendFetchAnalyzedDataMessage(payload?: string) { 42 | const message: IDEMessage = { 43 | command: "fetchAnalyzedData", 44 | payload, 45 | }; 46 | setTimeout(() => { 47 | // loading time을 시뮬레이션 하기 위함 48 | this.sendMessageToMe(message); 49 | }, 1500); 50 | } 51 | 52 | public sendFetchBranchListMessage() { 53 | const message: IDEMessage = { 54 | command: "fetchBranchList", 55 | }; 56 | this.sendMessageToMe(message); 57 | } 58 | 59 | public sendFetchGithubInfo() { 60 | const message: IDEMessage = { 61 | command: "fetchGithubInfo", 62 | }; 63 | this.sendMessageToMe(message); 64 | } 65 | 66 | public sendUpdateThemeMessage(theme: string) { 67 | sessionStorage.setItem("THEME", theme); 68 | const message: IDEMessage = { 69 | command: "updateTheme", 70 | }; 71 | this.sendMessageToMe(message); 72 | } 73 | 74 | private convertMessage(message: IDEMessage) { 75 | const { command } = message; 76 | 77 | switch (command) { 78 | case "fetchAnalyzedData": 79 | return { 80 | command, 81 | payload: JSON.stringify(fakeData), 82 | }; 83 | case "fetchBranchList": 84 | return { 85 | command, 86 | payload: JSON.stringify(fakeBranchList), 87 | }; 88 | case "updateTheme": 89 | return { 90 | command, 91 | payload: sessionStorage.getItem("CUSTOM_THEME") as string, 92 | }; 93 | default: 94 | return { 95 | command, 96 | }; 97 | } 98 | } 99 | 100 | private sendMessageToMe(message: IDEMessage) { 101 | window.postMessage(this.convertMessage(message)); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /packages/view/src/ide/IDEPort.ts: -------------------------------------------------------------------------------- 1 | import type { IDESentEvents } from "types/IDESentEvents"; 2 | 3 | export type IDEMessage = { 4 | command: string; 5 | uri: string; 6 | }; 7 | 8 | export default interface IDEPort { 9 | addIDESentEventListener: (apiCallbacks: IDESentEvents) => void; 10 | sendRefreshDataMessage: (payload?: string) => void; 11 | sendFetchAnalyzedDataMessage: (payload?: string) => void; 12 | sendFetchBranchListMessage: () => void; 13 | sendFetchGithubInfo: () => void; 14 | sendUpdateThemeMessage: (theme: string) => void; 15 | } 16 | -------------------------------------------------------------------------------- /packages/view/src/ide/VSCodeAPIWrapper.ts: -------------------------------------------------------------------------------- 1 | // referred: https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/webview-ui/src/utilities/vscode.ts 2 | /* eslint-disable no-undef */ 3 | import type { WebviewApi } from "vscode-webview"; 4 | 5 | /** 6 | * A utility wrapper around the acquireVsCodeApi() function, which enables 7 | * message passing and state management between the webview and extension 8 | * contexts. 9 | * 10 | * This utility also enables webview code to be run in a web browser-based 11 | * dev server by using native web browser features that mock the functionality 12 | * enabled by acquireVsCodeApi. 13 | */ 14 | export default class VSCodeAPIWrapper { 15 | private readonly vsCodeApi: WebviewApi | undefined; 16 | 17 | constructor() { 18 | // Check if the acquireVsCodeApi function exists in the current development 19 | // context (i.e. VS Code development window or web browser) 20 | if (typeof acquireVsCodeApi === "function") { 21 | this.vsCodeApi = acquireVsCodeApi(); 22 | } 23 | } 24 | 25 | /** 26 | * Post a message (i.e. send arbitrary data) to the owner of the webview. 27 | * 28 | * @remarks When running webview code inside a web browser, postMessage will instead 29 | * log the given message to the console. 30 | * 31 | * @param message Abitrary data (must be JSON serializable) to send to the extension context. 32 | */ 33 | public postMessage(message: unknown) { 34 | if (this.vsCodeApi) { 35 | this.vsCodeApi.postMessage(message); 36 | } else { 37 | console.log(message); 38 | } 39 | } 40 | 41 | /** 42 | * Get the persistent state stored for this webview. 43 | * 44 | * @remarks When running webview source code inside a web browser, getState will retrieve state 45 | * from local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). 46 | * 47 | * @return The current state or `undefined` if no state has been set. 48 | */ 49 | public getState(): unknown | undefined { 50 | if (this.vsCodeApi) { 51 | return this.vsCodeApi.getState(); 52 | } 53 | const state = localStorage.getItem("vscodeState"); 54 | return state ? JSON.parse(state) : undefined; 55 | } 56 | 57 | /** 58 | * Set the persistent state stored for this webview. 59 | * 60 | * @remarks When running webview source code inside a web browser, setState will set the given 61 | * state using local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). 62 | * 63 | * @param newState New persisted state. This must be a JSON serializable object. Can be retrieved 64 | * using {@link getState}. 65 | * 66 | * @return The new state. 67 | */ 68 | public setState(newState: T): T { 69 | if (this.vsCodeApi) { 70 | return this.vsCodeApi.setState(newState); 71 | } 72 | 73 | localStorage.setItem("vscodeState", JSON.stringify(newState)); 74 | return newState; 75 | } 76 | } 77 | 78 | // Exports class singleton to prevent multiple invocations of acquireVsCodeApi. 79 | export const vscode = new VSCodeAPIWrapper(); 80 | -------------------------------------------------------------------------------- /packages/view/src/ide/VSCodeIDEAdapter.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "tsyringe"; 2 | 3 | import type { IDEMessage, IDEMessageEvent } from "types"; 4 | import type { IDESentEvents } from "types/IDESentEvents"; 5 | 6 | import type IDEPort from "./IDEPort"; 7 | import { vscode } from "./VSCodeAPIWrapper"; 8 | 9 | @injectable() 10 | export default class VSCodeIDEAdapter implements IDEPort { 11 | public addIDESentEventListener(events: IDESentEvents) { 12 | const onReceiveMessage = (e: IDEMessageEvent): void => { 13 | const responseMessage = e.data; 14 | const { command, payload } = responseMessage; 15 | const payloadData = payload ? JSON.parse(payload) : undefined; 16 | 17 | switch (command) { 18 | case "fetchAnalyzedData": 19 | return events.handleChangeAnalyzedData(payloadData); 20 | case "fetchBranchList": 21 | return events.handleChangeBranchList(payloadData); 22 | case "fetchGithubInfo": 23 | return events.handleGithubInfo(payloadData); 24 | default: 25 | console.log("Unknown Message"); 26 | } 27 | }; 28 | window.addEventListener("message", onReceiveMessage); 29 | } 30 | 31 | public sendRefreshDataMessage(baseBranch?: string) { 32 | const message: IDEMessage = { 33 | command: "refresh", 34 | payload: JSON.stringify(baseBranch), 35 | }; 36 | this.sendMessageToIDE(message); 37 | } 38 | 39 | public sendFetchAnalyzedDataMessage(baseBranch?: string) { 40 | const message: IDEMessage = { 41 | command: "fetchAnalyzedData", 42 | payload: JSON.stringify(baseBranch), 43 | }; 44 | this.sendMessageToIDE(message); 45 | } 46 | 47 | public sendFetchBranchListMessage() { 48 | const message: IDEMessage = { 49 | command: "fetchBranchList", 50 | }; 51 | this.sendMessageToIDE(message); 52 | } 53 | 54 | public sendFetchGithubInfo() { 55 | const message: IDEMessage = { 56 | command: "fetchGithubInfo", 57 | }; 58 | this.sendMessageToIDE(message); 59 | } 60 | 61 | public sendUpdateThemeMessage(theme: string) { 62 | const message: IDEMessage = { 63 | command: "updateTheme", 64 | payload: JSON.stringify({ theme }), 65 | }; 66 | this.sendMessageToIDE(message); 67 | } 68 | 69 | private sendMessageToIDE(message: IDEMessage) { 70 | vscode.postMessage(message); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/view/src/index.common.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | 3 | import "./App.scss"; 4 | 5 | import App from "./App"; 6 | 7 | export const initRender = () => { 8 | const rootContainer = document.getElementById("root") as HTMLElement; 9 | 10 | // TODO - StrictMode disabled temporarily to review performance of visualization. 11 | ReactDOM.createRoot(rootContainer).render( 12 | // 13 | 14 | // 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/view/src/index.prod.tsx: -------------------------------------------------------------------------------- 1 | // index.prod.tsx is for production build. 2 | import "reflect-metadata"; 3 | import { container } from "tsyringe"; 4 | 5 | import VSCodeIDEAdapter from "./ide/VSCodeIDEAdapter"; 6 | import type IDEPort from "./ide/IDEPort"; 7 | import { initRender } from "./index.common"; 8 | 9 | container.register("IDEAdapter", { useClass: VSCodeIDEAdapter }); 10 | 11 | initRender(); 12 | -------------------------------------------------------------------------------- /packages/view/src/index.tsx: -------------------------------------------------------------------------------- 1 | // THIS index.tsx is only for development (CRA npm start) 2 | 3 | import "reflect-metadata"; 4 | import { container } from "tsyringe"; 5 | 6 | import FakeIDEAdapter from "./ide/FakeIDEAdapter"; 7 | import { initRender } from "./index.common"; 8 | import type IDEPort from "./ide/IDEPort"; 9 | 10 | container.register("IDEAdapter", { useClass: FakeIDEAdapter }); 11 | 12 | initRender(); 13 | -------------------------------------------------------------------------------- /packages/view/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from "tsyringe"; 2 | 3 | import type IDEPort from "ide/IDEPort"; 4 | 5 | export const sendUpdateThemeCommand = (theme: string) => { 6 | const ideAdapter = container.resolve("IDEAdapter"); 7 | ideAdapter.sendUpdateThemeMessage(theme); 8 | }; 9 | 10 | export const sendFetchAnalyzedDataCommand = (selectedBranch?: string) => { 11 | const ideAdapter = container.resolve("IDEAdapter"); 12 | ideAdapter.sendFetchAnalyzedDataMessage(selectedBranch); 13 | }; 14 | 15 | export const sendRefreshDataCommand = (selectedBranch?: string) => { 16 | const ideAdapter = container.resolve("IDEAdapter"); 17 | ideAdapter.sendRefreshDataMessage(selectedBranch); 18 | ideAdapter.sendFetchBranchListMessage(); 19 | }; 20 | 21 | export const sendFetchBranchListCommand = () => { 22 | const ideAdapter = container.resolve("IDEAdapter"); 23 | ideAdapter.sendFetchBranchListMessage(); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/view/src/setupTest.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /packages/view/src/store/branch.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | export type BranchListPayload = { 4 | branchList: string[]; 5 | head: string | null; 6 | }; 7 | 8 | type BranchStore = { 9 | branchList: string[]; 10 | selectedBranch: string; 11 | setBranchList: (branches: string[]) => void; 12 | setSelectedBranch: (branch: string) => void; 13 | handleChangeBranchList: (branches: BranchListPayload) => void; 14 | }; 15 | 16 | export const useBranchStore = create((set) => ({ 17 | branchList: [], 18 | selectedBranch: "", 19 | setBranchList: (branches) => set({ branchList: branches }), 20 | setSelectedBranch: (branch) => set({ selectedBranch: branch }), 21 | handleChangeBranchList: (branches) => 22 | set((state) => ({ 23 | branchList: branches.branchList, 24 | selectedBranch: !state.selectedBranch && branches.head ? branches.head : state.selectedBranch, 25 | })), 26 | })); 27 | -------------------------------------------------------------------------------- /packages/view/src/store/data.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import type { Dispatch, SetStateAction } from "react"; 3 | 4 | import type { ClusterNode } from "types"; 5 | 6 | type DataState = { 7 | data: ClusterNode[]; 8 | filteredData: ClusterNode[]; 9 | selectedData: ClusterNode[]; 10 | setData: (data: ClusterNode[]) => void; 11 | setFilteredData: (filteredData: ClusterNode[]) => void; 12 | setSelectedData: Dispatch>; 13 | }; 14 | 15 | export const useDataStore = create((set) => ({ 16 | data: [], 17 | filteredData: [], 18 | selectedData: [], 19 | setData: (data) => set({ data }), 20 | setFilteredData: (filteredData) => set({ filteredData }), 21 | setSelectedData: (selectedData) => 22 | set((state) => ({ 23 | selectedData: typeof selectedData === "function" ? selectedData(state.selectedData) : selectedData, 24 | })), 25 | })); 26 | -------------------------------------------------------------------------------- /packages/view/src/store/filteredRange.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | export type DateFilterRange = 4 | | { 5 | fromDate: string; 6 | toDate: string; 7 | } 8 | | undefined; 9 | 10 | type FilteredRangeStore = { 11 | filteredRange: DateFilterRange; 12 | setFilteredRange: (filteredRange: DateFilterRange) => void; 13 | }; 14 | 15 | export const useFilteredRangeStore = create((set) => ({ 16 | filteredRange: undefined, 17 | setFilteredRange: (filteredRange) => set({ filteredRange }), 18 | })); 19 | -------------------------------------------------------------------------------- /packages/view/src/store/githubInfo.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | export type githubInfo = { 4 | owner: string; 5 | repo: string; 6 | }; 7 | 8 | export const useGithubInfo = create< 9 | githubInfo & { 10 | handleGithubInfo: (repoInfo: githubInfo) => void; 11 | } 12 | >((set) => ({ 13 | owner: "githru", 14 | repo: "githru-vscode-ext", 15 | handleGithubInfo: (repoInfo: githubInfo) => { 16 | if (repoInfo) { 17 | set({ owner: repoInfo.owner, repo: repoInfo.repo }); 18 | } 19 | }, 20 | })); 21 | -------------------------------------------------------------------------------- /packages/view/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./loading"; 2 | export * from "./filteredRange"; 3 | export * from "./branch"; 4 | export * from "./githubInfo"; 5 | export * from "./data"; 6 | -------------------------------------------------------------------------------- /packages/view/src/store/loading.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type LoadingState = { 4 | loading: boolean; 5 | setLoading: (loading: boolean) => void; 6 | }; 7 | 8 | export const useLoadingStore = create((set) => ({ 9 | loading: false, 10 | setLoading: (loading) => set({ loading }), 11 | })); 12 | -------------------------------------------------------------------------------- /packages/view/src/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | $color-white: #f7f7f7; 2 | $color-black: #010818; 3 | $color-light-gray: #b4bac6; 4 | $color-medium-gray: #757880; 5 | $color-dark-gray: #3c4048; 6 | $color-background: #222324; 7 | 8 | html[theme="githru"] { 9 | --color-primary: #e06091; 10 | --color-secondary: #8840bb; 11 | --color-tertiary: #ffd08a; 12 | --color-success: #07bebe; 13 | --color-failed: #ee2479; 14 | } 15 | 16 | html[theme="hacker-blue"] { 17 | --color-primary: #456cf7; 18 | --color-secondary: #3f4c73; 19 | --color-tertiary: #6c60f0; 20 | --color-success: #1fc7a9; 21 | --color-failed: #ee2479; 22 | } 23 | 24 | html[theme="aqua"] { 25 | --color-primary: #51decd; 26 | --color-secondary: #0687a3; 27 | --color-tertiary: #a7ffff; 28 | --color-success: #008cde; 29 | --color-failed: #ee2479; 30 | } 31 | 32 | html[theme="cotton-candy"] { 33 | --color-primary: #ffcccb; 34 | --color-secondary: #feffd1; 35 | --color-tertiary: #a39aeb; 36 | --color-success: #7ad5c4; 37 | --color-failed: #ff8bbc; 38 | } 39 | 40 | html[theme="mono"] { 41 | --color-primary: #68788f; 42 | --color-secondary: #3a4776; 43 | --color-tertiary: #9aaed1; 44 | --color-success: #6cafaf; 45 | --color-failed: #aa4b72; 46 | } 47 | -------------------------------------------------------------------------------- /packages/view/src/styles/_font.scss: -------------------------------------------------------------------------------- 1 | $font-size-title: 1rem; 2 | $font-size-body: 0.875rem; 3 | $font-size-caption: 0.75rem; 4 | 5 | $font-weight-light: 300; 6 | $font-weight-regular: 400; 7 | $font-weight-semibold: 600; 8 | $font-weight-extrabold: 800; 9 | 10 | $line-height-base: 1.62; 11 | $line-height-title: 1.15; 12 | $line-height-quote: 1.3; 13 | $line-height-button: 1; 14 | -------------------------------------------------------------------------------- /packages/view/src/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | ul { 7 | list-style: none; 8 | } 9 | html { 10 | box-sizing: border-box; 11 | } 12 | *, 13 | *::before, 14 | *::after { 15 | box-sizing: border-box; 16 | } 17 | img, 18 | video { 19 | height: auto; 20 | max-width: 100%; 21 | } 22 | iframe { 23 | border: 0; 24 | } 25 | table { 26 | border-collapse: collapse; 27 | border-spacing: 0; 28 | } 29 | td, 30 | th { 31 | padding: 0; 32 | } 33 | 34 | body { 35 | &::-webkit-scrollbar, 36 | &::-webkit-scrollbar:horizontal { 37 | display: block; 38 | } 39 | *::-webkit-scrollbar { 40 | width: 0.5rem; 41 | } 42 | *::-webkit-scrollbar-thumb { 43 | background-color: $color-dark-gray; 44 | border: none; 45 | outline: none; 46 | border-radius: 0.625rem; 47 | 48 | &:hover { 49 | background-color: $color-medium-gray; 50 | } 51 | } 52 | *::-webkit-scrollbar-track { 53 | background-color: transparent; 54 | border-radius: 0.625rem; 55 | } 56 | 57 | *:hover::-webkit-scrollbar, 58 | *:hover::-webkit-scrollbar:horizontal { 59 | display: block; 60 | } 61 | 62 | *::-webkit-scrollbar:horizontal { 63 | display: none; 64 | height: 0.375rem; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/view/src/styles/_utils.scss: -------------------------------------------------------------------------------- 1 | //Padding mixin 2 | @mixin padding($top, $right, $bottom, $left) { 3 | padding-top: $top; 4 | padding-right: $right; 5 | padding-bottom: $bottom; 6 | padding-left: $left; 7 | } 8 | //Margin mixin 9 | @mixin margin($top, $right, $bottom, $left) { 10 | margin-top: $top; 11 | margin-right: $right; 12 | margin-bottom: $bottom; 13 | margin-left: $left; 14 | } 15 | -------------------------------------------------------------------------------- /packages/view/src/styles/app.scss: -------------------------------------------------------------------------------- 1 | @import "colors"; 2 | @import "font"; 3 | @import "reset"; 4 | @import "utils"; 5 | -------------------------------------------------------------------------------- /packages/view/src/types/Author.ts: -------------------------------------------------------------------------------- 1 | export type AuthorInfo = { 2 | name: string; 3 | src: string; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/view/src/types/ClusterGraphProps.ts: -------------------------------------------------------------------------------- 1 | import type { ClusterNode } from "./Nodes"; 2 | 3 | export interface ClusterGraphProps { 4 | data: ClusterNode[]; 5 | clusterSizes: number[]; 6 | } 7 | -------------------------------------------------------------------------------- /packages/view/src/types/Commit.ts: -------------------------------------------------------------------------------- 1 | import type { DiffStatistics } from "./DiffStatistics"; 2 | import type { GitHubUser } from "./GitHubUser"; 3 | 4 | export type Commit = { 5 | id: string; 6 | parentIds: string[]; 7 | author: GitHubUser; 8 | committer: GitHubUser; 9 | authorDate: string; 10 | commitDate: string; 11 | diffStatistics: DiffStatistics; 12 | message: string; 13 | tags: string[]; 14 | releaseTags: string[]; 15 | // fill necessary properties... 16 | }; 17 | -------------------------------------------------------------------------------- /packages/view/src/types/CommitMessageType.ts: -------------------------------------------------------------------------------- 1 | // This file here is all identical to 'types/CommitMessageType.ts' in 'analysis-engine'. 2 | // Since the commit is originally imported from b, this file can be changed when b changes. 3 | // You can create types derived from this code. 4 | // However, design so that errors do not occur even if this code is changed. 5 | export const CommitMessageTypeList = [ 6 | "build", 7 | "chore", 8 | "ci", 9 | "docs", 10 | "feat", 11 | "fix", 12 | "pert", 13 | "refactor", 14 | "revert", 15 | "style", 16 | "test", 17 | "", // default - 명시된 타입이 없거나 commitLint rule을 따르지 않은 경우 18 | ]; 19 | 20 | const COMMIT_MESSAGE_TYPE = [...CommitMessageTypeList] as const; 21 | 22 | export type CommitMessageType = (typeof COMMIT_MESSAGE_TYPE)[number]; 23 | -------------------------------------------------------------------------------- /packages/view/src/types/DiffStatistics.ts: -------------------------------------------------------------------------------- 1 | export type DiffStatistics = { 2 | changedFileCount: number; 3 | insertions: number; 4 | deletions: number; 5 | files: { 6 | [id: string]: { 7 | insertions: number; 8 | deletions: number; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/view/src/types/GitHubUser.ts: -------------------------------------------------------------------------------- 1 | export type GitHubUser = { 2 | id: string; 3 | names: string[]; 4 | emails: string[]; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/view/src/types/IDEMessage.ts: -------------------------------------------------------------------------------- 1 | export type IDEMessage = { 2 | command: IDEMessageCommandNames; 3 | payload?: string; 4 | }; 5 | 6 | export interface IDEMessageEvent extends MessageEvent { 7 | data: IDEMessage; 8 | } 9 | 10 | export type IDEMessageCommandNames = 11 | | "refresh" 12 | | "fetchAnalyzedData" 13 | | "fetchBranchList" 14 | | "fetchCurrentBranch" 15 | | "fetchGithubInfo" 16 | | "updateTheme"; -------------------------------------------------------------------------------- /packages/view/src/types/IDESentEvents.ts: -------------------------------------------------------------------------------- 1 | import type { BranchListPayload, githubInfo } from "store"; 2 | import type { ClusterNode } from "types"; 3 | 4 | // triggered by ide response 5 | export type IDESentEvents = { 6 | handleChangeAnalyzedData: (analyzedData: ClusterNode[]) => void; 7 | handleChangeBranchList: (branches: BranchListPayload) => void; 8 | handleGithubInfo: (repoInfo: githubInfo) => void; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/view/src/types/Nodes.ts: -------------------------------------------------------------------------------- 1 | import type { NODE_TYPES } from "constants/constants"; 2 | 3 | import type { Commit } from "./Commit"; 4 | 5 | export type NodeTypeName = (typeof NODE_TYPES)[number]; 6 | 7 | export type NodeBase = { 8 | nodeTypeName: NodeTypeName; 9 | }; 10 | 11 | // Node = Commit + analyzed Data as node 12 | export type CommitNode = NodeBase & { 13 | commit: Commit; 14 | seq: number; 15 | clusterId: number; // 동일한 Cluster 내부 commit 참조 id 16 | }; 17 | 18 | export type ClusterNode = NodeBase & { 19 | commitNodeList: CommitNode[]; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/view/src/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | acquireVsCodeApi: () => unknown; 3 | githruNodesData: unknown; 4 | githruBranchesData: unknown; 5 | isProduction: boolean; 6 | theme: string; 7 | } 8 | 9 | declare module "*.svg" { 10 | import type { ReactElement, SVGProps } from "react"; 11 | 12 | const content: (props: SVGProps) => ReactElement; 13 | export default content; 14 | } 15 | -------------------------------------------------------------------------------- /packages/view/src/types/global.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch } from "react"; 2 | import type React from "react"; 3 | 4 | import type { ClusterNode } from "./Nodes"; 5 | 6 | export type GlobalProps = { 7 | data: ClusterNode[]; 8 | setData: Dispatch>; 9 | }; 10 | export type SelectedDataProps = ClusterNode[]; 11 | -------------------------------------------------------------------------------- /packages/view/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./global"; 2 | export * from "./Nodes"; 3 | export * from "./IDESentEvents"; 4 | export * from "./IDEMessage"; 5 | export * from "./Author"; 6 | -------------------------------------------------------------------------------- /packages/view/src/utils/author.ts: -------------------------------------------------------------------------------- 1 | import md5 from "md5"; 2 | 3 | import type { AuthorInfo } from "types"; 4 | 5 | import { GITHUB_URL, GRAVATA_URL } from "../constants/constants"; 6 | 7 | export function getAuthorProfileImgSrc(authorName: string): Promise { 8 | return new Promise((resolve) => { 9 | const img = new Image(); 10 | 11 | img.onload = () => { 12 | const { src } = img; 13 | const srcInfo: AuthorInfo = { 14 | name: authorName, 15 | src, 16 | }; 17 | resolve(srcInfo); 18 | }; 19 | 20 | img.onerror = () => { 21 | img.src = `${GRAVATA_URL}/${md5(authorName)}}?d=identicon&f=y`; 22 | }; 23 | 24 | img.src = `${GITHUB_URL}/${authorName}.png?size=30`; 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /packages/view/src/utils/debounce.test.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from "./debounce"; 2 | 3 | jest.useFakeTimers(); 4 | 5 | describe("debounce", () => { 6 | it("check debounce", () => { 7 | const mockFn = jest.fn(); 8 | const debouncedFunc = debounce(mockFn, 1000); 9 | 10 | debouncedFunc("test1"); 11 | debouncedFunc("test2"); 12 | 13 | expect(mockFn).not.toBeCalled(); 14 | 15 | jest.advanceTimersByTime(999); 16 | expect(mockFn).not.toBeCalled(); 17 | 18 | jest.advanceTimersByTime(1); 19 | expect(mockFn).toHaveBeenCalledTimes(1); 20 | expect(mockFn).toHaveBeenCalledWith("test2"); 21 | }); 22 | 23 | it("check default delay", () => { 24 | const mockFn = jest.fn(); 25 | const debouncedFunc = debounce(mockFn); 26 | 27 | debouncedFunc("test"); 28 | 29 | jest.advanceTimersByTime(999); 30 | expect(mockFn).not.toBeCalled(); 31 | 32 | jest.advanceTimersByTime(1); 33 | expect(mockFn).toHaveBeenCalledTimes(1); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/view/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = (fn: Function, delay = 1000) => { 2 | let timeout: ReturnType; 3 | return function (value: string) { 4 | if (timeout) clearTimeout(timeout as ReturnType); 5 | timeout = setTimeout(() => { 6 | fn(value); 7 | }, delay); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/view/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./debounce"; 2 | export * from "./throttle"; 3 | export * from "./pxToRem"; 4 | -------------------------------------------------------------------------------- /packages/view/src/utils/pxToRem.ts: -------------------------------------------------------------------------------- 1 | export const pxToRem = (px: number) => `${px / 16}rem`; 2 | -------------------------------------------------------------------------------- /packages/view/src/utils/throttle.test.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from "./throttle"; 2 | 3 | jest.useFakeTimers(); 4 | 5 | describe("throttle", () => { 6 | it("check throttle", () => { 7 | const mockFn = jest.fn(); 8 | const throttledFunc = throttle(mockFn, 1000); 9 | 10 | throttledFunc(); 11 | expect(mockFn).toHaveBeenCalledTimes(1); 12 | 13 | throttledFunc(); 14 | throttledFunc(); 15 | expect(mockFn).toHaveBeenCalledTimes(1); 16 | 17 | jest.advanceTimersByTime(999); 18 | throttledFunc(); 19 | expect(mockFn).toHaveBeenCalledTimes(1); 20 | 21 | jest.advanceTimersByTime(1); 22 | throttledFunc(); 23 | expect(mockFn).toHaveBeenCalledTimes(2); 24 | }); 25 | 26 | it("check default delay", () => { 27 | const mockFn = jest.fn(); 28 | const throttledFunc = throttle(mockFn); 29 | 30 | throttledFunc(); 31 | expect(mockFn).toHaveBeenCalledTimes(1); 32 | 33 | throttledFunc(); 34 | jest.advanceTimersByTime(999); 35 | expect(mockFn).toHaveBeenCalledTimes(1); 36 | 37 | jest.advanceTimersByTime(1); 38 | throttledFunc(); 39 | expect(mockFn).toHaveBeenCalledTimes(2); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/view/src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | export const throttle = (cb: Function, delay = 1000) => { 2 | let lastTime = 0; 3 | return function () { 4 | const now = Date.now(); 5 | if (now - lastTime >= delay) { 6 | cb(); 7 | lastTime = now; 8 | } 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/view/tests/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.describe("home", () => { 4 | const CLICK_INDEX = 0; 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto("/"); 7 | }); 8 | 9 | test("has title", async ({ page }) => { 10 | await expect(page).toHaveTitle(/Githru/); 11 | }); 12 | 13 | test("opens detail container on cluster click", async ({ page }) => { 14 | await page.waitForSelector(".cluster-graph__container", { state: "attached" }); 15 | const childContainers = await page.$$(".cluster-graph__container"); 16 | 17 | if (childContainers.length > CLICK_INDEX) { 18 | await childContainers[CLICK_INDEX].scrollIntoViewIfNeeded(); 19 | await childContainers[CLICK_INDEX].click(); 20 | } else { 21 | throw new Error("No child containers found"); 22 | } 23 | 24 | // waiting for changing 25 | await page.waitForTimeout(10000); 26 | 27 | const detailContainer = await page.waitForSelector(".detail"); 28 | expect(detailContainer).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/view/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const prod = require("./webpack.prod.config"); 2 | 3 | const webviewAppRoot = "./src/index.tsx"; 4 | const { merge } = require("webpack-merge"); 5 | 6 | /** @type {import('webpack').Configuration} */ 7 | const config = merge(prod, { 8 | mode: "development", 9 | entry: { 10 | webviewApp: webviewAppRoot, 11 | }, 12 | devServer: { 13 | port: 3000, 14 | }, 15 | }); 16 | module.exports = config; 17 | -------------------------------------------------------------------------------- /packages/view/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const webpack = require("webpack"); 4 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 5 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 6 | const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); 7 | const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 8 | 9 | /** @type {import('webpack').Configuration} */ 10 | const config = { 11 | mode: "production", 12 | entry: { 13 | webviewApp: "./src/index.prod.tsx", 14 | }, 15 | output: { 16 | path: path.resolve(__dirname, "dist"), 17 | filename: "[name].js", 18 | }, 19 | devtool: "nosources-source-map", 20 | externals: { 21 | vscode: "commonjs vscode", 22 | }, 23 | resolve: { 24 | extensions: [".js", ".ts", ".tsx", ".json"], 25 | alias: { 26 | assets: path.resolve(__dirname, "src/assets/"), 27 | components: path.resolve(__dirname, "src/components/"), 28 | utils: path.resolve(__dirname, "src/utils/"), 29 | services: path.resolve(__dirname, "src/services/"), 30 | styles: path.resolve(__dirname, "src/styles/"), 31 | types: path.resolve(__dirname, "src/types/"), 32 | hooks: path.resolve(__dirname, "src/hooks/"), 33 | store: path.resolve(__dirname, "src/store/"), 34 | }, 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.(ts|tsx)$/, 40 | loader: "ts-loader", 41 | options: {}, 42 | }, 43 | { 44 | test: /\.css$/, 45 | use: [ 46 | { 47 | loader: "style-loader", 48 | }, 49 | { 50 | loader: "css-loader", 51 | }, 52 | ], 53 | }, 54 | { 55 | test: /.(sass|scss)$/, 56 | use: [{ loader: "style-loader" }, { loader: "css-loader" }, { loader: "sass-loader" }], 57 | }, 58 | { 59 | test: /\.svg$/, 60 | issuer: /\.[jt]sx?$/, 61 | loader: "@svgr/webpack", 62 | }, 63 | ], 64 | }, 65 | performance: { 66 | hints: false, 67 | }, 68 | plugins: [ 69 | new webpack.DefinePlugin({ 70 | VERSION: JSON.stringify("v0.1.0"), 71 | }), 72 | new CleanWebpackPlugin(), 73 | new HtmlWebpackPlugin({ 74 | template: "./public/index.html", 75 | }), 76 | new ReactRefreshWebpackPlugin(), 77 | new ForkTsCheckerWebpackPlugin(), 78 | ], 79 | }; 80 | module.exports = config; 81 | -------------------------------------------------------------------------------- /packages/vscode/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "simple-import-sort"], 5 | "extends": ["eslint:recommended", "prettier"], 6 | "rules": { 7 | "sort-imports": "off", 8 | "simple-import-sort/imports": [ 9 | "error", 10 | { 11 | "groups": [["^\\u0000"], ["^@?\\w"], ["^."], [".scss", ".svg", ".png"]] 12 | } 13 | ], 14 | "simple-import-sort/exports": "error", 15 | "no-duplicate-imports": "error", 16 | "import/no-commonjs": "off" 17 | }, 18 | "overrides": [ 19 | { 20 | "files": ["**/*.ts?(x)"], 21 | "settings": { 22 | "import/resolver": { 23 | "typescript": true 24 | } 25 | }, 26 | "parserOptions": { 27 | "project": ["./tsconfig.json"] 28 | }, 29 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 30 | "rules": { 31 | "no-empty": "warn", 32 | "@typescript-eslint/no-unused-vars": "warn", 33 | "@typescript-eslint/ban-types": "off", 34 | "@typescript-eslint/no-redeclare": "warn", 35 | "@typescript-eslint/no-empty-function": "off", 36 | "@typescript-eslint/no-non-null-assertion": "off", 37 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off", 38 | "@typescript-eslint/no-unsafe-return": "off", 39 | "@typescript-eslint/consistent-type-assertions": "warn", 40 | "@typescript-eslint/consistent-type-exports": ["error", { "fixMixedExportsWithInlineTypeSpecifier": true }], 41 | "@typescript-eslint/consistent-type-imports": [ 42 | "error", 43 | { 44 | "prefer": "type-imports", 45 | "disallowTypeAnnotations": true, 46 | "fixStyle": "inline-type-imports" 47 | } 48 | ], 49 | "@typescript-eslint/no-import-type-side-effects": "error" 50 | } 51 | } 52 | ], 53 | "ignorePatterns": ["out", "dist", "**/*.d.ts", "webpack*", "node_modules"] 54 | } 55 | -------------------------------------------------------------------------------- /packages/vscode/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | gh-pages/ 4 | .vscode-test/ 5 | out -------------------------------------------------------------------------------- /packages/vscode/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/vscode/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}" 15 | }, 16 | { 17 | "name": "Extension Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "args": [ 21 | "--extensionDevelopmentPath=${workspaceFolder}", 22 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 23 | ], 24 | "outFiles": ["${workspaceFolder}/out/**/*.js", "${workspaceFolder}/dist/**/*.js"], 25 | "preLaunchTask": "tasks: watch-tests" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /packages/vscode/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false, // set this to true to hide the "out" folder with the compiled JS files 5 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "dist": true // set this to false to include "dist" folder in search results 10 | }, 11 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 12 | "typescript.tsc.autoDetect": "off", 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.tabSize": 4, 15 | "editor.detectIndentation": false, 16 | "npm.packageManager": "npm" 17 | } 18 | -------------------------------------------------------------------------------- /packages/vscode/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": ["$ts-webpack-watch", "$tslint-webpack-watch"], 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never", 13 | "group": "watchers" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "watch-tests", 23 | "problemMatcher": "$tsc-watch", 24 | "isBackground": true, 25 | "presentation": { 26 | "reveal": "never", 27 | "group": "watchers" 28 | }, 29 | "group": "build" 30 | }, 31 | { 32 | "label": "tasks: watch-tests", 33 | "dependsOn": ["npm: watch", "npm: watch-tests"], 34 | "problemMatcher": [] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /packages/vscode/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/** 4 | node_modules/** 5 | src/** 6 | .gitignore 7 | .yarnrc 8 | webpack.config.js 9 | vsc-extension-quickstart.md 10 | **/tsconfig.json 11 | **/.eslintrc.json 12 | **/*.map 13 | **/*.ts 14 | node_modules -------------------------------------------------------------------------------- /packages/vscode/README.md: -------------------------------------------------------------------------------- 1 | # Githru for Visual Studio Code 2 | 3 | Githru-vscode-ext is an extension supporting visual analytics to understand the history of GitHub repositories. 4 | 5 | The theoretical basis of the extension is `Githru: Visual Analytics for Understanding Software Development History Through Git Metadata Analysis`(https://ieeexplore.ieee.org/document/9222261). 6 | 7 | ## Visualization Features 8 | 9 | ### Summarized Graph 10 | The straightened graph help users understand the complex GitHub commit graph. 11 | 12 | ### Visual Analytics Components 13 | Visualization components represent statistics for users' interested area. 14 | 15 | 18 | 19 | 29 | 30 | 33 | 34 | ## Release Notes 35 | ### 0.7.2 36 | - Show TAG information 37 | - Update internal frameworks 38 | - UI enhancement, Bug Fix 39 | 40 | ### 0.7.1 41 | - Theme Selector! 42 | - Optimized UI Rendering 43 | - Boost up second Loading 44 | 45 | ### 0.7.0 46 | - UI enhancement 47 | - Patches for UI bugs 48 | 49 | ### 0.6.1 50 | - subgraph 51 | - launch one panel only 52 | 53 | ### 0.6.0 54 | - branch selector 55 | - reset github auth 56 | - error handling for pr/auth 57 | - e2e test 58 | 59 | ### 0.5.0 60 | - login by github auth 61 | - fix minor ui issues 62 | - author avatar in details 63 | - error handling 64 | 65 | ### 0.4.0 66 | - app loading ui 67 | - improved internal architecture 68 | - theme color palette 69 | 70 | ### 0.3.0 71 | - temporal filter by brushing 72 | - refresh 73 | - put avatar in author bar chart 74 | - improved some UX. 75 | 76 | ### 0.2.0 77 | - New Logo! 78 | - GitHub User Avatar 79 | - GitHub Pull Request Association 80 | - Author Filtering 81 | 82 | ### 0.1.0 83 | 84 | Initial release. -------------------------------------------------------------------------------- /packages/vscode/images/githru_logo_temp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/packages/vscode/images/githru_logo_temp.png -------------------------------------------------------------------------------- /packages/vscode/images/githru_logo_v0.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/packages/vscode/images/githru_logo_v0.2.png -------------------------------------------------------------------------------- /packages/vscode/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githru/githru-vscode-ext/e37f94d5b7aa0602899ad9a45a8b07c5ce533b1e/packages/vscode/images/logo.png -------------------------------------------------------------------------------- /packages/vscode/jest.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testPathIgnorePatterns: ["/node_modules/", "/out/"], 5 | verbose: true, 6 | rootDir: "./", 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/vscode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "githru-vscode-ext", 3 | "displayName": "githru-vscode-ext", 4 | "description": "vscode extension module for githru-vscode-ext", 5 | "publisher": "githru", 6 | "repository": { 7 | "url": "https://github.com/githru/githru-vscode-ext", 8 | "type": "git" 9 | }, 10 | "version": "0.7.2", 11 | "engines": { 12 | "vscode": "^1.67.0" 13 | }, 14 | "icon": "images/githru_logo_v0.2.png", 15 | "author": { 16 | "name": "team githru" 17 | }, 18 | "categories": [ 19 | "Other", 20 | "SCM Providers" 21 | ], 22 | "keywords": [ 23 | "githru", 24 | "git", 25 | "GitHub", 26 | "log", 27 | "visualization", 28 | "visual analytics" 29 | ], 30 | "activationEvents": [ 31 | "*" 32 | ], 33 | "main": "./dist/extension.js", 34 | "contributes": { 35 | "menus": { 36 | "scm/title": [ 37 | { 38 | "command": "githru.command.launch", 39 | "when": "scmProvider == git", 40 | "group": "navigation@2" 41 | } 42 | ] 43 | }, 44 | "commands": [ 45 | { 46 | "command": "githru.command.launch", 47 | "title": "Open Githru View", 48 | "icon": "images/logo.png", 49 | "category": "Githru" 50 | }, 51 | { 52 | "command": "githru.command.login.github", 53 | "title": "Login with Github", 54 | "category": "Githru" 55 | }, 56 | { 57 | "command": "githru.command.reset.github_auth", 58 | "title": "Reset GitHub Authentication saved previously", 59 | "category": "Githru" 60 | } 61 | ], 62 | "configuration": { 63 | "title": "Githru", 64 | "properties": { 65 | "githru.theme": { 66 | "type": "string", 67 | "default": "githru", 68 | "description": "Insert your theme name: githru, hacker-blue, aqua, cotton-candy, mono" 69 | } 70 | } 71 | } 72 | }, 73 | "scripts": { 74 | "prebuild": "npm run --prefix ../view/ build", 75 | "build": "npm run compile", 76 | "compile": "webpack", 77 | "watch": "webpack --watch", 78 | "package": "webpack --mode production --devtool hidden-source-map && vsce package --no-dependencies", 79 | "compile-tests": "tsc -p . --outDir out", 80 | "watch-tests": "tsc -p . -w --outDir out", 81 | "pretest": "npm run compile-tests && npm run compile && npm run lint", 82 | "lint": "eslint src --ext ts", 83 | "lint:fix": "eslint src --ext ts --fix", 84 | "test": "jest" 85 | }, 86 | "dependencies": { 87 | "@githru-vscode-ext/analysis-engine": "^0.7.2", 88 | "@octokit/rest": "^20.0.1", 89 | "node-fetch": "^3.3.2" 90 | }, 91 | "devDependencies": { 92 | "@types/glob": "^7.2.0", 93 | "@types/jest": "^29.5.12", 94 | "@types/mocha": "^9.1.1", 95 | "@types/node": "14.x", 96 | "@types/vscode": "^1.67.0", 97 | "@types/webpack": "^5.28.0", 98 | "@typescript-eslint/eslint-plugin": "^6.2.1", 99 | "@typescript-eslint/parser": "^6.2.1", 100 | "@vscode/test-electron": "^2.1.3", 101 | "copy-webpack-plugin": "^11.0.0", 102 | "data-uri-to-buffer": "^4.0.1", 103 | "eslint": "^8.46.0", 104 | "eslint-config-prettier": "^8.10.0", 105 | "eslint-plugin-import": "^2.28.0", 106 | "eslint-plugin-prettier": "^5.0.0", 107 | "eslint-plugin-simple-import-sort": "^10.0.0", 108 | "eslint-plugin-unused-imports": "^3.0.0", 109 | "formdata-polyfill": "^4.0.10", 110 | "glob": "^8.0.1", 111 | "jest": "^29.7.0", 112 | "mocha": "^9.2.2", 113 | "prettier": "^3.0.1", 114 | "ts-jest": "^29.2.5", 115 | "ts-loader": "^9.2.8", 116 | "typescript": "^4.6.4", 117 | "webpack": "^5.70.0", 118 | "webpack-cli": "^4.9.2" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /packages/vscode/src/commands.ts: -------------------------------------------------------------------------------- 1 | export const COMMAND_PREFIX = "githru.command"; 2 | export const COMMAND_LAUNCH = `${COMMAND_PREFIX}.launch`; 3 | export const COMMAND_RELOAD = `${COMMAND_PREFIX}.reload`; 4 | export const COMMAND_LOGIN_WITH_GITHUB = `${COMMAND_PREFIX}.login.github`; 5 | export const COMMAND_RESET_GITHUB_AUTH = `${COMMAND_PREFIX}.reset.github_auth`; 6 | -------------------------------------------------------------------------------- /packages/vscode/src/credentials.ts: -------------------------------------------------------------------------------- 1 | import * as Octokit from "@octokit/rest"; 2 | import fetch from "node-fetch"; 3 | import * as vscode from "vscode"; 4 | 5 | interface OctokitAuth { 6 | type: string; 7 | token: string; 8 | tokenType: string; 9 | } 10 | 11 | const GITHUB_AUTH_PROVIDER_ID = "github"; 12 | const SCOPES = ["user:email"]; 13 | 14 | export class Credentials { 15 | private octokit: Octokit.Octokit | undefined; 16 | 17 | async initialize(context: vscode.ExtensionContext): Promise { 18 | this.registerListeners(context); 19 | this.setOctokit(); 20 | } 21 | 22 | private async createOctokit(session: vscode.AuthenticationSession) { 23 | return new Octokit.Octokit({ 24 | auth: session.accessToken, 25 | request: { 26 | fetch: fetch, 27 | }, 28 | }); 29 | } 30 | 31 | private async setOctokit() { 32 | const session = await vscode.authentication.getSession(GITHUB_AUTH_PROVIDER_ID, SCOPES, { createIfNone: false }); 33 | 34 | if (session) { 35 | this.octokit = await this.createOctokit(session); 36 | } 37 | 38 | this.octokit = undefined; 39 | } 40 | 41 | private registerListeners(context: vscode.ExtensionContext): void { 42 | context.subscriptions.push( 43 | vscode.authentication.onDidChangeSessions(async (e) => { 44 | if (e.provider.id === GITHUB_AUTH_PROVIDER_ID) { 45 | await this.setOctokit(); 46 | } 47 | }) 48 | ); 49 | } 50 | 51 | /** Octokit 인스턴스가 없다면, 인증 세션을 새로 만듦으로써, Octokit 인스턴스가 항상 존재하도록 보장한다. */ 52 | private async getOctokitInstance(): Promise { 53 | if (this.octokit) { 54 | return this.octokit; 55 | } else { 56 | const session = await vscode.authentication.getSession(GITHUB_AUTH_PROVIDER_ID, SCOPES, { createIfNone: true }); 57 | this.octokit = await this.createOctokit(session); 58 | return this.octokit; 59 | } 60 | } 61 | 62 | async getOctokit(): Promise { 63 | return this.getOctokitInstance(); 64 | } 65 | 66 | async getAuth(): Promise { 67 | return (await this.getOctokitInstance()).auth() as Promise; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/vscode/src/errors/ExtensionError.ts: -------------------------------------------------------------------------------- 1 | class WorkspacePathUndefinedError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = "WorkspacePathUndefinedError"; 5 | } 6 | } 7 | 8 | class GithubTokenUndefinedError extends Error { 9 | constructor(message: string) { 10 | super(message); 11 | this.name = "GithubTokenUndefinedError"; 12 | } 13 | } 14 | 15 | export { GithubTokenUndefinedError, WorkspacePathUndefinedError }; 16 | -------------------------------------------------------------------------------- /packages/vscode/src/setting-repository.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | const SETTING_PROPERTY_NAMES = { 4 | GITHUB_TOKEN: "githru.github.token", 5 | THEME: "githru.theme", 6 | }; 7 | 8 | export const getGithubToken = async (secrets: vscode.SecretStorage) => { 9 | return await secrets.get(SETTING_PROPERTY_NAMES.GITHUB_TOKEN); 10 | }; 11 | 12 | export const setGithubToken = async (secrets: vscode.SecretStorage, newGithubToken: string) => { 13 | return await secrets.store(SETTING_PROPERTY_NAMES.GITHUB_TOKEN, newGithubToken); 14 | }; 15 | 16 | export const deleteGithubToken = async (secrets: vscode.SecretStorage) => { 17 | return await secrets.delete(SETTING_PROPERTY_NAMES.GITHUB_TOKEN); 18 | }; 19 | 20 | export const setTheme = (theme: string) => { 21 | const configuration = vscode.workspace.getConfiguration(); 22 | configuration.update(SETTING_PROPERTY_NAMES.THEME, theme); 23 | }; 24 | 25 | export const getTheme = () => { 26 | const configuration = vscode.workspace.getConfiguration(); 27 | const theme = configuration.get(SETTING_PROPERTY_NAMES.THEME) as string; 28 | 29 | if (!theme) { 30 | setTheme("githru"); 31 | return "githru"; 32 | } 33 | return theme; 34 | }; 35 | -------------------------------------------------------------------------------- /packages/vscode/src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | // import * as path from 'path'; 2 | 3 | // import { runTests } from '@vscode/test-electron'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | // const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | // The path to test runner 11 | // Passed to --extensionTestsPath 12 | // const extensionTestsPath = path.resolve(__dirname, './suite/index'); 13 | // Download VS Code, unzip it and run the integration test 14 | // await runTests({ extensionDevelopmentPath, extensionTestsPath }); 15 | } catch (err) { 16 | console.error("Failed to run tests"); 17 | process.exit(1); 18 | } 19 | } 20 | 21 | main(); 22 | -------------------------------------------------------------------------------- /packages/vscode/src/test/suite/extension.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | // You can import and use all API from the 'vscode' module 3 | // as well as import your extension to test it 4 | import * as vscode from "vscode"; 5 | // import * as myExtension from '../../extension'; 6 | 7 | suite("Extension Test Suite", () => { 8 | vscode.window.showInformationMessage("Start all tests."); 9 | 10 | test("Sample test", () => { 11 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)); 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/vscode/src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as glob from "glob"; 2 | import * as Mocha from "mocha"; 3 | import * as path from "path"; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: "tdd", 9 | color: true, 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, ".."); 13 | 14 | return new Promise((c, e) => { 15 | glob("**/**.test.js", { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run((failures) => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /packages/vscode/src/test/utils/getGitLog.spec.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process"; 2 | 3 | import { getGitLog } from "../../utils/git.util"; 4 | 5 | const generateMockGitLogData = (index: number) => ` 6 | commit ${index}1234567890abcdef1234567890abcdef${index}5678 (HEAD -> main) 7 | Author: Mock User ${index} 8 | AuthorDate: Mon Sep ${index} 21:42:00 2023 +0000 9 | Commit: Mock Committer ${index} 10 | CommitDate: Mon Sep ${index} 21:43:00 2023 +0000 11 | 12 | Commit message ${index} 13 | `; 14 | 15 | jest.mock("child_process"); 16 | const mockSpawn = cp.spawn as jest.Mock; 17 | 18 | let mockSpawnCallCount = 0; 19 | 20 | mockSpawn.mockImplementation(() => { 21 | return { 22 | stdout: { 23 | on: jest.fn((event, callback) => { 24 | if (event === "data") { 25 | const mockData = generateMockGitLogData(mockSpawnCallCount); 26 | callback(Buffer.from(mockData)); 27 | mockSpawnCallCount++; 28 | } 29 | if (event === "close") { 30 | callback(); 31 | } 32 | }), 33 | }, 34 | stderr: { 35 | on: jest.fn((event, callback) => { 36 | callback(Buffer.from("mocked error message")); 37 | }), 38 | }, 39 | on: jest.fn((event, callback) => { 40 | if (event === "exit") { 41 | callback(0); 42 | } 43 | }), 44 | }; 45 | }); 46 | 47 | describe("getGitLog util test", () => { 48 | afterEach(() => { 49 | mockSpawnCallCount = 0; // initailize call count 50 | }); 51 | 52 | it("should return the combined git log output from number of threads", async () => { 53 | const result = await getGitLog("git", "/mocked/path/to/repo"); 54 | 55 | const expectedData = Array.from({ length: mockSpawnCallCount }) // Create an array with length equal to call count 56 | .map((_, index) => generateMockGitLogData(index)) // Insert mock data into the array for each index 57 | .join(""); // Concatenate all mock data into a single string 58 | 59 | return expect(result).toEqual(expectedData); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/vscode/src/types/CSMDictionary.ts: -------------------------------------------------------------------------------- 1 | import type { StemCommitNode } from "./StemCommitNode"; 2 | 3 | interface CSMNode { 4 | base: StemCommitNode; 5 | source: StemCommitNode[]; 6 | } 7 | 8 | export interface CSMDictionary { 9 | [branch: string]: CSMNode[]; 10 | } 11 | -------------------------------------------------------------------------------- /packages/vscode/src/types/Commit.ts: -------------------------------------------------------------------------------- 1 | import type { DiffStatistics } from "./DiffStatistics"; 2 | import type { GitHubUser } from "./GitHubUser"; 3 | 4 | export type Commit = { 5 | id: string; 6 | parentIds: string[]; 7 | author: GitHubUser; 8 | committer: GitHubUser; 9 | authorDate: string; 10 | commitDate: string; 11 | diffStatistics: DiffStatistics; 12 | message: string; 13 | tags: string[]; 14 | releaseTags: string[]; 15 | // fill necessary properties... 16 | }; 17 | -------------------------------------------------------------------------------- /packages/vscode/src/types/CommitMessageType.ts: -------------------------------------------------------------------------------- 1 | export const CommitMessageTypeList = [ 2 | "build", 3 | "chore", 4 | "ci", 5 | "docs", 6 | "feat", 7 | "fix", 8 | "pert", 9 | "refactor", 10 | "revert", 11 | "style", 12 | "test", 13 | ]; 14 | 15 | const COMMIT_MESSAGE_TYPE = [...CommitMessageTypeList] as const; 16 | 17 | export type CommitMessageType = (typeof COMMIT_MESSAGE_TYPE)[number]; 18 | -------------------------------------------------------------------------------- /packages/vscode/src/types/DiffStatistics.ts: -------------------------------------------------------------------------------- 1 | export type DiffStatistics = { 2 | changedFileCount: number; 3 | insertions: number; 4 | deletions: number; 5 | files: { 6 | [id: string]: { 7 | insertions: number; 8 | deletions: number; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/vscode/src/types/DifferenceStatistic.ts: -------------------------------------------------------------------------------- 1 | interface FileChanged { 2 | [path: string]: { 3 | insertionCount: number; 4 | deletionCount: number; 5 | }; 6 | } 7 | 8 | export interface DifferenceStatistic { 9 | totalInsertionCount: number; 10 | totalDeletionCount: number; 11 | fileDictionary: FileChanged; 12 | } 13 | -------------------------------------------------------------------------------- /packages/vscode/src/types/GitHubUser.ts: -------------------------------------------------------------------------------- 1 | export type GitHubUser = { 2 | id: string; 3 | names: string[]; 4 | emails: string[]; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/vscode/src/types/Node.ts: -------------------------------------------------------------------------------- 1 | import type { Commit } from "./Commit"; 2 | 3 | const NODE_TYPE_NAME = ["COMMIT", "CLUSTER"] as const; 4 | type NodeTypeName = (typeof NODE_TYPE_NAME)[number]; 5 | 6 | type NodeBase = { 7 | nodeTypeName: NodeTypeName; 8 | // isRootNode: boolean; 9 | // isLeafNode: boolean; 10 | 11 | // getParents: () => NodeType[]; 12 | }; 13 | 14 | // Node = Commit + analyzed Data as node 15 | export type CommitNode = NodeBase & { 16 | nodeTypeName: "COMMIT"; 17 | commit: Commit; 18 | // seq: number; 19 | clusterId: number; // 동일한 Cluster 내부 commit 참조 id 20 | // hasMajorTag: boolean; 21 | // hasMinorTag: boolean; 22 | // isMergeCommit: boolean; 23 | }; 24 | 25 | export type ClusterNode = NodeBase & { 26 | nodeTypeName: "CLUSTER"; 27 | commitNodeList: CommitNode[]; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/vscode/src/types/StemCommitNode.ts: -------------------------------------------------------------------------------- 1 | import type { CommitMessageType } from "./CommitMessageType"; 2 | import type { DifferenceStatistic } from "./DifferenceStatistic"; 3 | 4 | interface GitUser { 5 | name: string; 6 | email: string; 7 | } 8 | 9 | interface CommitRaw { 10 | sequence: number; 11 | id: string; 12 | parents: string[]; 13 | branches: string[]; 14 | tags: string[]; 15 | author: GitUser; 16 | authorDate: Date; 17 | committer: GitUser; 18 | committerDate: Date; 19 | message: string; 20 | differenceStatistic: DifferenceStatistic; 21 | commitMessageType: CommitMessageType; 22 | } 23 | 24 | export interface StemCommitNode { 25 | // 순회 이전에는 stemId가 존재하지 않음. 26 | stemId?: string; 27 | commit: CommitRaw; 28 | } 29 | -------------------------------------------------------------------------------- /packages/vscode/src/utils/csm.mapper.ts: -------------------------------------------------------------------------------- 1 | import type { CSMDictionary } from "../types/CSMDictionary"; 2 | import type { DifferenceStatistic } from "../types/DifferenceStatistic"; 3 | import type { DiffStatistics } from "../types/DiffStatistics"; 4 | import type { ClusterNode, CommitNode } from "../types/Node"; 5 | import type { StemCommitNode } from "../types/StemCommitNode"; 6 | 7 | /** 8 | * engine DifferenceStatistic → view DiffStatistics 9 | */ 10 | const mapDiffStatisticsFrom = (params: { differenceStatistic: DifferenceStatistic }): DiffStatistics => { 11 | const { differenceStatistic } = params; 12 | return { 13 | changedFileCount: Object.keys(differenceStatistic.fileDictionary).length, 14 | insertions: differenceStatistic.totalInsertionCount, 15 | deletions: differenceStatistic.totalDeletionCount, 16 | files: Object.entries(differenceStatistic.fileDictionary).reduce( 17 | (dict, [path, { insertionCount, deletionCount }]) => ({ 18 | ...dict, 19 | [path]: { 20 | insertions: insertionCount, 21 | deletions: deletionCount, 22 | }, 23 | }), 24 | {} 25 | ), 26 | }; 27 | }; 28 | 29 | /** 30 | * engine Commit[] → view Commit[] 31 | */ 32 | const mapCommitNodeListFrom = (params: { commits: StemCommitNode[]; clusterId: number }): CommitNode[] => { 33 | const { commits, clusterId } = params; 34 | return commits.map(({ commit }) => { 35 | const releaseTags = commit.tags.filter((tag) => tag.startsWith("v") || /^[0-9.]+$/.test(tag)); 36 | return { 37 | nodeTypeName: "COMMIT" as const, 38 | commit: { 39 | id: commit.id, 40 | parentIds: commit.parents, 41 | author: { 42 | id: "no-id", 43 | names: [commit.author.name], 44 | emails: [commit.author.email], 45 | }, 46 | committer: { 47 | id: "no-id", 48 | names: [commit.committer.name], 49 | emails: [commit.committer.email], 50 | }, 51 | authorDate: commit.authorDate.toString(), 52 | commitDate: commit.committerDate.toString(), 53 | diffStatistics: mapDiffStatisticsFrom({ differenceStatistic: commit.differenceStatistic }), 54 | message: commit.message, 55 | tags: commit.tags, 56 | releaseTags: releaseTags, 57 | }, 58 | // seq: 0, 59 | // implicitBranchNo: 0, 60 | // isMergeCommit: false, 61 | // hasMajorTag: false, 62 | // hasMinorTag: false, 63 | clusterId, 64 | }; 65 | }); 66 | }; 67 | 68 | /** 69 | * engine CSMDictionary → view ClusterNode[] 70 | */ 71 | export const mapClusterNodesFrom = (csmDict: CSMDictionary): ClusterNode[] => { 72 | return Object.values(csmDict).reduce( 73 | (allClusterNodes, csmNodes) => [ 74 | ...allClusterNodes, 75 | ...csmNodes.map(({ base, source }, idx) => ({ 76 | nodeTypeName: "CLUSTER" as const, 77 | commitNodeList: mapCommitNodeListFrom({ commits: [base, ...source], clusterId: idx }), 78 | })), 79 | ], 80 | [] 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /packages/vscode/src/utils/git.worker.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process"; 2 | import { parentPort, workerData } from "worker_threads"; 3 | 4 | import { resolveSpawnOutput } from "./git.util"; 5 | 6 | const { gitPath, currentWorkspacePath, skipCount, limitCount } = workerData; 7 | 8 | async function getPartialGitLog() { 9 | const gitLogFormat = 10 | "%n%n" + 11 | [ 12 | "%H", // commit hash (id) 13 | "%P", // parent hashes 14 | "%D", // ref names (branches, tags) 15 | "%an", // author name 16 | "%ae", // author email 17 | "%ad", // author date 18 | "%cn", // committer name 19 | "%ce", // committer email 20 | "%cd", // committer date 21 | "%w(0,0,4)%s", // commit message subject 22 | "%b", // commit message body 23 | ].join("%n"); 24 | 25 | const args = [ 26 | "--no-pager", 27 | "log", 28 | "--all", 29 | "--parents", 30 | "--numstat", 31 | "--date-order", 32 | `--pretty=format:${gitLogFormat}`, 33 | "--decorate", 34 | "-c", 35 | `--skip=${skipCount}`, 36 | `-n ${limitCount}`, 37 | ]; 38 | 39 | resolveSpawnOutput( 40 | cp.spawn(gitPath, args, { 41 | cwd: currentWorkspacePath, 42 | env: Object.assign({}, process.env), 43 | }) 44 | ) 45 | .then(([status, stdout, stderr]) => { 46 | const { code, error } = status; 47 | 48 | if (code === 0 && !error && parentPort !== null) { 49 | parentPort.postMessage(stdout.toString()); 50 | } else { 51 | if (parentPort !== null) parentPort.postMessage(stderr); 52 | } 53 | }) 54 | .catch((error) => { 55 | console.error("Spawn Error:", error); 56 | }); 57 | } 58 | 59 | getPartialGitLog(); 60 | -------------------------------------------------------------------------------- /packages/vscode/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 4 | "target": "ES2020", 5 | "outDir": "dist", 6 | "lib": ["ES2020"], 7 | "sourceMap": true, 8 | "rootDir": "./src", 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node" 14 | /* Additional Checks */ 15 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 16 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 17 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/vscode/vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | - This folder contains all of the files necessary for your extension. 6 | - `package.json` - this is the manifest file in which you declare your extension and command. 7 | - The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | - `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | - The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | - We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Setup 13 | 14 | - install the recommended extensions (amodio.tsl-problem-matcher and dbaeumer.vscode-eslint) 15 | 16 | ## Get up and running straight away 17 | 18 | - Press `F5` to open a new window with your extension loaded. 19 | - Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 20 | - Set breakpoints in your code inside `src/extension.ts` to debug your extension. 21 | - Find output from your extension in the debug console. 22 | 23 | ## Make changes 24 | 25 | - You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 26 | - You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 27 | 28 | ## Explore the API 29 | 30 | - You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 31 | 32 | ## Run tests 33 | 34 | - Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. 35 | - Press `F5` to run the tests in a new window with your extension loaded. 36 | - See the output of the test result in the debug console. 37 | - Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. 38 | - The provided test runner will only consider files matching the name pattern `**.test.ts`. 39 | - You can create folders inside the `test` folder to structure your tests any way you want. 40 | 41 | ## Go further 42 | 43 | - Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). 44 | - [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VSCode extension marketplace. 45 | - Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). 46 | -------------------------------------------------------------------------------- /packages/vscode/webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | "use strict"; 4 | const path = require("path"); 5 | const CopyPlugin = require("copy-webpack-plugin"); 6 | 7 | //@ts-check 8 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 9 | 10 | // /** @type WebpackConfig */ 11 | const extensionConfig = { 12 | target: "node", // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 13 | mode: "none", // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 14 | 15 | entry: { 16 | extension: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 17 | worker: "./src/utils/git.worker.ts" 18 | }, 19 | output: { 20 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 21 | path: path.resolve(__dirname, "dist"), 22 | filename: "[name].js", 23 | libraryTarget: "commonjs2", 24 | }, 25 | externals: { 26 | vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 27 | // modules added here also need to be added in the .vscodeignore file 28 | }, 29 | resolve: { 30 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 31 | extensions: [".ts", ".js"], 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.ts$/, 37 | exclude: /node_modules/, 38 | use: [ 39 | { 40 | loader: "ts-loader", 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | plugins: [ 47 | // new CopyPlugin(), 48 | new CopyPlugin({ 49 | patterns: [ 50 | { 51 | from: path.resolve(__dirname, "..", "view", "dist"), 52 | to: path.resolve(__dirname, "dist"), 53 | }, 54 | ], 55 | }), 56 | ], 57 | devtool: "nosources-source-map", 58 | infrastructureLogging: { 59 | level: "log", // enables logging required for problem matchers 60 | }, 61 | }; 62 | module.exports = [extensionConfig]; 63 | --------------------------------------------------------------------------------