├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── release-drafter.yml └── workflows │ └── merge_to_main.yaml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENSE ├── README.md ├── codetime.readme.md ├── custom.d.ts ├── eslint.config.mjs ├── images ├── 404-image.png ├── app-icon-blue.png ├── codetime-b-90.png ├── codetime-c-128.png └── codetime-g-30.png ├── package.json ├── resources ├── README.md ├── dark │ └── add.svg └── light │ └── add.svg ├── src ├── Constants.ts ├── DataController.ts ├── Util.ts ├── auth │ └── AuthProvider.ts ├── cache │ └── CacheManager.ts ├── command-helper.ts ├── events │ └── KpmItems.ts ├── extension.ts ├── http │ └── HttpClient.ts ├── local │ └── 404.ts ├── managers │ ├── ChangeStateManager.ts │ ├── ConfigManager.ts │ ├── ExecManager.ts │ ├── ExtensionManager.ts │ ├── FileManager.ts │ ├── FlowManager.ts │ ├── KpmManager.ts │ ├── LocalStorageManager.ts │ ├── ProgressManager.ts │ ├── PromptManager.ts │ ├── ScreenManager.ts │ ├── SlackManager.ts │ ├── StatusBarManager.ts │ ├── SummaryManager.ts │ ├── SyncManger.ts │ ├── TrackerManager.ts │ └── WebViewManager.ts ├── menu │ └── AccountManager.ts ├── message_handlers │ ├── authenticated_plugin_user.ts │ ├── current_day_stats_update.ts │ ├── flow_score.ts │ ├── flow_state.ts │ └── integration_connection.ts ├── model │ ├── CodeTimeSummary.ts │ ├── Project.ts │ └── models.ts ├── notifications │ └── endOfDay.ts ├── repo │ ├── GitUtil.ts │ └── KpmRepoManager.ts ├── sidebar │ └── CodeTimeView.ts ├── user │ └── OnboardManager.ts └── websockets.ts ├── swdc-vscode-2.8.8.vsix ├── test ├── extension.test.ts └── index.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | ```paste below 15 | 16 | ``` 17 | **ISSUE TYPE** 18 | - Bug Report 19 | 20 | **Steps to reproduce** 21 | 22 | 23 | 1. Go to '...' 24 | 2. Click on '....' 25 | 3. Scroll down to '....' 26 | 4. See error 27 | 28 | 29 | 30 | **Expected behavior** 31 | A clear and concise description of what you expected to happen. 32 | ```paste below 33 | 34 | ``` 35 | 36 | **ACTUAL RESULTS** 37 | 38 | ```paste below 39 | 40 | ``` 41 | 42 | **Screenshots** 43 | If applicable, add screenshots to help explain your problem. 44 | 45 | **Desktop (please complete the following information):** 46 | - OS: [e.g. macOS] 47 | - Plugin version [e.g. Code Time v1.2.1] 48 | 49 | **Additional context** 50 | Add any other context about the problem here. 51 | ```paste below 52 | 53 | ``` 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true # default 2 | contact_links: 3 | - name: 🌱 Join the community 4 | url: https://join.slack.com/t/software-community/shared_invite/enQtOTM4NjEyMjI1MDQ0LWM2ZDk0MDZiYzRjNjJiNGE2NWM3NDI3YzI1MjYzMmVjNjBmY2Q4Mzg4NjgyMjBmODMzZjk4M2RkMzYxMmY0ZjI 5 | about: Want to talk to our engineering team directly? Join us on Slack 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐾 Feature request 3 | about: Suggest an idea/feature for this project 4 | --- 5 | 6 | 7 | ##### SUMMARY 8 | 9 | ```paste below 10 | 11 | ``` 12 | 13 | ##### ISSUE TYPE 14 | - Feature Request 15 | 16 | ##### ADDITIONAL INFORMATION 17 | 18 | 19 | 20 | ```paste below 21 | 22 | ``` 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | prerelease: true 4 | categories: 5 | - title: "🚀 Features" 6 | labels: 7 | - "feature" 8 | - "enhancement" 9 | - title: "🐛 Bug Fixes" 10 | labels: 11 | - "fix" 12 | - "bugfix" 13 | - "bug" 14 | - title: "🧰 Maintenance" 15 | labels: 16 | - "chore" 17 | - "debt" 18 | - title: "🛠 Dependency Updates" 19 | labels: 20 | - "dependencies" 21 | - title: "⚙️ GitOps" 22 | labels: 23 | - "git-ops" 24 | - "ci-cd" 25 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 26 | change-title-escapes: '\<*_&' 27 | version-resolver: 28 | major: 29 | labels: 30 | - "major" 31 | - "breaking" 32 | minor: 33 | labels: 34 | - "minor" 35 | patch: 36 | labels: 37 | - "patch" 38 | default: patch 39 | template: | 40 | ## Changes 41 | 42 | $CHANGES 43 | -------------------------------------------------------------------------------- /.github/workflows/merge_to_main.yaml: -------------------------------------------------------------------------------- 1 | name: Merge to main 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | draft_release: 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: release-drafter/release-drafter@v5.15.0 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .DS_Store 4 | lib/Constants.ts.local 5 | dist/ 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "extensionHost", 6 | "request": "launch", 7 | "name": "Launch Extension Prod", 8 | "runtimeExecutable": "${execPath}", 9 | "sourceMaps": true, 10 | "args": [ 11 | "--extensionDevelopmentPath=${workspaceFolder}" 12 | ], 13 | "env": { 14 | "NODE_ENV": "test", 15 | "APP_ENV": "prod" 16 | }, 17 | "outFiles": [ 18 | "${workspaceFolder}/dist/extension.js" 19 | ], 20 | "preLaunchTask": "npm: watch" 21 | }, 22 | { 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "name": "Launch Extension Dev", 26 | "runtimeExecutable": "${execPath}", 27 | "sourceMaps": true, 28 | "args": [ 29 | "--extensionDevelopmentPath=${workspaceFolder}" 30 | ], 31 | "env": { 32 | "NODE_ENV": "test", 33 | "APP_ENV": "development" 34 | }, 35 | "outFiles": [ 36 | "${workspaceFolder}/dist/extension.js" 37 | ], 38 | "preLaunchTask": "npm: watch" 39 | }, 40 | { 41 | "name": "Extension Tests", 42 | "type": "extensionHost", 43 | "request": "launch", 44 | "runtimeExecutable": "${execPath}", 45 | "args": [ 46 | "--extensionDevelopmentPath=${workspaceFolder}", 47 | "--extensionTestsPath=${workspaceFolder}/out/test" 48 | ], 49 | "outFiles": [ 50 | "${workspaceFolder}/out/test/**/*.js" 51 | ], 52 | "preLaunchTask": "npm: test-compile" 53 | } 54 | ], 55 | "compounds": [ 56 | { 57 | "name": "Multiple Extensions", 58 | // Launch 2 debugger extensions at once 59 | "configurations": [ 60 | "Launch Extension", 61 | "Launch Extension" 62 | ] 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false 4 | }, 5 | "search.exclude": { 6 | "out": true 7 | }, 8 | "editor.tabSize": 2, 9 | "editor.insertSpaces": true, 10 | "workbench.colorCustomizations": {}, 11 | "editor.formatOnSave": true 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "problemMatcher": { 4 | "owner": "typescript", 5 | "fileLocation": "relative", 6 | "pattern": { 7 | "regexp": "^([^\\s].*)\\((\\d+|\\,\\d+|\\d+,\\d+,\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$", 8 | "file": 1, 9 | "location": 2, 10 | "severity": 3, 11 | "code": 4, 12 | "message": 5 13 | }, 14 | "background": { 15 | "activeOnStart": true, 16 | "beginsPattern": "^\\s*\\d{1,2}:\\d{1,2}:\\d{1,2}(?: AM| PM)? - File change detected\\. Starting incremental compilation\\.\\.\\.", 17 | "endsPattern": "^\\s*\\d{1,2}:\\d{1,2}:\\d{1,2}(?: AM| PM)? - Compilation complete\\. Watching for file changes\\." 18 | } 19 | }, 20 | "tasks": [ 21 | { 22 | "type": "npm", 23 | "script": "watch", 24 | "problemMatcher": "$tsc-watch", 25 | "isBackground": true, 26 | "presentation": { 27 | "reveal": "never" 28 | }, 29 | "group": { 30 | "kind": "build", 31 | "isDefault": true 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | typings/** 3 | test/** 4 | **/*.ts 5 | **/*.map 6 | .gitignore 7 | .github/** 8 | *.vsix 9 | tsconfig.json 10 | eslint.config.mjs 11 | node_modules 12 | out/ 13 | lib/ 14 | webpack.config.js 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Software 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Time 2 | 3 |

Software.com

4 | 5 | [Code Time](https://www.software.com/code-time) is an open source plugin for automatic programming metrics and time tracking in Visual Studio Code. Join our community of nearly half a million developers who use Code Time to reclaim time for focused, uninterrupted coding. Protect valuable code time and stay in flow. 6 | 7 | Looking for more ways to help your team improve their productivity? When you connect our [GitHub App](https://bit.ly/software-github) or [Bitbucket App](https://bit.ly/software-bitbucket), you can measure your team’s performance against engineering KPIs and benchmarks, including velocity, quality, and scale. You can identify bottlenecks in your release process — like slow code reviews — and take immediate action, so you can ship faster and more efficiently. 8 | 9 | ![Code Time features for VS Code](https://assets.software.com/readme/code-time/vscode/features-2.7.3.png) 10 | 11 | ## Getting Started 12 | 13 | Create an account to keep track of your coding data and unlock access to advanced data visualizations in the Code Time dashboard and web app. You can customize your profile, such as your work hours and office type, for advanced time tracking. You can also connect Outlook or Google Calendar to visualize your code time vs. meetings in a single calendar. 14 | 15 | Registering an account also lets you connect multiple code editors on multiple devices using the same email account. Your code time data will sync automatically across your devices. 16 | 17 | Open Code Time in the sidebar and follow the **Getting Started** prompts to create a new account, or click **Log in** to sign into an existing account. 18 | 19 | ## Protect Code Time 20 | 21 | ![Code Time for VS Code Flow Mode](https://assets.software.com/readme/code-time/vscode/stay-in-flow-2.7.3.png) 22 | 23 | [Automatic Flow Mode](https://www.software.com/src/auto-flow-mode) makes it easy to eliminate distractions, mute notifications, and stay focused when you are in flow. It detects when you are in a high velocity coding session and automatically silences distractions and prevents interruptions. 24 | 25 | With Automatic Flow Mode, you can quickly toggle Zen mode or enter full screen. If you connect a Slack workspace, you can pause notifications, update your profile status, and set your presence to *away*. If you connect Google Calendar or Microsoft Outlook, you can also block time on your calendar. You can customize Flow Mode by clicking *Configure settings* in the Code Time sidebar. 26 | 27 | If you'd prefer to turn on Flow Mode manually during each coding session, you can enable or disable Automatic Flow Mode from the Code Time settings view. Make sure to hit the save button after you're done updating your settings. To then turn on Flow Mode manually, click *Enter Flow Mode* in the sidebar to toggle your flow automations. Once you are in Flow Mode, click *Exit Flow Mode* to resume receiving notifications and reset your Slack status. 28 | 29 | ## Track Development Metrics 30 | 31 | ![Code Time programming metrics](https://assets.software.com/readme/code-time/vscode/improve-your-focus-2.7.3.png) 32 | 33 | Your coding stats can help you understand how you are improving over time. 34 | 35 | Your status bar shows you in real-time how many hours and minutes you code each day. A rocket will appear if your active code time exceeds your daily average on this day of the week. 36 | 37 | To see an overview of your coding activity and project metrics, open the **Code Time panel** by clicking on the Code Time icon in your sidebar. Click **Dashboard** to open your personalized Code Time dashboard in a new editor tab. Your dashboard summarizes your coding data — such as your code time, code time outside work hours, and meeting time — today, yesterday, last week, and over the last 90 days. 38 | 39 | Your _code time_ is the total time you have spent in your editor today. Your _active code time_ is the time you spend actively writing and editing code in your editor or IDE. It captures periods of intense focus and flow. Each metric shows how you compare today to your personal averages. Each average is calculated by day of week over the last 90 days (e.g. a Friday average is an average of all previous Fridays). 40 | 41 | ## Explore Data Visualizations 42 | 43 | ![Code Time web app](https://assets.software.com/readme/code-time/vscode/optimize-code-time-2.7.3.png) 44 | 45 | Click **More data at software.com** in the Code Time sidebar or visit [app.software.com](https://app.software.com) to see more advanced data visualizations. You will need to create a free Software.com account to use the web app. In the Code Time dashboard, you will be able to track: 46 | 47 | **Active code time.** Visualize your daily active code time. See productivity trends compared to 90-day averages. See how you stack up against the Software.com community. 48 | 49 | **Meeting time.** Connect your [Google Calendar](https://www.software.com/integrations/google-calendar) or [Outlook Calendar](https://www.software.com/integrations/microsoft-outlook) to visualize meeting time versus code time. 50 | 51 | **Work-life balance.** See how much coding happens during work hours versus nights and weekends so you can find ways to improve your work-life balance. 52 | 53 | ## Engineering KPIs for Teams 54 | 55 | ![Software.com engineering metrics](https://assets.software.com/readme/code-time/vscode/collaborate-more-efficiently-2.7.3.png) 56 | 57 | Take your team to the next level. Software.com makes it easy to automatically track engineering KPIs, including lead time, deployment frequency, and more. 58 | 59 | **Measure the impact of new tools.** See how changes to your engineering organization impact performance over time. Roll out new hiring plans, AI coding tools, platforms, and processes with greater confidence. 60 | 61 | **Remove bottlenecks and unlock new growth.** Setting up the right systems and tools sits at the heart of the developer experience. Development observability helps you uncover hidden bottlenecks and create an environment where every developer can be their most productive. 62 | 63 | **Build high-performing teams.** Better business outcomes are driven by teams that ship predictable and consistent results. Set up standardized metrics across your organization that help teams measure and improve development performance on their own. 64 | 65 | **Code Time for your entire team.** When you [upgrade your organization](https://www.software.com/pricing) to the Pro plan, you’ll unlock advanced Code Time features for every developer on your team, including custom reports and data exports. 66 | 67 | To see your team's engineering KPIs, visit the [web app](https://app.software.com) and connect our [GitHub](https://bit.ly/software-github) or [Bitbucket](https://bit.ly/software-bitbucket) app to your Software.com account. It's free and takes just a few minutes. 68 | 69 | ## It’s Safe, Secure, and Free 70 | 71 | **We never access your code:** We do not read, transmit, or store source code. We only provide metrics about programming, and we make it easy to see the data we collect. You can learn more about how we secure your data on our [security page](https://www.software.com/security). 72 | 73 | **Your data is private:** We will never share your individually identifiable data with your boss. When you create or join a team, we only show KPIs at the team and company level. 74 | 75 | **Free for you, forever:** We provide 90 days of data history for free, forever. We provide [premium plans](https://app.software.com/billing) for advanced features and historical data access. 76 | 77 | Code Time also collects basic usage metrics to help us make informed decisions about our roadmap. 78 | 79 | **Data collected and purpose** 80 | | Data Collected | Purpose of Data | 81 | | -------------- | --------------- | 82 | | Local Timezone | Applying time zones to aggregate metrics so that daily values are accurate | 83 | | Hostname | Used for de-duplicating accounts and linking multiple installations together under same login. Hostname is hashed before usage | 84 | | File open, save, close events | These events are used to capture general activity within the editor and extend code time sessions | 85 | | Editor focus / unfocus | These events are used to capture general activity within the editor and extend code time sessions | 86 | | Document change events | These events are used to characterize what types of changes are being made to a document. CONTENT OF A DOCUMENT IS NEVER STORED OR TRANSMITTED | 87 | | Project directory | Used to segment activity and Code Time across projects | 88 | | Git Repo Name | Used to segment activity and Code Time across git repos (Can be turned off in settings) | 89 | | File name | Used to segment activity and Code Time across file names (File names are one-way hashed before use) | 90 | 91 | [More details here](https://docs.software.com/article/43-code-time-data) 92 | 93 | ## Join the Community 94 | 95 | Enjoying Code Time? Let us know how it’s going by tweeting or following us at [@software_hq](https://twitter.com/software_hq). 96 | 97 | Have any questions? Create an issue in the [Code Time project](https://github.com/swdotcom/swdc-vscode) on GitHub or send us an email at [support@software.com](mailto:support@software.com) and we’ll get back to you as soon as we can. 98 | -------------------------------------------------------------------------------- /codetime.readme.md: -------------------------------------------------------------------------------- 1 | # Code Time for Visual Studio Code 2 | 3 | > Programming metrics right in VS Code. 4 | 5 |

6 | Code Time for VS Code 7 |

8 | 9 | ## Power up your development 10 | 11 | **In-editor dashboard** 12 | Get daily and weekly reports of your programming activity right in your code editor. 13 | 14 | **Status bar metrics** 15 | After installing our plugin, your status bar will show real-time metrics about time coded per day. 16 | 17 | **Weekly email reports** 18 | Get a weekly report delivered right to your email inbox. 19 | 20 | **Data visualizations** 21 | Go to our web app to get simple data visualizations, such as a rolling heatmap of your best programming times by hour of the day. 22 | 23 | **Calendar integration** 24 | Integrate with Google Calendar to automatically set calendar events to protect your best programming times from meetings and interrupts. 25 | 26 | **More stats** 27 | See your best music for coding and the speed, frequency, and top files across your commits. 28 | 29 | ## Why you should try it out 30 | 31 | - Automatic time reports by project 32 | - See what time you code your best—find your “flow” 33 | - Defend your best code times against meetings and interrupts 34 | - Find out what you can learn from your data 35 | 36 | ## It’s safe, secure, and free 37 | 38 | **We never access your code** 39 | We do not process, send, or store your proprietary code. We only provide metrics about programming, and we make it easy to see the data we collect. 40 | 41 | **Your data is private** 42 | We will never share your individually identifiable data with your boss. In the future, we will roll up data into groups and teams but we will keep your data anonymized. 43 | 44 | **Free for you, forever** 45 | We provide 90 days of data history for free, forever. In the future, we will provide premium plans for advanced features and historical data access. 46 | 47 | 48 | 49 | ## Getting started 50 | 51 | 1. [Install the Code Time plugin](https://marketplace.visualstudio.com/items?itemName=softwaredotcom.swdc-vscode) from the Visual Studio Code Marketplace. 52 | 53 | 2. After installing Code Time, an alert will appear prompting you to login (you can also click on "Code Time" in the status bar of Visual Studio Code. 54 | 55 | 3. You can visit the web app any time at [https://app.software.com/](https://app.software.com/). 56 | 57 | 58 | 59 | ## FAQs 60 | 61 | **What does the rocket ship icon in my status bar mean?** 62 | 63 | In the status bar of your editor/IDE, we show a rocket ship icon when your time code today exceeds your daily code time's 90-day average 64 | 65 | **Does keeping VSCode open affect time tracking/metrics?** 66 | 67 | The answer is "No", keeping the editor open should not skew your metrics. You do not need to close VS Code to ensure that Code Time metrics are correct. 68 | 69 | The timer starts when you start typing. It stops after 15 minutes without typing or opening/closing files in the editor. We then truncate the last 15 minutes of inactivity from the data. 70 | 71 | ## Contributing & Feedback 72 | 73 | Definitely let us know if you have more questions! 74 | 75 | Contact [cody@software.com](mailto:cody@software.com) with any additional questions or comments. 76 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: any; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from 'eslint/config'; 2 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 3 | import globals from 'globals'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | 6 | export default defineConfig([globalIgnores(['**/out', '**/dist', '**/*.d.ts']), { 7 | plugins: { 8 | '@typescript-eslint': typescriptEslint, 9 | }, 10 | 11 | languageOptions: { 12 | globals: { 13 | ...globals.node, 14 | }, 15 | 16 | parser: tsParser, 17 | ecmaVersion: 6, 18 | sourceType: 'module', 19 | }, 20 | 21 | rules: { 22 | curly: 'warn', 23 | eqeqeq: 'warn', 24 | 'no-throw-literal': 'warn', 25 | quotes: ['warn', 'single'], 26 | }, 27 | }]); -------------------------------------------------------------------------------- /images/404-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swdotcom/swdc-vscode/85b307b7f8641ae1f2210e02e06d83fc236d789a/images/404-image.png -------------------------------------------------------------------------------- /images/app-icon-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swdotcom/swdc-vscode/85b307b7f8641ae1f2210e02e06d83fc236d789a/images/app-icon-blue.png -------------------------------------------------------------------------------- /images/codetime-b-90.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swdotcom/swdc-vscode/85b307b7f8641ae1f2210e02e06d83fc236d789a/images/codetime-b-90.png -------------------------------------------------------------------------------- /images/codetime-c-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swdotcom/swdc-vscode/85b307b7f8641ae1f2210e02e06d83fc236d789a/images/codetime-c-128.png -------------------------------------------------------------------------------- /images/codetime-g-30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swdotcom/swdc-vscode/85b307b7f8641ae1f2210e02e06d83fc236d789a/images/codetime-g-30.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swdc-vscode", 3 | "displayName": "Code Time", 4 | "version": "2.8.8", 5 | "publisher": "softwaredotcom", 6 | "description": "Code Time is an open source plugin that provides programming metrics right in Visual Studio Code.", 7 | "author": { 8 | "name": "Software.com" 9 | }, 10 | "license": "SEE LICENSE IN LICENSE", 11 | "icon": "images/codetime-c-128.png", 12 | "galleryBanner": { 13 | "color": "#384356", 14 | "theme": "dark" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/swdotcom/swdc-vscode" 19 | }, 20 | "categories": [ 21 | "Other" 22 | ], 23 | "keywords": [ 24 | "codetime", 25 | "flow", 26 | "mode", 27 | "time", 28 | "productivity" 29 | ], 30 | "capabilities": { 31 | "supported": true 32 | }, 33 | "activationEvents": [ 34 | "onStartupFinished" 35 | ], 36 | "extensionKind": [ 37 | "ui", 38 | "workspace" 39 | ], 40 | "engines": { 41 | "vscode": "^1.100.2" 42 | }, 43 | "main": "./dist/extension.js", 44 | "scripts": { 45 | "lint": "eslint .", 46 | "build": "vsce package", 47 | "watch": "tsc -watch -p ./", 48 | "webpack": "yarn compile:extension & yarn compile:views", 49 | "webpack-dev": "yarn watch:extension & yarn watch:views", 50 | "compile:extension": "tsc -p ./", 51 | "compile:views": "webpack --mode development", 52 | "watch:extension": "tsc -watch -p ./", 53 | "watch:views": "webpack --watch --mode development", 54 | "vscode:prepublish": "webpack --mode production", 55 | "test-compile": "tsc -p ./" 56 | }, 57 | "contributes": { 58 | "commands": [ 59 | { 60 | "command": "codetime.viewProjectReports", 61 | "title": "Code Time: Project reports" 62 | }, 63 | { 64 | "command": "codetime.displayReadme", 65 | "title": "Code Time: Learn more" 66 | }, 67 | { 68 | "command": "codetime.manageSlackConnection", 69 | "title": "Code Time: Manage Slack connection" 70 | }, 71 | { 72 | "command": "codetime.connectSlack", 73 | "title": "Code Time: Connect Slack workspace" 74 | }, 75 | { 76 | "command": "codetime.viewDashboard", 77 | "title": "Code Time: View Dashboard" 78 | }, 79 | { 80 | "command": "codetime.enableFlowMode", 81 | "title": "Code Time: Enable Flow Mode" 82 | }, 83 | { 84 | "command": "codetime.exitFlowMode", 85 | "title": "Code Time: Exit Flow Mode" 86 | }, 87 | { 88 | "command": "codetime.logout", 89 | "title": "Code Time: Log out" 90 | }, 91 | { 92 | "command": "codetime.authSignIn", 93 | "title": "Code Time: Software.com Sign In", 94 | "icon": "$(sign-in)" 95 | } 96 | ], 97 | "configuration": [ 98 | { 99 | "type": "object", 100 | "title": "Code Time" 101 | } 102 | ], 103 | "viewsContainers": { 104 | "activitybar": [ 105 | { 106 | "id": "code-time-sidebar", 107 | "title": "Code Time", 108 | "icon": "images/codetime-g-30.png" 109 | } 110 | ] 111 | }, 112 | "views": { 113 | "code-time-sidebar": [ 114 | { 115 | "id": "codetime.webView", 116 | "type": "webview", 117 | "name": "", 118 | "icon": "images/codetime-g-30.png" 119 | } 120 | ] 121 | } 122 | }, 123 | "devDependencies": { 124 | "@types/copy-webpack-plugin": "^8.0.1", 125 | "@types/mocha": "^9.0.0", 126 | "@types/node": "^16.10.3", 127 | "@types/vscode": "^1.52.0", 128 | "@typescript-eslint/eslint-plugin": "^8.32.0", 129 | "@typescript-eslint/parser": "^8.32.0", 130 | "eslint": "^9.26.0", 131 | "copy-webpack-plugin": "^11.0.0", 132 | "file-loader": "^6.2.0", 133 | "ts-loader": "^9.2.6", 134 | "typescript": "^5.4.5", 135 | "webpack": "^5.94.0", 136 | "webpack-cli": "^4.10.0" 137 | }, 138 | "dependencies": { 139 | "@swdotcom/editor-flow": "1.1.3", 140 | "@types/uuid": "10.0.0", 141 | "@types/ws": "^8.5.12", 142 | "axios": "1.9.0", 143 | "date-fns": "4.1.0", 144 | "node-cache": "5.1.2", 145 | "swdc-tracker": "1.6.0", 146 | "uuid": "11.1.0", 147 | "ws": "8.18.2" 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | # Code Time 2 | 3 |

Software.com

4 | 5 | [Code Time](https://www.software.com/code-time) is an open source plugin for automatic programming metrics and time tracking in Visual Studio Code. Join our community of over 250,000 developers who use Code Time to reclaim time for focused, uninterrupted coding. Protect valuable code time and stay in flow. 6 | 7 | --- 8 | 9 | ✨ Want more out of *Software*? When you connect our [GitHub App](https://bit.ly/software-github), you can measure your team’s key DevOps metrics, including lead time and delivery velocity. You can identify bottlenecks in your release process — like slow code reviews — and take immediate action, so you can ship faster and more efficiently. 10 | 11 | --- 12 | 13 | ![Code Time features for VS Code](https://assets.software.com/readme/code-time/vscode/features-2.5.0.png) 14 | 15 | ## Getting Started 16 | 17 | Create an account to keep track of your coding data and unlock access to advanced data visualizations in the Code Time dashboard and web app. You can customize your profile, such as your work hours and office type, for advanced time tracking. You can also connect Outlook or Google Calendar to visualize your code time vs. meetings in a single calendar. 18 | 19 | Registering an account also lets you connect multiple code editors on multiple devices using the same email account. Your code time data will sync automatically across your devices. 20 | 21 | Open Code Time in the sidebar and follow the **Getting Started** prompts to create a new account, or click **Log in** to sign into an existing account. 22 | 23 | ## Protect Code Time 24 | 25 | ![Code Time for VS Code Flow Mode](https://assets.software.com/readme/code-time/vscode/stay-in-flow-2.5.0.png) 26 | 27 | [Automatic Flow Mode](https://www.software.com/src/auto-flow-mode) makes it easy to eliminate distractions, mute notifications, and stay focused when you are in flow. It detects when you are in a high velocity coding session and automatically silences distractions and prevents interruptions. 28 | 29 | With Automatic Flow Mode, you can quickly toggle Zen mode or enter full screen. If you connect a Slack workspace, you can pause notifications, update your profile status, and set your presence to *away*. If you connect Google Calendar or Microsoft Outlook, you can also block time on your calendar. You can customize Flow Mode by clicking the gear icon at the the top of the Code Time sidebar. 30 | 31 | If you'd prefer to turn on Flow Mode manually during each coding session, you can enable or disable Automatic Flow Mode from the Code Time settings view. Make sure to hit the save button after you're done updating your settings. To then turn on Flow Mode manually, click *Enter Flow Mode* in the sidebar to toggle your flow automations. Once you are in Flow Mode, click *Exit Flow Mode* to resume receiving notifications and reset your Slack status. 32 | 33 | ## Track Development Metrics 34 | 35 | ![Code Time programming metrics](https://assets.software.com/readme/code-time/vscode/measure-progress-2.5.0.png) 36 | 37 | Your coding stats can help you understand how you are improving over time. 38 | 39 | Your status bar shows you in real-time how many hours and minutes you code each day. A rocket will appear if your active code time exceeds your daily average on this day of the week. 40 | 41 | To see an overview of your coding activity and project metrics, open the **Code Time panel** by clicking on the Code Time icon in your sidebar. Click **Dashboard** to open your personalized Code Time dashboard in a new editor tab. Your dashboard summarizes your coding data — such as your code time, code time outside work hours, and meeting time — yesterday, last week, and over the last 90 days. 42 | 43 | Your _code time_ is the total time you have spent in your editor today. Your _active code time_ is the time you spend actively writing and editing code in your editor or IDE. It captures periods of intense focus and flow. Each metric shows how you compare today to your personal averages. Each average is calculated by day of week over the last 90 days (e.g. a Friday average is an average of all previous Fridays). 44 | 45 | ## Explore Data Visualizations 46 | 47 | ![Code Time web app](https://assets.software.com/readme/code-time/vscode/visualize-everything.png) 48 | 49 | Click **More data at software.com** in the Code Time sidebar or visit [app.software.com](https://app.software.com) to see more advanced data visualizations. You will need to create a free Software account to use the web app. In the Code Time dashboard, you will be able to track: 50 | 51 | **Active code time.** Visualize your daily active code time. See productivity trends compared to 90-day averages. See how you stack up against the Software community of over 250,000 developers. 52 | 53 | **Meeting time.** Connect your [Google Calendar](https://www.software.com/integrations/google-calendar) or [Outlook Calendar](https://www.software.com/integrations/microsoft-outlook) to visualize meeting time versus code time. 54 | 55 | **Work-life balance.** See how much coding happens during work hours versus nights and weekends so you can find ways to improve your work-life balance. 56 | 57 | ## Measure Key DevOps Metrics 58 | 59 | ![Software.com DevOps Metrics](https://assets.software.com/readme/code-time/vscode/bottlenecks-take-action.png) 60 | 61 | *Software* makes it easy to measure your team's key DevOps metrics, including [lead time](https://www.software.com/devops-guides/lead-time) and [delivery velocity](https://www.software.com/devops-guides/delivery-velocity-score). With better visibility, you can quickly find and fix bottlenecks in your delivery pipeline. 62 | 63 | **Diagnose bottlenecks with alerts.** Monitors automatically notify you when metrics cross a certain threshold (like when [code reviews take longer](https://www.software.com/src/code-reviews-bottleneck-in-your-delivery-pipeline) to review, approve, or merge), so you can take action and prevent delays. 64 | 65 | **Customize dashboards to track DevOps efficiency.** Organize, visualize, and monitor the engineering metrics you care about most — like lead time, delivery frequency, and pull request review time. 66 | 67 | **Benchmark your DevOps performance.** According to research from DORA, Google Cloud's DevOps Research and Assessment team, elite engineering teams have 973x more frequent code deployments and 6570x faster lead time. With Software, you can compare your delivery speed and efficiency with other high-performing engineering organizations in our global community. 68 | 69 | To see your team's DevOps metrics, visit the [web app](https://app.software.com) and connect the [*Software* GitHub App](https://bit.ly/software-github) to your Software account. It's free and takes just a few minutes. 70 | 71 | ## It’s Safe, Secure, and Free 72 | 73 | **We never access your code:** We do not read, transmit, or store source code. We only provide metrics about programming, and we make it easy to see the data we collect. You can learn more about how we secure your data on our [security page](https://www.software.com/security). 74 | 75 | **Your data is private:** We will never share your individually identifiable data with your boss. When you create or join a team, we only show system-level metrics for your GitHub organization. 76 | 77 | **Free for you, forever:** We provide 90 days of data history for free, forever. We provide [premium plans](https://www.software.com/pricing) for advanced features and historical data access. 78 | 79 | Code Time also collects basic usage metrics to help us make informed decisions about our roadmap. 80 | 81 | ## Join the Community 82 | 83 | Enjoying Code Time? Let us know how it’s going by tweeting or following us at [@software_hq](https://twitter.com/software_hq). 84 | 85 | We're also the creators behind Music Time for VS Code, an extension that helps you find your most productive songs for coding. You can learn more [here](https://www.software.com/music-time). 86 | 87 | Have any questions? Create an issue in the [Code Time project](https://github.com/swdotcom/swdc-vscode) on GitHub or send us an email at [support@software.com](mailto:support@software.com) and we’ll get back to you as soon as we can. 88 | -------------------------------------------------------------------------------- /resources/dark/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/light/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Constants.ts: -------------------------------------------------------------------------------- 1 | export const LOGIN_LABEL = 'Log in'; 2 | export const LOGOUT_LABEL = 'Log out'; 3 | export const UNTITLED = 'Untitled'; 4 | export const NO_PROJ_NAME = 'Unnamed'; 5 | export const CODE_TIME_PLUGIN_ID = 2; 6 | export const CODE_TIME_EXT_ID = 'softwaredotcom.swdc-vscode'; 7 | export const MUSIC_TIME_EXT_ID = "softwaredotcom.music-time"; 8 | export const EDITOR_OPS_EXT_ID = "softwaredotcom.editor-ops"; 9 | export const CODE_TIME_TYPE = 'codetime'; 10 | export const YES_LABEL = 'Yes'; 11 | export const SIGN_UP_LABEL = 'Sign up'; 12 | export const DISCONNECT_LABEL = 'Disconnect'; 13 | export const HIDE_CODE_TIME_STATUS_LABEL = 'Hide Code Time status'; 14 | export const SHOW_CODE_TIME_STATUS_LABEL = 'Show Code Time status'; 15 | 16 | const isDev = process.env.APP_ENV === 'development' 17 | export const SOFTWARE_DIRECTORY = isDev ? '.software-dev' : '.software'; 18 | export const websockets_url = isDev ? 'ws://localhost:5001/websockets' : 'wss://api.software.com/websockets'; 19 | export const app_url = isDev ? 'http://localhost:3000' : 'https://app.software.com'; 20 | 21 | export const vscode_issues_url = 'https://github.com/swdotcom/swdc-vscode/issues'; 22 | 23 | export const ONE_MIN_MILLIS = 1000 * 60; 24 | -------------------------------------------------------------------------------- /src/DataController.ts: -------------------------------------------------------------------------------- 1 | import {commands} from 'vscode'; 2 | import {isResponseOk, appGet} from './http/HttpClient'; 3 | import { 4 | getItem, 5 | setItem, 6 | setAuthCallbackState, 7 | logIt, 8 | musicTimeExtInstalled, 9 | editorOpsExtInstalled, 10 | showInformationMessage, 11 | } from './Util'; 12 | import {initializeWebsockets} from './websockets'; 13 | import {SummaryManager} from './managers/SummaryManager'; 14 | import { updateFlowModeStatus } from './managers/FlowManager'; 15 | import { AuthProvider } from './auth/AuthProvider'; 16 | 17 | let currentUser: any | null = null; 18 | let authProvider: AuthProvider | null = null; 19 | 20 | export function initializeAuthProvider(provider: AuthProvider) { 21 | authProvider = provider; 22 | } 23 | 24 | export async function getCachedSlackIntegrations() { 25 | currentUser = await getCachedUser(); 26 | if (currentUser?.integration_connections?.length) { 27 | return currentUser?.integration_connections?.filter( 28 | (integration: any) => integration.status === 'ACTIVE' && (integration.integration_type_id === 14)); 29 | } 30 | return []; 31 | } 32 | 33 | export async function getCachedUser() { 34 | if (!currentUser) { 35 | currentUser = await getUser(); 36 | } 37 | return currentUser; 38 | } 39 | 40 | export function isRegistered() { 41 | return !!getItem('name'); 42 | } 43 | 44 | export async function getUserPreferences() { 45 | currentUser = await getCachedUser() 46 | if (currentUser) { 47 | return currentUser.preferences_parsed; 48 | } 49 | return {} 50 | } 51 | 52 | export async function getUser(token_override: any = '') { 53 | const resp = await appGet('/api/v1/user', {}, token_override); 54 | if (isResponseOk(resp) && resp.data) { 55 | currentUser = resp.data; 56 | return currentUser; 57 | } 58 | return null; 59 | } 60 | 61 | export async function authenticationCompleteHandler(user: any, override_jwt: any = '') { 62 | setAuthCallbackState(null); 63 | 64 | if (user?.registered === 1) { 65 | currentUser = user; 66 | // new user 67 | if (override_jwt) { 68 | setItem('jwt', override_jwt); 69 | } else if (user.plugin_jwt) { 70 | setItem('jwt', user.plugin_jwt); 71 | } 72 | setItem('name', user.email); 73 | setItem('updatedAt', new Date().getTime()); 74 | 75 | setItem('logging_in', false); 76 | // ensure the session is updated 77 | if (authProvider) { 78 | authProvider.updateSession(getItem('jwt'), user); 79 | } 80 | // update the login status 81 | showInformationMessage('Successfully logged on to Code Time'); 82 | 83 | await reload(); 84 | } 85 | } 86 | 87 | export async function userDeletedCompletionHandler() { 88 | commands.executeCommand('codetime.logout'); 89 | } 90 | 91 | export async function reload() { 92 | updateFlowModeStatus(); 93 | 94 | try { 95 | initializeWebsockets(); 96 | } catch (e: any) { 97 | logIt(`Failed to initialize websockets: ${e.message}`); 98 | } 99 | 100 | // re-initialize user and preferences 101 | await getUser(); 102 | 103 | // fetch after logging on 104 | SummaryManager.getInstance().updateSessionSummaryFromServer(); 105 | 106 | if (musicTimeExtInstalled()) { 107 | setTimeout(() => { 108 | commands.executeCommand("musictime.refreshMusicTimeView") 109 | }, 1000); 110 | } 111 | 112 | if (editorOpsExtInstalled()) { 113 | setTimeout(() => { 114 | commands.executeCommand("editorOps.refreshEditorOpsView") 115 | }, 1000); 116 | } 117 | 118 | commands.executeCommand('codetime.refreshCodeTimeView'); 119 | } 120 | -------------------------------------------------------------------------------- /src/Util.ts: -------------------------------------------------------------------------------- 1 | import {workspace, extensions, window, Uri, commands, ViewColumn, WorkspaceFolder, env} from 'vscode'; 2 | import { 3 | CODE_TIME_EXT_ID, 4 | CODE_TIME_PLUGIN_ID, 5 | CODE_TIME_TYPE, 6 | SOFTWARE_DIRECTORY, 7 | MUSIC_TIME_EXT_ID, 8 | EDITOR_OPS_EXT_ID 9 | } from './Constants'; 10 | import { v4 as uuidv4 } from 'uuid'; 11 | 12 | import {showModalSignupPrompt} from './managers/SlackManager'; 13 | import {execCmd} from './managers/ExecManager'; 14 | import {getBooleanJsonItem, getJsonItem, setJsonItem, storeJsonData} from './managers/FileManager'; 15 | import { formatISO } from 'date-fns'; 16 | import { initializeWebsockets, websocketAlive } from './websockets'; 17 | 18 | import * as fs from 'fs'; 19 | import * as path from 'path'; 20 | import * as os from 'os'; 21 | 22 | const outputChannel = window.createOutputChannel('CodeTime'); 23 | 24 | export const alpha = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; 25 | 26 | let workspace_name: string | null = null; 27 | let hostname: string | null = null; 28 | let osUsername: string | null = null; 29 | let editorName: string = ''; 30 | let osName: string = ''; 31 | 32 | export function getRandomNumberWithinRange(min: number, max: number) { 33 | return Math.floor(Math.random() * (max - min) + min); 34 | } 35 | 36 | export function getWorkspaceName() { 37 | if (!workspace_name) { 38 | workspace_name = uuidv4(); 39 | } 40 | return workspace_name; 41 | } 42 | 43 | export function getPluginId() { 44 | return CODE_TIME_PLUGIN_ID; 45 | } 46 | 47 | export function getPluginName() { 48 | return CODE_TIME_EXT_ID; 49 | } 50 | 51 | export function getPluginType() { 52 | return CODE_TIME_TYPE; 53 | } 54 | 55 | export function getVersion() { 56 | const extension = extensions.getExtension(CODE_TIME_EXT_ID); 57 | return extension ? extension.packageJSON.version : '2.5.27'; 58 | } 59 | 60 | export function getEditorName() { 61 | if (!editorName) { 62 | try { 63 | editorName = env.appName 64 | } catch (e) { 65 | editorName = 'vscode' 66 | } 67 | } 68 | return editorName; 69 | } 70 | 71 | export function isGitProject(projectDir: string) { 72 | if (!projectDir) { 73 | return false; 74 | } 75 | 76 | const gitRemotesDir = path.join(projectDir, '.git', 'refs', 'remotes'); 77 | if (!fs.existsSync(gitRemotesDir)) { 78 | return false; 79 | } 80 | return true; 81 | } 82 | 83 | /** 84 | * These will return the workspace folders. 85 | * use the uri.fsPath to get the full path 86 | * use the name to get the folder name 87 | */ 88 | export function getWorkspaceFolders(): WorkspaceFolder[] { 89 | let folders = []; 90 | if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { 91 | for (let i = 0; i < workspace.workspaceFolders.length; i++) { 92 | let workspaceFolder = workspace.workspaceFolders[i]; 93 | let folderUri = workspaceFolder.uri; 94 | if (folderUri && folderUri.fsPath) { 95 | folders.push(workspaceFolder); 96 | } 97 | } 98 | } 99 | return folders; 100 | } 101 | 102 | export function getFirstWorkspaceFolder(): WorkspaceFolder | null { 103 | const workspaceFolders: WorkspaceFolder[] = getWorkspaceFolders(); 104 | if (workspaceFolders && workspaceFolders.length) { 105 | return workspaceFolders[0]; 106 | } 107 | return null; 108 | } 109 | 110 | export function getNumberOfTextDocumentsOpen() { 111 | return workspace.textDocuments ? workspace.textDocuments.length : 0; 112 | } 113 | 114 | export function updateFlowChange(in_flow: boolean) { 115 | setJsonItem(getFlowChangeFile(), "in_flow", in_flow); 116 | } 117 | 118 | export function isFlowModeEnabled() { 119 | // nullish coalesce the "in_flow" flag if it doesn't exist 120 | return getBooleanJsonItem(getFlowChangeFile(), "in_flow") ?? false; 121 | } 122 | 123 | export function setItem(key: string, value: any) { 124 | setJsonItem(getSoftwareSessionFile(), key, value); 125 | } 126 | 127 | export function getItem(key: string) { 128 | return getJsonItem(getSoftwareSessionFile(), key); 129 | } 130 | 131 | export function getBooleanItem(key: string) { 132 | return getBooleanJsonItem(getSoftwareSessionFile(), key); 133 | } 134 | 135 | export function isActiveIntegration(type: string, integration: any) { 136 | if (integration && integration.status.toLowerCase() === "active") { 137 | // handle integration_connection attribute 138 | if (integration.integration_type) { 139 | return !!(integration.integration_type.type.toLowerCase() === type.toLowerCase()) 140 | } 141 | // still hasn't updated to use that in within the file, check the older version attribute 142 | return !!(integration.name.toLowerCase() === type.toLowerCase()) 143 | } 144 | return false; 145 | } 146 | 147 | export function getPluginUuid() { 148 | let plugin_uuid = getJsonItem(getDeviceFile(), 'plugin_uuid'); 149 | if (!plugin_uuid) { 150 | let name = `${getOsUsername()}${getHostname()}`; 151 | if (!name) { 152 | name = getOs(); 153 | } 154 | const hashName = require('crypto') 155 | .createHash('sha1') 156 | .update(name) 157 | .digest('hex'); 158 | plugin_uuid = `${hashName.trim()}:${uuidv4()}`; 159 | // set it for the 1st and only time 160 | setJsonItem(getDeviceFile(), 'plugin_uuid', plugin_uuid); 161 | } 162 | return plugin_uuid; 163 | } 164 | 165 | export function getAuthCallbackState(autoCreate = true) { 166 | let auth_callback_state = getJsonItem(getDeviceFile(), 'auth_callback_state', false); 167 | if (!auth_callback_state && autoCreate) { 168 | auth_callback_state = uuidv4(); 169 | setAuthCallbackState(auth_callback_state); 170 | } 171 | return auth_callback_state; 172 | } 173 | 174 | export function setAuthCallbackState(value: string | null) { 175 | setJsonItem(getDeviceFile(), 'auth_callback_state', value); 176 | } 177 | 178 | export function isLinux() { 179 | return isWindows() || isMac() ? false : true; 180 | } 181 | 182 | // process.platform return the following... 183 | // -> 'darwin', 'freebsd', 'linux', 'sunos' or 'win32' 184 | export function isWindows() { 185 | return process.platform.indexOf('win32') !== -1; 186 | } 187 | 188 | export function isMac() { 189 | return process.platform.indexOf('darwin') !== -1; 190 | } 191 | 192 | export function getHostname(): any { 193 | if (!hostname) { 194 | hostname = execCmd('hostname'); 195 | } 196 | return hostname; 197 | } 198 | 199 | export function getOs() { 200 | if (!osName) { 201 | let parts = []; 202 | let osType = os.type(); 203 | if (osType) { 204 | parts.push(osType); 205 | } 206 | let osRelease = os.release(); 207 | if (osRelease) { 208 | parts.push(osRelease); 209 | } 210 | let platform = os.platform(); 211 | if (platform) { 212 | parts.push(platform); 213 | } 214 | if (parts.length > 0) { 215 | osName = parts.join('_'); 216 | } 217 | } 218 | return osName; 219 | } 220 | 221 | export function getOsUsername() { 222 | if (!osUsername) { 223 | try { 224 | // Throws a SystemError if a user has no username or homedir 225 | osUsername = os.userInfo().username; 226 | } catch (e: any) { 227 | console.error('Username not available.', e.message); 228 | } 229 | 230 | if (!osUsername) { 231 | osUsername = execCmd('whoami'); 232 | } 233 | } 234 | return osUsername; 235 | } 236 | 237 | function getFile(name: string, default_data: any = {}) { 238 | const file_path = getSoftwareDir(); 239 | const file = isWindows() ? `${file_path}\\${name}` : `${file_path}/${name}`; 240 | if (!fs.existsSync(file)) { 241 | storeJsonData(file, default_data); 242 | } 243 | return file; 244 | } 245 | 246 | export function getDeviceFile() { 247 | return getFile('device.json'); 248 | } 249 | 250 | export function getSoftwareSessionFile() { 251 | return getFile('session.json'); 252 | } 253 | 254 | export function getGitEventFile() { 255 | return getFile('gitEvents.json'); 256 | } 257 | 258 | export function getSessionSummaryFile() { 259 | return getFile('sessionSummary.json'); 260 | } 261 | 262 | export function getFlowChangeFile() { 263 | return getFile('flowChange.json'); 264 | } 265 | 266 | export function getExtensionsFile() { 267 | return getFile('extensions.json'); 268 | } 269 | 270 | export function getSoftwareDir() { 271 | const homedir = os.homedir(); 272 | const softwareDataDir = isWindows() ? `${homedir}\\${SOFTWARE_DIRECTORY}` : (process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/${SOFTWARE_DIRECTORY.substring(1)}` : `${homedir}/${SOFTWARE_DIRECTORY}`); 273 | 274 | if (!fs.existsSync(softwareDataDir)) { 275 | fs.mkdirSync(softwareDataDir); 276 | } 277 | return softwareDataDir; 278 | } 279 | 280 | export function getLocalREADMEFile() { 281 | const resourcePath: string = path.join(__dirname, 'resources'); 282 | const file = path.join(resourcePath, 'README.md'); 283 | return file; 284 | } 285 | 286 | export function displayReadme() { 287 | const readmeUri = Uri.file(getLocalREADMEFile()); 288 | 289 | commands.executeCommand('markdown.showPreview', readmeUri, ViewColumn.One); 290 | setItem('vscode_CtReadme', true); 291 | } 292 | 293 | export function getExtensionName() { 294 | return 'swdc-vscode'; 295 | } 296 | 297 | export function getLogId() { 298 | return 'CodeTime'; 299 | } 300 | 301 | export function logIt(message: string, isError: boolean = false) { 302 | const windowMsg: string = isPrimaryWindow() ? '(p)' : ''; 303 | outputChannel.appendLine(`${formatISO(new Date())} ${getLogId()}${windowMsg}: ${message}`); 304 | if (isError) { 305 | console.error(message) 306 | } 307 | } 308 | 309 | export function getOffsetSeconds() { 310 | let d = new Date(); 311 | return d.getTimezoneOffset() * 60; 312 | } 313 | 314 | export function getAuthQueryObject(): URLSearchParams { 315 | const params = new URLSearchParams(); 316 | params.append('plugin_uuid', getPluginUuid()); 317 | params.append('plugin_id', `${getPluginId()}`); 318 | params.append('plugin_version', getVersion()); 319 | params.append('auth_callback_state', getAuthCallbackState(true)); 320 | return params; 321 | } 322 | 323 | export function launchWebUrl(url: string) { 324 | if (!websocketAlive()) { 325 | try { 326 | initializeWebsockets(); 327 | } catch (e) { 328 | console.error('Failed to initialize websockets', e); 329 | } 330 | } 331 | env.openExternal(Uri.parse(url)); 332 | } 333 | 334 | /** 335 | * humanize the minutes 336 | */ 337 | export function humanizeMinutes(min: any) { 338 | min = parseInt(min, 0) || 0; 339 | let str = ''; 340 | if (min === 60) { 341 | str = '1h'; 342 | } else if (min > 60) { 343 | const hours = Math.floor(min / 60); 344 | const minutes = min % 60; 345 | 346 | const hoursStr = Math.floor(hours).toFixed(0) + 'h'; 347 | if ((parseFloat(min) / 60) % 1 === 0) { 348 | str = hoursStr; 349 | } else { 350 | str = `${hoursStr} ${minutes}m`; 351 | } 352 | } else if (min === 1) { 353 | str = '1m'; 354 | } else { 355 | // less than 60 seconds 356 | str = min.toFixed(0) + 'm'; 357 | } 358 | return str; 359 | } 360 | 361 | export function showInformationMessage(message: string) { 362 | logIt(message); 363 | return window.showInformationMessage(`${message}`); 364 | } 365 | 366 | export function showWarningMessage(message: string) { 367 | return window.showWarningMessage(`${message}`); 368 | } 369 | 370 | export function noSpacesProjectDir(projectDir: string): string { 371 | return projectDir.replace(/^\s+/g, ''); 372 | } 373 | 374 | export function checkRegistrationForReport(showSignup = true) { 375 | if (!getItem('name')) { 376 | if (showSignup) { 377 | showModalSignupPrompt( 378 | 'Unlock your personalized dashboard and visualize your coding activity. Create an account to get started.' 379 | ); 380 | } 381 | return false; 382 | } 383 | return true; 384 | } 385 | 386 | export function isPrimaryWindow() { 387 | let workspaceWindow = getItem('vscode_primary_window'); 388 | if (!workspaceWindow) { 389 | // its not set yet, update it to this window 390 | workspaceWindow = getWorkspaceName(); 391 | setItem('vscode_primary_window', workspaceWindow); 392 | } 393 | return !!(workspaceWindow === getWorkspaceName()); 394 | } 395 | 396 | export function musicTimeExtInstalled() { 397 | return !!extensions.getExtension(MUSIC_TIME_EXT_ID); 398 | } 399 | 400 | export function editorOpsExtInstalled() { 401 | return !!extensions.getExtension(EDITOR_OPS_EXT_ID) 402 | } 403 | 404 | export function getFileNameFromPath(filePath: string) { 405 | const parts = isWindows() ? filePath.split('\\') : filePath.split('/'); 406 | return parts[parts.length - 1].split('.')[0]; 407 | } 408 | -------------------------------------------------------------------------------- /src/auth/AuthProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | authentication, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, 3 | AuthenticationSession, Disposable, Event, env, EventEmitter, ExtensionContext, ProgressLocation, 4 | Uri, UriHandler, window 5 | } from "vscode"; 6 | import { v4 as uuid } from 'uuid'; 7 | import { app_url } from "../Constants"; 8 | import { getAuthQueryObject, getBooleanItem, logIt, setItem } from "../Util"; 9 | import { authenticationCompleteHandler, getUser } from "../DataController"; 10 | 11 | export const AUTH_TYPE = 'codetime_auth'; 12 | const AUTH_NAME = 'Software.com'; 13 | const SESSIONS_KEY = `${AUTH_TYPE}.sessions` 14 | 15 | let instance: AuthProvider; 16 | 17 | export function getAuthInstance(): AuthProvider { 18 | if (!instance) { 19 | logIt('AuthenticationProvider not initialized'); 20 | } 21 | return instance; 22 | } 23 | 24 | class UriEventHandler extends EventEmitter implements UriHandler { 25 | public handleUri(uri: Uri) { 26 | this.fire(uri); 27 | } 28 | } 29 | 30 | export class AuthProvider implements AuthenticationProvider, Disposable { 31 | private _sessionChangeEmitter = new EventEmitter(); 32 | private _disposable: Disposable; 33 | private _pendingStates: string[] = []; 34 | private _codeExchangePromises = new Map; cancel: EventEmitter }>(); 35 | private _uriHandler = new UriEventHandler(); 36 | 37 | constructor(private readonly context: ExtensionContext) { 38 | this._disposable = Disposable.from( 39 | authentication.registerAuthenticationProvider(AUTH_TYPE, AUTH_NAME, this, { supportsMultipleAccounts: false }), 40 | window.registerUriHandler(this._uriHandler) 41 | ) 42 | instance = this; 43 | } 44 | 45 | get onDidChangeSessions() { 46 | return this._sessionChangeEmitter.event; 47 | } 48 | 49 | get redirectUri() { 50 | const publisher = this.context.extension.packageJSON.publisher; 51 | const name = this.context.extension.packageJSON.name; 52 | return `${env.uriScheme}://${publisher}.${name}`; 53 | } 54 | 55 | /** 56 | * Get the existing sessions 57 | * @param scopes 58 | * @returns 59 | */ 60 | public async getSessions(scopes?: string[]): Promise { 61 | const allSessions = await this.context.secrets.get(SESSIONS_KEY); 62 | 63 | if (allSessions) { 64 | return JSON.parse(allSessions) as AuthenticationSession[]; 65 | } 66 | 67 | return []; 68 | } 69 | 70 | public async updateSession(jwtToken: string, user: any = null): Promise { 71 | let session: AuthenticationSession = { 72 | id: uuid(), 73 | accessToken: jwtToken, 74 | account: { 75 | label: '', 76 | id: '' 77 | }, 78 | scopes: [] 79 | } 80 | try { 81 | const sessionUpdate = !!user 82 | if (!user) { 83 | user = await getUser(jwtToken); 84 | await authenticationCompleteHandler(user, jwtToken); 85 | } 86 | 87 | session = { 88 | id: uuid(), 89 | accessToken: jwtToken, 90 | account: { 91 | label: user.email, 92 | id: user.id 93 | }, 94 | scopes: [] 95 | }; 96 | 97 | await this.context.secrets.store(SESSIONS_KEY, JSON.stringify([session])) 98 | 99 | if (sessionUpdate) { 100 | this._sessionChangeEmitter.fire({ added: [], removed: [], changed: [session] }); 101 | } else { 102 | this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] }); 103 | } 104 | } catch (e: any) { 105 | if (e.message) { 106 | logIt(`Error creating session: ${e?.message}`); 107 | } 108 | } 109 | return session; 110 | } 111 | 112 | /** 113 | * Create a new auth session 114 | * @param scopes 115 | * @returns 116 | */ 117 | public async createSession(scopes: string[]): Promise { 118 | const jwtToken = await this.login(scopes); 119 | if (!jwtToken) { 120 | throw new Error(`Software.com login failure`); 121 | } 122 | return this.updateSession(jwtToken); 123 | } 124 | 125 | /** 126 | * Remove an existing session 127 | * @param sessionId 128 | */ 129 | public async removeSession(sessionId: string): Promise { 130 | const allSessions = await this.context.secrets.get(SESSIONS_KEY); 131 | if (allSessions) { 132 | let sessions = JSON.parse(allSessions) as AuthenticationSession[]; 133 | const sessionIdx = sessions.findIndex(s => s.id === sessionId); 134 | const session = sessions[sessionIdx]; 135 | sessions.splice(sessionIdx, 1); 136 | 137 | await this.context.secrets.store(SESSIONS_KEY, JSON.stringify(sessions)); 138 | 139 | if (session) { 140 | this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] }); 141 | } 142 | } 143 | } 144 | 145 | /** 146 | * Dispose the registered services 147 | */ 148 | public async dispose() { 149 | this._disposable.dispose(); 150 | } 151 | 152 | /** 153 | * Auth Log 154 | */ 155 | private async login(scopes: string[] = []) { 156 | return await window.withProgress({ 157 | location: ProgressLocation.Notification, 158 | title: "Signing in to Software.com...", 159 | cancellable: true 160 | }, async (_, token) => { 161 | setItem('logging_in', true); 162 | const stateId = uuid(); 163 | 164 | this._pendingStates.push(stateId); 165 | 166 | const scopeString = scopes.join(' '); 167 | let params: URLSearchParams = getAuthQueryObject(); 168 | params.append('response_type', 'token'); 169 | params.append('redirect_uri', this.redirectUri); 170 | params.append('state', stateId); 171 | params.append('prompt', 'login'); 172 | const uri = Uri.parse(`${app_url}/plugin/authorize?${params.toString()}`); 173 | await env.openExternal(uri); 174 | 175 | let codeExchangePromise = this._codeExchangePromises.get(scopeString); 176 | if (!codeExchangePromise) { 177 | codeExchangePromise = promiseFromEvent(this._uriHandler.event, this.handleUri(scopes)); 178 | this._codeExchangePromises.set(scopeString, codeExchangePromise); 179 | } 180 | 181 | try { 182 | return await Promise.race([ 183 | codeExchangePromise.promise, 184 | // 2 minute timeout 185 | new Promise((_, reject) => setTimeout(() => reject('Cancelled'), 120000)), 186 | // websocket login check 187 | new Promise((_, reject) => { 188 | const interval = setInterval(async () => { 189 | if (getBooleanItem('logging_in') === false) { 190 | clearInterval(interval); 191 | reject('Cancelled'); 192 | } 193 | }, 1500); 194 | }), 195 | // cancel button 196 | promiseFromEvent(token.onCancellationRequested, (_, __, reject) => { reject('Login Cancelled'); }).promise 197 | ]); 198 | } finally { 199 | this._pendingStates = this._pendingStates.filter(n => n !== stateId); 200 | codeExchangePromise?.cancel.fire(); 201 | this._codeExchangePromises.delete(scopeString); 202 | // reset logging_in flag 203 | setItem('logging_in', false); 204 | } 205 | }); 206 | } 207 | 208 | /** 209 | * Handle the redirect to VS Code (after sign in from Auth0) 210 | * @param scopes 211 | * @returns 212 | */ 213 | private handleUri: (scopes: readonly string[]) => PromiseAdapter = 214 | (scopes) => async (uri, resolve, reject) => { 215 | const query = new URLSearchParams(uri.query); 216 | const access_token = query.get('access_token'); 217 | const state = query.get('state'); 218 | 219 | if (!access_token) { 220 | reject(new Error('Authentication token not found')); 221 | return; 222 | } 223 | if (!state) { 224 | reject(new Error('Authentication state not found')); 225 | return; 226 | } 227 | 228 | // Check if it is a valid auth request started by the extension 229 | if (!this._pendingStates.some(n => n === state)) { 230 | reject(new Error('Authentication state not found')); 231 | return; 232 | } 233 | 234 | resolve(access_token); 235 | } 236 | } 237 | 238 | export interface PromiseAdapter { 239 | ( 240 | value: T, 241 | resolve: 242 | (value: U | PromiseLike) => void, 243 | reject: 244 | (reason: any) => void 245 | ): any; 246 | } 247 | 248 | const passthrough = (value: any, resolve: (value?: any) => void) => resolve(value); 249 | 250 | /** 251 | * Return a promise that resolves with the next emitted event, or with some future 252 | * event as decided by an adapter. 253 | * 254 | * If specified, the adapter is a function that will be called with 255 | * `(event, resolve, reject)`. It will be called once per event until it resolves or 256 | * rejects. 257 | * 258 | * The default adapter is the passthrough function `(value, resolve) => resolve(value)`. 259 | * 260 | * @param event the event 261 | * @param adapter controls resolution of the returned promise 262 | * @returns a promise that resolves or rejects as specified by the adapter 263 | */ 264 | export function promiseFromEvent(event: Event, adapter: PromiseAdapter = passthrough): { promise: Promise; cancel: EventEmitter } { 265 | let subscription: Disposable; 266 | let cancel = new EventEmitter(); 267 | 268 | return { 269 | promise: new Promise((resolve, reject) => { 270 | cancel.event(_ => reject('Cancelled')); 271 | subscription = event((value: T) => { 272 | try { 273 | Promise.resolve(adapter(value, resolve, reject)) 274 | .catch(reject); 275 | } catch (error) { 276 | reject(error); 277 | } 278 | }); 279 | }).then( 280 | (result: U) => { 281 | subscription.dispose(); 282 | return result; 283 | }, 284 | error => { 285 | subscription.dispose(); 286 | throw error; 287 | } 288 | ), 289 | cancel 290 | }; 291 | } 292 | 293 | -------------------------------------------------------------------------------- /src/cache/CacheManager.ts: -------------------------------------------------------------------------------- 1 | const NodeCache = require("node-cache"); 2 | 3 | export class CacheManager { 4 | private static instance: CacheManager; 5 | private myCache; 6 | 7 | private constructor() { 8 | // default cache of 2 minutes 9 | this.myCache = new NodeCache({ stdTTL: 120 }); 10 | } 11 | 12 | static getInstance(): CacheManager { 13 | if (!CacheManager.instance) { 14 | CacheManager.instance = new CacheManager(); 15 | } 16 | 17 | return CacheManager.instance; 18 | } 19 | 20 | get(key: string) { 21 | return this.myCache.get(key); 22 | } 23 | 24 | set(key: string, value: any, ttl: number = -1) { 25 | if (ttl > 0) { 26 | this.myCache.set(key, value, ttl); 27 | } else { 28 | // use the standard cache ttl 29 | this.myCache.set(key, value); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/command-helper.ts: -------------------------------------------------------------------------------- 1 | import {commands, Disposable, window, ExtensionContext, authentication} from 'vscode'; 2 | import {launchWebUrl, displayReadme, setItem, showInformationMessage} from './Util'; 3 | import {KpmManager} from './managers/KpmManager'; 4 | import {KpmItem} from './model/models'; 5 | import {createAnonymousUser, authLogin} from './menu/AccountManager'; 6 | import {app_url, vscode_issues_url} from './Constants'; 7 | import {enableFlow, pauseFlow} from './managers/FlowManager'; 8 | import {showDashboard} from './managers/WebViewManager'; 9 | import {closeSettings, configureSettings, updateSettings} from './managers/ConfigManager'; 10 | import {toggleStatusBar, updateFlowModeStatusBar, updateStatusBarWithSummaryData} from './managers/StatusBarManager'; 11 | import {CodeTimeView} from './sidebar/CodeTimeView'; 12 | import { progressIt } from './managers/ProgressManager'; 13 | import { LocalStorageManager } from './managers/LocalStorageManager'; 14 | import { getCachedUser, reload } from './DataController'; 15 | import { AUTH_TYPE, getAuthInstance } from './auth/AuthProvider'; 16 | 17 | export function createCommands( 18 | ctx: ExtensionContext, 19 | kpmController: KpmManager, 20 | storageManager: LocalStorageManager 21 | ): { 22 | dispose: () => void; 23 | } { 24 | let cmds = []; 25 | ctx.subscriptions.push(getAuthInstance()); 26 | 27 | cmds.push(kpmController); 28 | 29 | // INITALIZE SIDEBAR WEB VIEW PROVIDER 30 | const sidebar: CodeTimeView = new CodeTimeView(ctx.extensionUri); 31 | cmds.push( 32 | commands.registerCommand('codetime.softwareKpmDashboard', () => { 33 | launchWebUrl(`${app_url}/dashboard/code_time`) 34 | }) 35 | ) 36 | 37 | cmds.push( 38 | window.registerWebviewViewProvider('codetime.webView', sidebar, { 39 | webviewOptions: { 40 | retainContextWhenHidden: false, 41 | }, 42 | }) 43 | ); 44 | 45 | // REFRESH EDITOR OPS SIDEBAR 46 | cmds.push( 47 | commands.registerCommand('codetime.refreshCodeTimeView', () => { 48 | sidebar.refresh(); 49 | }) 50 | ); 51 | 52 | // DISPLAY EDITOR OPS SIDEBAR 53 | cmds.push( 54 | commands.registerCommand('codetime.displaySidebar', () => { 55 | // opens the sidebar manually from a the above command 56 | commands.executeCommand('workbench.view.extension.code-time-sidebar'); 57 | }) 58 | ); 59 | 60 | // TOGGLE STATUS BAR METRIC VISIBILITY 61 | cmds.push( 62 | commands.registerCommand('codetime.toggleStatusBar', () => { 63 | toggleStatusBar(); 64 | commands.executeCommand('codetime.refreshCodeTimeView'); 65 | }) 66 | ); 67 | 68 | // LAUNCH SWITCH ACCOUNT 69 | cmds.push( 70 | commands.registerCommand('codetime.switchAccount', () => { 71 | authLogin(); 72 | }) 73 | ); 74 | 75 | // LAUNCH EMAIL LOGIN 76 | cmds.push( 77 | commands.registerCommand('codetime.codeTimeLogin', (item: KpmItem) => { 78 | authLogin(); 79 | }) 80 | ); 81 | 82 | // LAUNCH EMAIL LOGIN 83 | cmds.push( 84 | commands.registerCommand('codetime.codeTimeSignup', (item: KpmItem) => { 85 | authLogin(); 86 | }) 87 | ); 88 | 89 | // LAUNCH SIGN UP FLOW 90 | cmds.push( 91 | commands.registerCommand('codetime.registerAccount', () => { 92 | authLogin(); 93 | }) 94 | ); 95 | 96 | // LAUNCH EXISTING ACCOUNT LOGIN 97 | cmds.push( 98 | commands.registerCommand('codetime.login', () => { 99 | authLogin(); 100 | }) 101 | ); 102 | 103 | // LAUNCH GOOGLE LOGIN 104 | cmds.push( 105 | commands.registerCommand('codetime.googleLogin', (item: KpmItem) => { 106 | authLogin(); 107 | }) 108 | ); 109 | 110 | // LAUNCH GITHUB LOGIN 111 | cmds.push( 112 | commands.registerCommand('codetime.githubLogin', (item: KpmItem) => { 113 | authLogin(); 114 | }) 115 | ); 116 | 117 | // SUBMIT AN ISSUE 118 | cmds.push( 119 | commands.registerCommand('codetime.submitAnIssue', (item: KpmItem) => { 120 | launchWebUrl(vscode_issues_url); 121 | }) 122 | ); 123 | 124 | // DISPLAY README MD 125 | cmds.push( 126 | commands.registerCommand('codetime.displayReadme', () => { 127 | displayReadme(); 128 | }) 129 | ); 130 | 131 | // DISPLAY PROJECT METRICS REPORT 132 | cmds.push( 133 | commands.registerCommand('codetime.viewProjectReports', () => { 134 | launchWebUrl(`${app_url}/code_time/reports`); 135 | }) 136 | ); 137 | 138 | // DISPLAY CODETIME DASHBOARD WEBVIEW 139 | cmds.push( 140 | commands.registerCommand('codetime.viewDashboard', (params: any) => { 141 | showDashboard(params); 142 | }) 143 | ); 144 | 145 | cmds.push( 146 | commands.registerCommand('codetime.connectSlack', () => { 147 | launchWebUrl(`${app_url}/code_time/integration_type/slack`); 148 | }) 149 | ); 150 | 151 | cmds.push( 152 | commands.registerCommand('codetime.enableFlowMode', () => { 153 | enableFlow({automated: false}); 154 | }) 155 | ); 156 | 157 | cmds.push( 158 | commands.registerCommand('codetime.exitFlowMode', () => { 159 | pauseFlow(); 160 | }) 161 | ); 162 | 163 | cmds.push( 164 | commands.registerCommand('codetime.manageSlackConnection', () => { 165 | launchWebUrl(`${app_url}/code_time/integration_type/slack`); 166 | }) 167 | ); 168 | 169 | cmds.push( 170 | commands.registerCommand('codetime.skipSlackConnect', () => { 171 | setItem('vscode_CtskipSlackConnect', true); 172 | // refresh the view 173 | commands.executeCommand('codetime.refreshCodeTimeView'); 174 | }) 175 | ); 176 | 177 | cmds.push( 178 | commands.registerCommand('codetime.updateViewMetrics', () => { 179 | updateFlowModeStatusBar(); 180 | updateStatusBarWithSummaryData(); 181 | }) 182 | ); 183 | 184 | // Close the settings view 185 | cmds.push( 186 | commands.registerCommand('codetime.closeSettings', (payload: any) => { 187 | closeSettings(); 188 | }) 189 | ); 190 | 191 | cmds.push( 192 | commands.registerCommand('codetime.configureSettings', () => { 193 | configureSettings(); 194 | }) 195 | ); 196 | 197 | cmds.push( 198 | commands.registerCommand('codetime.updateSidebarSettings', (payload: any) => { 199 | progressIt('Updating settings...', updateSettings, [payload.path, payload.json, true]); 200 | }) 201 | ); 202 | 203 | // Update the settings preferences 204 | cmds.push( 205 | commands.registerCommand('codetime.updateSettings', (payload: any) => { 206 | progressIt('Updating settings...', updateSettings, [payload.path, payload.json]); 207 | }) 208 | ); 209 | 210 | // show the org overview 211 | cmds.push( 212 | commands.registerCommand('codetime.showOrgDashboard', (slug: string) => { 213 | launchWebUrl(`${app_url}/organizations/${slug}`); 214 | }) 215 | ); 216 | 217 | // show the connect org view 218 | cmds.push( 219 | commands.registerCommand('codetime.createOrg', () => { 220 | launchWebUrl(`${app_url}/organizations/new`); 221 | }) 222 | ); 223 | 224 | // show the Software.com flow mode info 225 | cmds.push( 226 | commands.registerCommand('codetime.displayFlowModeInfo', () => { 227 | launchWebUrl("https://www.software.com/src/auto-flow-mode"); 228 | }) 229 | ) 230 | 231 | cmds.push( 232 | commands.registerCommand('codetime.logout', async () => { 233 | const user = await getCachedUser() 234 | if (user?.registered) { 235 | // clear the storage and recreate an anon user 236 | storageManager.clearStorage(); 237 | 238 | // reset the user session 239 | await createAnonymousUser(); 240 | 241 | // update the login status 242 | showInformationMessage(`Successfully logged out of your Code Time account`); 243 | await reload() 244 | } 245 | }) 246 | ) 247 | 248 | cmds.push( 249 | commands.registerCommand('codetime.authSignIn', async () => { 250 | authLogin(); 251 | }) 252 | ) 253 | 254 | cmds.push( 255 | authentication.onDidChangeSessions(async e => { 256 | await authentication.getSession(AUTH_TYPE, ['profile'], { createIfNone: false }); 257 | }) 258 | ) 259 | 260 | return Disposable.from(...cmds); 261 | } 262 | -------------------------------------------------------------------------------- /src/events/KpmItems.ts: -------------------------------------------------------------------------------- 1 | import {KpmItem, UIInteractionType} from '../model/models'; 2 | 3 | export function configureSettingsKpmItem(): KpmItem { 4 | const item: KpmItem = new KpmItem(); 5 | item.name = 'ct_configure_settings_btn'; 6 | item.description = 'End of day notification - configure settings'; 7 | item.location = 'ct_notification'; 8 | item.label = 'Settings'; 9 | item.interactionType = UIInteractionType.Click; 10 | item.interactionIcon = null; 11 | item.color = null; 12 | return item; 13 | } 14 | 15 | export function showMeTheDataKpmItem(): KpmItem { 16 | const item: KpmItem = new KpmItem(); 17 | item.name = 'ct_show_me_the_data_btn'; 18 | item.description = 'End of day notification - Show me the data'; 19 | item.location = 'ct_notification'; 20 | item.label = 'Show me the data'; 21 | item.interactionType = UIInteractionType.Click; 22 | item.interactionIcon = null; 23 | item.color = null; 24 | return item; 25 | } 26 | 27 | export function getActionButton( 28 | label: string, 29 | tooltip: string, 30 | command: string, 31 | icon: any | null = null, 32 | eventDescription: string = '', 33 | color: any | null = null, 34 | description: string | null = '' 35 | ): KpmItem { 36 | const item: KpmItem = new KpmItem(); 37 | item.tooltip = tooltip ?? ''; 38 | item.label = label; 39 | item.id = label; 40 | item.command = command; 41 | item.icon = icon; 42 | item.contextValue = 'action_button'; 43 | item.eventDescription = eventDescription; 44 | item.color = color; 45 | item.description = description; 46 | return item; 47 | } 48 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Software. All Rights Reserved. 2 | 3 | // The module 'vscode' contains the VS Code extensibility API 4 | // Import the module and reference it with the alias vscode in your code below 5 | import {window, ExtensionContext, commands, authentication} from 'vscode'; 6 | import {getUser, initializeAuthProvider} from './DataController'; 7 | import {onboardInit} from './user/OnboardManager'; 8 | import { 9 | getVersion, 10 | logIt, 11 | getPluginName, 12 | getItem, 13 | setItem, 14 | getWorkspaceName, 15 | isPrimaryWindow, 16 | displayReadme, 17 | getRandomNumberWithinRange, 18 | getBooleanItem 19 | } from './Util'; 20 | import {createCommands} from './command-helper'; 21 | import {KpmManager} from './managers/KpmManager'; 22 | import {TrackerManager} from './managers/TrackerManager'; 23 | import {initializeWebsockets, disposeWebsocketTimeouts} from './websockets'; 24 | import { 25 | initializeStatusBar, 26 | updateFlowModeStatusBar, 27 | updateStatusBarWithSummaryData, 28 | } from './managers/StatusBarManager'; 29 | import {SummaryManager} from './managers/SummaryManager'; 30 | import {SyncManager} from './managers/SyncManger'; 31 | import {ChangeStateManager} from './managers/ChangeStateManager'; 32 | import {initializeFlowModeState} from './managers/FlowManager'; 33 | import { ExtensionManager } from './managers/ExtensionManager'; 34 | import { LocalStorageManager } from './managers/LocalStorageManager'; 35 | import { setEndOfDayNotification } from './notifications/endOfDay'; 36 | import { AUTH_TYPE, AuthProvider } from './auth/AuthProvider'; 37 | 38 | let currentColorKind: number | undefined = undefined; 39 | let storageManager: LocalStorageManager | undefined = undefined; 40 | let user: any = null; 41 | 42 | const tracker: TrackerManager = TrackerManager.getInstance(); 43 | 44 | // 45 | // Add the keystroke controller to the ext ctx, which 46 | // will then listen for text document changes. 47 | // 48 | const kpmController: KpmManager = KpmManager.getInstance(); 49 | 50 | export function deactivate(ctx: ExtensionContext) { 51 | // store the deactivate event 52 | tracker.trackEditorAction('editor', 'deactivate'); 53 | 54 | TrackerManager.getInstance().dispose(); 55 | ChangeStateManager.getInstance().dispose(); 56 | ExtensionManager.getInstance().dispose(); 57 | 58 | // dispose the file watchers 59 | kpmController.dispose(); 60 | 61 | if (isPrimaryWindow()) { 62 | if (storageManager) storageManager.clearDupStorageKeys(); 63 | } 64 | 65 | disposeWebsocketTimeouts(); 66 | } 67 | 68 | export async function activate(ctx: ExtensionContext) { 69 | const authProvider:AuthProvider = new AuthProvider(ctx); 70 | storageManager = LocalStorageManager.getInstance(ctx); 71 | initializeSession(storageManager); 72 | initializeAuthProvider(authProvider); 73 | 74 | // add the code time commands 75 | ctx.subscriptions.push(createCommands(ctx, kpmController, storageManager)); 76 | TrackerManager.storageMgr = storageManager; 77 | 78 | // session: {id: , accessToken: , account: {label: , id: }, scopes: [,...]} 79 | const session = await authentication.getSession(AUTH_TYPE, [], { createIfNone: false }); 80 | let jwt = getItem('jwt'); 81 | user = await getUser(); 82 | if (session) { 83 | // fetch the user with the non-session jwt to compare 84 | if (!user || user.email != session.account.label) { 85 | jwt = session.accessToken; 86 | // update the local storage with the new user 87 | setItem('name', session.account.label); 88 | setItem('jwt', jwt); 89 | user = await getUser(jwt); 90 | } 91 | } else if (jwt && user?.registered) { 92 | // update the session with the existing jwt 93 | authProvider.updateSession(jwt, user); 94 | } 95 | 96 | if (jwt) { 97 | intializePlugin(); 98 | } else if (window.state.focused) { 99 | onboardInit(ctx, intializePlugin /*successFunction*/); 100 | } else { 101 | // 5 to 10 second delay 102 | const secondDelay = getRandomNumberWithinRange(6, 10); 103 | setTimeout(() => { 104 | onboardInit(ctx, intializePlugin /*successFunction*/); 105 | }, 1000 * secondDelay); 106 | } 107 | } 108 | 109 | export async function intializePlugin() { 110 | logIt(`Loaded ${getPluginName()} v${getVersion()}`); 111 | 112 | // INIT websockets 113 | try { 114 | initializeWebsockets(); 115 | } catch (e: any) { 116 | logIt(`Failed to initialize websockets: ${e.message}`); 117 | } 118 | 119 | // INIT keystroke analysis tracker 120 | await tracker.init(); 121 | 122 | // initialize user and preferences 123 | if (!user) user = await getUser(); 124 | 125 | // show the sidebar if this is the 1st 126 | if (!getBooleanItem('vscode_CtInit')) { 127 | setItem('vscode_CtInit', true); 128 | 129 | setTimeout(() => { 130 | commands.executeCommand('codetime.displaySidebar'); 131 | }, 1000); 132 | 133 | displayReadme(); 134 | } 135 | 136 | initializeStatusBar(); 137 | 138 | if (isPrimaryWindow()) { 139 | // store the activate event 140 | tracker.trackEditorAction('editor', 'activate'); 141 | // it's the primary window. initialize flow mode and session summary information 142 | initializeFlowModeState(); 143 | SummaryManager.getInstance().updateSessionSummaryFromServer(); 144 | } else { 145 | // it's a secondary window. update the statusbar 146 | updateFlowModeStatusBar(); 147 | updateStatusBarWithSummaryData(); 148 | } 149 | 150 | setTimeout(() => { 151 | // INIT doc change events 152 | ChangeStateManager.getInstance(); 153 | 154 | // INIT extension manager change listener 155 | ExtensionManager.getInstance().initialize(); 156 | 157 | // INIT session summary sync manager 158 | SyncManager.getInstance(); 159 | }, 3000); 160 | 161 | setTimeout(() => { 162 | // Set the end of the day notification trigger if it's enabled 163 | setEndOfDayNotification(); 164 | }, 5000); 165 | } 166 | 167 | export function getCurrentColorKind() { 168 | if (!currentColorKind) { 169 | currentColorKind = window.activeColorTheme.kind; 170 | } 171 | return currentColorKind; 172 | } 173 | 174 | function initializeSession(storageManager: LocalStorageManager) { 175 | if (window.state.focused) { 176 | setItem('vscode_primary_window', getWorkspaceName()); 177 | if (storageManager) storageManager.clearDupStorageKeys(); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/http/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { version, window } from 'vscode'; 3 | import { app_url } from '../Constants'; 4 | import { 5 | logIt, 6 | getPluginId, 7 | getPluginName, 8 | getVersion, 9 | getOs, 10 | getPluginUuid, 11 | getItem, 12 | setItem 13 | } from '../Util'; 14 | 15 | // build the axios client 16 | const appApi: any = axios.create({ 17 | baseURL: app_url, 18 | timeout: 15000, 19 | headers: { 20 | 'X-SWDC-Plugin-Id': getPluginId(), 21 | 'X-SWDC-Plugin-Name': getPluginName(), 22 | 'X-SWDC-Plugin-Version': getVersion(), 23 | 'X-SWDC-Plugin-OS': getOs(), 24 | 'X-SWDC-Plugin-UUID': getPluginUuid(), 25 | 'X-SWDC-Plugin-Type': 'codetime', 26 | 'X-SWDC-Plugin-Editor': 'vscode', 27 | 'X-SWDC-Plugin-Editor-Version': version 28 | } 29 | }); 30 | 31 | // Evaluate these headers on every request since these values can change 32 | async function dynamicHeaders(override_token?: string) { 33 | let headers: any = { 34 | 'X-SWDC-Is-Light-Mode': (!!(window.activeColorTheme.kind === 1)).toString(), 35 | 'X-SWDC-Plugin-TZ': Intl.DateTimeFormat().resolvedOptions().timeZone, 36 | 'X-SWDC-Plugin-Offset': new Date().getTimezoneOffset() 37 | } 38 | 39 | const token = await getAuthorization() 40 | 41 | if (token || override_token) { 42 | if (override_token) { 43 | headers['Authorization'] = override_token; 44 | } else { 45 | headers['Authorization'] = token; 46 | } 47 | } 48 | 49 | return headers 50 | } 51 | 52 | export async function appGet(api: string, queryParams: any = {}, token_override: any = '') { 53 | return await appApi.get(api, { params: queryParams, headers: await dynamicHeaders(token_override) }).catch((err: any) => { 54 | logIt(`error for GET ${api}, message: ${err.message}`); 55 | if (getResponseStatus(err?.response) === 401) { 56 | // clear the JWT because it is invalid 57 | setItem('jwt', null) 58 | } 59 | return err; 60 | }); 61 | } 62 | 63 | export async function appPut(api: string, payload: any) { 64 | return await appApi.put(api, payload, { headers: await dynamicHeaders() }).catch((err: any) => { 65 | logIt(`error for PUT ${api}, message: ${err.message}`); 66 | return err; 67 | }); 68 | } 69 | 70 | export async function appPost(api: string, payload: any) { 71 | return await appApi.post(api, payload, { headers: await dynamicHeaders() }).catch((err: any) => { 72 | logIt(`error for POST ${api}, message: ${err.message}`); 73 | return err; 74 | }); 75 | } 76 | 77 | export async function appDelete(api: string) { 78 | return await appApi.delete(api, { headers: await dynamicHeaders() }).catch((err: any) => { 79 | logIt(`error for DELETE ${api}, message: ${err.message}`); 80 | return err; 81 | }); 82 | } 83 | 84 | /** 85 | * check if the reponse is ok or not 86 | * axios always sends the following 87 | * status:200 88 | * statusText:"OK" 89 | * 90 | code:"ENOTFOUND" 91 | config:Object {adapter: , transformRequest: Object, transformResponse: Object, …} 92 | errno:"ENOTFOUND" 93 | host:"api.spotify.com" 94 | hostname:"api.spotify.com" 95 | message:"getaddrinfo ENOTFOUND api.spotify.com api.spotify.com:443" 96 | port:443 97 | */ 98 | export function isResponseOk(resp: any) { 99 | let status = getResponseStatus(resp); 100 | if (status && resp && status < 300) { 101 | return true; 102 | } 103 | return false; 104 | } 105 | 106 | function getResponseStatus(resp: any) { 107 | let status = null; 108 | if (resp?.status) { 109 | status = resp.status; 110 | } else if (resp?.response && resp.response.status) { 111 | status = resp.response.status; 112 | } else if (resp?.code === 'ECONNABORTED') { 113 | status = 500; 114 | } else if (resp?.code === 'ECONNREFUSED') { 115 | status = 503; 116 | } 117 | return status; 118 | } 119 | 120 | async function getAuthorization() { 121 | const token = getItem('jwt'); 122 | 123 | // Split the string and return the last portion incase it has a prefix like `JWT ` 124 | return token?.trim().split(' ').at(-1); 125 | } 126 | -------------------------------------------------------------------------------- /src/local/404.ts: -------------------------------------------------------------------------------- 1 | export async function getConnectionErrorHtml() { 2 | return ` 3 | 4 | 5 | 6 | 7 | Code Time 8 | 67 | 78 | 79 | 80 |
81 |

Oops! Something went wrong.

82 |
83 |

84 | It looks like this view is temporarily unavailable, but we're working to fix the problem. 85 |

86 |

87 | Keep an eye on our status page or reach out to us at support@software.com if you need help. 88 |

89 |
90 |
91 | Refresh 92 |
93 |
94 | 95 | `; 96 | } 97 | -------------------------------------------------------------------------------- /src/managers/ChangeStateManager.ts: -------------------------------------------------------------------------------- 1 | import {commands, Disposable, window, workspace} from 'vscode'; 2 | import {TrackerManager} from './TrackerManager'; 3 | import {EditorFlow, EditorType, FlowEventType, ProjectChangeInfo, VSCodeInterface} from '@swdotcom/editor-flow'; 4 | import {configureSettings, showingConfigureSettingsPanel} from './ConfigManager'; 5 | import {getWorkspaceName, isPrimaryWindow, setItem} from '../Util'; 6 | import { checkWebsocketConnection } from '../websockets'; 7 | 8 | export class ChangeStateManager { 9 | private static instance: ChangeStateManager; 10 | private disposable: Disposable; 11 | private tracker: TrackerManager; 12 | 13 | constructor() { 14 | let subscriptions: Disposable[] = []; 15 | 16 | this.tracker = TrackerManager.getInstance(); 17 | 18 | const iface: VSCodeInterface = { 19 | disposable: Disposable, 20 | window: window, 21 | workspace: workspace, 22 | }; 23 | 24 | const editorFlow: EditorFlow = EditorFlow.getInstance(EditorType.VSCODE, iface); 25 | const emitter: any = editorFlow.getEmitter(); 26 | 27 | emitter.on('editor_flow_data', (data: any) => { 28 | switch (data.flow_event_type) { 29 | case FlowEventType.SAVE: 30 | this.fileSaveHandler(data.event); 31 | break; 32 | case FlowEventType.UNFOCUS: 33 | this.windowStateChangeHandler(data.event); 34 | break; 35 | case FlowEventType.FOCUS: 36 | this.windowStateChangeHandler(data.event); 37 | break; 38 | case FlowEventType.THEME: 39 | this.themeKindChangeHandler(data.event); 40 | break; 41 | case FlowEventType.KPM: 42 | // get the project_change_info attribute and post it 43 | this.kpmHandler(data.project_change_info); 44 | break; 45 | } 46 | }); 47 | 48 | this.disposable = Disposable.from(...subscriptions); 49 | } 50 | 51 | static getInstance(): ChangeStateManager { 52 | if (!ChangeStateManager.instance) { 53 | ChangeStateManager.instance = new ChangeStateManager(); 54 | } 55 | 56 | return ChangeStateManager.instance; 57 | } 58 | 59 | private kpmHandler(projectChangeInfo: ProjectChangeInfo) { 60 | this.tracker.trackCodeTimeEvent(projectChangeInfo); 61 | } 62 | 63 | private fileSaveHandler(event: any) { 64 | this.tracker.trackEditorAction('file', 'save', event); 65 | } 66 | 67 | private windowStateChangeHandler(event: any) { 68 | if (event.focused) { 69 | this.tracker.trackEditorAction('editor', 'focus'); 70 | setItem('vscode_primary_window', getWorkspaceName()); 71 | // check if the websocket connection is stale 72 | checkWebsocketConnection(); 73 | } else if (isPrimaryWindow() && event.active) { 74 | // primary editor window is unfocused 75 | this.tracker.trackEditorAction('editor', 'unfocus'); 76 | } 77 | } 78 | 79 | private themeKindChangeHandler(event: any) { 80 | // let the sidebar know the new current color kind 81 | setTimeout(() => { 82 | commands.executeCommand('codetime.refreshCodeTimeView'); 83 | if (showingConfigureSettingsPanel()) { 84 | setTimeout(() => { 85 | configureSettings(); 86 | }, 500); 87 | } 88 | }, 150); 89 | } 90 | 91 | public dispose() { 92 | this.disposable.dispose(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/managers/ConfigManager.ts: -------------------------------------------------------------------------------- 1 | import {commands, ViewColumn, WebviewPanel, window} from 'vscode'; 2 | import {getUser} from '../DataController'; 3 | import {isResponseOk, appGet, appPut} from '../http/HttpClient'; 4 | import {getConnectionErrorHtml} from '../local/404'; 5 | import { setEndOfDayNotification } from '../notifications/endOfDay'; 6 | 7 | let currentPanel: WebviewPanel | undefined = undefined; 8 | 9 | export function showingConfigureSettingsPanel() { 10 | return !!currentPanel; 11 | } 12 | 13 | export function closeSettings() { 14 | if (currentPanel) { 15 | // dispose the previous one. always use the same tab 16 | currentPanel.dispose(); 17 | } 18 | } 19 | 20 | export async function configureSettings() { 21 | if (currentPanel) { 22 | // dispose the previous one. always use the same tab 23 | currentPanel.dispose(); 24 | } 25 | 26 | if (!currentPanel) { 27 | currentPanel = window.createWebviewPanel('edit_settings', 'Code Time Settings', ViewColumn.One, { 28 | enableScripts: true, 29 | }); 30 | currentPanel.onDidDispose(() => { 31 | currentPanel = undefined; 32 | }); 33 | 34 | currentPanel.webview.onDidReceiveMessage(async (message: any) => { 35 | if (message?.action) { 36 | const cmd = message.action.includes('codetime.') ? message.action : `codetime.${message.action}`; 37 | switch (message.command) { 38 | case 'command_execute': 39 | if (message.payload && Object.keys(message.payload).length) { 40 | commands.executeCommand(cmd, message.payload); 41 | } else { 42 | commands.executeCommand(cmd); 43 | } 44 | break; 45 | } 46 | } 47 | }); 48 | } 49 | currentPanel.webview.html = await getEditSettingsHtml(); 50 | currentPanel.reveal(ViewColumn.One); 51 | } 52 | 53 | export async function getEditSettingsHtml(): Promise { 54 | const resp = await appGet(`/plugin/settings`, {editor: 'vscode'}); 55 | 56 | if (isResponseOk(resp)) { 57 | return resp.data.html; 58 | } 59 | return await getConnectionErrorHtml(); 60 | } 61 | 62 | export async function updateSettings(path: string, jsonData: any, reloadSettings: false) { 63 | await appPut(path, jsonData); 64 | await getUser(); 65 | // update the end of the day notification trigger 66 | setEndOfDayNotification(); 67 | // update the sidebar 68 | commands.executeCommand('codetime.refreshCodeTimeView'); 69 | 70 | if (reloadSettings && currentPanel) { 71 | configureSettings(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/managers/ExecManager.ts: -------------------------------------------------------------------------------- 1 | import { logIt } from '../Util'; 2 | 3 | const { execSync } = require('child_process'); 4 | 5 | export function execCmd(cmd: string = '', projectDir: string | null = null, returnLines: boolean = false): any { 6 | let result = returnLines ? [] : null; 7 | if (!cmd) { 8 | // no command to run, return default 9 | return result; 10 | } 11 | 12 | try { 13 | const opts = projectDir ? { cwd: projectDir, encoding: 'utf8' } : { encoding: 'utf8' }; 14 | 15 | const cmdResult = execSync(cmd, opts); 16 | if (cmdResult && cmdResult.length) { 17 | const lines = cmdResult.trim().replace(/^\s+/g, ' ').replace(//g, '').split(/\r?\n/); 18 | if (lines.length) { 19 | result = returnLines ? lines : lines[0]; 20 | } 21 | } 22 | } catch (e: any) { 23 | logIt(`${e.message}`); 24 | } 25 | return result; 26 | } 27 | -------------------------------------------------------------------------------- /src/managers/ExtensionManager.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, extensions } from 'vscode'; 2 | import { getExtensionsFile, getItem, getOs } from '../Util'; 3 | import { getJsonItem, setJsonItem, storeJsonData } from './FileManager'; 4 | import { TrackerManager } from './TrackerManager'; 5 | 6 | export class ExtensionManager { 7 | private static instance: ExtensionManager; 8 | 9 | private _disposable: Disposable; 10 | private tracker: TrackerManager; 11 | private ONE_WEEK_MILLIS: number = 1000 * 60 * 60 * 24 * 7; 12 | private INSTALLED_ACTION: string = 'installed'; 13 | private UNINSTALLED_ACTION: string = 'uninstalled'; 14 | 15 | constructor() { 16 | let subscriptions: Disposable[] = []; 17 | this.tracker = TrackerManager.getInstance(); 18 | subscriptions.push(extensions.onDidChange(this.onExtensionChange, this)); 19 | this._disposable = Disposable.from(...subscriptions); 20 | } 21 | 22 | static getInstance(): ExtensionManager { 23 | if (!ExtensionManager.instance) { 24 | ExtensionManager.instance = new ExtensionManager(); 25 | } 26 | 27 | return ExtensionManager.instance; 28 | } 29 | 30 | public dispose() { 31 | this._disposable.dispose(); 32 | } 33 | 34 | public initialize() { 35 | if (getItem('jwt')) { 36 | this.initializeExtensionsFile(); 37 | this.reconcileInstalledAndUninstalledPlugins(); 38 | } 39 | } 40 | 41 | private initializeExtensionsFile() { 42 | const jwt = getItem('jwt'); 43 | // initialize the extension file if it doesn't already exist 44 | const extensionsFile: string = getExtensionsFile(); 45 | const eventDate = getJsonItem(extensionsFile, 'eventDate'); 46 | const extensionsJwt = getJsonItem(extensionsFile, 'jwt') 47 | 48 | // initialize or re-send the installed plugins 49 | const now = new Date().toISOString(); 50 | if (!eventDate || (new Date().getTime() - new Date(eventDate).getTime() > this.ONE_WEEK_MILLIS) || jwt !== extensionsJwt) { 51 | storeJsonData(extensionsFile, { eventDate: now, jwt: jwt, data: {} }); 52 | this.getInstalledPlugins(now); 53 | } 54 | } 55 | 56 | private async onExtensionChange() { 57 | if (getItem('jwt')) { 58 | this.reconcileInstalledAndUninstalledPlugins(); 59 | } 60 | } 61 | 62 | private reconcileInstalledAndUninstalledPlugins(): void { 63 | const now = new Date().toISOString(); 64 | const extensionsFile: string = getExtensionsFile(); 65 | const extensionData: any = getJsonItem(extensionsFile, 'data', {}); 66 | const installedPlugins: any[] = this.getInstalledPlugins(now); 67 | const missingPlugins: any[] = Object.keys(extensionData).map( 68 | (key: string) => { 69 | if (!installedPlugins.find((n) => n.id === extensionData[key].id)) { 70 | const missingPlugin = extensionData[key]; 71 | delete extensionData[key]; 72 | return missingPlugin; 73 | } 74 | } 75 | ).filter((n) => n != null); 76 | 77 | // update the file 78 | setJsonItem(extensionsFile, 'data', extensionData); 79 | 80 | if (missingPlugins.length) { 81 | // send these events 82 | missingPlugins.forEach((plugin) => { 83 | plugin['action'] = this.UNINSTALLED_ACTION; 84 | this.tracker.trackVSCodeExtension(plugin); 85 | }); 86 | } 87 | } 88 | 89 | private getInstalledPlugins(now: string): any[] { 90 | const extensionsFile: string = getExtensionsFile(); 91 | const extensionData: any = getJsonItem(extensionsFile, 'data', {}) 92 | const os = getOs(); 93 | const plugins = extensions.all.filter( 94 | (extension: any) => extension.packageJSON.publisher != 'vscode' && !extension.packageJSON.isBuiltin 95 | ).map((extension: any) => { 96 | const pkg: any = extension.packageJSON; 97 | const existingExtension: any = extensionData[pkg.id]; 98 | 99 | // set the plugin info into the extensions file if it doesn't exist 100 | if (!existingExtension) { 101 | const plugin: any = this.buildInstalledExtensionInfo(pkg, now, os); 102 | extensionData[pkg.id] = plugin; 103 | // Track the newly installed extension 104 | this.tracker.trackVSCodeExtension(plugin); 105 | } 106 | 107 | return extensionData[pkg.id]; 108 | }); 109 | // write the data back to the file 110 | setJsonItem(extensionsFile, 'data', extensionData); 111 | 112 | return plugins; 113 | } 114 | 115 | private buildInstalledExtensionInfo(pkg: any, eventDate: string, os: string) { 116 | return { 117 | action: this.INSTALLED_ACTION, 118 | event_at: eventDate, 119 | os: os, 120 | vscode_extension: { 121 | id: pkg.id, 122 | publisher: pkg.publisher, 123 | name: pkg.name, 124 | display_name: pkg.displayName, 125 | author: pkg.author?.name || pkg.publisher, 126 | version: pkg.version, 127 | description: this.truncateString(pkg.description, 2048), 128 | categories: pkg.categories, 129 | extension_kind: pkg.extensionKind ? [].concat(pkg.extensionKind) : null 130 | } 131 | } 132 | } 133 | 134 | private truncateString(str: string, maxLen: number) { 135 | if (str && str.length > maxLen) { 136 | return str.slice(0, maxLen - 3) + "..."; 137 | } else { 138 | return str; 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/managers/FileManager.ts: -------------------------------------------------------------------------------- 1 | import { getFileNameFromPath, logIt } from '../Util'; 2 | import { LocalStorageManager } from './LocalStorageManager'; 3 | 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | 7 | let storageMgr: LocalStorageManager | undefined = undefined; 8 | 9 | function getStorageManager() { 10 | if (!storageMgr) { 11 | storageMgr = LocalStorageManager.getCachedStorageManager() 12 | } 13 | return storageMgr 14 | } 15 | 16 | export function getBooleanJsonItem(file: string, key: string) { 17 | const value = getJsonItem(file, key); 18 | try { 19 | return !!JSON.parse(value); 20 | } catch (e) { 21 | return false; 22 | } 23 | } 24 | 25 | export function getJsonItem(file: string, key: string, defaultValue: any = '') { 26 | return getStorageManager()?.getValue(`${getFileNameFromPath(file)}_${key}`) || defaultValue; 27 | } 28 | 29 | export function setJsonItem(file: string, key: string, value: any) { 30 | getStorageManager()?.setValue(`${getFileNameFromPath(file)}_${key}`, value); 31 | } 32 | 33 | export function getFileDataAsJson(filePath: string): any { 34 | try { 35 | let content: string = fs.readFileSync(filePath, 'utf8')?.trim(); 36 | return JSON.parse(content); 37 | } catch (e: any) { 38 | logIt(`Unable to read ${getBaseName(filePath)} info: ${e.message}`, true); 39 | } 40 | return null; 41 | } 42 | 43 | /** 44 | * Single place to write json data (json obj or json array) 45 | * @param filePath 46 | * @param json 47 | */ 48 | export function storeJsonData(filePath: string, json: any) { 49 | try { 50 | const content: string = JSON.stringify(json); 51 | fs.writeFileSync(filePath, content, 'utf8'); 52 | } catch (e: any) { 53 | logIt(`Unable to write ${getBaseName(filePath)} info: ${e.message}`, true); 54 | } 55 | } 56 | 57 | function getBaseName(filePath: string) { 58 | let baseName = filePath; 59 | try { baseName = path.basename(filePath); } catch (e: any) {} 60 | return baseName; 61 | } 62 | -------------------------------------------------------------------------------- /src/managers/FlowManager.ts: -------------------------------------------------------------------------------- 1 | import {commands, ProgressLocation, window} from 'vscode'; 2 | import {appPost, appDelete, appGet} from '../http/HttpClient'; 3 | import {getBooleanItem, getItem, isFlowModeEnabled, isPrimaryWindow, logIt, updateFlowChange} from '../Util'; 4 | 5 | import {showModalSignupPrompt, checkSlackConnectionForFlowMode} from './SlackManager'; 6 | import { 7 | FULL_SCREEN_MODE_ID, 8 | getConfiguredScreenMode, 9 | showFullScreenMode, 10 | showNormalScreenMode, 11 | showZenMode, 12 | ZEN_MODE_ID, 13 | } from './ScreenManager'; 14 | import {updateFlowModeStatusBar} from './StatusBarManager'; 15 | import { isRegistered } from '../DataController'; 16 | 17 | let inFlowLocally: boolean = false; 18 | 19 | export function isInFlowLocally() { 20 | return inFlowLocally; 21 | } 22 | 23 | export function updateInFlowLocally(inFlow: boolean) { 24 | inFlowLocally = inFlow; 25 | } 26 | 27 | export async function initializeFlowModeState() { 28 | await determineFlowModeFromApi(); 29 | updateFlowStatus(); 30 | } 31 | 32 | export async function updateFlowModeStatus() { 33 | await initializeFlowModeState(); 34 | } 35 | 36 | export async function enableFlow({automated = false}) { 37 | window.withProgress( 38 | { 39 | location: ProgressLocation.Notification, 40 | title: 'Enabling flow...', 41 | cancellable: false, 42 | }, 43 | async (progress) => { 44 | await initiateFlow({automated}).catch((e) => { 45 | console.error('[Code Time] Unable to initiate flow. ', e.message); 46 | }); 47 | } 48 | ); 49 | } 50 | 51 | export async function initiateFlow({automated = false}) { 52 | if (!isRegistered() && !automated) { 53 | // manually initiated, show the flow mode prompt 54 | showModalSignupPrompt('To enable Flow Mode, please sign up or log in.'); 55 | return; 56 | } 57 | 58 | const skipSlackCheck = !!getBooleanItem('vscode_CtskipSlackConnect'); 59 | 60 | if (!skipSlackCheck && !automated) { 61 | const connectInfo = await checkSlackConnectionForFlowMode(); 62 | if (!connectInfo.continue) { 63 | return; 64 | } 65 | } 66 | 67 | const preferredScreenMode = await getConfiguredScreenMode(); 68 | 69 | // process if... 70 | // 1) its the primary window 71 | // 2) flow mode is not current enabled via the flowChange.json state 72 | const primary = isPrimaryWindow(); 73 | const flowEnabled = isFlowModeEnabled(); 74 | if (primary && !flowEnabled) { 75 | logIt('Entering Flow Mode'); 76 | await appPost('/plugin/flow_sessions', { automated: automated }); 77 | // only update flow change here 78 | inFlowLocally = true; 79 | updateFlowChange(true); 80 | } 81 | 82 | // update screen mode 83 | if (preferredScreenMode === FULL_SCREEN_MODE_ID) { 84 | showFullScreenMode(); 85 | } else if (preferredScreenMode === ZEN_MODE_ID) { 86 | showZenMode(); 87 | } else { 88 | showNormalScreenMode(); 89 | } 90 | 91 | updateFlowStatus(); 92 | } 93 | 94 | export async function pauseFlow() { 95 | window.withProgress( 96 | { 97 | location: ProgressLocation.Notification, 98 | title: 'Turning off flow...', 99 | cancellable: false, 100 | }, 101 | async (progress) => { 102 | await pauseFlowInitiate().catch((e) => {}); 103 | } 104 | ); 105 | } 106 | 107 | export async function pauseFlowInitiate() { 108 | const flowEnabled = isFlowModeEnabled(); 109 | if (flowEnabled) { 110 | logIt('Exiting Flow Mode'); 111 | await appDelete('/plugin/flow_sessions'); 112 | // only update flow change in here 113 | inFlowLocally = false; 114 | updateFlowChange(false); 115 | } 116 | 117 | showNormalScreenMode(); 118 | updateFlowStatus(); 119 | } 120 | 121 | function updateFlowStatus() { 122 | setTimeout(() => { 123 | commands.executeCommand('codetime.refreshCodeTimeView'); 124 | }, 2000); 125 | 126 | updateFlowModeStatusBar(); 127 | } 128 | 129 | export async function determineFlowModeFromApi() { 130 | const flowSessionsReponse = getItem('jwt') 131 | ? await appGet('/plugin/flow_sessions') 132 | : {data: {flow_sessions: []}}; 133 | 134 | const openFlowSessions = flowSessionsReponse?.data?.flow_sessions ?? []; 135 | // make sure "enabledFlow" is set as it's used as a getter outside this export 136 | const enabledFlow: boolean = !!(openFlowSessions?.length); 137 | // update the local inFlow state 138 | inFlowLocally = enabledFlow; 139 | // initialize the file value 140 | updateFlowChange(enabledFlow); 141 | } 142 | -------------------------------------------------------------------------------- /src/managers/KpmManager.ts: -------------------------------------------------------------------------------- 1 | import {workspace, Disposable, RelativePattern, Uri} from 'vscode'; 2 | import { getUserPreferences } from '../DataController'; 3 | import { getFirstWorkspaceFolder, logIt } from '../Util'; 4 | import { TrackerManager } from './TrackerManager'; 5 | 6 | import * as fs from 'fs'; 7 | 8 | export class KpmManager { 9 | private static instance: KpmManager; 10 | 11 | private _disposable: Disposable; 12 | 13 | private tracker: TrackerManager; 14 | 15 | constructor() { 16 | let subscriptions: Disposable[] = []; 17 | this.tracker = TrackerManager.getInstance(); 18 | 19 | const workspaceFolder = getFirstWorkspaceFolder(); 20 | if (workspaceFolder) { 21 | // Watch .git directory changes 22 | // Only works if the git directory is in the workspace 23 | const localGitWatcher = workspace.createFileSystemWatcher( 24 | new RelativePattern(workspaceFolder, '{**/.git/refs/heads/**}') 25 | ); 26 | const remoteGitWatcher = workspace.createFileSystemWatcher( 27 | new RelativePattern(workspaceFolder, '{**/.git/refs/remotes/**}') 28 | ); 29 | subscriptions.push(localGitWatcher); 30 | subscriptions.push(remoteGitWatcher); 31 | subscriptions.push(localGitWatcher.onDidChange(this._onCommitHandler, this)); 32 | subscriptions.push(remoteGitWatcher.onDidChange(this._onCommitHandler, this)); 33 | subscriptions.push(remoteGitWatcher.onDidCreate(this._onCommitHandler, this)); 34 | subscriptions.push(remoteGitWatcher.onDidDelete(this._onBranchDeleteHandler, this)); 35 | } 36 | 37 | this._disposable = Disposable.from(...subscriptions); 38 | } 39 | 40 | static getInstance(): KpmManager { 41 | if (!KpmManager.instance) { 42 | KpmManager.instance = new KpmManager(); 43 | } 44 | 45 | return KpmManager.instance; 46 | } 47 | 48 | private async _onCommitHandler(event: Uri) { 49 | const preferences: any = await getUserPreferences(); 50 | if (preferences?.disableGitData) return; 51 | 52 | // Branches with naming style of "feature/fix_the_thing" will fire an 53 | // event when the /feature directory is created. Check if file. 54 | const stat = fs.statSync(event.path); 55 | if (!stat?.isFile()) return; 56 | 57 | if (event.path.includes('/.git/refs/heads/')) { 58 | // /.git/refs/heads/ 59 | const branch = event.path.split('.git/')[1]; 60 | let commit; 61 | try { 62 | commit = fs.readFileSync(event.path, 'utf8').trimEnd(); 63 | } catch (err: any) { 64 | logIt(`Error reading ${event.path}: ${err.message}`); 65 | } 66 | this.tracker.trackGitLocalEvent('local_commit', branch, commit); 67 | } else if (event.path.includes('/.git/refs/remotes/')) { 68 | // /.git/refs/remotes/ 69 | this.tracker.trackGitRemoteEvent(event); 70 | } 71 | } 72 | 73 | private async _onBranchDeleteHandler(event: Uri) { 74 | this.tracker.trackGitDeleteEvent(event); 75 | } 76 | 77 | public dispose() { 78 | this._disposable.dispose(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/managers/LocalStorageManager.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext, Memento } from "vscode"; 2 | 3 | export class LocalStorageManager { 4 | 5 | private static instance: LocalStorageManager; 6 | private storage: Memento; 7 | 8 | constructor(ctx: ExtensionContext) { 9 | this.storage = ctx.globalState; 10 | } 11 | 12 | static getInstance(ctx: ExtensionContext): LocalStorageManager { 13 | if (!LocalStorageManager.instance) { 14 | LocalStorageManager.instance = new LocalStorageManager(ctx); 15 | } 16 | return LocalStorageManager.instance; 17 | } 18 | 19 | static getCachedStorageManager(): LocalStorageManager { 20 | return LocalStorageManager.instance; 21 | } 22 | 23 | public getValue(key : string) : string { 24 | return this.storage.get(key,''); 25 | } 26 | 27 | public setValue(key : string, value : string) { 28 | this.storage.update(key, value); 29 | } 30 | 31 | public deleteValue(key: string) { 32 | this.storage.update(key, undefined); 33 | } 34 | 35 | public clearDupStorageKeys() { 36 | const keys = this.storage.keys(); 37 | if (keys?.length) { 38 | keys.forEach(key => { 39 | if (key?.includes('$ct_event_')) { 40 | this.deleteValue(key) 41 | } 42 | }); 43 | } 44 | } 45 | 46 | public clearStorage() { 47 | const keys = this.storage.keys(); 48 | if (keys?.length) { 49 | for (let i = 0; i < keys.length; i++) { 50 | this.deleteValue(keys[i]) 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/managers/ProgressManager.ts: -------------------------------------------------------------------------------- 1 | import {ProgressLocation, window} from 'vscode'; 2 | 3 | export class ProgressManager { 4 | private static instance: ProgressManager; 5 | 6 | public doneWriting: boolean = true; 7 | 8 | constructor() { 9 | // 10 | } 11 | 12 | static getInstance(): ProgressManager { 13 | if (!ProgressManager.instance) { 14 | ProgressManager.instance = new ProgressManager(); 15 | } 16 | 17 | return ProgressManager.instance; 18 | } 19 | } 20 | 21 | export function progressIt(msg: string, asyncFunc: any, args: any[] = []) { 22 | window.withProgress( 23 | { 24 | location: ProgressLocation.Notification, 25 | title: msg, 26 | cancellable: false, 27 | }, 28 | async (progress) => { 29 | if (typeof asyncFunc === 'function') { 30 | if (args?.length) { 31 | await asyncFunc(...args).catch((e: any) => {}); 32 | } else { 33 | await asyncFunc().catch((e: any) => {}); 34 | } 35 | } else { 36 | await asyncFunc; 37 | } 38 | } 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/managers/PromptManager.ts: -------------------------------------------------------------------------------- 1 | import {commands, window} from 'vscode'; 2 | 3 | export const SIGN_UP_LABEL = 'Sign up'; 4 | 5 | export function showModalSignupPrompt(msg: string) { 6 | window 7 | .showInformationMessage( 8 | msg, 9 | { 10 | modal: true, 11 | }, 12 | SIGN_UP_LABEL 13 | ) 14 | .then((selection: string | undefined) => { 15 | if (selection === SIGN_UP_LABEL) { 16 | commands.executeCommand(''); 17 | } 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/managers/ScreenManager.ts: -------------------------------------------------------------------------------- 1 | import { commands } from "vscode"; 2 | import { getUserPreferences } from "../DataController"; 3 | 4 | export const NORMAL_SCREEN_MODE = 0; 5 | export const ZEN_MODE_ID = 1; 6 | export const FULL_SCREEN_MODE_ID = 2; 7 | 8 | let preferredScreenMode: number = 0; 9 | let currentModeId: number = 0; 10 | 11 | export async function getConfiguredScreenMode() { 12 | const preferences: any = await getUserPreferences(); 13 | 14 | const flowModeSettings = preferences?.flowMode || {}; 15 | const screenMode = flowModeSettings?.editor?.vscode?.screenMode; 16 | if (screenMode?.includes("Full Screen")) { 17 | preferredScreenMode = FULL_SCREEN_MODE_ID; 18 | } else if (screenMode?.includes("Zen")) { 19 | preferredScreenMode = ZEN_MODE_ID; 20 | } else { 21 | preferredScreenMode = NORMAL_SCREEN_MODE; 22 | } 23 | return preferredScreenMode; 24 | } 25 | 26 | export function showZenMode() { 27 | if (currentModeId !== ZEN_MODE_ID) { 28 | currentModeId = ZEN_MODE_ID; 29 | commands.executeCommand("workbench.action.toggleZenMode"); 30 | } 31 | } 32 | 33 | export function showFullScreenMode() { 34 | if (currentModeId !== FULL_SCREEN_MODE_ID) { 35 | commands.executeCommand("workbench.action.toggleFullScreen"); 36 | currentModeId = FULL_SCREEN_MODE_ID; 37 | } 38 | } 39 | 40 | export function showNormalScreenMode() { 41 | if (currentModeId !== NORMAL_SCREEN_MODE) { 42 | if (currentModeId === FULL_SCREEN_MODE_ID) { 43 | currentModeId = NORMAL_SCREEN_MODE; 44 | commands.executeCommand("workbench.action.toggleFullScreen"); 45 | } else if (currentModeId === ZEN_MODE_ID) { 46 | currentModeId = NORMAL_SCREEN_MODE; 47 | commands.executeCommand("workbench.action.toggleZenMode"); 48 | } 49 | } 50 | } 51 | 52 | export function isInZenMode() { 53 | return !!(currentModeId === ZEN_MODE_ID); 54 | } 55 | 56 | export function isInFullScreenMode() { 57 | return !!(currentModeId === FULL_SCREEN_MODE_ID); 58 | } 59 | -------------------------------------------------------------------------------- /src/managers/SlackManager.ts: -------------------------------------------------------------------------------- 1 | import { commands, window } from 'vscode'; 2 | import { SIGN_UP_LABEL } from '../Constants'; 3 | import { 4 | isActiveIntegration, 5 | setItem 6 | } from '../Util'; 7 | import { getCachedSlackIntegrations } from '../DataController'; 8 | 9 | export async function getSlackWorkspaces() { 10 | return (await getCachedSlackIntegrations()).filter((n: any) => isActiveIntegration('slack', n)); 11 | } 12 | 13 | export async function hasSlackWorkspaces() { 14 | return !!(await getCachedSlackIntegrations()).length; 15 | } 16 | 17 | export function showModalSignupPrompt(msg: string) { 18 | window 19 | .showInformationMessage( 20 | msg, 21 | { 22 | modal: true, 23 | }, 24 | SIGN_UP_LABEL 25 | ) 26 | .then(async (selection) => { 27 | if (selection === SIGN_UP_LABEL) { 28 | commands.executeCommand('codetime.registerAccount'); 29 | } 30 | }); 31 | } 32 | 33 | export async function checkSlackConnection(showConnect = true) { 34 | if (!(await hasSlackWorkspaces())) { 35 | if (showConnect) { 36 | window 37 | .showInformationMessage( 38 | 'Connect a Slack workspace to continue.', 39 | { 40 | modal: true, 41 | }, 42 | 'Connect' 43 | ) 44 | .then(async (selection) => { 45 | if (selection === 'Connect') { 46 | commands.executeCommand('codetime.connectSlackWorkspace'); 47 | } 48 | }); 49 | } 50 | return false; 51 | } 52 | return true; 53 | } 54 | 55 | export async function checkSlackConnectionForFlowMode() { 56 | if (!(await hasSlackWorkspaces())) { 57 | const selection = await window.showInformationMessage( 58 | "Slack isn't connected", 59 | { modal: true }, 60 | ...['Continue anyway', 'Connect Slack'] 61 | ); 62 | if (!selection) { 63 | // the user selected "cancel" 64 | return { continue: false, useSlackSettings: true }; 65 | } else if (selection === 'Continue anyway') { 66 | // slack is not connected, but continue. set useSlackSettings to FALSE 67 | // set continue to TRUE 68 | setItem('vscode_CtskipSlackConnect', true); 69 | return { continue: true, useSlackSettings: false }; 70 | } else { 71 | // connect was selected 72 | commands.executeCommand('codetime.manageSlackConnection'); 73 | return { continue: false, useSlackSettings: true }; 74 | } 75 | } 76 | return { continue: true, useSlackSettings: true }; 77 | } 78 | -------------------------------------------------------------------------------- /src/managers/StatusBarManager.ts: -------------------------------------------------------------------------------- 1 | import {commands, StatusBarAlignment, StatusBarItem, window} from 'vscode'; 2 | import { isRegistered } from '../DataController'; 3 | import {getItem, getSessionSummaryFile, humanizeMinutes, isFlowModeEnabled} from '../Util'; 4 | import {getJsonItem} from './FileManager'; 5 | 6 | let showStatusBarText = true; 7 | let ctMetricStatusBarItem: StatusBarItem | undefined = undefined; 8 | let ctFlowModeStatusBarItem: StatusBarItem | undefined = undefined; 9 | 10 | export async function initializeStatusBar() { 11 | ctMetricStatusBarItem = window.createStatusBarItem(StatusBarAlignment.Right, 10); 12 | // add the name to the tooltip if we have it 13 | const name = getItem('name'); 14 | let tooltip = 'Click to see more from Code Time'; 15 | if (name) { 16 | tooltip = `${tooltip} (${name})`; 17 | } 18 | ctMetricStatusBarItem.tooltip = tooltip; 19 | ctMetricStatusBarItem.command = 'codetime.displaySidebar'; 20 | ctMetricStatusBarItem.show(); 21 | 22 | ctFlowModeStatusBarItem = window.createStatusBarItem(StatusBarAlignment.Right, 9); 23 | await updateFlowModeStatusBar(); 24 | } 25 | 26 | export async function updateFlowModeStatusBar() { 27 | const prevCmd: any | undefined = ctFlowModeStatusBarItem ? ctFlowModeStatusBarItem.command : undefined; 28 | const {flowModeCommand, flowModeText, flowModeTooltip} = await getFlowModeStatusBarInfo(); 29 | if (ctFlowModeStatusBarItem) { 30 | ctFlowModeStatusBarItem.command = flowModeCommand; 31 | ctFlowModeStatusBarItem.text = flowModeText; 32 | ctFlowModeStatusBarItem.tooltip = flowModeTooltip; 33 | if (isRegistered()) { 34 | ctFlowModeStatusBarItem.show(); 35 | } else { 36 | ctFlowModeStatusBarItem.hide(); 37 | } 38 | 39 | if (prevCmd !== undefined && prevCmd !== flowModeCommand) { 40 | // refresh the sidebar 41 | commands.executeCommand('codetime.refreshCodeTimeView'); 42 | } 43 | } 44 | } 45 | 46 | async function getFlowModeStatusBarInfo() { 47 | let flowModeCommand = 'codetime.enableFlowMode'; 48 | let flowModeText = '$(circle-large-outline) Flow'; 49 | let flowModeTooltip = 'Enter Flow Mode'; 50 | if (isFlowModeEnabled()) { 51 | flowModeCommand = 'codetime.exitFlowMode'; 52 | flowModeText = '$(circle-large-filled) Flow'; 53 | flowModeTooltip = 'Exit Flow Mode'; 54 | } 55 | return {flowModeCommand, flowModeText, flowModeTooltip}; 56 | } 57 | 58 | export function toggleStatusBar() { 59 | showStatusBarText = !showStatusBarText; 60 | 61 | // toggle the flow mode 62 | if (ctFlowModeStatusBarItem) { 63 | if (showStatusBarText && isRegistered()) { 64 | ctFlowModeStatusBarItem.show(); 65 | } else if (!showStatusBarText) { 66 | ctFlowModeStatusBarItem.hide(); 67 | } 68 | } 69 | 70 | // toggle the metrics value 71 | updateStatusBarWithSummaryData(); 72 | } 73 | 74 | export function isStatusBarTextVisible() { 75 | return showStatusBarText; 76 | } 77 | 78 | /** 79 | * Updates the status bar text with the current day minutes (session minutes) 80 | */ 81 | export function updateStatusBarWithSummaryData() { 82 | // Number will convert undefined/null to 0 83 | let averageDailyMinutes = Number(getJsonItem(getSessionSummaryFile(), 'averageDailyMinutes')); 84 | let currentDayMinutes = Number(getJsonItem(getSessionSummaryFile(), 'currentDayMinutes')); 85 | const inFlowIcon = currentDayMinutes > averageDailyMinutes ? '$(rocket)' : '$(clock)'; 86 | const minutesStr = humanizeMinutes(currentDayMinutes); 87 | 88 | const msg = `${inFlowIcon} ${minutesStr}`; 89 | showStatus(msg, null); 90 | } 91 | 92 | function showStatus(msg: string, tooltip: string | null) { 93 | if (!tooltip) { 94 | tooltip = 'Code time today. Click to see more from Code Time.'; 95 | } 96 | 97 | const email = getItem('name'); 98 | let userInfo = ''; 99 | if (email) { 100 | userInfo = ` Connected as ${email}`; 101 | } 102 | 103 | if (!showStatusBarText) { 104 | // add the message to the tooltip 105 | tooltip = msg + ' | ' + tooltip; 106 | } 107 | if (!ctMetricStatusBarItem) { 108 | return; 109 | } 110 | ctMetricStatusBarItem.tooltip = `${tooltip}${userInfo}`; 111 | 112 | if (!showStatusBarText) { 113 | ctMetricStatusBarItem.text = '$(clock)'; 114 | } else { 115 | ctMetricStatusBarItem.text = msg; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/managers/SummaryManager.ts: -------------------------------------------------------------------------------- 1 | import {updateStatusBarWithSummaryData} from './StatusBarManager'; 2 | import {isResponseOk, appGet} from '../http/HttpClient'; 3 | import { getSessionSummaryFile } from '../Util'; 4 | import { setJsonItem } from './FileManager'; 5 | 6 | export class SummaryManager { 7 | private static instance: SummaryManager; 8 | 9 | constructor() { 10 | // 11 | } 12 | 13 | static getInstance(): SummaryManager { 14 | if (!SummaryManager.instance) { 15 | SummaryManager.instance = new SummaryManager(); 16 | } 17 | 18 | return SummaryManager.instance; 19 | } 20 | 21 | /** 22 | * This is only called from the new day checker 23 | */ 24 | async updateSessionSummaryFromServer() { 25 | const result = await appGet('/api/v1/user/session_summary'); 26 | if (isResponseOk(result) && result.data) { 27 | this.updateCurrentDayStats(result.data); 28 | } 29 | } 30 | 31 | updateCurrentDayStats(summary: any) { 32 | if (summary) { 33 | Object.keys(summary).forEach((key: string) => { 34 | setJsonItem(getSessionSummaryFile(), key, summary[key]) 35 | }); 36 | } 37 | updateStatusBarWithSummaryData(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/managers/SyncManger.ts: -------------------------------------------------------------------------------- 1 | import {getFlowChangeFile, isFlowModeEnabled} from '../Util'; 2 | import {updateFlowModeStatusBar} from './StatusBarManager'; 3 | import { isInFlowLocally, updateInFlowLocally } from './FlowManager'; 4 | import { commands } from 'vscode'; 5 | 6 | import * as fs from 'fs'; 7 | 8 | 9 | export class SyncManager { 10 | private static _instance: SyncManager; 11 | 12 | static getInstance(): SyncManager { 13 | if (!SyncManager._instance) { 14 | SyncManager._instance = new SyncManager(); 15 | } 16 | 17 | return SyncManager._instance; 18 | } 19 | 20 | constructor() { 21 | // make sure the flow change file exists 22 | getFlowChangeFile(); 23 | 24 | // flowChange.json watch 25 | fs.watch(getFlowChangeFile(), (curr: any, prev: any) => { 26 | const currFlowState = isFlowModeEnabled(); 27 | if (curr === 'change' && isInFlowLocally() !== currFlowState) { 28 | updateInFlowLocally(currFlowState); 29 | // update the status bar 30 | updateFlowModeStatusBar(); 31 | // update the sidebar 32 | commands.executeCommand('codetime.refreshCodeTimeView'); 33 | } 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/managers/TrackerManager.ts: -------------------------------------------------------------------------------- 1 | import swdcTracker from 'swdc-tracker'; 2 | import { app_url } from '../Constants'; 3 | import { version } from 'vscode'; 4 | import { 5 | getPluginName, 6 | getItem, 7 | getPluginId, 8 | getVersion, 9 | getWorkspaceFolders, 10 | getGitEventFile, 11 | isGitProject, 12 | getEditorName, 13 | logIt 14 | } from '../Util'; 15 | import { KpmItem } from '../model/models'; 16 | import { getResourceInfo } from '../repo/KpmRepoManager'; 17 | import { 18 | getDefaultBranchFromRemoteBranch, 19 | getRepoIdentifierInfo, 20 | getLocalChanges, 21 | getLatestCommitForBranch, 22 | getChangesForCommit, 23 | authors, 24 | getCommitsForAuthors, 25 | getInfoForCommit, 26 | commitAlreadyOnRemote, 27 | isMergeCommit, 28 | } from '../repo/GitUtil'; 29 | import { getFileDataAsJson, getJsonItem, setJsonItem, storeJsonData } from './FileManager'; 30 | import { DocChangeInfo, ProjectChangeInfo } from '@swdotcom/editor-flow'; 31 | import { LocalStorageManager } from './LocalStorageManager'; 32 | import { getUserPreferences } from '../DataController'; 33 | 34 | export class TrackerManager { 35 | private static instance: TrackerManager; 36 | 37 | private trackerReady: boolean = false; 38 | private pluginParams: any = this.getPluginParams(); 39 | private eventVersions: Map = new Map(); 40 | public static storageMgr: LocalStorageManager | undefined = undefined; 41 | 42 | private constructor() { } 43 | 44 | static getInstance(): TrackerManager { 45 | if (!TrackerManager.instance) { 46 | TrackerManager.instance = new TrackerManager(); 47 | } 48 | 49 | return TrackerManager.instance; 50 | } 51 | 52 | public dispose() { 53 | swdcTracker.dispose(); 54 | } 55 | 56 | public async init() { 57 | // initialize tracker with swdc api host, namespace, and appId 58 | const result = await swdcTracker.initialize(app_url, 'CodeTime', 'swdc-vscode'); 59 | if (result.status === 200) { 60 | this.trackerReady = true; 61 | } else { 62 | logIt(`swdc-tracker failed to initialize - ${JSON.stringify(result)}`) 63 | } 64 | } 65 | 66 | public async trackCodeTimeEvent(projectChangeInfo: ProjectChangeInfo) { 67 | if (!this.trackerReadyWithJwt()) { 68 | return; 69 | } 70 | 71 | // extract the project info from the keystroke stats 72 | const projectInfo = { 73 | project_directory: projectChangeInfo.project_directory, 74 | project_name: projectChangeInfo.project_name, 75 | }; 76 | 77 | // loop through the files in the keystroke stats "source" 78 | const fileKeys = Object.keys(projectChangeInfo.docs_changed); 79 | for await (const file of fileKeys) { 80 | const docChangeInfo: DocChangeInfo = projectChangeInfo.docs_changed[file]; 81 | 82 | const startDate = new Date(docChangeInfo.start).toISOString(); 83 | const endDate = new Date(docChangeInfo.end).toISOString(); 84 | 85 | // check if this is a dup (i.e. secondary workspace or window sending the same event) 86 | if (this.isDupCodeTimeEvent(startDate, endDate)) return; 87 | 88 | const codetime_entity = { 89 | keystrokes: docChangeInfo.keystrokes, 90 | lines_added: docChangeInfo.linesAdded, 91 | lines_deleted: docChangeInfo.linesDeleted, 92 | characters_added: docChangeInfo.charactersAdded, 93 | characters_deleted: docChangeInfo.charactersDeleted, 94 | single_deletes: docChangeInfo.singleDeletes, 95 | multi_deletes: docChangeInfo.multiDeletes, 96 | single_adds: docChangeInfo.singleAdds, 97 | multi_adds: docChangeInfo.multiAdds, 98 | auto_indents: docChangeInfo.autoIndents, 99 | replacements: docChangeInfo.replacements, 100 | start_time: startDate, 101 | end_time: endDate, 102 | }; 103 | 104 | const file_entity = { 105 | file_name: docChangeInfo.file_name, 106 | file_path: docChangeInfo.file_path, 107 | syntax: docChangeInfo.syntax, 108 | line_count: docChangeInfo.line_count, 109 | character_count: docChangeInfo.character_count, 110 | }; 111 | 112 | const repoParams = await this.getRepoParams(projectChangeInfo.project_directory); 113 | 114 | const codetime_event = { 115 | ...codetime_entity, 116 | ...file_entity, 117 | ...projectInfo, 118 | ...this.pluginParams, 119 | ...this.getJwtParams(), 120 | ...repoParams, 121 | }; 122 | 123 | swdcTracker.trackCodeTimeEvent(codetime_event); 124 | } 125 | } 126 | 127 | public async trackUIInteraction(item: KpmItem) { 128 | // ui interaction doesn't require a jwt, no need to check for that here 129 | if (!this.trackerReadyWithJwt() || !item) { 130 | return; 131 | } 132 | 133 | const ui_interaction = { 134 | interaction_type: item.interactionType, 135 | }; 136 | 137 | const ui_element = { 138 | element_name: item.name, 139 | element_location: item.location, 140 | color: item.color ? item.color : null, 141 | icon_name: item.interactionIcon ? item.interactionIcon : null, 142 | cta_text: !item.hideCTAInTracker ? item.label || item.description || item.tooltip : 'redacted', 143 | }; 144 | 145 | const ui_event = { 146 | ...ui_interaction, 147 | ...ui_element, 148 | ...this.pluginParams, 149 | ...this.getJwtParams(), 150 | }; 151 | 152 | swdcTracker.trackUIInteraction(ui_event); 153 | } 154 | 155 | public async trackGitLocalEvent(gitEventName: string, branch?: string, commit?: string) { 156 | if (!this.trackerReadyWithJwt()) { 157 | return; 158 | } 159 | const projectParams = this.getProjectParams(); 160 | 161 | if (gitEventName === 'uncommitted_change') { 162 | this.trackUncommittedChangeGitEvent(projectParams); 163 | } else if (gitEventName === 'local_commit' && branch) { 164 | this.trackLocalCommitGitEvent(projectParams, branch, commit); 165 | } else { 166 | return; 167 | } 168 | } 169 | 170 | public async trackGitRemoteEvent(event: any) { 171 | if (!this.trackerReadyWithJwt()) { 172 | return; 173 | } 174 | const projectParams = this.getProjectParams(); 175 | const remoteBranch = event.path.split('.git/')[1]; 176 | 177 | this.trackBranchCommitGitEvent(projectParams, remoteBranch, event.path); 178 | } 179 | 180 | public async trackGitDeleteEvent(event: any) { 181 | this.removeBranchFromTrackingHistory(event.path); 182 | } 183 | 184 | private async trackUncommittedChangeGitEvent(projectParams: any) { 185 | const uncommittedChanges = await this.getUncommittedChangesParams(projectParams.project_directory); 186 | 187 | this.sendGitEvent('uncommitted_change', projectParams, uncommittedChanges); 188 | } 189 | 190 | private async trackLocalCommitGitEvent(projectParams: any, branch: string, commit?: string) { 191 | if (!commit) { 192 | commit = await getLatestCommitForBranch(projectParams.project_directory, branch); 193 | } 194 | if (await commitAlreadyOnRemote(projectParams.project_directory, commit)) { 195 | return; 196 | } 197 | if (await isMergeCommit(projectParams.project_directory, commit)) { 198 | return; 199 | } 200 | const commitInfo = await getInfoForCommit(projectParams.project_directory, commit); 201 | const file_changes = await getChangesForCommit(projectParams.project_directory, commit); 202 | const eventData = { commit_id: commit, git_event_timestamp: commitInfo.authoredTimestamp, file_changes }; 203 | 204 | this.sendGitEvent('local_commit', projectParams, eventData); 205 | } 206 | 207 | private async trackBranchCommitGitEvent(projectParams: any, remoteBranch: string, event_path: string) { 208 | const defaultBranch = await getDefaultBranchFromRemoteBranch(projectParams.project_directory, remoteBranch); 209 | const gitAuthors = await authors(projectParams.project_directory); 210 | let lastTrackedRef = this.getLatestTrackedCommit(event_path); 211 | let gitEventName; 212 | 213 | if (remoteBranch === defaultBranch) { 214 | gitEventName = 'default_branch_commit'; 215 | } else { 216 | gitEventName = 'branch_commit'; 217 | // If we have not tracked this branch before, then pull all commits 218 | // based on the default branch being the parent. This may not be true 219 | // but it will prevent us from pulling the entire commit history of 220 | // the author. 221 | if (lastTrackedRef === '') { 222 | lastTrackedRef = defaultBranch; 223 | } 224 | } 225 | 226 | const commits = await getCommitsForAuthors( 227 | projectParams.project_directory, 228 | remoteBranch, 229 | lastTrackedRef, 230 | gitAuthors 231 | ); 232 | 233 | for (const commit of commits) { 234 | const file_changes = await getChangesForCommit(projectParams.project_directory, commit.commit); 235 | const eventData = { commit_id: commit.commit, git_event_timestamp: commit.authoredTimestamp, file_changes }; 236 | 237 | this.sendGitEvent(gitEventName, projectParams, eventData); 238 | } 239 | 240 | // Save the latest commit SHA 241 | if (commits[0]) { 242 | this.setLatestTrackedCommit(event_path, commits[0].commit); 243 | } 244 | } 245 | 246 | private async sendGitEvent(gitEventName: string, projectParams: any, eventData?: any) { 247 | const preferences: any = await getUserPreferences(); 248 | if (preferences?.disableGitData) return; 249 | 250 | const repoParams = await this.getRepoParams(projectParams.project_directory); 251 | const gitEvent = { 252 | git_event_type: gitEventName, 253 | ...eventData, 254 | ...this.pluginParams, 255 | ...this.getJwtParams(), 256 | ...projectParams, 257 | ...repoParams, 258 | }; 259 | // send the event 260 | swdcTracker.trackGitEvent(gitEvent); 261 | } 262 | 263 | public async trackEditorAction(entity: string, type: string, event?: any) { 264 | if (!this.trackerReadyWithJwt()) { 265 | return; 266 | } 267 | 268 | const projectParams = this.getProjectParams(); 269 | 270 | if (type == 'save') { 271 | if (this.eventVersionIsTheSame(event)) return; 272 | if (isGitProject(projectParams.project_directory)) { 273 | this.trackGitLocalEvent('uncommitted_change', event); 274 | } 275 | } 276 | 277 | const repoParams = await this.getRepoParams(projectParams.project_directory); 278 | 279 | const editor_event = { 280 | entity, 281 | type, 282 | ...this.pluginParams, 283 | ...this.getJwtParams(), 284 | ...projectParams, 285 | ...this.getFileParams(event, projectParams.project_directory), 286 | ...repoParams, 287 | }; 288 | // send the event 289 | swdcTracker.trackEditorAction(editor_event); 290 | } 291 | 292 | // action: installed | uninstalled | enabled | disabled 293 | public async trackVSCodeExtension(eventData: any) { 294 | if (!this.trackerReadyWithJwt()) { 295 | return; 296 | } 297 | 298 | const vscode_extension_event = { 299 | ...eventData, 300 | ...this.pluginParams, 301 | ...this.getJwtParams(), 302 | } 303 | 304 | swdcTracker.trackVSCodeExtension(vscode_extension_event) 305 | } 306 | 307 | // Static attributes 308 | getPluginParams(): any { 309 | return { 310 | plugin_id: getPluginId(), 311 | plugin_name: getPluginName(), 312 | plugin_version: getVersion(), 313 | editor_name: getEditorName(), 314 | editor_version: version, 315 | }; 316 | } 317 | 318 | // Dynamic attributes 319 | 320 | getJwtParams(): any { 321 | let token: string = getItem('jwt'); 322 | 323 | return { jwt: token?.trim().split(' ').at(-1) }; 324 | } 325 | 326 | getProjectParams() { 327 | const workspaceFolders = getWorkspaceFolders(); 328 | const project_directory = workspaceFolders.length ? workspaceFolders[0].uri.fsPath : ''; 329 | const project_name = workspaceFolders.length ? workspaceFolders[0].name : ''; 330 | 331 | return { project_directory, project_name }; 332 | } 333 | 334 | async getRepoParams(projectRootPath: string) { 335 | const resourceInfo = await getResourceInfo(projectRootPath); 336 | if (!resourceInfo || !resourceInfo.identifier) { 337 | // return empty data, no need to parse further 338 | return { 339 | identifier: '', 340 | org_name: '', 341 | repo_name: '', 342 | repo_identifier: '', 343 | git_branch: '', 344 | git_tag: '', 345 | }; 346 | } 347 | 348 | // retrieve the git identifier info 349 | const gitIdentifiers = getRepoIdentifierInfo(resourceInfo.identifier); 350 | 351 | return { 352 | ...gitIdentifiers, 353 | repo_identifier: resourceInfo.identifier, 354 | git_branch: resourceInfo.branch, 355 | git_tag: resourceInfo.tag, 356 | }; 357 | } 358 | 359 | async getUncommittedChangesParams(projectRootPath: string) { 360 | const stats = await getLocalChanges(projectRootPath); 361 | 362 | return { file_changes: stats }; 363 | } 364 | 365 | eventVersionIsTheSame(event: any) { 366 | const isSame = this.eventVersions.get(event.fileName) == event.version; 367 | if (isSame) { 368 | return true; 369 | } else { 370 | // Add filename and version to map 371 | this.eventVersions.set(event.fileName, event.version); 372 | if (this.eventVersions.size > 5) { 373 | // remove oldest entry in map to stay small 374 | try { 375 | const key = this.eventVersions.keys().next().value; 376 | if (key !== undefined) { 377 | this.eventVersions.delete(key); 378 | } 379 | } catch (e) { 380 | // ignore 381 | } 382 | } 383 | return false; 384 | } 385 | } 386 | 387 | getFileParams(event: any, projectRootPath: string) { 388 | if (!event) return {}; 389 | // File Open and Close have document attributes on the event. 390 | // File Change has it on a `document` attribute 391 | const textDoc = event.document || event; 392 | if (!textDoc) { 393 | return { 394 | file_name: '', 395 | file_path: '', 396 | syntax: '', 397 | line_count: 0, 398 | character_count: 0, 399 | }; 400 | } 401 | 402 | let character_count = 0; 403 | if (typeof textDoc.getText === 'function') { 404 | character_count = textDoc.getText().length; 405 | } 406 | 407 | return { 408 | file_name: textDoc.fileName?.split(projectRootPath)?.[1], 409 | file_path: textDoc.fileName, 410 | syntax: textDoc.languageId || textDoc.fileName?.split('.')?.slice(-1)?.[0], 411 | line_count: textDoc.lineCount || 0, 412 | character_count, 413 | }; 414 | } 415 | 416 | setLatestTrackedCommit(dotGitFilePath: string, commit: string) { 417 | // dotGitFilePath: /Users/somebody/code/repo_name/.git/refs/remotes/origin/main 418 | setJsonItem(getGitEventFile(), dotGitFilePath, { latestTrackedCommit: commit }); 419 | } 420 | 421 | getLatestTrackedCommit(dotGitFilePath: string): string { 422 | // dotGitFilePath: /Users/somebody/code/repo_name/.git/refs/remotes/origin/main 423 | const data = getJsonItem(getGitEventFile(), dotGitFilePath, null); 424 | if (data) { 425 | try { 426 | const jsonData = JSON.parse(data) 427 | return jsonData.latestTrackedCommit || ''; 428 | } catch (e) { 429 | // ignore 430 | } 431 | } 432 | return ''; 433 | } 434 | 435 | removeBranchFromTrackingHistory(dotGitFilePath: string) { 436 | let data = getFileDataAsJson(getGitEventFile()); 437 | 438 | delete data[dotGitFilePath]; 439 | storeJsonData(getGitEventFile(), data); 440 | } 441 | 442 | isDupCodeTimeEvent(startDate: string, endDate: string) { 443 | // check if this is a dup (i.e. secondary workspace or window sending the same event) 444 | const key = `$ct_event_${startDate}` 445 | if (TrackerManager.storageMgr) { 446 | const dupEvent = TrackerManager.storageMgr.getValue(key); 447 | if (dupEvent) { 448 | return true; 449 | } else { 450 | TrackerManager.storageMgr.setValue(key, endDate); 451 | // delete the key/value after 10 seconds 452 | setTimeout(() => { 453 | TrackerManager.storageMgr?.deleteValue(key); 454 | }, 1000 * 10); 455 | } 456 | } 457 | return false; 458 | } 459 | 460 | trackerReadyWithJwt(): boolean { 461 | return this.trackerReady && !!getItem('jwt')?.trim(); 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /src/managers/WebViewManager.ts: -------------------------------------------------------------------------------- 1 | import {commands, ViewColumn, WebviewPanel, window, ProgressLocation} from 'vscode'; 2 | import {isResponseOk, appGet} from '../http/HttpClient'; 3 | import {getConnectionErrorHtml} from '../local/404'; 4 | import {checkRegistrationForReport, isPrimaryWindow} from '../Util'; 5 | 6 | let currentPanel: WebviewPanel | undefined = undefined; 7 | 8 | export async function showDashboard(params: any = {}) { 9 | if (!checkRegistrationForReport(true)) { 10 | return; 11 | } 12 | initiatePanel('Dashboard', 'dashboard'); 13 | if (isPrimaryWindow()) { 14 | window.withProgress( 15 | { 16 | location: ProgressLocation.Notification, 17 | title: 'Loading dashboard...', 18 | cancellable: false, 19 | }, 20 | async () => { 21 | loadDashboard(params); 22 | } 23 | ); 24 | } else { 25 | // no need to show the loading notification for secondary windows 26 | loadDashboard(params); 27 | } 28 | } 29 | 30 | async function loadDashboard(params: any) { 31 | const html = await getDashboardHtml(params); 32 | if (currentPanel) { 33 | currentPanel.webview.html = html; 34 | currentPanel.reveal(ViewColumn.One); 35 | } 36 | } 37 | 38 | function initiatePanel(title: string, viewType: string) { 39 | if (currentPanel) { 40 | // dipose the previous one 41 | currentPanel.dispose(); 42 | } 43 | 44 | if (!currentPanel) { 45 | currentPanel = window.createWebviewPanel(viewType, title, ViewColumn.One, {enableScripts: true}); 46 | currentPanel.onDidDispose(() => { 47 | currentPanel = undefined; 48 | }); 49 | } 50 | 51 | // commandMessage can be anything; object, number, string, etc 52 | currentPanel.webview.onDidReceiveMessage(async (commandMessage: any) => { 53 | // 54 | }); 55 | 56 | currentPanel.webview.onDidReceiveMessage(async (message: any) => { 57 | if (message?.action) { 58 | const cmd = message.action.includes('codetime.') ? message.action : `codetime.${message.action}`; 59 | switch (message.command) { 60 | case 'command_execute': 61 | if (message.payload && Object.keys(message.payload).length) { 62 | commands.executeCommand(cmd, message.payload); 63 | } else { 64 | commands.executeCommand(cmd); 65 | } 66 | break; 67 | } 68 | } 69 | }); 70 | } 71 | 72 | async function getDashboardHtml(params: any) { 73 | const qryString = new URLSearchParams(params).toString() 74 | const resp = await appGet(`/plugin/dashboard?${qryString}`); 75 | if (isResponseOk(resp)) { 76 | return resp.data.html; 77 | } else { 78 | window.showErrorMessage('Unable to generate dashboard. Please try again later.'); 79 | return await getConnectionErrorHtml(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/menu/AccountManager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getItem, 3 | getOsUsername, 4 | getHostname, 5 | setItem, 6 | getPluginUuid, 7 | getAuthCallbackState, 8 | setAuthCallbackState, 9 | } from '../Util'; 10 | import {isResponseOk, appPost} from '../http/HttpClient'; 11 | import { authentication } from 'vscode'; 12 | import { AUTH_TYPE, getAuthInstance } from '../auth/AuthProvider'; 13 | 14 | let creatingAnonUser = false; 15 | 16 | export async function authLogin() { 17 | const session = await authentication.getSession(AUTH_TYPE, [], { createIfNone: true }); 18 | if (session) { 19 | const latestUpdate = getItem('updatedAt'); 20 | if (!latestUpdate || new Date().getTime() - latestUpdate > (1000 * 3)) { 21 | await getAuthInstance().removeSession(session.account.id); 22 | await authentication.getSession(AUTH_TYPE, [], { createIfNone: true }); 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * create an anonymous user based on github email or mac addr 29 | */ 30 | export async function createAnonymousUser() { 31 | if (creatingAnonUser) { 32 | return; 33 | } 34 | const jwt = getItem('jwt'); 35 | // check one more time before creating the anon user 36 | if (!jwt) { 37 | creatingAnonUser = true; 38 | // this should not be undefined if its an account reset 39 | let plugin_uuid = getPluginUuid(); 40 | let auth_callback_state = getAuthCallbackState(); 41 | const username = getOsUsername(); 42 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 43 | const hostname = getHostname(); 44 | 45 | const resp = await appPost('/api/v1/anonymous_user', { 46 | timezone, 47 | username, 48 | plugin_uuid, 49 | hostname, 50 | auth_callback_state, 51 | }); 52 | if (isResponseOk(resp) && resp.data) { 53 | 54 | setItem('jwt', resp.data.plugin_jwt); 55 | if (!resp.data.registered) { 56 | setItem('name', null); 57 | } 58 | setAuthCallbackState(''); 59 | } 60 | } 61 | creatingAnonUser = false; 62 | } 63 | -------------------------------------------------------------------------------- /src/message_handlers/authenticated_plugin_user.ts: -------------------------------------------------------------------------------- 1 | import { authenticationCompleteHandler } from "../DataController"; 2 | 3 | export async function handleAuthenticatedPluginUser(user: any) { 4 | authenticationCompleteHandler(user); 5 | } 6 | -------------------------------------------------------------------------------- /src/message_handlers/current_day_stats_update.ts: -------------------------------------------------------------------------------- 1 | import {commands} from 'vscode'; 2 | import {SummaryManager} from '../managers/SummaryManager'; 3 | 4 | // { user_id: row["USER_ID"], data: SessionSummary, action: "update" } 5 | export async function handleCurrentDayStatsUpdate(currentDayStatsInfo: any) { 6 | if (currentDayStatsInfo.data) { 7 | // update the session summary data 8 | SummaryManager.getInstance().updateCurrentDayStats(currentDayStatsInfo.data); 9 | commands.executeCommand('codetime.updateViewMetrics'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/message_handlers/flow_score.ts: -------------------------------------------------------------------------------- 1 | import { isRegistered } from '../DataController'; 2 | import { enableFlow } from "../managers/FlowManager"; 3 | import { logIt } from '../Util'; 4 | 5 | export async function handleFlowScoreMessage(message: any) { 6 | 7 | try { 8 | if (isRegistered()) { 9 | enableFlow({ automated: true }); 10 | } 11 | } catch (e: any) { 12 | logIt("Error handling flow score message: " + e.message); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/message_handlers/flow_state.ts: -------------------------------------------------------------------------------- 1 | import { pauseFlowInitiate } from "../managers/FlowManager"; 2 | 3 | export async function handleFlowStateMessage(body: any) { 4 | // body contains {enable_flow: true | false} 5 | const { enable_flow } = body; 6 | 7 | // exit flow mode if we get "enable_flow = false" 8 | if (!enable_flow) { 9 | // disable it 10 | pauseFlowInitiate(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/message_handlers/integration_connection.ts: -------------------------------------------------------------------------------- 1 | import { commands, ProgressLocation, window } from "vscode"; 2 | import { getCachedUser } from "../DataController"; 3 | import { setAuthCallbackState } from "../Util"; 4 | 5 | export async function handleIntegrationConnectionSocketEvent(body: any) { 6 | // integration_type_id = 14 (slack) 7 | // action = add, update, remove 8 | const { integration_type_id, action } = body; 9 | 10 | if (integration_type_id === 14) { 11 | await getCachedUser() 12 | 13 | if (action === "add") { 14 | // refresh the slack integrations 15 | // clear the auth callback state 16 | setAuthCallbackState(null); 17 | showSuccessMessage("Successfully connected to Slack"); 18 | } 19 | 20 | commands.executeCommand("codetime.refreshCodeTimeView"); 21 | } 22 | } 23 | 24 | function showSuccessMessage(message: string) { 25 | window.withProgress( 26 | { 27 | location: ProgressLocation.Notification, 28 | title: message, 29 | cancellable: false, 30 | }, 31 | async (progress) => { 32 | setTimeout(() => { 33 | return true; 34 | }, 1000); 35 | } 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/model/CodeTimeSummary.ts: -------------------------------------------------------------------------------- 1 | export default class CodeTimeSummary { 2 | // this is the editor session minutes 3 | activeCodeTimeMinutes: number = 0; 4 | // this is the total focused editor minutes 5 | codeTimeMinutes: number = 0; 6 | // this is the total time spent coding on files 7 | fileTimeMinutes: number = 0; 8 | } 9 | -------------------------------------------------------------------------------- /src/model/Project.ts: -------------------------------------------------------------------------------- 1 | // ? marks that the parameter is optional 2 | export default class Project { 3 | public directory: string = ''; 4 | public name?: string = ''; 5 | public identifier: string = ''; 6 | public resource: any = {}; 7 | } 8 | -------------------------------------------------------------------------------- /src/model/models.ts: -------------------------------------------------------------------------------- 1 | import {getVersion, getPluginId, getOs} from '../Util'; 2 | import {TreeItemCollapsibleState} from 'vscode'; 3 | 4 | export enum UIInteractionType { 5 | Keyboard = 'keyboard', 6 | Click = 'click', 7 | } 8 | 9 | export class KpmItem { 10 | id: string = ''; 11 | label: string = ''; 12 | description: string | null = ''; 13 | value: string = ''; 14 | tooltip: string = ''; 15 | command: string = ''; 16 | commandArgs: any[] = []; 17 | type: string = ''; 18 | contextValue: string = ''; 19 | callback: any = null; 20 | icon: string | null = null; 21 | children: KpmItem[] = []; 22 | color: string | null = ''; 23 | location: string = ''; 24 | name: string = ''; 25 | eventDescription: string | null = null; 26 | initialCollapsibleState: TreeItemCollapsibleState = TreeItemCollapsibleState.Collapsed; 27 | interactionType: UIInteractionType = UIInteractionType.Click; 28 | interactionIcon: string | null = ''; 29 | hideCTAInTracker: boolean = false; 30 | } 31 | 32 | export class SessionSummary { 33 | currentDayMinutes: number = 0; 34 | averageDailyMinutes: number = 0; 35 | } 36 | 37 | export class DiffNumStats { 38 | file_name: string = ''; 39 | insertions: number = 0; 40 | deletions: number = 0; 41 | } 42 | 43 | // example: {type: "window", name: "close", timestamp: 1234, 44 | // timestamp_local: 1233, description: "OnboardPrompt"} 45 | export class CodeTimeEvent { 46 | type: string = ''; 47 | name: string = ''; 48 | timestamp: number = 0; 49 | timestamp_local: number = 0; 50 | description: string = ''; 51 | pluginId: number = getPluginId(); 52 | os: string = getOs(); 53 | version: string = getVersion(); 54 | hostname: string = ''; // this is gathered using an await 55 | timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; 56 | } 57 | -------------------------------------------------------------------------------- /src/notifications/endOfDay.ts: -------------------------------------------------------------------------------- 1 | import {commands, window} from 'vscode'; 2 | import {showDashboard} from '../managers/WebViewManager'; 3 | import {configureSettings} from '../managers/ConfigManager'; 4 | import {TrackerManager} from '../managers/TrackerManager'; 5 | import {format, startOfDay, differenceInMilliseconds} from 'date-fns'; 6 | import { configureSettingsKpmItem, showMeTheDataKpmItem } from '../events/KpmItems'; 7 | import { getCachedUser, isRegistered } from '../DataController'; 8 | 9 | const MIN_IN_MILLIS = 60 * 1000; 10 | const HOUR_IN_MILLIS = 60 * 60 * 1000; 11 | const DEFAULT_WORK_HOURS = { 12 | mon: { ranges: [ { start: "09:00", end: "17:00" } ], active: true }, 13 | tue: { ranges: [ { start: "09:00", end: "17:00" } ], active: true }, 14 | wed: { ranges: [ { start: "09:00", end: "17:00" } ], active: true }, 15 | thu: { ranges: [ { start: "09:00", end: "17:00" } ], active: true }, 16 | fri: { ranges: [ { start: "09:00", end: "17:00" } ], active: true }, 17 | sat: { ranges: [ { start: "09:00", end: "17:00" } ], active: false }, 18 | sun: { ranges: [ { start: "09:00", end: "17:00" } ], active: false } 19 | } 20 | 21 | let timer: NodeJS.Timeout | undefined = undefined; 22 | 23 | export const setEndOfDayNotification = async () => { 24 | // clear any existing timer 25 | if (timer) { 26 | clearTimeout(timer); 27 | } 28 | 29 | const cachedUser: any = await getCachedUser(); 30 | if (!cachedUser) { 31 | return; 32 | } 33 | const workHours: any = cachedUser?.profile?.work_hours ? JSON.parse(cachedUser.profile.work_hours) : DEFAULT_WORK_HOURS 34 | 35 | // If the end of day notification setting is turned on (if undefined or null, will default to true) 36 | if (cachedUser?.preferences_parsed?.notifications?.endOfDayNotification) { 37 | const d = new Date(); 38 | const day = format(d, 'EEE').toLowerCase(); 39 | let msUntilEndOfTheDay = 0; 40 | 41 | // [[118800,147600],[205200,234000],[291600,320400],[378000,406800],[464400,493200]] 42 | // default of 5pm if the response or work_hours format doesn't have the {dow:...} 43 | if (day !== 'sun' && day !== 'sat') { 44 | msUntilEndOfTheDay = getMillisUntilEndOfTheDay(d, HOUR_IN_MILLIS * 17); 45 | } 46 | 47 | // get the day of the week that matches today 48 | const work_hours_today = workHours[day] || undefined; 49 | if (work_hours_today?.active) { 50 | // it's active, get the largest end range 51 | const endTimes = work_hours_today.ranges.map((n: any) => { 52 | // convert end to total seconds in a day 53 | return getEndTimeSeconds(n.end); 54 | }); 55 | 56 | // sort milliseconds in descending order 57 | endTimes.sort(function (a: any, b: any) { 58 | return b - a; 59 | }); 60 | 61 | msUntilEndOfTheDay = getMillisUntilEndOfTheDay(d, endTimes[0]); 62 | } 63 | 64 | if (msUntilEndOfTheDay > 0) { 65 | // set the timer to fire in "n" milliseconds 66 | timer = setTimeout(showEndOfDayNotification, msUntilEndOfTheDay); 67 | } 68 | } 69 | }; 70 | 71 | export const showEndOfDayNotification = async () => { 72 | const tracker: TrackerManager = TrackerManager.getInstance(); 73 | if (!isRegistered()) { 74 | const selection = await window.showInformationMessage( 75 | "It's the end of the day. Sign up to see your stats.", 76 | ...['Sign up'] 77 | ); 78 | 79 | if (selection === 'Sign up') { 80 | commands.executeCommand('codetime.registerAccount'); 81 | } 82 | } else { 83 | const selection = await window.showInformationMessage( 84 | "It's the end of your work day! Would you like to see your code time stats for today?", 85 | ...['Settings', 'Show me the data'] 86 | ); 87 | 88 | if (selection === 'Show me the data') { 89 | tracker.trackUIInteraction(showMeTheDataKpmItem()); 90 | showDashboard(); 91 | } else if (selection === 'Settings') { 92 | tracker.trackUIInteraction(configureSettingsKpmItem()); 93 | configureSettings(); 94 | } 95 | } 96 | }; 97 | 98 | function getEndTimeSeconds(end: any) { 99 | const hourMin = end.split(':'); 100 | return parseInt(hourMin[0], 10) * HOUR_IN_MILLIS + parseInt(hourMin[1], 10) * MIN_IN_MILLIS; 101 | } 102 | 103 | function getMillisUntilEndOfTheDay(date: any, endMillis: number) { 104 | var startD = startOfDay(date); 105 | var millisDiff = differenceInMilliseconds(date, startD); 106 | return endMillis - millisDiff; 107 | } 108 | -------------------------------------------------------------------------------- /src/repo/GitUtil.ts: -------------------------------------------------------------------------------- 1 | import {DiffNumStats} from '../model/models'; 2 | import {isGitProject, noSpacesProjectDir} from '../Util'; 3 | import {CacheManager} from '../cache/CacheManager'; 4 | import {execCmd} from '../managers/ExecManager'; 5 | 6 | const ONE_HOUR_IN_SEC = 60 * 60; 7 | const ONE_DAY_IN_SEC = ONE_HOUR_IN_SEC * 24; 8 | 9 | const cacheMgr: CacheManager = CacheManager.getInstance(); 10 | 11 | export function accumulateNumStatChanges(results: any): DiffNumStats[] { 12 | /* 13 | //Insert Delete Filename 14 | 10 0 src/api/billing_client.js 15 | 5 2 src/api/projects_client.js 16 | - - binary_file.bin 17 | */ 18 | const diffNumStatList = []; 19 | 20 | for (const result of results) { 21 | const diffNumStat = new DiffNumStats(); 22 | const parts = result.split('\t'); 23 | diffNumStat.insertions = Number(parts[0]); 24 | diffNumStat.deletions = Number(parts[1]); 25 | // Add backslash to match other filenames in tracking 26 | diffNumStat.file_name = `/${parts[2]}`; 27 | if (Number.isInteger(diffNumStat.insertions) && Number.isInteger(diffNumStat.deletions)) 28 | diffNumStatList.push(diffNumStat); 29 | } 30 | 31 | return diffNumStatList; 32 | } 33 | 34 | export async function getDefaultBranchFromRemoteBranch(projectDir: string, remoteBranch: string): Promise { 35 | if (!projectDir || !isGitProject(projectDir)) { 36 | return ''; 37 | } 38 | 39 | const cacheId = `getDefaultBranchFromRemoteBranch-${noSpacesProjectDir(projectDir)}`; 40 | 41 | let defaultBranchFromRemoteBranch = cacheMgr.get(cacheId); 42 | if (defaultBranchFromRemoteBranch) { 43 | return defaultBranchFromRemoteBranch; 44 | } 45 | 46 | defaultBranchFromRemoteBranch = ''; 47 | 48 | const remotes = execCmd('git remote', projectDir, true) || []; 49 | const remoteName = remotes.sort((a: any, b: any) => b.length - a.length).find((r: any) => remoteBranch.includes(r)); 50 | 51 | if (remoteName) { 52 | // Check if the remote has a HEAD symbolic-ref defined 53 | const headBranchList = execCmd(`git symbolic-ref refs/remotes/${remoteName}/HEAD`, projectDir, true); 54 | if (headBranchList.length) { 55 | // Make sure it's not a broken HEAD ref 56 | const verify = execCmd(`git show-ref --verify '${headBranchList[0]}'`, projectDir, true); 57 | 58 | if (verify?.length) { 59 | defaultBranchFromRemoteBranch = headBranchList[0]; 60 | } 61 | } 62 | 63 | if (!defaultBranchFromRemoteBranch) { 64 | const assumedDefaultBranch = await guessDefaultBranchForRemote(projectDir, remoteName); 65 | if (assumedDefaultBranch) { 66 | defaultBranchFromRemoteBranch = assumedDefaultBranch; 67 | } 68 | } 69 | } 70 | 71 | if (!defaultBranchFromRemoteBranch) { 72 | // Check if any HEAD branch is defined on any remote 73 | const remoteBranchesResult = execCmd("git branch -r -l '*/HEAD'", projectDir, true); 74 | if (remoteBranchesResult?.length) { 75 | // ['origin/HEAD - origin/main'] 76 | const remoteBranches = remoteBranchesResult[0].split(' '); 77 | defaultBranchFromRemoteBranch = remoteBranches[remoteBranches.length - 1]; 78 | } 79 | } 80 | 81 | if (!defaultBranchFromRemoteBranch) { 82 | const originIndex = remotes.indexOf('origin'); 83 | if (originIndex > 0) { 84 | // Move origin to the beginning 85 | remotes.unshift(remotes.splice(originIndex, 1)[0]); 86 | } 87 | 88 | // Check each remote for a possible default branch 89 | for (const remote of remotes) { 90 | const assumedRemoteDefaultBranch = await guessDefaultBranchForRemote(projectDir, remote); 91 | 92 | if (assumedRemoteDefaultBranch) { 93 | defaultBranchFromRemoteBranch = assumedRemoteDefaultBranch; 94 | } 95 | } 96 | } 97 | 98 | if (defaultBranchFromRemoteBranch) { 99 | // cache for a day 100 | cacheMgr.set(cacheId, defaultBranchFromRemoteBranch, ONE_DAY_IN_SEC); 101 | } 102 | 103 | // We have no clue, return something 104 | return defaultBranchFromRemoteBranch || ''; 105 | } 106 | 107 | async function guessDefaultBranchForRemote(projectDir: string, remoteName: string): Promise { 108 | // Get list of branches for the remote 109 | const remoteBranchesList = execCmd(`git branch -r -l '${remoteName}/*'`, projectDir, true) || []; 110 | const possibleDefaultBranchNames = ['main', 'master']; 111 | let assumedDefault; 112 | 113 | for (const possibleDefault of possibleDefaultBranchNames) { 114 | assumedDefault = remoteBranchesList.find((b: any) => b.trim() === `${remoteName}/${possibleDefault}`); 115 | 116 | if (assumedDefault) break; 117 | } 118 | 119 | return assumedDefault?.trim(); 120 | } 121 | 122 | export async function getLatestCommitForBranch(projectDir: string, branch: string): Promise { 123 | const cmd = `git rev-parse ${branch}`; 124 | 125 | if (!projectDir || !isGitProject(projectDir)) { 126 | return ''; 127 | } 128 | 129 | const resultList = execCmd(cmd, projectDir, true); 130 | return resultList?.length ? resultList[0] : ''; 131 | } 132 | 133 | export async function commitAlreadyOnRemote(projectDir: string, commit: string): Promise { 134 | const resultList = execCmd(`git branch -r --contains ${commit}`, projectDir, true); 135 | 136 | // If results returned, then that means the commit exists on 137 | // at least 1 remote branch, so return true. 138 | return resultList?.length ? true : false; 139 | } 140 | 141 | export async function isMergeCommit(projectDir: string, commit: string): Promise { 142 | const resultList = execCmd(`git rev-list --parents -n 1 ${commit}`, projectDir, true); 143 | 144 | const parents = resultList?.[0]?.split(' '); 145 | 146 | // If more than 2 commit SHA's are returned, then it 147 | // has multiple parents and is therefore a merge commit. 148 | return parents?.length > 2 ? true : false; 149 | } 150 | 151 | export async function getInfoForCommit(projectDir: string, commit: string) { 152 | const resultList = execCmd(`git show ${commit} --pretty=format:"%aI" -s`, projectDir, true); 153 | 154 | return {authoredTimestamp: resultList?.length ? resultList[0] : ''}; 155 | } 156 | 157 | // Returns an array of authors including names and emails from the git config 158 | export async function authors(projectDir: string): Promise { 159 | if (!projectDir || !isGitProject(projectDir)) { 160 | return []; 161 | } 162 | 163 | const cacheId = `git-authors-${noSpacesProjectDir(projectDir)}`; 164 | 165 | let authors = cacheMgr.get(cacheId); 166 | if (authors) { 167 | return authors; 168 | } 169 | const configUsers = execCmd(`git config --get-regex "^user\\."`, projectDir, true); 170 | 171 | authors = configUsers?.length 172 | ? configUsers.map((configUser: any) => { 173 | let [_, ...author] = configUser.split(' '); 174 | return author.join(' '); 175 | }) 176 | : []; 177 | const uniqueAuthors = authors.filter((author: string, index: number, self: any) => { 178 | return self.indexOf(author) === index; 179 | }); 180 | 181 | cacheMgr.set(cacheId, uniqueAuthors, ONE_HOUR_IN_SEC); 182 | 183 | return uniqueAuthors; 184 | } 185 | 186 | export async function getCommitsForAuthors(projectDir: string, branch: string, startRef: string, authors: string[]) { 187 | if (!projectDir || !isGitProject(projectDir)) { 188 | return []; 189 | } 190 | 191 | // If there is no startRef, then only pull 2 weeks of history 192 | const range = startRef !== '' ? `${startRef}..HEAD` : `HEAD --since="2 weeks ago"`; 193 | let cmd = `git log ${branch} ${range} --no-merges --pretty=format:"%aI =.= %H"`; 194 | for (const author of authors) { 195 | cmd += ` --author="${author}"`; 196 | } 197 | 198 | const resultList = execCmd(cmd, projectDir, true); 199 | 200 | if (resultList?.length) { 201 | return resultList.map((result: string) => { 202 | const [authoredTimestamp, commit] = result.split(' =.= '); 203 | return {commit, authoredTimestamp}; 204 | }); 205 | } 206 | return []; 207 | } 208 | 209 | export async function getChangesForCommit(projectDir: string, commit: string): Promise { 210 | if (!projectDir || !isGitProject(projectDir) || !commit) { 211 | return []; 212 | } 213 | 214 | const cmd = `git diff --numstat ${commit}~ ${commit}`; 215 | const resultList = execCmd(cmd, projectDir, true); 216 | 217 | if (resultList?.length) { 218 | // just look for the line with "insertions" and "deletions" 219 | return accumulateNumStatChanges(resultList); 220 | } 221 | 222 | return []; 223 | } 224 | 225 | export async function getLocalChanges(projectDir: string): Promise { 226 | if (!projectDir || !isGitProject(projectDir)) { 227 | return []; 228 | } 229 | 230 | const cmd = `git diff --numstat`; 231 | const resultList = execCmd(cmd, projectDir, true); 232 | 233 | if (resultList?.length) { 234 | // just look for the line with "insertions" and "deletions" 235 | return accumulateNumStatChanges(resultList); 236 | } 237 | 238 | return []; 239 | } 240 | 241 | function stripOutSlashes(str: string) { 242 | var parts = str.split('//'); 243 | return parts.length === 2 ? parts[1] : str; 244 | } 245 | 246 | function stripOutAmpersand(str: string) { 247 | var parts = str.split('@'); 248 | return parts.length === 2 ? parts[1] : str; 249 | } 250 | 251 | function replaceColonWithSlash(str: string) { 252 | return str.replace(':', '/'); 253 | } 254 | 255 | function normalizeRepoIdentifier(identifier: string) { 256 | if (identifier) { 257 | // repos['standardId'] = repos['identifier'] 258 | // repos['standardId'] = repos['standardId'].str.split('\//').str[-1].str.strip() 259 | // repos['standardId'] = repos['standardId'].str.split('\@').str[-1].str.strip() 260 | // repos['standardId'] = repos['standardId'].str.replace(':', "/") 261 | identifier = stripOutSlashes(identifier); 262 | identifier = stripOutAmpersand(identifier); 263 | identifier = replaceColonWithSlash(identifier); 264 | } 265 | 266 | return identifier || ''; 267 | } 268 | 269 | /** 270 | * Retrieve the github org name and repo name from the identifier 271 | * i.e. https://github.com\\swdotcom\\swdc-codemetrics-service.git 272 | * would return "swdotcom" 273 | * Returns: {identifier, org_name, repo_name} 274 | */ 275 | export function getRepoIdentifierInfo(identifier: string) { 276 | identifier = normalizeRepoIdentifier(identifier); 277 | 278 | if (!identifier) { 279 | // no identifier to pull out info 280 | return {identifier: '', org_name: '', repo_name: ''}; 281 | } 282 | 283 | // split the identifier into parts 284 | const parts = identifier.split(/[\\/]/); 285 | 286 | // it needs to have at least 3 parts 287 | // for example, this shouldn't return an org "github.com//string.git" 288 | let owner_id = ''; 289 | const gitMatch = parts[0].match(/.*github.com/i); 290 | if (parts && parts.length > 2 && gitMatch) { 291 | // return the 2nd part 292 | owner_id = parts[1]; 293 | } 294 | 295 | let repo_name = ''; 296 | if (parts && parts.length > 2 && identifier.indexOf('.git') !== -1) { 297 | // https://github.com/swdotcom/swdc-atom.git 298 | // this will return "swdc-atom" 299 | repo_name = identifier.split('/').slice(-1)[0].split('.git')[0]; 300 | } 301 | 302 | return {identifier, owner_id, repo_name}; 303 | } 304 | -------------------------------------------------------------------------------- /src/repo/KpmRepoManager.ts: -------------------------------------------------------------------------------- 1 | import { isGitProject } from '../Util'; 2 | import { CacheManager } from '../cache/CacheManager'; 3 | import { execCmd } from '../managers/ExecManager'; 4 | 5 | const cacheMgr: CacheManager = CacheManager.getInstance(); 6 | const cacheTimeoutSeconds = 60 * 15; 7 | 8 | // 9 | // use "git symbolic-ref --short HEAD" to get the git branch 10 | // use "git config --get remote.origin.url" to get the remote url 11 | export async function getResourceInfo(projectDir: string) { 12 | if (!projectDir || !isGitProject(projectDir)) { 13 | return null; 14 | } 15 | 16 | const noSpacesProjDir = projectDir.replace(/^\s+/g, ''); 17 | const cacheId = `resource-info-${noSpacesProjDir}`; 18 | 19 | let resourceInfo = cacheMgr.get(cacheId); 20 | // return from cache if we have it 21 | if (resourceInfo) { 22 | return resourceInfo; 23 | } 24 | 25 | resourceInfo = {}; 26 | 27 | const branch = execCmd('git symbolic-ref --short HEAD', projectDir); 28 | // returns something like: ['origin\thttps://github.com/swdotcom/swdc-vscode.git (fetch)', 'origin\thttps://github.com/swdotcom/swdc-vscode.git (push)'] 29 | const remotes = execCmd('git remote -v', projectDir, true); 30 | // find a line that starts with 'origin' 31 | const origin_remote = remotes.find((line: string) => line.startsWith('origin\t')); 32 | const remote_name = origin_remote ? 'origin' : remotes[0].split('\t')[0]; 33 | 34 | const identifier = execCmd(`git remote get-url ${remote_name}`, projectDir); 35 | let email = execCmd('git config user.email', projectDir); 36 | const tag = execCmd('git describe --all', projectDir); 37 | 38 | // both should be valid to return the resource info 39 | if (branch && identifier) { 40 | resourceInfo = { branch, identifier, email, tag }; 41 | cacheMgr.set(cacheId, resourceInfo, cacheTimeoutSeconds); 42 | } 43 | return resourceInfo; 44 | } 45 | -------------------------------------------------------------------------------- /src/sidebar/CodeTimeView.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CancellationToken, 3 | commands, 4 | Disposable, 5 | Event, 6 | EventEmitter, 7 | Uri, 8 | ViewColumn, 9 | WebviewView, 10 | WebviewViewProvider, 11 | WebviewViewResolveContext, 12 | } from 'vscode'; 13 | import { appGet, isResponseOk } from '../http/HttpClient'; 14 | import { getConnectionErrorHtml } from '../local/404'; 15 | import { getBooleanItem, getItem } from '../Util'; 16 | import { createAnonymousUser } from '../menu/AccountManager'; 17 | import { isStatusBarTextVisible } from '../managers/StatusBarManager'; 18 | 19 | export class CodeTimeView implements Disposable, WebviewViewProvider { 20 | private _webview: WebviewView | undefined; 21 | private _disposable: Disposable | undefined; 22 | 23 | constructor(private readonly _extensionUri: Uri) { 24 | // 25 | } 26 | 27 | public async refresh() { 28 | if (!this._webview) { 29 | // its not available to refresh yet 30 | return; 31 | } 32 | if (!getItem('jwt')) { 33 | await createAnonymousUser(); 34 | } 35 | 36 | this._webview.webview.html = await this.getHtml(); 37 | } 38 | 39 | private _onDidClose = new EventEmitter(); 40 | get onDidClose(): Event { 41 | return this._onDidClose.event; 42 | } 43 | 44 | // this is called when a view first becomes visible. This may happen when the view is first loaded 45 | // or when the user hides and then shows a view again 46 | public async resolveWebviewView( 47 | webviewView: WebviewView, 48 | context: WebviewViewResolveContext, 49 | token: CancellationToken 50 | ) { 51 | if (!this._webview) { 52 | this._webview = webviewView; 53 | } 54 | 55 | this._webview.webview.options = { 56 | // Allow scripts in the webview 57 | enableScripts: true, 58 | enableCommandUris: true, 59 | localResourceRoots: [this._extensionUri], 60 | }; 61 | 62 | this._disposable = Disposable.from(this._webview.onDidDispose(this.onWebviewDisposed, this)); 63 | 64 | this._webview.webview.onDidReceiveMessage(async (message: any) => { 65 | if (message?.action) { 66 | const cmd = message.action.includes('codetime.') ? message.action : `codetime.${message.action}`; 67 | switch (message.command) { 68 | case 'command_execute': 69 | if (message.payload && Object.keys(message.payload).length) { 70 | commands.executeCommand(cmd, message.payload); 71 | } else { 72 | commands.executeCommand(cmd); 73 | } 74 | break; 75 | } 76 | } 77 | }); 78 | 79 | if (!getItem('jwt')) { 80 | // the sidebar can sometimes try to render before we've created an anon user, create that first 81 | await createAnonymousUser(); 82 | setTimeout(() => { 83 | commands.executeCommand('codetime.refreshCodeTimeView'); 84 | }, 2000); 85 | } else { 86 | this._webview.webview.html = await this.getHtml(); 87 | } 88 | } 89 | 90 | dispose() { 91 | this._disposable && this._disposable.dispose(); 92 | } 93 | 94 | private onWebviewDisposed() { 95 | this._onDidClose.fire(); 96 | } 97 | 98 | get viewColumn(): ViewColumn | undefined { 99 | return undefined; 100 | } 101 | 102 | get visible() { 103 | return this._webview ? this._webview.visible : false; 104 | } 105 | 106 | private async getHtml(): Promise { 107 | const params = { 108 | showing_statusbar: isStatusBarTextVisible(), 109 | skip_slack_connect: !!getBooleanItem('vscode_CtskipSlackConnect'), 110 | }; 111 | const resp = await appGet('/plugin/sidebar', params); 112 | if (isResponseOk(resp)) { 113 | return resp.data; 114 | } 115 | 116 | return await getConnectionErrorHtml(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/user/OnboardManager.ts: -------------------------------------------------------------------------------- 1 | import {window, ExtensionContext} from 'vscode'; 2 | import {getItem} from '../Util'; 3 | import {createAnonymousUser} from '../menu/AccountManager'; 4 | 5 | let retry_counter = 0; 6 | 7 | export async function onboardInit(ctx: ExtensionContext, callback: any) { 8 | if (getItem('jwt')) { 9 | // we have the jwt, call the callback that anon was not created 10 | return callback(ctx, false /*anonCreated*/); 11 | } 12 | 13 | if (window.state.focused) { 14 | // perform primary window related work 15 | primaryWindowOnboarding(ctx, callback); 16 | } else { 17 | // call the secondary onboarding logic 18 | secondaryWindowOnboarding(ctx, callback); 19 | } 20 | } 21 | 22 | async function primaryWindowOnboarding(ctx: ExtensionContext, callback: any) { 23 | await createAnonymousUser(); 24 | callback(ctx, true /*anonCreated*/); 25 | } 26 | 27 | /** 28 | * This is called if there's no JWT. If it reaches a 29 | * 6th call it will create an anon user. 30 | * @param ctx 31 | * @param callback 32 | */ 33 | async function secondaryWindowOnboarding(ctx: ExtensionContext, callback: any) { 34 | if (getItem("jwt")) { 35 | return; 36 | } 37 | 38 | if (retry_counter < 5) { 39 | retry_counter++; 40 | // call activate again in about 15 seconds 41 | setTimeout(() => { 42 | onboardInit(ctx, callback); 43 | }, 1000 * 15); 44 | return; 45 | } 46 | 47 | // tried enough times, create an anon user 48 | await createAnonymousUser(); 49 | // call the callback 50 | return callback(ctx, true /*anonCreated*/); 51 | } 52 | -------------------------------------------------------------------------------- /src/websockets.ts: -------------------------------------------------------------------------------- 1 | import { ONE_MIN_MILLIS, websockets_url } from './Constants'; 2 | import { getItem, getPluginId, getPluginName, getVersion, getOs, getPluginUuid, logIt, getRandomNumberWithinRange, isPrimaryWindow, editorOpsExtInstalled } from './Util'; 3 | import { handleFlowScoreMessage } from './message_handlers/flow_score'; 4 | import { handleIntegrationConnectionSocketEvent } from './message_handlers/integration_connection'; 5 | import { handleCurrentDayStatsUpdate } from './message_handlers/current_day_stats_update'; 6 | import { handleFlowStateMessage } from './message_handlers/flow_state'; 7 | import { userDeletedCompletionHandler } from './DataController'; 8 | import { setEndOfDayNotification } from './notifications/endOfDay'; 9 | import { handleAuthenticatedPluginUser } from './message_handlers/authenticated_plugin_user'; 10 | 11 | const WebSocket = require('ws'); 12 | 13 | // The server should send its timeout to allow the client to adjust. 14 | // Default of 30 minutes 15 | const DEFAULT_PING_INTERVAL_MILLIS = ONE_MIN_MILLIS * 30; 16 | let SERVER_PING_INTERVAL_MILLIS = DEFAULT_PING_INTERVAL_MILLIS + ONE_MIN_MILLIS; 17 | let livenessPingTimeout: NodeJS.Timer | undefined = undefined; 18 | let lastPingResetMillis: number = 0; 19 | let retryTimeout: NodeJS.Timer | undefined = undefined; 20 | 21 | // Reconnect constants 22 | const INITIAL_RECONNECT_DELAY: number = 12000; 23 | const MAX_RECONNECT_DELAY: number = 25000; 24 | const LONG_RECONNECT_DELAY: number = ONE_MIN_MILLIS * 5; 25 | // Reconnect vars 26 | let useLongReconnectDelay: boolean = false; 27 | let currentReconnectDelay: number = INITIAL_RECONNECT_DELAY; 28 | 29 | let ws: any | undefined = undefined; 30 | let alive: boolean = false; 31 | 32 | export function websocketAlive() { 33 | return alive; 34 | } 35 | 36 | export function initializeWebsockets() { 37 | logIt('initializing websocket connection'); 38 | clearWebsocketRetryTimeout(); 39 | if (ws) { 40 | // 1000 indicates a normal closure, meaning that the purpose for 41 | // which the connection was established has been fulfilled 42 | ws.close(1000, 're-initializing websocket'); 43 | } 44 | 45 | const options = { 46 | headers: { 47 | Authorization: getItem('jwt'), 48 | 'X-SWDC-Plugin-Id': getPluginId(), 49 | 'X-SWDC-Plugin-Name': getPluginName(), 50 | 'X-SWDC-Plugin-Version': getVersion(), 51 | 'X-SWDC-Plugin-OS': getOs(), 52 | 'X-SWDC-Plugin-TZ': Intl.DateTimeFormat().resolvedOptions().timeZone, 53 | 'X-SWDC-Plugin-Offset': new Date().getTimezoneOffset(), 54 | 'X-SWDC-Plugin-UUID': getPluginUuid(), 55 | }, 56 | perMessageDeflate: false 57 | }; 58 | 59 | ws = new WebSocket(websockets_url, options); 60 | 61 | function heartbeat(buf: any) { 62 | try { 63 | // convert the buffer to the json payload containing the server timeout 64 | const data = JSON.parse(buf.toString()); 65 | if (data?.timeout) { 66 | // add a 1 minute buffer to the millisconds timeout the server provides 67 | const interval = data.timeout; 68 | if (interval > DEFAULT_PING_INTERVAL_MILLIS) { 69 | SERVER_PING_INTERVAL_MILLIS = interval + ONE_MIN_MILLIS; 70 | } else { 71 | SERVER_PING_INTERVAL_MILLIS = DEFAULT_PING_INTERVAL_MILLIS + ONE_MIN_MILLIS; 72 | } 73 | } 74 | } catch (e) { 75 | // defaults to the DEFAULT_PING_INTERVAL_MILLIS 76 | SERVER_PING_INTERVAL_MILLIS = DEFAULT_PING_INTERVAL_MILLIS + ONE_MIN_MILLIS; 77 | } 78 | 79 | // Received a ping from the server. Clear the timeout so 80 | // our client doesn't terminate the connection 81 | clearLivenessPingTimeout(); 82 | 83 | // Use `WebSocket#terminate()`, which immediately destroys the connection, 84 | // instead of `WebSocket#close()`, which waits for the close timer. 85 | // Delay should be equal to the interval at which your server 86 | // sends out pings plus a conservative assumption of the latency. 87 | livenessPingTimeout = setTimeout(() => { 88 | if (ws) { 89 | ws.terminate(); 90 | } 91 | }, SERVER_PING_INTERVAL_MILLIS); 92 | } 93 | 94 | ws.on('open', function open() { 95 | // clear out the retry timeout 96 | clearWebsocketRetryTimeout(); 97 | 98 | // reset long reconnect flag 99 | useLongReconnectDelay = false; 100 | 101 | // RESET reconnect delay 102 | currentReconnectDelay = INITIAL_RECONNECT_DELAY; 103 | alive = true; 104 | logIt('Websocket connection open'); 105 | }); 106 | 107 | ws.on('ping', heartbeat); 108 | 109 | ws.on('message', function incoming(data: any) { 110 | handleIncomingMessage(data); 111 | }); 112 | 113 | ws.on('close', function close(code: any, reason: any) { 114 | if (code !== 1000) { 115 | useLongReconnectDelay = false; 116 | retryConnection(); 117 | } 118 | }); 119 | 120 | ws.on('unexpected-response', function unexpectedResponse(request: any, response: any) { 121 | logIt(`unexpected websocket response: ${response.statusCode}`); 122 | 123 | if (response.statusCode === 426) { 124 | logIt('websocket request had invalid headers. Are you behind a proxy?'); 125 | } else if (response.statusCode >= 500) { 126 | useLongReconnectDelay = true; 127 | retryConnection(); 128 | } 129 | }); 130 | 131 | ws.on('error', function error(e: any) { 132 | logIt(`error connecting to websocket: ${e.message}`); 133 | }); 134 | } 135 | 136 | function retryConnection() { 137 | alive = false; 138 | if (!retryTimeout) { 139 | 140 | // clear this client side liveness timeout 141 | clearLivenessPingTimeout(); 142 | 143 | let delay: number = getDelay(); 144 | if (useLongReconnectDelay) { 145 | // long reconnect (5 minutes) 146 | delay = LONG_RECONNECT_DELAY; 147 | } else { 148 | // shorter reconnect: 10 to 50 seconds 149 | if (currentReconnectDelay < MAX_RECONNECT_DELAY) { 150 | // multiply until we've reached the max reconnect 151 | currentReconnectDelay *= 2; 152 | } else { 153 | currentReconnectDelay = Math.min(currentReconnectDelay, MAX_RECONNECT_DELAY); 154 | } 155 | } 156 | 157 | logIt(`retrying websocket connection in ${delay / 1000} second(s)`); 158 | 159 | retryTimeout = setTimeout(() => { 160 | initializeWebsockets(); 161 | }, delay); 162 | } 163 | } 164 | 165 | function getDelay() { 166 | let rand: number = getRandomNumberWithinRange(-5, 5); 167 | if (currentReconnectDelay < MAX_RECONNECT_DELAY) { 168 | // if less than the max reconnect delay then increment the delay 169 | rand = Math.random(); 170 | } 171 | return currentReconnectDelay + Math.floor(rand * 1000); 172 | } 173 | 174 | export function disposeWebsocketTimeouts() { 175 | clearWebsocketRetryTimeout(); 176 | clearLivenessPingTimeout(); 177 | } 178 | 179 | function clearLivenessPingTimeout() { 180 | if (livenessPingTimeout) { 181 | clearTimeout(livenessPingTimeout); 182 | livenessPingTimeout = undefined; 183 | } 184 | lastPingResetMillis = new Date().getTime(); 185 | } 186 | 187 | export function checkWebsocketConnection() { 188 | const nowMillis = new Date().getTime(); 189 | const threshold = SERVER_PING_INTERVAL_MILLIS * 2; 190 | if (lastPingResetMillis && nowMillis - lastPingResetMillis > threshold) { 191 | logIt('Resetting websocket connection'); 192 | initializeWebsockets(); 193 | } 194 | } 195 | 196 | function clearWebsocketRetryTimeout() { 197 | if (retryTimeout) { 198 | clearTimeout(retryTimeout); 199 | retryTimeout = undefined; 200 | } 201 | } 202 | 203 | // handle incoming websocket messages 204 | const handleIncomingMessage = (data: any) => { 205 | try { 206 | let message: any = { 207 | type: 'none' 208 | } 209 | try { 210 | message = JSON.parse(data); 211 | } catch (e: any) { 212 | logIt(`Unable to handle incoming message: ${e.message}`); 213 | } 214 | 215 | switch (message.type) { 216 | case 'flow_score': 217 | if (isPrimaryWindow() && !(editorOpsExtInstalled())) { 218 | try { logIt(`Flow score: ${JSON.stringify(message.body.flowScore)}`) } catch (e) { } 219 | handleFlowScoreMessage(message); 220 | } 221 | break; 222 | case 'authenticated_plugin_user': 223 | const user = message.body; 224 | const currentEmail = getItem('name'); 225 | if (user.email !== currentEmail) { 226 | handleAuthenticatedPluginUser(user); 227 | } 228 | break; 229 | case 'flow_state': 230 | try { logIt(`Flow state: ${JSON.stringify(message.body)}`) } catch (e) { } 231 | handleFlowStateMessage(message.body); 232 | break; 233 | case 'user_integration_connection': 234 | handleIntegrationConnectionSocketEvent(message.body); 235 | break; 236 | case 'current_day_stats_update': 237 | try { logIt(`Current day stats: ${JSON.stringify(message.body.data)}`) } catch (e) { } 238 | handleCurrentDayStatsUpdate(message.body); 239 | break; 240 | case 'account_deleted': 241 | userDeletedCompletionHandler(); 242 | break; 243 | case 'preferences_update': 244 | setEndOfDayNotification(); 245 | break; 246 | } 247 | } catch (e) { 248 | if (data) { 249 | let dataStr: string = ''; 250 | try { 251 | dataStr = JSON.stringify(data); 252 | } catch (e) { 253 | dataStr = data.toString(); 254 | } 255 | logIt(`Unable to handle incoming message: ${dataStr}`); 256 | } 257 | } 258 | }; 259 | -------------------------------------------------------------------------------- /swdc-vscode-2.8.8.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swdotcom/swdc-vscode/85b307b7f8641ae1f2210e02e06d83fc236d789a/swdc-vscode-2.8.8.vsix -------------------------------------------------------------------------------- /test/extension.test.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Note: This example test is leveraging the Mocha test framework. 3 | // Please refer to their documentation on https://mochajs.org/ for help. 4 | // 5 | 6 | // The module 'assert' provides assertion methods from node 7 | import * as assert from "assert"; 8 | 9 | // You can import and use all API from the 'vscode' module 10 | // as well as import your extension to test it 11 | import * as vscode from "vscode"; 12 | 13 | // Defines a Mocha test suite to group tests of similar kind together 14 | suite("Extension Tests", () => { 15 | // Defines a Mocha unit test 16 | test("Hello commands can be executed", async () => { 17 | await vscode.commands.executeCommand("extension.sayHello"); 18 | assert(true); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING 3 | // 4 | // This file is providing the test runner to use when running extension tests. 5 | // By default the test runner in use is Mocha based. 6 | // 7 | // You can provide your own test runner if you want to override it by exporting 8 | // a function run(testRoot: string, clb: (error:Error) => void) that the extension 9 | // host can call to run the tests. The test runner is expected to use console.log 10 | // to report the results back to the caller. When the tests are finished, return 11 | // a possible error to the callback or null if none. 12 | 13 | // import * as testRunner from "vscode/lib/testrunner"; 14 | 15 | // // You can directly control Mocha options by uncommenting the following lines 16 | // // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info 17 | // testRunner.configure({ 18 | // ui: "tdd", 19 | // useColors: true // colored output from test results, 20 | // }); 21 | 22 | // module.exports = testRunner; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "dist", 6 | "lib": [ 7 | "ES2020" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": ".", 11 | "strict": true 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | ".vscode-test", 16 | "**/src/app/**", 17 | ".github" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | 'use strict'; 4 | 5 | const webpack = require('webpack'); 6 | const path = require('path'); 7 | const CopyPlugin = require('copy-webpack-plugin'); 8 | 9 | /**@type {import('webpack').Configuration}*/ 10 | const config = { 11 | target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 12 | output: { 13 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 14 | path: path.resolve(__dirname, 'dist'), 15 | filename: 'extension.js', 16 | libraryTarget: 'commonjs2', 17 | devtoolModuleFilenameTemplate: '../[resource-path]', 18 | }, 19 | entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 20 | node: { 21 | __dirname: false, 22 | __filename: false, 23 | }, 24 | devtool: 'source-map', 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 | bufferutil: 'commonjs bufferutil', 28 | 'utf-8-validate': 'commonjs utf-8-validate', 29 | }, 30 | resolve: { 31 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 32 | extensions: ['.ts', '.js'], 33 | }, 34 | plugins: [ 35 | new webpack.ContextReplacementPlugin(/keyv/), 36 | new CopyPlugin({ 37 | patterns: [ 38 | { from: './resources', to: 'resources' }, 39 | { from: './images', to: 'images' }, 40 | { from: './README.md', to: 'resources' }, 41 | ], 42 | }), 43 | ], 44 | module: { 45 | rules: [ 46 | { 47 | test: /\.ts$/, 48 | loader: 'ts-loader', 49 | options: { allowTsInNodeModules: true }, 50 | }, 51 | { 52 | test: /\.(png|svg|jpg|gif)$/, 53 | use: ['file-loader'], 54 | }, 55 | ], 56 | }, 57 | }; 58 | 59 | module.exports = config; 60 | --------------------------------------------------------------------------------