├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── stale.yml └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc.js ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README.zh-CN.md ├── TODO.md ├── demo └── how-worktree-works.excalidraw.png.bak ├── images ├── add-worktrees-to-workspace.mp4 ├── create-worktree.mp4 ├── donate │ ├── paypal.png │ └── wechat.png ├── drop-to-favorites.mp4 ├── how-worktree-works.png ├── icon.png ├── icon.svg ├── manage-multiple-repositories.mp4 ├── overview.png └── switch-branch.mp4 ├── l10n ├── bundle.l10n.ja.json ├── bundle.l10n.zh-cn.json └── bundle.l10n.zh-tw.json ├── package.json ├── package.nls.ja.json ├── package.nls.json ├── package.nls.zh-cn.json ├── package.nls.zh-tw.json ├── pnpm-lock.yaml ├── rspack.config.js ├── src ├── constants.ts ├── core │ ├── bootstrap.ts │ ├── command │ │ ├── addDirsToRepo.ts │ │ ├── addGitFolderCmd.ts │ │ ├── addRootsToRepoCmd.ts │ │ ├── addToFavoriteCmd.ts │ │ ├── addToGitFolder.ts │ │ ├── addToGitFolderCmd.ts │ │ ├── addToWorkspaceCmd.ts │ │ ├── addWorktreeCmd.ts │ │ ├── addWorktreeFromBranchCmd.ts │ │ ├── bundleRepoCmd.ts │ │ ├── checkoutBranchCmd.ts │ │ ├── commonWorktreeCmd.ts │ │ ├── copyFilePathCmd.ts │ │ ├── createWorktreeFromInfo.ts │ │ ├── deleteBranchCmd.ts │ │ ├── fetchRepoCmd.ts │ │ ├── fetchWorktreeCmd.ts │ │ ├── index.ts │ │ ├── loadAllTreeDataCmd.ts │ │ ├── lockWorktreeCmd.ts │ │ ├── moveWorktreeCmd.ts │ │ ├── openExternalTerminalCmd.ts │ │ ├── openFavoriteCmd.ts │ │ ├── openRecentCmd.ts │ │ ├── openRepositoryCmd.ts │ │ ├── openSettingCmd.ts │ │ ├── openTerminalCmd.ts │ │ ├── openWalkthroughsCmd.ts │ │ ├── openWorkspaceWorktreeCmd.ts │ │ ├── pruneWorktreeCmd.ts │ │ ├── pullWorktreeCmd.ts │ │ ├── pushWorktreeCmd.ts │ │ ├── refreshFavoriteCmd.ts │ │ ├── refreshGitFolderCmd.ts │ │ ├── refreshRecentFolderCmd.ts │ │ ├── refreshWorktreeCacheCmd.ts │ │ ├── refreshWorktreeCmd.ts │ │ ├── removeFavoriteCmd.ts │ │ ├── removeFromWorkspaceCmd.ts │ │ ├── removeGitFolderCmd.ts │ │ ├── removeMultiFavoriteCmd.ts │ │ ├── removeMultiGitFolderCmd.ts │ │ ├── removeWorktreeCmd.ts │ │ ├── renameBranchCmd.ts │ │ ├── renameGitFolderCmd.ts │ │ ├── repairWorktreeCmd.ts │ │ ├── revealInSystemExplorerCmd.ts │ │ ├── searchAllWorktreeCmd.ts │ │ ├── switchToSelectWorkspaceCmd.ts │ │ ├── switchWorktreeCmd.ts │ │ ├── toggleGitFolderOpenCmd.ts │ │ ├── toggleGitFolderViewAs.ts │ │ ├── toggleLogCmd.ts │ │ ├── unlockWorktreeCmd.ts │ │ ├── unwatchWorktreeEventCmd.ts │ │ ├── viewHistoryCmd.ts │ │ └── watchWorktreeEventCmd.ts │ ├── config │ │ └── setting.ts │ ├── event │ │ ├── events.ts │ │ └── git.ts │ ├── folderRoot.ts │ ├── git │ │ ├── addWorktree.ts │ │ ├── bundleRepo.ts │ │ ├── checkBranchNameValid.ts │ │ ├── checkGitValid.ts │ │ ├── checkoutBranch.ts │ │ ├── createBranch.ts │ │ ├── deleteBranch.ts │ │ ├── exec-base.ts │ │ ├── exec.ts │ │ ├── fetchRemoteRef.ts │ │ ├── fetchRepo.ts │ │ ├── getAheadBehindCommitCount.ts │ │ ├── getAllRefList.ts │ │ ├── getChanges.ts │ │ ├── getCurrentBranch.ts │ │ ├── getLashCommitDetail.ts │ │ ├── getLastCommitHash.ts │ │ ├── getMainFolder.ts │ │ ├── getNameRev.ts │ │ ├── getUpstream.ts │ │ ├── getWorktreeList.ts │ │ ├── index.ts │ │ ├── lockWorktree.ts │ │ ├── moveWorktree.ts │ │ ├── pruneWorktree.ts │ │ ├── pullBranch.ts │ │ ├── pushBranch.ts │ │ ├── removeWorktree.ts │ │ ├── renameBranch.ts │ │ ├── repairWorktree.ts │ │ └── unlockWorktree.ts │ ├── gitHistory.ts │ ├── log │ │ └── logger.ts │ ├── quickPick │ │ ├── pickAction.ts │ │ ├── pickBranch.ts │ │ ├── pickWorktree.ts │ │ └── quickPick.button.ts │ ├── state │ │ └── index.ts │ ├── treeView │ │ ├── items │ │ │ ├── file.ts │ │ │ ├── folder.ts │ │ │ ├── gitFolder.ts │ │ │ ├── index.ts │ │ │ └── worktree.ts │ │ ├── treeViewManager.ts │ │ └── views │ │ │ ├── favorite.ts │ │ │ ├── gitFolder.ts │ │ │ ├── index.ts │ │ │ ├── recentFolder.ts │ │ │ ├── setting.ts │ │ │ └── worktree.ts │ ├── ui │ │ ├── inputNewBranch.ts │ │ ├── inputWorktreeDir.ts │ │ ├── message.ts │ │ ├── modal.ts │ │ ├── pickGitFolder.ts │ │ ├── progress.ts │ │ └── pullOrPushAction.ts │ └── util │ │ ├── branch.ts │ │ ├── cache.ts │ │ ├── copyWorktreeFiles.ts │ │ ├── external.ts │ │ ├── file.ts │ │ ├── folder.ts │ │ ├── open.ts │ │ ├── parse.ts │ │ ├── postCreateWorktree.ts │ │ ├── promise.ts │ │ ├── ref.ts │ │ ├── state.ts │ │ ├── tree.ts │ │ ├── workspace.ts │ │ └── worktree.ts ├── extension.ts └── types.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | # 匹配全部文件 3 | [*] 4 | # 设置字符集 5 | charset = utf-8 6 | # 缩进风格,可选space、tab 7 | indent_style = space 8 | # 缩进的空格数 9 | indent_size = 4 10 | # 结尾换行符,可选lf、cr、crlf 11 | end_of_line = lf 12 | # 在文件结尾插入新行 13 | insert_final_newline = true 14 | # 删除一行中的前后空格 15 | trim_trailing_whitespace = true 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | webpack.config.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "rules": { 10 | "@typescript-eslint/naming-convention": "warn", 11 | "@typescript-eslint/semi": "warn", 12 | "curly": ["error", "multi-line", "consistent"], 13 | "eqeqeq": "warn", 14 | "no-throw-literal": "warn", 15 | "semi": "off", 16 | // 禁止使用 \ 串联多行字符串 17 | "no-multi-str": "error", 18 | // 推荐使用字符串模板连接字符串 19 | "prefer-template": "warn", 20 | // 模板字符串中的花括号内不使用空格 21 | "template-curly-spacing": "error", 22 | // 禁用不必要的转义 23 | "no-useless-escape": "error" 24 | }, 25 | "ignorePatterns": ["out", "dist", "**/*.d.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve | 提交错误报告帮助我们改进 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please fill out the sections below to help everyone identify and fix the bug | 请填写以下部分,以便帮助识别和修复错误。 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Describe the bug | 描述错误 14 | placeholder: A clear and concise description of what the bug is. | 简要描述错误是什么。 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: steps 20 | attributes: 21 | label: Steps to reproduce the behavior | 重现错误的步骤 22 | placeholder: | 23 | 1. Go to '...' 24 | 2. Click on '...' 25 | 3. Scroll down to '...' 26 | 4. See error | 依次列出步骤,描述错误出现的过程 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: expected 32 | attributes: 33 | label: Expected behavior | 预期行为 34 | placeholder: A clear and concise description of what you expected to happen. | 简要描述预期的行为。 35 | 36 | - type: textarea 37 | id: screenshots 38 | attributes: 39 | label: If applicable, add screenshots to help explain your problem. | 如果适用,添加截图以帮助说明问题。 40 | 41 | - type: textarea 42 | id: environment 43 | attributes: 44 | label: Environment | 环境信息 45 | placeholder: | 46 | OS: Windows 10 47 | VSCode: 1.80.2 48 | Extension: 1.0.0 49 | 50 | - type: dropdown 51 | id: assign 52 | attributes: 53 | label: Would you like to work on this issue? | 你是否愿意参与此错误的修复? 54 | options: 55 | - "Yes" 56 | 57 | - type: markdown 58 | attributes: 59 | value: | 60 | Thanks for reporting this issue! We will get back to you as soon as possible. | 感谢报告此问题!我们会尽快回复你。 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project | 提出项目的功能建议 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please fill out the sections below to properly describe the new feature you are suggesting. | 请填写以下部分,以便正确描述你建议的新功能。 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Feature description | 功能描述 14 | placeholder: A clear and concise description of what the feature should do. | 简要描述这个功能应具备的内容。 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: rationale 20 | attributes: 21 | label: How would you use this feature? | 你希望如何使用此功能? 22 | placeholder: Provide some concrete use cases and expectations. | 提供一些具体的使用场景和功能期望。 23 | 24 | - type: textarea 25 | id: alternative 26 | attributes: 27 | label: Alternative solutions | 替代方案 28 | placeholder: | 29 | If you've considered other alternatives, please describe them. | 如果你考虑过其他替代方案,请描述。 30 | 31 | - type: textarea 32 | id: additional 33 | attributes: 34 | label: Additional context | 其他信息 35 | placeholder: | 36 | Any other context about the feature request. | 任何与该功能请求相关的其他信息。 37 | 38 | - type: dropdown 39 | id: assign 40 | attributes: 41 | label: Would you like to work on this issue? | 你是否愿意参与此功能的开发? 42 | options: 43 | - "Yes" 44 | 45 | - type: markdown 46 | attributes: 47 | value: | 48 | Thanks for your suggestion! Let's see together if it can be implemented. | 感谢你的建议!让我们一起看看是否可以实现。 49 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | daysUntilStale: 60 2 | daysUntilClose: 7 3 | exemptLabels: 4 | - pinned 5 | - security 6 | - no-stale 7 | - no stale 8 | - pr welcome 9 | staleLabel: stale 10 | markComment: > 11 | This issue has been automatically marked as stale because it has not had 12 | recent activity. It will be closed if no further activity occurs. Thank you 13 | for your contributions. 14 | closeComment: false 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: 10 24 | run_install: false 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '>=20.18.2' 30 | registry-url: 'https://registry.npmjs.org' 31 | cache: 'pnpm' 32 | 33 | - run: npx changelogithub 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Install dependencies 38 | run: pnpm install --no-frozen-lockfile 39 | 40 | - name: Package VSIX 41 | run: pnpm exec vsce package --no-dependencies --out ./git-worktree-manager-${{ github.ref_name }}.vsix 42 | 43 | - name: Upload Release Artifact 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | run: gh release upload ${{ github.ref_name }} ./git-worktree-manager-${{ github.ref_name }}.vsix 47 | 48 | - name: Publish Extension to VSCode Marketplace 49 | run: pnpm exec vsce publish -p ${{ secrets.VSCE_TOKEN }} --no-dependencies 50 | 51 | - name: Publish Extension to OVSX 52 | run: pnpm exec ovsx publish -p ${{ secrets.OVSX_TOKEN }} --no-dependencies 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .pnpm-* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=true 2 | auto-install-peers=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.18.2 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "printWidth": 120, // 每行最大字符数,超出会换行 3 | "tabWidth": 4, // 缩进的空格数 4 | "useTabs": false, // 是否使用 Tab 进行缩进 5 | "semi": true, // 语句末尾是否加分号 6 | "singleQuote": true, // 是否使用单引号 7 | "quoteProps": "as-needed", // 仅在需要时为对象的 key 添加引号 8 | "jsxSingleQuote": false, // 在 JSX 中使用单引号 9 | "trailingComma": "all", // 末尾逗号:none | es5 | all 10 | "bracketSpacing": true, // 对象花括号内部是否有空格 11 | "bracketSameLine": false, // JSX 标签的 `>` 是否换行 12 | "arrowParens": "always", // 箭头函数参数是否使用括号(always | avoid) 13 | "proseWrap": "preserve", // Markdown 文本换行方式 14 | "htmlWhitespaceSensitivity": "css", // HTML 空格敏感度 15 | "endOfLine": "lf", // 换行符:lf | crlf | cr | auto 16 | "embeddedLanguageFormatting": "auto" // 是否格式化嵌入的代码 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/dist/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/**/*.js", 30 | "${workspaceFolder}/dist/**/*.js" 31 | ], 32 | "preLaunchTask": "tasks: watch-tests" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false, // set this to true to hide the "out" folder with the compiled JS files 5 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "dist": true // set this to false to include "dist" folder in search results 10 | }, 11 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 12 | "typescript.tsc.autoDetect": "off", 13 | "cSpell.words": [ 14 | "worktree" 15 | ], 16 | "editor.wordSeparators": "`~!@#$%^&*()=+[{]}\\|;:'\",<>/?.", 17 | // "files.trimTrailingWhitespace": false, 18 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$ts-webpack-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never", 13 | "group": "watchers" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "watch-tests", 23 | "problemMatcher": "$tsc-watch", 24 | "isBackground": true, 25 | "presentation": { 26 | "reveal": "never", 27 | "group": "watchers" 28 | }, 29 | "group": "build" 30 | }, 31 | { 32 | "label": "tasks: watch-tests", 33 | "dependsOn": [ 34 | "npm: watch", 35 | "npm: watch-tests" 36 | ], 37 | "problemMatcher": [] 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/** 4 | node_modules/** 5 | src/** 6 | .gitignore 7 | .yarnrc 8 | webpack.config.js 9 | rspack.config.js 10 | vsc-extension-quickstart.md 11 | pnpm-lock.yaml 12 | .pnpm* 13 | **/tsconfig.json 14 | **/.eslintrc.json 15 | **/.eslintignore 16 | **/*.map 17 | **/*.ts 18 | images/** 19 | !images/icon.svg 20 | !images/icon.png 21 | demo/** 22 | TODO.md 23 | README.zh-CN.md 24 | .github/** 25 | .nvmrc 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 huang bingfeng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Git Worktree Manager for VSCode 3 | 4 | [![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/jackiotyu.git-worktree-manager)](https://marketplace.visualstudio.com/items?itemName=jackiotyu.git-worktree-manager) 5 | [![GitHub release](https://img.shields.io/github/v/release/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager/releases) 6 | [![GitHub Open Issues](https://img.shields.io/github/issues/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager/issues) 7 | [![License](https://img.shields.io/github/license/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager/blob/main/LICENSE) 8 | [![GitHub Stars](https://img.shields.io/github/stars/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager) 9 | 10 | English | [简体中文](./README.zh-CN.md) 11 | 12 | Effortlessly manage Git worktrees in Visual Studio Code! 🚀 Simplify your workflow, work on multiple branches simultaneously, and boost productivity with this powerful extension. 13 | 14 | 15 | 16 | ## Support 💖 17 | 18 | If you enjoy this extension, consider giving it a [star ⭐](https://github.com/jackiotyu/git-worktree-manager) and sharing it on social platforms like [X.com](https://x.com/intent/post?text=Check%20out%20this%20awesome%20VSCode%20extension%20for%20managing%20Git%20worktrees!!%20https%3A%2F%2Fgithub.com%2Fjackiotyu%2Fgit-worktree-manager)—it really helps! 19 | 20 | 21 | 25 | 29 | 30 |
22 | PayPal Donate
23 | PayPal 24 |
26 | WeChat Donate
27 | 微信 28 |
31 | 32 | 33 | ## Why Git Worktree Manager? 🌟 34 | 35 | Tired of juggling branches, stashing changes, or resolving merge conflicts? **Git Worktree Manager** makes parallel development a breeze by leveraging Git worktrees, letting you work on multiple branches in separate directories without leaving VSCode. Whether you’re tackling hotfixes, experimenting with features, or managing complex projects, this extension saves time, reduces friction, and keeps your workspace organized. With seamless integration and intuitive controls, it’s the ultimate tool for developers who want a smoother Git experience. 36 | 37 | 38 | > [Manage multiple repositories effortlessly within VSCode.](./images/manage-multiple-repositories.mp4) 39 | 40 | 41 | ### Key Features 🎯 42 | - **Quick Worktree Switching**: Switch between worktrees using `Ctrl+Shift+R` or the Source Control view. 43 | 44 | > [Switch branches seamlessly with a single command.](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/switch-branch.mp4) 45 | - **Effortless Worktree Creation**: Create new worktrees without touching the command line. 46 | 47 | > [Create a new worktree in seconds.](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/create-worktree.mp4) 48 | - **Workspace Integration**: Add worktrees to your VSCode workspace for easy access. 49 | 50 | > [Add worktrees to your workspace with a click.](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/add-worktrees-to-workspace.mp4) 51 | - **Favorites Management**: Save frequently used worktrees for quick access. 52 | 53 | > [Drop worktrees to favorites for instant access.](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/drop-to-favorites.mp4) 54 | - **Copy Untracked Files**: Automatically include untracked files when creating a new worktree. 55 | - **Multi-Language Support**: Available in English, Simplified Chinese, Traditional Chinese and Japanese. 56 | - **Customizable Terminal**: Use your preferred terminal (e.g., iTerm on macOS, Git Bash on Windows). 57 | 58 | ## Getting Started 🚀 59 | 60 | 1. **Install the Extension**: 61 | - Download from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=jackiotyu.git-worktree-manager). 62 | - Or search for "Git Worktree Manager" in VSCode’s Extensions view and install. 63 | 64 | 2. **Quick Start**: 65 | - Open VSCode in a Git repository. 66 | - Press `Ctrl+Shift+R` to launch the worktree manager. 67 | - Create, switch, or delete worktrees using the intuitive interface. 68 | 69 | 3. **Example Workflow**: 70 | - Create a new worktree: Select “Create Worktree” and specify a branch. 71 | - Switch to it instantly via the Source Control view or command palette. 72 | - Add it to your VSCode workspace to work on multiple branches side by side. 73 | - Save it to favorites for quick access in the future. 74 | 75 | ## Configuration ⚙️ 76 | 77 | Customize your experience: 78 | - **`git-worktree-manager.treeView.toSCM`**: Display worktrees in the Source Control view. 79 | - **`terminal.external.windowsExec`**: Set your preferred terminal (e.g., `"C:\\Program Files\\Git\\bin\\bash.exe"` for Git Bash). 80 | - **`terminal.external.osxExec`**: Use iTerm or another terminal on macOS (e.g., `"iTerm.app"`). 81 | 82 | ## Contributing 🤝 83 | 84 | We love contributions! Here’s how to get involved: 85 | 1. Fork the repository. 86 | 2. Create a feature branch (`git checkout -b feature/awesome-idea`). 87 | 3. Commit your changes (`git commit -m "Add awesome idea"`). 88 | 4. Push to the branch (`git push origin feature/awesome-idea`). 89 | 5. Open a Pull Request. 90 | 91 | Have ideas? Open an issue with the "enhancement" tag or explore [open issues](https://github.com/jackiotyu/git-worktree-manager/issues). 92 | 93 | ## License 📜 94 | 95 | Distributed under the [MIT License](LICENSE). Use, modify, and share freely! 96 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # VSCode Git Worktree Manager 2 | 3 | 4 | 5 | [![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/jackiotyu.git-worktree-manager)](https://marketplace.visualstudio.com/items?itemName=jackiotyu.git-worktree-manager) 6 | [![GitHub release](https://img.shields.io/github/v/release/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager/releases) 7 | [![GitHub Open Issues](https://img.shields.io/github/issues/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager/issues) 8 | [![License](https://img.shields.io/github/license/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager/blob/main/LICENSE) 9 | [![GitHub Stars](https://img.shields.io/github/stars/jackiotyu/git-worktree-manager)](https://github.com/jackiotyu/git-worktree-manager) 10 | 11 | 简体中文 | [English](./README.md) 12 | 13 | 在 Visual Studio Code 中轻松管理 Git Worktree!🚀 简化工作流程,同时处理多个分支,提升生产力。这个扩展让 Git 管理变得更简单、更高效! 14 | 15 | 16 | 17 | ## Support 💖 18 | 19 | 如果这个插件帮到了你,请点个 [star ⭐](https://github.com/jackiotyu/git-worktree-manager) 吧! 20 | 21 | 22 | 23 | 27 | 31 | 32 |
24 | PayPal Donate
25 | PayPal 26 |
28 | WeChat Donate
29 | 微信 30 |
33 | 34 | ## 为什么选择 Git Worktree Manager?🌟 35 | 36 | 厌倦了频繁切换分支、暂存更改或解决合并冲突?**Git Worktree Manager** 通过 Git Worktree 功能,让您在不同目录中同时处理多个分支,无需离开 VSCode。无论是修复紧急问题、开发新功能还是管理复杂项目,这款扩展都能节省时间、减少麻烦,让您的工作区井然有序。凭借无缝集成和直观的操作,它是追求高效 Git 工作流的开发者的理想选择! 37 | 38 | 39 | > [在 VSCode 中轻松管理多个仓库。](./images/manage-multiple-repositories.mp4) 40 | 41 | ### 核心功能 🎯 42 | - **快速切换 Worktree**:使用 `Ctrl+Shift+R` 或源代码管理视图快速切换 Worktree。 43 | 44 | > [一键无缝切换分支。](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/switch-branch.mp4) 45 | - **轻松创建 Worktree**:无需命令行,直接在 VSCode 中创建 Worktree。 46 | 47 | > [几秒钟内创建新 Worktree。](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/create-worktree.mp4) 48 | - **工作区集成**:将 Worktree 添加到 VSCode 工作区,轻松访问。 49 | 50 | > [一键将 Worktree 添加到工作区。](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/add-worktrees-to-workspace.mp4) 51 | - **收藏夹管理**:保存常用 Worktree,方便快速访问。 52 | 53 | > [将 Worktree 拖入收藏夹,随时访问。](https://cdn.jsdelivr.net/gh/jackiotyu/git-worktree-manager@0.4.6/images/drop-to-favorites.mp4) 54 | - **复制未跟踪文件**:创建 Worktree 时自动复制主仓库的未跟踪文件。 55 | - **多语言支持**:支持英语、简体中文、繁体中文和日语。 56 | - **自定义终端**:支持 macOS 的 iTerm 或 Windows 的 Git Bash 等终端。 57 | 58 | ## 快速上手 🚀 59 | 60 | 1. **安装扩展**: 61 | - 从 [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=jackiotyu.git-worktree-manager) 下载。 62 | - 或在 VSCode 扩展视图中搜索 "Git Worktree Manager" 并安装。 63 | 64 | 2. **快速开始**: 65 | - 在 Git 仓库中打开 VSCode。 66 | - 按 `Ctrl+Shift+R` 启动 Worktree 管理器。 67 | - 使用直观界面创建、切换或删除 Worktree。 68 | 69 | 3. **示例工作流**: 70 | - 创建新 Worktree:选择“创建 Worktree”并指定分支。 71 | - 通过源代码管理视图或命令面板即时切换。 72 | - 将 Worktree 添加到 VSCode 工作区,同时处理多个分支。 73 | - 保存到收藏夹以便日后快速访问。 74 | 75 | ## 配置 ⚙️ 76 | 77 | 自定义您的体验: 78 | - **`git-worktree-manager.treeView.toSCM`**:在源代码管理视图中显示 Worktree。 79 | - **`terminal.external.windowsExec`**:设置首选终端(例如,Windows 的 Git Bash:`"C:\\Program Files\\Git\\bin\\bash.exe"`)。 80 | - **`terminal.external.osxExec`**:在 macOS 上使用 iTerm 等终端(例如,`"iTerm.app"`)。 81 | 82 | ## 贡献 🤝 83 | 84 | 我们欢迎贡献!参与方式: 85 | 1. 克隆仓库。 86 | 2. 创建功能分支(`git checkout -b feature/awesome-idea`)。 87 | 3. 提交更改(`git commit -m "添加新功能"`)。 88 | 4. 推送分支(`git push origin feature/awesome-idea`)。 89 | 5. 提交 Pull Request。 90 | 91 | 有好主意?请在 [issues](https://github.com/jackiotyu/git-worktree-manager/issues) 中创建“enhancement”标签的问题。 92 | 93 | ## 许可证 📜 94 | 95 | 采用 [MIT 许可证](LICENSE) 分发,欢迎自由使用、修改和分享! 96 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | [x] 监听 git 仓库文件变动,触发刷新 2 | [ ] 整合 quickPick 操作,规范返回操作 3 | [ ] 标识当前工作区的终端、识别工作区内的package.json的scripts命令 4 | [ ] 统一worktree数据管理,监听git仓库更新,统一更新 5 | [ ] 拆分worktree路径和worktree状态,独立更新 6 | -------------------------------------------------------------------------------- /demo/how-worktree-works.excalidraw.png.bak: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/demo/how-worktree-works.excalidraw.png.bak -------------------------------------------------------------------------------- /images/add-worktrees-to-workspace.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/add-worktrees-to-workspace.mp4 -------------------------------------------------------------------------------- /images/create-worktree.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/create-worktree.mp4 -------------------------------------------------------------------------------- /images/donate/paypal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/donate/paypal.png -------------------------------------------------------------------------------- /images/donate/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/donate/wechat.png -------------------------------------------------------------------------------- /images/drop-to-favorites.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/drop-to-favorites.mp4 -------------------------------------------------------------------------------- /images/how-worktree-works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/how-worktree-works.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/icon.png -------------------------------------------------------------------------------- /images/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/manage-multiple-repositories.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/manage-multiple-repositories.mp4 -------------------------------------------------------------------------------- /images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/overview.png -------------------------------------------------------------------------------- /images/switch-branch.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/images/switch-branch.mp4 -------------------------------------------------------------------------------- /rspack.config.js: -------------------------------------------------------------------------------- 1 | // rspack.config.js 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | /** @type {import('@rspack/cli').Configuration} */ 8 | const extensionConfig = { 9 | target: 'node14', // VS Code extensions run in a Node.js-context 10 | mode: 'none', // this leaves the source code as close as possible to the original 11 | 12 | entry: './src/extension.ts', // the entry point of this extension 13 | output: { 14 | // the bundle is stored in the 'dist' folder 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: 'extension.js', 17 | library: { 18 | type: 'commonjs2', 19 | }, 20 | }, 21 | externals: { 22 | vscode: 'commonjs vscode', // exclude the vscode-module 23 | // other modules that cannot be rspack'ed 24 | }, 25 | resolve: { 26 | // support reading TypeScript and JavaScript files 27 | tsConfig: { 28 | configFile: path.resolve(__dirname, 'tsconfig.json'), 29 | }, 30 | extensions: ['...', '.ts'], 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.ts$/, 36 | exclude: [/node_modules/], 37 | loader: 'builtin:swc-loader', 38 | options: { 39 | jsc: { 40 | parser: { 41 | syntax: 'typescript', 42 | }, 43 | target: 'es2020', 44 | externalHelpers: true, 45 | }, 46 | }, 47 | type: 'javascript/auto', 48 | }, 49 | ], 50 | }, 51 | devtool: 'nosources-source-map', 52 | infrastructureLogging: { 53 | level: 'log', // enables logging required for problem matchers 54 | }, 55 | optimization: { 56 | usedExports: true, 57 | innerGraph: true, 58 | }, 59 | cache: true, 60 | experiments: { 61 | parallelCodeSplitting: true, 62 | nativeWatcher: true, 63 | } 64 | }; 65 | 66 | module.exports = extensionConfig; 67 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_NAME = 'git-worktree-manager'; 2 | 3 | export enum Commands { 4 | refreshWorktree = 'git-worktree-manager.refreshWorktree', 5 | switchWorktree = 'git-worktree-manager.switchWorktree', 6 | addWorktree = 'git-worktree-manager.addWorktree', 7 | addGitFolder = 'git-worktree-manager.addGitFolder', 8 | addToFavorite = 'git-worktree-manager.addToFavorite', 9 | addRootsToRepo = 'git-worktree-manager.addRootsToRepo', 10 | removeFavorite = 'git-worktree-manager.removeFavorite', 11 | removeMultiFavorite = 'git-worktree-manager.removeMultiFavorite', 12 | removeGitFolder = 'git-worktree-manager.removeGitFolder', 13 | removeMultiGitFolder = 'git-worktree-manager.removeMultiGitFolder', 14 | renameGitFolder = 'git-worktree-manager.renameGitFolder', 15 | repairWorktree = 'git-worktree-manager.repairWorktree', 16 | removeWorktree = 'git-worktree-manager.removeWorktree', 17 | moveWorktree = 'git-worktree-manager.moveWorktree', 18 | lockWorktree = 'git-worktree-manager.lockWorktree', 19 | unlockWorktree = 'git-worktree-manager.unlockWorktree', 20 | pullWorktree = 'git-worktree-manager.pullWorktree', 21 | pushWorktree = 'git-worktree-manager.pushWorktree', 22 | pruneWorktree = 'git-worktree-manager.pruneWorktree', 23 | switchToSelectWorkspace = 'git-worktree-manager.switchToSelectWorkspace', 24 | addWorktreeFromBranch = 'git-worktree-manager.addWorktreeFromBranch', 25 | revealInSystemExplorer = 'git-worktree-manager.revealInSystemExplorer', 26 | revealInSystemExplorerContext = 'git-worktree-manager.revealInSystemExplorer.context', 27 | openSetting = 'git-worktree-manager.openSetting', 28 | refreshGitFolder = 'git-worktree-manager.refreshGitFolder', 29 | openWalkthroughs = 'git-worktree-manager.openWalkthroughs', 30 | openTerminal = 'git-worktree-manager.openTerminal', 31 | openExternalTerminal = 'git-worktree-manager.openExternalTerminal', 32 | openExternalTerminalContext = 'git-worktree-manager.openExternalTerminal.context', 33 | addToWorkspace = 'git-worktree-manager.addToWorkspace', 34 | removeFromWorkspace = 'git-worktree-manager.removeFromWorkspace', 35 | copyFilePath = 'git-worktree-manager.copyFilePath', 36 | refreshRecentFolder = 'git-worktree-manager.refreshRecentFolder', 37 | refreshFavorite = 'git-worktree-manager.refreshFavorite', 38 | addToGitFolder = 'git-worktree-manager.addToGitFolder', 39 | checkoutBranch = 'git-worktree-manager.checkoutBranch', 40 | deleteBranch = 'git-worktree-manager.deleteBranch', 41 | gitFolderViewAsTree = 'git-worktree-manager.gitFolderViewAsTree', 42 | gitFolderViewAsList = 'git-worktree-manager.gitFolderViewAsList', 43 | gitFolderSetOpen = 'git-worktree-manager.gitFolderSetOpen', 44 | gitFolderSetClose = 'git-worktree-manager.gitFolderSetClose', 45 | searchAllWorktree = 'git-worktree-manager.searchAllWorktree', 46 | loadMoreRecentFolder = 'git-worktree-manager.loadMoreRecentFolder', 47 | loadAllTreeData = 'git-worktree-manager.loadAllTreeData', 48 | viewHistory = 'git-worktree-manager.viewHistory', 49 | openRecent = 'git-worktree-manager.openRecent', 50 | openFavorite = 'git-worktree-manager.openFavorite', 51 | openWorkspaceWorktree = 'git-worktree-manager.openWorkspaceWorktree', 52 | fetchWorktree = 'git-worktree-manager.fetchWorktree', 53 | fetchRepo = 'git-worktree-manager.fetchRepo', 54 | toggleLog = 'git-worktree-manager.toggleLog', 55 | openRepository = 'git-worktree-manager.openRepository', 56 | openChanges = 'git-worktree-manager.openChanges', 57 | bundleRepo = 'git-worktree-manager.bundleRepo', 58 | 59 | renameBranch = 'git-worktree-manager.internal.renameBranch', 60 | refreshWorktreeCache = 'git-worktree-manager.internal.refreshWorktreeCache', 61 | watchWorktreeEvent = 'git-worktree-manager.internal.watchWorktreeEvent', 62 | unwatchWorktreeEvent = 'git-worktree-manager.internal.unwatchWorktreeEvent', 63 | } 64 | 65 | export enum ContextKey { 66 | gitFolderViewAsTree = 'gwm.context.gitFolderViewAsTree', 67 | addRootsToRepo = 'gwm.context.addRootsToRepo', 68 | } 69 | 70 | export const WORK_TREE_SCHEME = 'git-worktree-manager-scheme'; 71 | 72 | export const WORK_TREE = 'worktree'; 73 | 74 | export enum TreeItemKind { 75 | worktree = 'worktree', 76 | workspaceGitMainFolder = 'workspaceGitMainFolder', 77 | gitFolder = 'gitFolder', 78 | folder = 'folder', 79 | setting = 'setting', 80 | file = 'file', 81 | } 82 | 83 | export enum QuickPickKind { 84 | pickWorktree = 'pickWorktree', 85 | } 86 | 87 | export type AlertLevel = 'warn' | 'info' | 'error'; 88 | 89 | export enum ViewId { 90 | folderList = 'git-worktree-manager-recent', 91 | worktreeList = 'git-worktree-manager-list', 92 | gitFolderList = 'git-worktree-manager-folders', 93 | settingList = 'git-worktree-manager-setting', 94 | worktreeListSCM = 'git-worktree-manager-list.scm', 95 | gitFolderListSCM = 'git-worktree-manager-folders.scm', 96 | favorite = 'git-worktree-manager-favorite', 97 | } 98 | 99 | export const refArgList = [ 100 | 'refname', 101 | 'objectname:short', 102 | '*objectname', 103 | 'worktreepath', 104 | 'authordate', 105 | '*authordate', 106 | 'HEAD', 107 | 'refname:short', 108 | 'taggername', 109 | 'authorname', 110 | '*authorname', 111 | 'subject', 112 | '*subject', 113 | ] as const; 114 | 115 | export enum HEAD { 116 | current = '*', 117 | } 118 | 119 | export enum StateKey { 120 | gitFolderViewAsTree = 'gitFolderViewAsTree', 121 | gitFolders = 'gitFolders', 122 | workTreeCache = 'workTreeCache', 123 | mainFolders = 'mainFolders', 124 | } 125 | 126 | export enum RefreshCacheType { 127 | all = 'all', 128 | workspace = 'workspace', 129 | } 130 | 131 | export enum RecentItemType { 132 | workspace = 0, 133 | folder = 1, 134 | file = 2, 135 | } -------------------------------------------------------------------------------- /src/core/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { 3 | treeDataEvent, 4 | updateTreeDataEvent, 5 | collectEvent, 6 | globalStateEvent, 7 | refreshWorktreeCacheEvent, 8 | updateWorktreeCacheEvent, 9 | worktreeChangeEvent, 10 | updateRecentEvent, 11 | } from '@/core/event/events'; 12 | import folderRoot from '@/core/folderRoot'; 13 | import { updateWorkspaceMainFolders, checkRecentFolderCache } from '@/core/util/cache'; 14 | import { getGitFolderByUri } from '@/core/util/folder'; 15 | import { checkRoots, updateAddDirsContext } from '@/core/util/workspace'; 16 | import { registerCommands } from '@/core/command'; 17 | import { GlobalState, WorkspaceState } from '@/core/state'; 18 | import { Alert } from '@/core/ui/message'; 19 | import { TreeViewManager } from '@/core/treeView/treeViewManager'; 20 | import { throttle, debounce } from 'lodash-es'; 21 | import logger from '@/core/log/logger'; 22 | import { WorktreeDecorator } from '@/core/util/worktree'; 23 | import { worktreeEventRegister } from '@/core/event/git'; 24 | import { Config } from '@/core/config/setting'; 25 | import { Commands, RefreshCacheType } from '@/constants'; 26 | import { updateWorkspaceListCache, updateWorktreeCache, updateRecentItems } from '@/core/util/cache'; 27 | 28 | const setupCacheEvents = (context: vscode.ExtensionContext) => { 29 | const updateWorktreeCacheHandler = updateWorktreeCacheEvent.event((repoPath) => { 30 | updateWorktreeCache(repoPath); 31 | updateWorkspaceListCache(repoPath); 32 | }); 33 | const updateCacheHandler = refreshWorktreeCacheEvent.event( 34 | debounce( 35 | (e) => { 36 | if (e === RefreshCacheType.all) { 37 | updateWorktreeCache(); 38 | } else if (e === RefreshCacheType.workspace) { 39 | updateWorkspaceListCache(); 40 | } 41 | }, 42 | 1000, 43 | { leading: true }, 44 | ), 45 | ); 46 | const updateRecentCacheEvent = updateRecentEvent.event(debounce(updateRecentItems, 1000, { leading: true })); 47 | context.subscriptions.push(updateWorktreeCacheHandler, updateCacheHandler, updateRecentCacheEvent); 48 | }; 49 | 50 | const setupWorkspaceEvent = (context: vscode.ExtensionContext) => { 51 | const worktreeChangeHandler = worktreeChangeEvent.event((uri) => { 52 | // Navigate to specific repository 53 | const repoPath = getGitFolderByUri(uri); 54 | updateWorktreeCacheEvent.fire(repoPath); 55 | }); 56 | const updateHandler = updateTreeDataEvent.event( 57 | throttle( 58 | async () => { 59 | await updateWorkspaceMainFolders(); 60 | treeDataEvent.fire(); 61 | }, 62 | 1000, 63 | { trailing: true, leading: true }, 64 | ), 65 | ); 66 | const workspaceFoldersHandler = vscode.workspace.onDidChangeWorkspaceFolders(checkRoots); 67 | const stateChangeHandler = globalStateEvent.event((key) => { 68 | if (key === 'gitFolders') { 69 | updateAddDirsContext(); 70 | checkRoots(); 71 | } 72 | }); 73 | const windowStateHandler = vscode.window.onDidChangeWindowState((e) => { 74 | vscode.commands.executeCommand(e.focused ? Commands.watchWorktreeEvent : Commands.unwatchWorktreeEvent); 75 | }); 76 | context.subscriptions.push( 77 | worktreeChangeHandler, 78 | updateHandler, 79 | workspaceFoldersHandler, 80 | stateChangeHandler, 81 | windowStateHandler, 82 | ); 83 | }; 84 | 85 | const setupEvent = (context: vscode.ExtensionContext) => { 86 | collectEvent(context); 87 | setupCacheEvents(context); 88 | setupWorkspaceEvent(context); 89 | }; 90 | 91 | const setupState = (context: vscode.ExtensionContext) => { 92 | GlobalState.init(context); 93 | WorkspaceState.init(context); 94 | Alert.init(context); 95 | }; 96 | 97 | const registerAllSubscriptions = (context: vscode.ExtensionContext) => { 98 | context.subscriptions.push( 99 | vscode.window.registerFileDecorationProvider(new WorktreeDecorator()), 100 | folderRoot, 101 | logger, 102 | worktreeEventRegister, 103 | Config, 104 | ); 105 | }; 106 | 107 | const initializeWorkspace = () => { 108 | queueMicrotask(() => { 109 | checkRoots(); 110 | checkRecentFolderCache(); 111 | vscode.commands.executeCommand(Commands.watchWorktreeEvent); 112 | }); 113 | }; 114 | 115 | export function bootstrap(context: vscode.ExtensionContext) { 116 | registerAllSubscriptions(context); 117 | setupState(context); 118 | setupEvent(context); 119 | registerCommands(context); 120 | TreeViewManager.register(context); 121 | initializeWorkspace(); 122 | } 123 | -------------------------------------------------------------------------------- /src/core/command/addDirsToRepo.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Alert } from '@/core/ui/message'; 3 | import { toSimplePath } from '@/core/util/folder'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | import { getFolderConfig, updateFolderConfig } from '@/core/util/state'; 6 | import { worktreeEventRegister } from '@/core/event/git'; 7 | import { pickMultiFolder } from '@/core/ui/pickGitFolder'; 8 | import { withResolvers } from '@/core/util/promise'; 9 | 10 | const withProgress = () => { 11 | const loading = withResolvers(); 12 | const cancelTokenSource = new vscode.CancellationTokenSource(); 13 | cancelTokenSource.token.onCancellationRequested(loading.resolve); 14 | vscode.window.withProgress( 15 | { 16 | location: vscode.ProgressLocation.Notification, 17 | title: vscode.l10n.t('Searching folders'), 18 | cancellable: true, 19 | }, 20 | async (progress, token) => { 21 | progress.report({ message: vscode.l10n.t('Loading...') }); 22 | token.onCancellationRequested(cancelTokenSource.cancel.bind(cancelTokenSource)); 23 | await loading.promise.catch(() => {}); 24 | progress.report({ increment: 100 }); 25 | cancelTokenSource.dispose(); 26 | } 27 | ); 28 | return { endLoading: loading.resolve, cancelToken: cancelTokenSource.token }; 29 | }; 30 | 31 | export const addDirsToRepo = async (dirs: string[]) => { 32 | const { endLoading, cancelToken } = withProgress(); 33 | const folders = await Promise.all( 34 | dirs.map(async (filePath) => { 35 | try { 36 | if (cancelToken.isCancellationRequested) return null; 37 | return toSimplePath(await getMainFolder(filePath)); 38 | } catch { 39 | return null; 40 | } 41 | }) 42 | ); 43 | endLoading(); 44 | 45 | const distinctFolders = [...new Set(folders.filter((i) => i))]; 46 | if (!distinctFolders.length) { 47 | Alert.showErrorMessage(vscode.l10n.t('There are no folders to add')); 48 | return; 49 | } 50 | const existFolders = getFolderConfig(); 51 | const existFoldersMap = new Map(existFolders.map((i) => [toSimplePath(i.path), true])); 52 | const gitFolders = distinctFolders.filter((i) => i && !existFoldersMap.has(toSimplePath(i))) as string[]; 53 | if (!gitFolders.length) { 54 | Alert.showErrorMessage(vscode.l10n.t('All folders have been added, no more folders available')); 55 | return; 56 | } 57 | const selectGitFolders = await pickMultiFolder(gitFolders); 58 | if (!selectGitFolders || !selectGitFolders.length) return; 59 | const newFolders = getFolderConfig(); 60 | newFolders.push(...selectGitFolders); 61 | await updateFolderConfig(newFolders); 62 | selectGitFolders.forEach((item) => worktreeEventRegister.add(vscode.Uri.file(item.path))); 63 | Alert.showInformationMessage(vscode.l10n.t('Saved successfully')); 64 | }; 65 | -------------------------------------------------------------------------------- /src/core/command/addGitFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import folderRoot from '@/core/folderRoot'; 3 | import path from 'path'; 4 | import fs from 'fs/promises'; 5 | import { addToGitFolder } from '@/core/command/addToGitFolder'; 6 | import { addDirsToRepo } from '@/core/command/addDirsToRepo'; 7 | 8 | const addMultiGitFolder = async () => { 9 | let uriList = await vscode.window.showOpenDialog({ 10 | canSelectFiles: false, 11 | canSelectFolders: true, 12 | canSelectMany: false, 13 | defaultUri: folderRoot.uri ? vscode.Uri.file(path.dirname(folderRoot.uri.fsPath)) : void 0, 14 | title: vscode.l10n.t('Please select the root directory containing multiple Git repositories'), 15 | }); 16 | if (!uriList?.length) return; 17 | let folderUri = uriList[0]; 18 | let folderPath = folderUri.fsPath; 19 | const dirs = await fs 20 | .readdir(folderPath, { encoding: 'utf-8' }) 21 | .then((files) => files.map((fileName) => path.join(folderPath, fileName))) 22 | .catch(() => []); 23 | if (!dirs.length) return; 24 | return await addDirsToRepo(dirs); 25 | 26 | }; 27 | 28 | const addSingleGitFolder = async () => { 29 | let uriList = await vscode.window.showOpenDialog({ 30 | canSelectFiles: false, 31 | canSelectFolders: true, 32 | canSelectMany: false, 33 | defaultUri: folderRoot.uri ? vscode.Uri.file(path.dirname(folderRoot.uri.fsPath)) : void 0, 34 | title: vscode.l10n.t('Please select the Git repository folder path'), 35 | }); 36 | if (!uriList?.length) return; 37 | let folderUri = uriList[0]; 38 | let folderPath = folderUri.fsPath; 39 | await addToGitFolder(folderPath); 40 | }; 41 | 42 | export const addGitFolderCmd = async () => { 43 | const multiLabel = vscode.l10n.t('Multiple repositories'); 44 | const multiTips = vscode.l10n.t('Please select the root directory containing multiple Git repositories'); 45 | const singleLabel = vscode.l10n.t('Single repository'); 46 | const singleTips = vscode.l10n.t('Select Git repository to create worktree from'); 47 | let options: vscode.QuickPickItem[] = [ 48 | { label: multiLabel, iconPath: new vscode.ThemeIcon('checklist'), description: multiTips }, 49 | { label: singleLabel, iconPath: new vscode.ThemeIcon('repo'), description: singleTips }, 50 | ]; 51 | let selected = await vscode.window.showQuickPick(options, { 52 | canPickMany: false, 53 | title: vscode.l10n.t('Add Git repository'), 54 | }); 55 | if (!selected) return; 56 | if (selected.label === multiLabel) addMultiGitFolder(); 57 | else addSingleGitFolder(); 58 | }; 59 | -------------------------------------------------------------------------------- /src/core/command/addRootsToRepoCmd.ts: -------------------------------------------------------------------------------- 1 | import folderRoot from '@/core/folderRoot'; 2 | import { addToGitFolder } from '@/core/command/addToGitFolder'; 3 | import { addDirsToRepo } from '@/core/command/addDirsToRepo'; 4 | 5 | export const addRootsToRepoCmd = () => { 6 | const folderPathSet = folderRoot.folderPathSet; 7 | const dirs = [...folderPathSet]; 8 | if(folderPathSet.size === 1) { 9 | addToGitFolder(dirs[0]); 10 | } else { 11 | addDirsToRepo(dirs); 12 | } 13 | }; -------------------------------------------------------------------------------- /src/core/command/addToFavoriteCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { IWorktreeLess } from '@/types'; 3 | import { getFavoriteCache, updateFavoriteCache } from '@/core/util/cache'; 4 | 5 | export const addToFavoriteCmd = async (viewItem?: IWorktreeLess) => { 6 | if (!viewItem?.item) return; 7 | const favorite = getFavoriteCache(); 8 | if (favorite.every(row => vscode.Uri.parse(row.path).toString() !== vscode.Uri.parse(viewItem.uriPath).toString())) { 9 | favorite.push(viewItem.item); 10 | updateFavoriteCache(favorite); 11 | } 12 | }; -------------------------------------------------------------------------------- /src/core/command/addToGitFolder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { verifyDirExistence } from '@/core/util/file'; 3 | import { getFolderConfig, updateFolderConfig } from '@/core/util/state'; 4 | import { comparePath } from '@/core/util/folder'; 5 | import { checkGitValid } from '@/core/git/checkGitValid'; 6 | import { getMainFolder } from '@/core/git/getMainFolder'; 7 | import { Alert } from '@/core/ui/message'; 8 | import { worktreeEventRegister } from '@/core/event/git'; 9 | import path from 'path'; 10 | 11 | export const addToGitFolder = async (folderPath: string) => { 12 | if (!(await verifyDirExistence(folderPath))) return; 13 | let existFolders = getFolderConfig(); 14 | if (!(await checkGitValid(folderPath))) { 15 | return Alert.showErrorMessage(vscode.l10n.t('The folder is not a valid Git repository')); 16 | } 17 | folderPath = await getMainFolder(folderPath); 18 | if (existFolders.some((i) => comparePath(i.path, folderPath))) { 19 | return Alert.showErrorMessage(vscode.l10n.t('The Git repository folder already exists in settings')); 20 | } 21 | let folderName = await vscode.window.showInputBox({ 22 | title: vscode.l10n.t('Enter the repository name for display'), 23 | placeHolder: vscode.l10n.t('Please enter a name for display'), 24 | value: folderPath, 25 | valueSelection: [0, folderPath.length - path.basename(folderPath).length], 26 | validateInput: (value) => { 27 | if (!value) { 28 | return vscode.l10n.t('Please enter a name for display'); 29 | } 30 | }, 31 | }); 32 | if (!folderName) return; 33 | existFolders.push({ name: folderName, path: folderPath }); 34 | await updateFolderConfig(existFolders); 35 | worktreeEventRegister.add(vscode.Uri.file(folderPath)); 36 | Alert.showInformationMessage(vscode.l10n.t('Saved successfully')); 37 | }; 38 | -------------------------------------------------------------------------------- /src/core/command/addToGitFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import { addToGitFolder } from '@/core/command/addToGitFolder'; 2 | import { IWorktreeLess } from '@/types'; 3 | 4 | export const addToGitFolderCmd = (item?: IWorktreeLess) => { 5 | if (!item) return; 6 | return addToGitFolder(item.fsPath); 7 | }; -------------------------------------------------------------------------------- /src/core/command/addToWorkspaceCmd.ts: -------------------------------------------------------------------------------- 1 | import { verifyDirExistence } from '@/core/util/file'; 2 | import { addToWorkspace } from '@/core/util/workspace'; 3 | import { IWorktreeLess } from '@/types'; 4 | 5 | export const addToWorkspaceCmd = async (item: IWorktreeLess) => { 6 | const fsPath = item.fsPath; 7 | if (!(await verifyDirExistence(fsPath))) return; 8 | return addToWorkspace(fsPath); 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/command/addWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { pickGitFolder } from '@/core/ui/pickGitFolder'; 3 | import type { IWorktreeLess } from '@/types'; 4 | import { Alert } from '@/core/ui/message'; 5 | import { pickBranch } from '@/core/quickPick/pickBranch'; 6 | import { createWorktreeFromInfo } from '@/core/command/createWorktreeFromInfo'; 7 | import { getMainFolder } from '@/core/git/getMainFolder'; 8 | import { inputWorktreeDir } from '@/core/ui/inputWorktreeDir'; 9 | 10 | const pickBranchItem = async (dir: string, mainFolder: string) => { 11 | let branchItem = await pickBranch({ 12 | title: vscode.l10n.t('Create Worktree ({0})', dir.length > 35 ? `...${dir.slice(-34)}` : dir), 13 | placeholder: vscode.l10n.t('Choose a branch to create a new worktree from'), 14 | mainFolder, 15 | cwd: dir, 16 | step: 2, 17 | totalSteps: 2, 18 | showCreate: true, 19 | }); 20 | return branchItem; 21 | }; 22 | 23 | export const addWorktreeCmd = async (item?: IWorktreeLess) => { 24 | let gitFolder = item?.fsPath || (await pickGitFolder(vscode.l10n.t('Select Git repository to create worktree from'))); 25 | if (gitFolder === null) Alert.showErrorMessage(vscode.l10n.t('Please open at least one Git repository in workspace')); 26 | if (!gitFolder) return false; 27 | const mainFolder = await getMainFolder(gitFolder); 28 | if (!mainFolder) return false; 29 | 30 | // 选择文件夹 31 | let folderPath = await inputWorktreeDir({ baseDir: mainFolder, step: 1, totalSteps: 2 }); 32 | if (!folderPath) return; 33 | 34 | // 选择ref 35 | let branchItem = await pickBranchItem(gitFolder, mainFolder); 36 | // FIXME 改造quickPick 37 | // 没有选择ref时,返回选择文件夹 38 | while (branchItem === void 0) { 39 | folderPath = await inputWorktreeDir({ 40 | baseDir: mainFolder, 41 | baseWorktreeDir: folderPath, 42 | step: 1, 43 | totalSteps: 2, 44 | }); 45 | if (!folderPath) return; 46 | branchItem = await pickBranchItem(gitFolder, mainFolder); 47 | } 48 | 49 | if (!branchItem) return false; 50 | let { branch, hash } = branchItem; 51 | let label = branch ? vscode.l10n.t('branch') : vscode.l10n.t('commit hash'); 52 | await createWorktreeFromInfo({ 53 | name: branch || hash || '', 54 | label, 55 | folderPath, 56 | isBranch: !!branch, 57 | cwd: mainFolder, 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/core/command/addWorktreeFromBranchCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { WorktreeItem } from '@/core/treeView/items'; 3 | import { createWorktreeFromInfo } from '@/core/command/createWorktreeFromInfo'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | import folderRoot from "@/core/folderRoot"; 6 | 7 | export const addWorktreeFromBranchCmd = async (item?: WorktreeItem) => { 8 | if (!item) return; 9 | let uriList = await vscode.window.showOpenDialog({ 10 | canSelectFiles: false, 11 | canSelectFolders: true, 12 | canSelectMany: false, 13 | defaultUri: folderRoot.uri, 14 | openLabel: vscode.l10n.t('Select folder'), 15 | title: vscode.l10n.t('Select the folder where you want to create the worktree?'), 16 | }); 17 | if (!uriList?.length) { 18 | return; 19 | } 20 | let folderUri = uriList[0]; 21 | let folderPath = folderUri.fsPath; 22 | const mainFolder = await getMainFolder(item.fsPath); 23 | return createWorktreeFromInfo({ 24 | name: item.name, 25 | label: '分支', 26 | folderPath, 27 | isBranch: !!item.isBranch, 28 | cwd: mainFolder, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/core/command/bundleRepoCmd.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import { Alert } from '@/core/ui/message'; 3 | import { IWorktreeLess } from '@/types'; 4 | import { bundleRepo } from '@/core/git/bundleRepo'; 5 | import { getBaseBundleDir } from '@/core/util/folder'; 6 | import dayjs from 'dayjs'; 7 | import fs from 'fs/promises'; 8 | import path from 'path'; 9 | import { actionProgressWrapper } from '@/core/ui/progress'; 10 | 11 | export async function bundleRepoCmd(item: IWorktreeLess) { 12 | const baseBundleDir = getBaseBundleDir(item.fsPath); 13 | const bundlePath = path.join(baseBundleDir, `${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.bundle`); 14 | actionProgressWrapper( 15 | vscode.l10n.t('Bundling repository..., {path}', { path: bundlePath }), 16 | async () => { 17 | await fs.mkdir(baseBundleDir, { recursive: true }); 18 | await bundleRepo(item.fsPath, bundlePath); 19 | Alert.showInformationMessage(vscode.l10n.t('Repository backup successful: {path}', { path: bundlePath })); 20 | }, 21 | () => {} 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/core/command/checkoutBranchCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Alert } from '@/core/ui/message'; 3 | import { IWorktreeLess } from '@/types'; 4 | import folderRoot from '@/core/folderRoot'; 5 | import { checkGitValid } from '@/core/git/checkGitValid'; 6 | import { getMainFolder } from '@/core/git/getMainFolder'; 7 | import { getNameRev } from '@/core/git/getNameRev'; 8 | import { checkoutBranch } from '@/core/git/checkoutBranch'; 9 | import { pickBranch } from '@/core/quickPick/pickBranch'; 10 | import { actionProgressWrapper } from '@/core/ui/progress'; 11 | 12 | interface WorktreeInfo { 13 | name: string; 14 | fsPath: string; 15 | } 16 | 17 | /** 获取当前工作目录信息 */ 18 | async function getCurrentWorkingDirectory(): Promise { 19 | const isValidGit = await checkGitValid(); 20 | if (!isValidGit) { 21 | Alert.showErrorMessage(vscode.l10n.t('The folder is not a valid Git repository')); 22 | return false; 23 | } 24 | 25 | const fsPath = folderRoot.uri?.fsPath || ''; 26 | const name = (await getNameRev(fsPath)) 27 | .replace(/^tags\//, '') 28 | .replace(/^heads\//, '') 29 | .trim(); 30 | 31 | return { fsPath, name }; 32 | } 33 | 34 | /** 构建标题 */ 35 | function buildTitle(info: WorktreeInfo): string { 36 | const maxPathLength = 35; 37 | const truncatedPath = info.fsPath.length > maxPathLength ? `...${info.fsPath.slice(-34)}` : info.fsPath; 38 | 39 | return `${info.name} ⇄ ${truncatedPath}`; 40 | } 41 | 42 | /** 执行分支切换操作 */ 43 | async function performCheckout(info: WorktreeInfo, refName: string, isBranch: boolean): Promise { 44 | const progressMessage = vscode.l10n.t('Checkout branch ( {0} ) on {1}', refName, info.fsPath); 45 | 46 | actionProgressWrapper( 47 | progressMessage, 48 | () => checkoutBranch(info.fsPath, refName, isBranch), 49 | () => {} // 成功回调为空 50 | ); 51 | } 52 | 53 | /** 切换分支命令 */ 54 | export const checkoutBranchCmd = async (item?: IWorktreeLess): Promise => { 55 | // 获取工作目录信息 56 | let worktreeInfo: WorktreeInfo | false = item 57 | ? { name: item.name, fsPath: item.fsPath } 58 | : await getCurrentWorkingDirectory(); 59 | 60 | if (!worktreeInfo) return false; 61 | 62 | // 获取主文件夹 63 | const mainFolder = await getMainFolder(worktreeInfo.fsPath); 64 | if (!mainFolder) return false; 65 | 66 | // 构建标题并选择分支 67 | const title = buildTitle(worktreeInfo); 68 | const branchItem = await pickBranch({ 69 | title: vscode.l10n.t('Checkout branch ( {0} )', title), 70 | placeholder: vscode.l10n.t('Select branch to checkout'), 71 | mainFolder, 72 | cwd: worktreeInfo.fsPath, 73 | showCreate: true, 74 | }); 75 | 76 | // 处理选择结果 77 | if (branchItem === void 0) return; 78 | if (!branchItem) return false; 79 | 80 | // 执行切换操作 81 | const refName = branchItem.branch || branchItem.hash || ''; 82 | const isBranch = !!branchItem.branch; 83 | 84 | performCheckout(worktreeInfo, refName, isBranch); 85 | }; 86 | -------------------------------------------------------------------------------- /src/core/command/commonWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Commands } from '@/constants'; 3 | import { lockWorktree } from '@/core/git/lockWorktree'; 4 | import { unlockWorktree } from '@/core/git/unlockWorktree'; 5 | import { repairWorktree } from '@/core/git/repairWorktree'; 6 | import { Alert } from '@/core/ui/message'; 7 | import * as util from 'util'; 8 | import logger from '@/core/log/logger'; 9 | 10 | export const commonWorktreeCmd = async (path: string, cmd: Commands, cwd?: string) => { 11 | let cmdName = vscode.l10n.t('operation'); 12 | try { 13 | switch (cmd) { 14 | case Commands.lockWorktree: 15 | await lockWorktree(path, cwd); 16 | cmdName = vscode.l10n.t('Lock'); 17 | break; 18 | case Commands.unlockWorktree: 19 | await unlockWorktree(path, cwd); 20 | cmdName = vscode.l10n.t('Unlock'); 21 | break; 22 | case Commands.repairWorktree: 23 | await repairWorktree(path, cwd); 24 | cmdName = vscode.l10n.t('Repair'); 25 | break; 26 | } 27 | Alert.showInformationMessage(vscode.l10n.t('Worktree {0} completed successfully', cmdName)); 28 | } catch (error) { 29 | Alert.showErrorMessage(vscode.l10n.t('Worktree {0} failed: {1}', cmdName, util.inspect(error, false, 1, true))); 30 | logger.error(error); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/core/command/copyFilePathCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { Alert } from '@/core/ui/message'; 3 | import { IWorktreeLess } from '@/types'; 4 | 5 | export const copyFilePathCmd = (item?: IWorktreeLess) => { 6 | if (!item) return; 7 | vscode.env.clipboard.writeText(item.fsPath).then(() => { 8 | Alert.showInformationMessage(vscode.l10n.t('Copied successfully: {0}', item.fsPath)); 9 | }); 10 | }; -------------------------------------------------------------------------------- /src/core/command/createWorktreeFromInfo.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { addWorktree } from '@/core/git/addWorktree'; 3 | import { getMainFolder } from '@/core/git/getMainFolder'; 4 | import { confirmModal } from '@/core/ui/modal'; 5 | import { copyWorktreeFiles } from '@/core/util/copyWorktreeFiles'; 6 | import { postCreateWorktree } from '@/core/util/postCreateWorktree'; 7 | import { actionProgressWrapper } from '@/core/ui/progress'; 8 | import { withResolvers } from '@/core/util/promise'; 9 | import type { ICreateWorktreeInfo } from '@/types'; 10 | 11 | export async function createWorktreeFromInfo(info: ICreateWorktreeInfo) { 12 | const { folderPath, name, label, isBranch, cwd } = info; 13 | let confirmCreate = await confirmModal( 14 | vscode.l10n.t('Create worktree'), 15 | vscode.l10n.t('Create'), 16 | vscode.l10n.t('A worktree for {label} {name} will be created under {folder}', {folder: folderPath, label, name}) 17 | ); 18 | if (!confirmCreate) { 19 | return; 20 | } 21 | 22 | const waitingCreate = withResolvers(); 23 | actionProgressWrapper( 24 | vscode.l10n.t('Creating worktree {path}', { path: folderPath }), 25 | () => waitingCreate.promise, 26 | () => {} 27 | ); 28 | let created = await addWorktree(folderPath, name, isBranch, cwd); 29 | waitingCreate.resolve(); 30 | if (!created) { 31 | return; 32 | } 33 | 34 | const mainFolder = await getMainFolder(folderPath); 35 | // Copy files after worktree creation is successful 36 | if (mainFolder) { 37 | await copyWorktreeFiles(mainFolder, folderPath); 38 | } 39 | 40 | await postCreateWorktree({ 41 | worktreePath: folderPath, 42 | basePath: mainFolder, 43 | }); 44 | 45 | let confirmOpen = await confirmModal( 46 | vscode.l10n.t('Open folder'), 47 | vscode.l10n.t('Open'), 48 | vscode.l10n.t('Open the new worktree in a new window?') 49 | ); 50 | if (!confirmOpen) { 51 | return; 52 | } 53 | let folderUri = vscode.Uri.file(folderPath); 54 | vscode.commands.executeCommand('vscode.openFolder', folderUri, { 55 | forceNewWindow: true, 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/core/command/deleteBranchCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { deleteBranch } from '@/core/git/deleteBranch'; 3 | import { confirmModal } from '@/core/ui/modal'; 4 | import { Alert } from '@/core/ui/message'; 5 | import logger from '@/core/log/logger'; 6 | import { BranchForWorktree } from '@/types'; 7 | 8 | export const deleteBranchCmd = async (item: BranchForWorktree) => { 9 | if(!item.mainFolder || !item.branch) return; 10 | try { 11 | const confirm = await confirmModal( 12 | vscode.l10n.t('Delete branch'), 13 | vscode.l10n.t('Delete'), 14 | vscode.l10n.t('The branch {0} based on {1} will be deleted', item.branch, item.mainFolder), 15 | ); 16 | if(!confirm) return; 17 | await deleteBranch(item.mainFolder, item.branch); 18 | } catch (error) { 19 | Alert.showErrorMessage(vscode.l10n.t('Failed to delete branch ({0}): {1}', item.branch, String(error))); 20 | logger.error(error); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/core/command/fetchRepoCmd.ts: -------------------------------------------------------------------------------- 1 | import { IWorktreeLess } from '@/types'; 2 | import { fetchRepo } from '@/core/git/fetchRepo'; 3 | 4 | export const fetchRepoCmd = (item: IWorktreeLess) => { 5 | fetchRepo(item.fsPath); 6 | }; -------------------------------------------------------------------------------- /src/core/command/fetchWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { WorktreeItem } from '@/core/treeView/items'; 2 | import { fetchRemoteRef } from '@/core/git/fetchRemoteRef'; 3 | 4 | export const fetchWorktreeCmd = (item: WorktreeItem) => { 5 | const { fsPath: cwd, remote, remoteRef } = item; 6 | if(!remote || !remoteRef) return; 7 | fetchRemoteRef({ cwd, remote, remoteRef }); 8 | }; -------------------------------------------------------------------------------- /src/core/command/loadAllTreeDataCmd.ts: -------------------------------------------------------------------------------- 1 | import { ILoadMoreItem } from '@/types'; 2 | import { loadAllTreeDataEvent } from '@/core/event/events'; 3 | 4 | export const loadAllTreeDataCmd = (item?: ILoadMoreItem) => { 5 | if (!item) return; 6 | loadAllTreeDataEvent.fire(item.viewId); 7 | }; -------------------------------------------------------------------------------- /src/core/command/lockWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { WorktreeItem } from '@/core/treeView/items'; 2 | import { Commands } from '@/constants'; 3 | import { commonWorktreeCmd } from '@/core/command/commonWorktreeCmd'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | 6 | export const lockWorktreeCmd = async (item?: WorktreeItem) => { 7 | if (!item) return; 8 | commonWorktreeCmd(item.fsPath, Commands.lockWorktree, await getMainFolder(item.fsPath)); 9 | }; -------------------------------------------------------------------------------- /src/core/command/moveWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { WorktreeItem } from '@/core/treeView/items'; 3 | import { moveWorktree } from '@/core/git/moveWorktree'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | import { Alert } from '@/core/ui/message'; 6 | import logger from '@/core/log/logger'; 7 | import { inputWorktreeDir } from '@/core/ui/inputWorktreeDir'; 8 | 9 | export const moveWorktreeCmd = async (item?: WorktreeItem) => { 10 | if (!item) return; 11 | try { 12 | const mainFolder = await getMainFolder(item.fsPath); 13 | if (!mainFolder) return false; 14 | let folderPath = await inputWorktreeDir({ 15 | baseDir: mainFolder, 16 | targetDirTip: vscode.l10n.t('Select the new location to move the worktree folder from {0}', item.fsPath), 17 | }); 18 | if (!folderPath) return; 19 | await moveWorktree(item.fsPath, folderPath, mainFolder); 20 | Alert.showInformationMessage(vscode.l10n.t('Worktree moved successfully')); 21 | } catch (error) { 22 | Alert.showErrorMessage(vscode.l10n.t('Worktree move failed \n\n {0}', String(error))); 23 | logger.error(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/core/command/openExternalTerminalCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { verifyDirExistence } from '@/core/util/file'; 3 | import { revealTreeItem } from '@/core/util/tree'; 4 | import { openExternalTerminal } from '@/core/util/external'; 5 | import { AllViewItem } from '@/core/treeView/items'; 6 | import { Alert } from '@/core/ui/message'; 7 | 8 | export const openExternalTerminalCmd = async (item?: AllViewItem, needRevealTreeItem = true) => { 9 | if (!item) return; 10 | const fsPath = item.fsPath; 11 | if (!(await verifyDirExistence(fsPath))) return; 12 | try { 13 | if (needRevealTreeItem) await revealTreeItem(item); 14 | await openExternalTerminal(`${fsPath}`); 15 | } catch (error) { 16 | Alert.showErrorMessage(vscode.l10n.t('Failed to open external terminal\n\n{0}', String(error))); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/core/command/openFavoriteCmd.ts: -------------------------------------------------------------------------------- 1 | import { pickWorktree } from "@/core/quickPick/pickWorktree"; 2 | import { DefaultDisplayList } from '@/types'; 3 | 4 | export const openFavoriteCmd = () => { 5 | pickWorktree(DefaultDisplayList.favorites); 6 | }; -------------------------------------------------------------------------------- /src/core/command/openRecentCmd.ts: -------------------------------------------------------------------------------- 1 | import { pickWorktree } from "@/core/quickPick/pickWorktree"; 2 | import { DefaultDisplayList } from '@/types'; 3 | 4 | export const openRecentCmd = () => { 5 | pickWorktree(DefaultDisplayList.recentlyOpened); 6 | }; -------------------------------------------------------------------------------- /src/core/command/openRepositoryCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { IWorktreeLess } from '@/types'; 3 | 4 | export const openRepositoryCmd = (item: IWorktreeLess) => { 5 | vscode.commands.executeCommand('git.openRepository', item.fsPath); 6 | }; -------------------------------------------------------------------------------- /src/core/command/openSettingCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export function openSettingCmd() { 4 | void vscode.commands.executeCommand('workbench.action.openSettings', `@ext:jackiotyu.git-worktree-manager`); 5 | } -------------------------------------------------------------------------------- /src/core/command/openTerminalCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import path from 'path'; 3 | import { verifyDirExistence } from '@/core/util/file'; 4 | import { judgeIncludeFolder } from '@/core/util/folder'; 5 | import { getTerminalLocationConfig, getTerminalCmdListConfig, getTerminalNameTemplateConfig } from '@/core/util/state'; 6 | import { AllViewItem } from '@/core/treeView/items'; 7 | 8 | interface CmdItem extends vscode.QuickPickItem { 9 | use?: 'close'; 10 | } 11 | 12 | export const openTerminalCmd = async (item?: AllViewItem) => { 13 | if (!item) return; 14 | const fsPath = item.fsPath; 15 | if (!(await verifyDirExistence(fsPath))) return; 16 | // Prepare variables for template 17 | const label = item.name; 18 | const fullPath = fsPath; 19 | const baseName = path.basename(fullPath); 20 | let name: string | undefined = getTerminalNameTemplateConfig(); 21 | if (typeof name === 'string' && name.trim()) { 22 | name = name 23 | .replace(/\$LABEL/g, label) 24 | .replace(/\$FULL_PATH/g, fullPath) 25 | .replace(/\$BASE_NAME/g, baseName); 26 | } else { 27 | name = undefined; 28 | } 29 | const terminalOptions: vscode.TerminalOptions = { 30 | cwd: fsPath, 31 | color: judgeIncludeFolder(fsPath) ? new vscode.ThemeColor('terminal.ansiBlue') : void 0, 32 | iconPath: new vscode.ThemeIcon('terminal-bash'), 33 | isTransient: false, 34 | hideFromUser: false, 35 | location: getTerminalLocationConfig(), 36 | }; 37 | if (name !== undefined) { 38 | terminalOptions.name = name; 39 | } 40 | const terminal = vscode.window.createTerminal(terminalOptions); 41 | terminal.show(); 42 | const cmdList = getTerminalCmdListConfig(); 43 | if (!cmdList.length) return; 44 | const watchOpenTerminal = vscode.window.onDidOpenTerminal(async t => { 45 | let [pid, currentPid] = await Promise.all([t.processId, terminal.processId]); 46 | if(pid !== currentPid) return; 47 | let cmdText = cmdList[0]; 48 | watchOpenTerminal.dispose(); 49 | // 单个 50 | if(cmdList.length <= 1) { 51 | cmdText && terminal.sendText(cmdText, true); 52 | return; 53 | } 54 | const close = () => { 55 | cancelToken.cancel(); 56 | disposable.dispose(); 57 | }; 58 | // 多选 59 | let cancelToken = new vscode.CancellationTokenSource(); 60 | let disposable = vscode.window.onDidCloseTerminal(async (t) => { 61 | if((await t.processId) !== currentPid) return; 62 | close(); 63 | }); 64 | const items: CmdItem[] = cmdList.map((text) => ({ 65 | label: text, 66 | iconPath: new vscode.ThemeIcon('terminal-bash'), 67 | })); 68 | let item = await vscode.window.showQuickPick( 69 | items, 70 | { 71 | title: vscode.l10n.t('Select command'), 72 | placeHolder: vscode.l10n.t('Select the command you want to execute in the terminal'), 73 | canPickMany: false, 74 | }, 75 | cancelToken.token, 76 | ); 77 | close(); 78 | cmdText = item && item.use !== 'close' ? item.label : ''; 79 | cmdText && terminal.sendText(cmdText, true); 80 | }); 81 | }; -------------------------------------------------------------------------------- /src/core/command/openWalkthroughsCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export const openWalkthroughsCmd = () => { 4 | vscode.commands.executeCommand( 5 | 'workbench.action.openWalkthrough', 6 | 'jackiotyu.git-worktree-manager#git-worktree-usage', 7 | false, 8 | ); 9 | }; -------------------------------------------------------------------------------- /src/core/command/openWorkspaceWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { pickWorktree } from "@/core/quickPick/pickWorktree"; 2 | import { DefaultDisplayList } from '@/types'; 3 | 4 | export const openWorkspaceWorktreeCmd = () => { 5 | pickWorktree(DefaultDisplayList.workspace); 6 | }; -------------------------------------------------------------------------------- /src/core/command/pruneWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { pruneWorktree } from '@/core/git/pruneWorktree'; 3 | import { Alert } from '@/core/ui/message'; 4 | import logger from '@/core/log/logger'; 5 | import { pickGitFolder } from '@/core/ui/pickGitFolder'; 6 | 7 | export const pruneWorktreeCmd = async () => { 8 | try { 9 | const repoPath = await pickGitFolder(vscode.l10n.t('Select Git repository to prune worktree from')) || ''; 10 | let output = await pruneWorktree(true, repoPath); 11 | if (!output?.length) { 12 | return; 13 | } 14 | let ok = vscode.l10n.t('Prune'); 15 | let confirm = await vscode.window.showInformationMessage( 16 | vscode.l10n.t('The following worktree folders will be pruned'), 17 | { 18 | detail: output.join(' \n'), 19 | modal: true, 20 | }, 21 | ok, 22 | ); 23 | if (confirm !== ok) { 24 | return; 25 | } 26 | await pruneWorktree(false, repoPath); 27 | Alert.showInformationMessage(vscode.l10n.t('Worktree pruning completed successfully')); 28 | } catch (error) { 29 | Alert.showErrorMessage(vscode.l10n.t('Failed to prune worktree')); 30 | logger.error(error); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/core/command/pullWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { WorktreeItem } from '@/core/treeView/items'; 2 | import { pullOrPushAction } from '@/core/ui/pullOrPushAction'; 3 | 4 | export const pullWorktreeCmd = (item?: WorktreeItem) => { 5 | if (!item || !item.remoteRef || !item.remote) return; 6 | pullOrPushAction('pull', { 7 | branch: item.name, 8 | cwd: item.fsPath, 9 | remote: item.remote, 10 | remoteRef: item.remoteRef, 11 | }); 12 | }; -------------------------------------------------------------------------------- /src/core/command/pushWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { WorktreeItem } from '@/core/treeView/items'; 2 | import { pullOrPushAction } from '@/core/ui/pullOrPushAction'; 3 | 4 | export const pushWorktreeCmd = (item?: WorktreeItem) => { 5 | if (!item || !item.remoteRef || !item.remote) return; 6 | pullOrPushAction('push', { 7 | branch: item.name, 8 | cwd: item.fsPath, 9 | remote: item.remote, 10 | remoteRef: item.remoteRef, 11 | }); 12 | }; -------------------------------------------------------------------------------- /src/core/command/refreshFavoriteCmd.ts: -------------------------------------------------------------------------------- 1 | import { updateFavoriteEvent } from '@/core/event/events'; 2 | 3 | export const refreshFavoriteCmd = async () => { 4 | updateFavoriteEvent.fire(); 5 | }; -------------------------------------------------------------------------------- /src/core/command/refreshGitFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import { updateFolderEvent } from '@/core/event/events'; 2 | 3 | export const refreshGitFolderCmd = () => { 4 | updateFolderEvent.fire(); 5 | }; -------------------------------------------------------------------------------- /src/core/command/refreshRecentFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import { updateRecentEvent } from '@/core/event/events'; 2 | 3 | export const refreshRecentFolderCmd = async () => { 4 | updateRecentEvent.fire(); 5 | }; -------------------------------------------------------------------------------- /src/core/command/refreshWorktreeCacheCmd.ts: -------------------------------------------------------------------------------- 1 | import { refreshWorktreeCacheEvent } from '@/core/event/events'; 2 | import { RefreshCacheType } from '@/constants'; 3 | 4 | export const refreshWorktreeCacheCmd = (type: RefreshCacheType) => { 5 | refreshWorktreeCacheEvent.fire(type); 6 | }; -------------------------------------------------------------------------------- /src/core/command/refreshWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { updateTreeDataEvent } from '@/core/event/events'; 2 | 3 | export const refreshWorktreeCmd = () => { 4 | updateTreeDataEvent.fire(); 5 | }; -------------------------------------------------------------------------------- /src/core/command/removeFavoriteCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getFavoriteCache, updateFavoriteCache } from '@/core/util/cache'; 3 | import { Alert } from '@/core/ui/message'; 4 | import { confirmModal } from '@/core/ui/modal'; 5 | import { IWorktreeLess } from '@/types'; 6 | import { comparePath } from '@/core/util/folder'; 7 | 8 | export const removeFavoriteCmd = async (item: IWorktreeLess) => { 9 | let uriPath = item.uriPath; 10 | let folders = getFavoriteCache(); 11 | if (!folders.some((f) => comparePath(f.path, uriPath))) { 12 | return; 13 | } 14 | let ok = await confirmModal( 15 | vscode.l10n.t('Remove items from the list'), 16 | vscode.l10n.t('Remove'), 17 | item.fsPath, 18 | ); 19 | if (!ok) { 20 | return; 21 | } 22 | folders = folders.filter((f) => !comparePath(f.path, uriPath)); 23 | await updateFavoriteCache(folders); 24 | Alert.showInformationMessage(vscode.l10n.t('Removed successfully')); 25 | }; 26 | -------------------------------------------------------------------------------- /src/core/command/removeFromWorkspaceCmd.ts: -------------------------------------------------------------------------------- 1 | import { removeFromWorkspace } from '@/core/util/workspace'; 2 | import { WorktreeItem } from '@/core/treeView/items'; 3 | 4 | export const removeFromWorkspaceCmd = async (item: WorktreeItem) => { 5 | return removeFromWorkspace(item.fsPath); 6 | }; -------------------------------------------------------------------------------- /src/core/command/removeGitFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getFolderConfig, updateFolderConfig } from '@/core/util/state'; 3 | import { Alert } from '@/core/ui/message'; 4 | import { worktreeEventRegister } from '@/core/event/git'; 5 | import { confirmModal } from '@/core/ui/modal'; 6 | import { IWorktreeLess } from '@/types'; 7 | import { comparePath } from '@/core/util/folder'; 8 | 9 | export const removeGitFolderCmd = async (item: IWorktreeLess) => { 10 | let fsPath = item.fsPath; 11 | let folders = getFolderConfig(); 12 | if (!folders.some((f) => comparePath(f.path, fsPath))) { 13 | return; 14 | } 15 | let ok = await confirmModal( 16 | vscode.l10n.t('Remove the Git repository reference from the list'), 17 | vscode.l10n.t('Remove'), 18 | vscode.l10n.t( 19 | 'Are you sure you want to delete this repository reference with path {0} and alias {1}?', 20 | item.fsPath, 21 | item.name, 22 | ), 23 | ); 24 | if (!ok) { 25 | return; 26 | } 27 | folders = folders.filter((f) => !comparePath(f.path, fsPath)); 28 | await updateFolderConfig(folders); 29 | worktreeEventRegister.remove(vscode.Uri.file(fsPath)); 30 | Alert.showInformationMessage(vscode.l10n.t('Removed successfully')); 31 | }; 32 | -------------------------------------------------------------------------------- /src/core/command/removeMultiFavoriteCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getFavoriteCache, updateFavoriteCache } from '@/core/util/cache'; 3 | import { Alert } from '@/core/ui/message'; 4 | import { confirmModal } from '@/core/ui/modal'; 5 | import { toSimplePath, getRecentItemIcon } from '@/core/util/folder'; 6 | import { IRecentItem } from '@/types'; 7 | 8 | export const removeMultiFavoriteCmd = async () => { 9 | const items: (vscode.QuickPickItem & { description: string, path: string })[] = getFavoriteCache().map((item) => { 10 | const uri = vscode.Uri.parse(item.path); 11 | return { 12 | iconPath: getRecentItemIcon(item.type), 13 | label: item.label, 14 | description: uri.fsPath, 15 | path: item.path, 16 | }; 17 | }); 18 | const selected = await vscode.window.showQuickPick(items, { 19 | title: vscode.l10n.t('Please select the items you want to remove'), 20 | matchOnDetail: true, 21 | canPickMany: true, 22 | }); 23 | 24 | if (!selected?.length) return; 25 | 26 | const confirm = await confirmModal( 27 | vscode.l10n.t('The selected items will be removed from the list'), 28 | vscode.l10n.t('Remove'), 29 | selected.map((item) => item.description!).join('\n'), 30 | ); 31 | if (!confirm) return; 32 | 33 | const selectedSet = new Set(selected.map((item) => toSimplePath(item.path))); 34 | const updateList: IRecentItem[] = []; 35 | const currentList = getFavoriteCache(); 36 | currentList.forEach((item) => { 37 | if (selectedSet.has(toSimplePath(item.path))) return; 38 | updateList.push(item); 39 | }); 40 | updateFavoriteCache(updateList); 41 | Alert.showInformationMessage(vscode.l10n.t('Removed successfully')); 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/command/removeMultiGitFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getFolderConfig, updateFolderConfig } from '@/core/util/state'; 3 | import { Alert } from '@/core/ui/message'; 4 | import { confirmModal } from '@/core/ui/modal'; 5 | import { toSimplePath } from '@/core/util/folder'; 6 | import { IFolderItemConfig } from '@/types'; 7 | 8 | export const removeMultiGitFolderCmd = async () => { 9 | const items: (vscode.QuickPickItem & { description: string })[] = getFolderConfig().map((item) => { 10 | return { 11 | iconPath: vscode.ThemeIcon.Folder, 12 | label: item.name, 13 | description: item.path, 14 | }; 15 | }); 16 | const selected = await vscode.window.showQuickPick(items, { 17 | title: vscode.l10n.t('Please select the items you want to remove'), 18 | matchOnDetail: true, 19 | canPickMany: true, 20 | }); 21 | 22 | if (!selected?.length) return; 23 | 24 | const confirm = await confirmModal( 25 | vscode.l10n.t('The selected items will be removed from the list'), 26 | vscode.l10n.t('Remove'), 27 | selected.map((item) => item.description!).join('\n'), 28 | ); 29 | if (!confirm) return; 30 | 31 | const selectedSet = new Set(selected.map((item) => toSimplePath(item.description))); 32 | const updateList: IFolderItemConfig[] = []; 33 | const currentList = getFolderConfig(); 34 | currentList.forEach((item) => { 35 | if (selectedSet.has(toSimplePath(item.path))) return; 36 | updateList.push(item); 37 | }); 38 | updateFolderConfig(updateList); 39 | Alert.showInformationMessage(vscode.l10n.t('Removed successfully')); 40 | }; 41 | -------------------------------------------------------------------------------- /src/core/command/removeWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | // import { WorktreeItem } from '@/core/treeView/items'; 3 | import { Commands } from '@/constants'; 4 | import { removeWorktree } from '@/core/git/removeWorktree'; 5 | import { getCurrentBranch } from '@/core/git/getCurrentBranch'; 6 | import { getMainFolder } from '@/core/git/getMainFolder'; 7 | import { getChanges, type IChanges } from '@/core/git/getChanges'; 8 | import { Alert } from '@/core/ui/message'; 9 | import logger from '@/core/log/logger'; 10 | import { Config } from '@/core/config/setting'; 11 | import { actionProgressWrapper } from '@/core/ui/progress'; 12 | import { withResolvers } from '@/core/util/promise'; 13 | import { IBranchForWorktree, IWorktreeLess } from '@/types'; 14 | 15 | async function showDeleteConfirmation(worktreePath: string): Promise<'remove' | 'force' | undefined> { 16 | const remove = vscode.l10n.t('Remove'); 17 | const forceDelete = vscode.l10n.t('Force remove'); 18 | 19 | let detail = vscode.l10n.t('The worktree for the {0} folder will be removed', worktreePath); 20 | const changes = await getChanges(worktreePath); 21 | if (changes.length) { 22 | detail += '\n\n'; 23 | detail += vscode.l10n.t('Contains uncommitted changes:\n\n{changes}', { changes: changes.map(change => change.raw).join('\n') }); 24 | } 25 | 26 | const selected = await vscode.window.showWarningMessage( 27 | vscode.l10n.t('Remove Worktree'), 28 | { 29 | modal: true, 30 | detail: detail, 31 | }, 32 | remove, 33 | forceDelete, 34 | ); 35 | 36 | if (selected === remove) return 'remove'; 37 | if (selected === forceDelete) return 'force'; 38 | return undefined; 39 | } 40 | 41 | export const removeWorktreeCmd = async (item?: IWorktreeLess): Promise => { 42 | if (!item?.fsPath) return; 43 | 44 | const worktreePath = item.fsPath; 45 | const { promise, resolve } = withResolvers(); 46 | 47 | try { 48 | const confirmation = await showDeleteConfirmation(worktreePath); 49 | if (!confirmation) return; 50 | const isForceDelete = confirmation === 'force'; 51 | 52 | const needDeleteBranch = Config.get('promptDeleteBranchAfterWorktreeDeletion', false); 53 | const mainFolder = await getMainFolder(worktreePath); 54 | 55 | actionProgressWrapper( 56 | vscode.l10n.t('Removing worktree {path}', { path: worktreePath }), 57 | () => promise, 58 | () => {}, 59 | ); 60 | await removeWorktree(worktreePath, isForceDelete, mainFolder); 61 | resolve(); 62 | 63 | Alert.showInformationMessage( 64 | vscode.l10n.t('Successfully removed the worktree for the {0} folder', worktreePath), 65 | ); 66 | 67 | await vscode.commands.executeCommand(Commands.refreshWorktree); 68 | 69 | if (needDeleteBranch) { 70 | const branchName = await getCurrentBranch(worktreePath); 71 | if (branchName) { 72 | const branchInfo: IBranchForWorktree = { branch: branchName, mainFolder }; 73 | await vscode.commands.executeCommand(Commands.deleteBranch, branchInfo); 74 | } 75 | } 76 | } catch (error) { 77 | resolve(); 78 | Alert.showErrorMessage(vscode.l10n.t('Worktree removal failed\n\n {0}', String(error))); 79 | logger.error(error); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /src/core/command/renameBranchCmd.ts: -------------------------------------------------------------------------------- 1 | import { inputNewBranch } from '@/core/ui/inputNewBranch'; 2 | import { renameBranch } from '@/core/git/renameBranch'; 3 | import { Alert } from '@/core/ui/message'; 4 | import logger from '@/core/log/logger'; 5 | import { BranchForWorktree } from '@/types'; 6 | 7 | export const renameBranchCmd = async (item: BranchForWorktree) => { 8 | try { 9 | if(!item.mainFolder || !item.branch) return; 10 | const newBranchName = await inputNewBranch(item.mainFolder, item.branch); 11 | if (!newBranchName) return; 12 | await renameBranch(item.mainFolder, item.branch, newBranchName); 13 | } catch (error) { 14 | logger.error(error); 15 | Alert.showInformationMessage(`${error}`); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/core/command/renameGitFolderCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getFolderConfig, updateFolderConfig } from '@/core/util/state'; 3 | import { Alert } from '@/core/ui/message'; 4 | import { IWorktreeLess } from '@/types'; 5 | import { comparePath } from '@/core/util/folder'; 6 | import path from 'path'; 7 | 8 | const pickFolderConfig = (item?: IWorktreeLess) => { 9 | if (!item) return; 10 | return getFolderConfig().find((row) => comparePath(row.path, item.fsPath)); 11 | }; 12 | 13 | export const renameGitFolderCmd = async (item?: IWorktreeLess) => { 14 | if (!item) return; 15 | let folder = pickFolderConfig(item); 16 | if (!folder) return; 17 | const folderName = folder.name; 18 | const baseName = path.basename(folderName); 19 | const valueSelection: [number, number] = 20 | baseName === folderName ? [0, folderName.length] : [0, folderName.length - baseName.length]; 21 | let name = await vscode.window.showInputBox({ 22 | title: vscode.l10n.t('Rename the Git repository alias'), 23 | placeHolder: vscode.l10n.t('Enter the repository name for display'), 24 | value: folderName, 25 | valueSelection: valueSelection, 26 | validateInput(value) { 27 | if (!value) { 28 | return vscode.l10n.t('Enter the repository name for display'); 29 | } 30 | }, 31 | }); 32 | if (!name) return; 33 | folder.name = name; 34 | let allFolders = getFolderConfig(); 35 | const folderPath = folder.path; 36 | let index = allFolders.findIndex((i) => comparePath(i.path, folderPath)); 37 | if (~index) { 38 | allFolders[index].name = name; 39 | await updateFolderConfig(allFolders); 40 | Alert.showInformationMessage(vscode.l10n.t('Saved successfully')); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/command/repairWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { WorktreeItem } from '@/core/treeView/items'; 2 | import { Commands } from '@/constants'; 3 | import { commonWorktreeCmd } from '@/core/command/commonWorktreeCmd'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | 6 | export const repairWorktreeCmd = async (item?: WorktreeItem) => { 7 | if (!item) return; 8 | commonWorktreeCmd(item.fsPath, Commands.repairWorktree, await getMainFolder(item.fsPath)); 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/command/revealInSystemExplorerCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { AllViewItem } from '@/core/treeView/items'; 3 | import { verifyFileExistence, checkIsFolder } from '@/core/util/file'; 4 | import { revealTreeItem } from '@/core/util/tree'; 5 | import { revealFolderInOS } from '@/core/util/external'; 6 | import { Config } from '@/core/config/setting'; 7 | import path from 'path'; 8 | 9 | export const revealInSystemExplorerCmd = async (item?: AllViewItem, needRevealTreeItem = true) => { 10 | if (!item) return; 11 | const fsPath = item.fsPath; 12 | const exist = await verifyFileExistence(fsPath); 13 | if (!exist) return; 14 | if (needRevealTreeItem) await revealTreeItem(item); 15 | const openInsideFolder = Config.get('openInsideFolder', false); 16 | const isFolder = await checkIsFolder(fsPath); 17 | if (openInsideFolder && isFolder) revealFolderInOS(path.resolve(fsPath)); 18 | else vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(fsPath)); 19 | }; 20 | -------------------------------------------------------------------------------- /src/core/command/searchAllWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { pickWorktree } from "@/core/quickPick/pickWorktree"; 2 | 3 | export const searchAllWorktreeCmd = () => { 4 | pickWorktree(); 5 | }; -------------------------------------------------------------------------------- /src/core/command/switchToSelectWorkspaceCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { WorktreeItem } from '@/core/treeView/items'; 3 | import { Alert } from '@/core/ui/message'; 4 | import logger from '@/core/log/logger'; 5 | 6 | export const switchToSelectWorkspaceCmd = async (item?: WorktreeItem) => { 7 | if (!item) return; 8 | try { 9 | await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.parse(item.uriPath), { 10 | forceNewWindow: false, 11 | forceReuseWindow: true, 12 | }); 13 | } catch (error) { 14 | Alert.showErrorMessage(vscode.l10n.t('Switching worktree failed \n\n {0}', String(error))); 15 | logger.error(error); 16 | } 17 | }; -------------------------------------------------------------------------------- /src/core/command/switchWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getWorktreeList } from '@/core/git/getWorktreeList'; 3 | import { getFolderIcon } from '@/core/util/folder'; 4 | 5 | export const switchWorktreeCmd = async () => { 6 | let workTrees = await getWorktreeList(); 7 | const items: vscode.QuickPickItem[] = workTrees.map((item) => { 8 | return { 9 | label: item.name, 10 | description: item.path, 11 | iconPath: getFolderIcon(item.path), 12 | }; 13 | }); 14 | const options: vscode.QuickPickOptions = { 15 | canPickMany: false, 16 | placeHolder: vscode.l10n.t('Please select the directory to switch to'), 17 | title: vscode.l10n.t('Switch Worktree'), 18 | matchOnDetail: true, 19 | matchOnDescription: true, 20 | }; 21 | vscode.window.showQuickPick(items, options).then((workTree) => { 22 | if (!workTree) { 23 | return; 24 | } 25 | let path = workTrees[workTrees.findIndex((object) => object.name === workTree.label)].path; 26 | let uri = vscode.Uri.file(path); 27 | vscode.commands.executeCommand('vscode.openFolder', uri, { 28 | forceNewWindow: true, 29 | }); 30 | }); 31 | }; -------------------------------------------------------------------------------- /src/core/command/toggleGitFolderOpenCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { Alert } from '@/core/ui/message'; 3 | import { getFolderConfig, updateFolderConfig } from '@/core/util/state'; 4 | import { IFolderItemConfig } from '@/types'; 5 | import { GitFolderItem } from '@/core/treeView/items'; 6 | import { comparePath } from '@/core/util/folder'; 7 | 8 | async function updateFolderItem(config: IFolderItemConfig) { 9 | let allFolders = getFolderConfig(); 10 | let index = allFolders.findIndex((i) => comparePath(i.path, config.path)); 11 | if (~index) { 12 | allFolders[index] = config; 13 | await updateFolderConfig(allFolders); 14 | Alert.showInformationMessage(vscode.l10n.t('Saved successfully')); 15 | } 16 | } 17 | 18 | export const toggleGitFolderOpenCmd = async (item?: GitFolderItem) => { 19 | if (!item) return; 20 | item.defaultOpen = !item.defaultOpen; 21 | await updateFolderItem({ 22 | name: item.name, 23 | path: item.fsPath, 24 | defaultOpen: item.defaultOpen, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/core/command/toggleGitFolderViewAs.ts: -------------------------------------------------------------------------------- 1 | import { toggleGitFolderViewAsEvent } from '@/core/event/events'; 2 | 3 | export const toggleGitFolderViewAs = (asTree: boolean) => { 4 | toggleGitFolderViewAsEvent.fire(asTree); 5 | }; -------------------------------------------------------------------------------- /src/core/command/toggleLogCmd.ts: -------------------------------------------------------------------------------- 1 | import logger from "@/core/log/logger"; 2 | 3 | export const toggleLogCmd = () => { 4 | logger.toggle(); 5 | }; -------------------------------------------------------------------------------- /src/core/command/unlockWorktreeCmd.ts: -------------------------------------------------------------------------------- 1 | import { WorktreeItem } from '@/core/treeView/items'; 2 | import { Commands } from '@/constants'; 3 | import { commonWorktreeCmd } from '@/core/command/commonWorktreeCmd'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | 6 | export const unlockWorktreeCmd = async (item?: WorktreeItem) => { 7 | if (!item) return; 8 | commonWorktreeCmd(item.fsPath, Commands.unlockWorktree, await getMainFolder(item.fsPath)); 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/command/unwatchWorktreeEventCmd.ts: -------------------------------------------------------------------------------- 1 | import { worktreeEventRegister } from '@/core/event/git'; 2 | 3 | export const unwatchWorktreeEventCmd = () => { 4 | worktreeEventRegister.dispose(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/command/viewHistoryCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { IWorktreeLess } from '@/types'; 3 | import folderRoot from '@/core/folderRoot'; 4 | import { GitHistory } from '@/core/gitHistory'; 5 | 6 | export const viewHistoryCmd = (item?: IWorktreeLess) => { 7 | let uri = item ? vscode.Uri.file(item.fsPath) : folderRoot.uri; 8 | uri && GitHistory.openHistory(uri); 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/command/watchWorktreeEventCmd.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { WorkspaceState, GlobalState } from '@/core/state'; 3 | import { worktreeEventRegister } from '@/core/event/git'; 4 | 5 | export const watchWorktreeEventCmd = () => { 6 | // 手动打开监听 7 | queueMicrotask(() => { 8 | const folders = [ 9 | ...new Set([ 10 | ...WorkspaceState.get('mainFolders', []).map((i) => i.path), 11 | ...GlobalState.get('gitFolders', []).map((i) => i.path), 12 | ]), 13 | ]; 14 | folders.forEach((folderPath) => { 15 | worktreeEventRegister.add(vscode.Uri.file(folderPath)); 16 | }); 17 | }); 18 | }; -------------------------------------------------------------------------------- /src/core/config/setting.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration Management Module 3 | * Manages all configuration items for the Git Worktree Manager extension 4 | * Provides unified configuration access, update, and monitoring functionality 5 | */ 6 | import * as vscode from 'vscode'; 7 | import { APP_NAME, AlertLevel } from '@/constants'; 8 | import { DefaultDisplayList, GitHistoryExtension } from '@/types'; 9 | 10 | /** 11 | * Configuration Management Class 12 | * Encapsulates VSCode configuration system operations and provides type-safe configuration access 13 | */ 14 | export class Config { 15 | private constructor() {} 16 | 17 | /** Array of disposable objects for storing configuration change listeners */ 18 | static disposables: vscode.Disposable[] = []; 19 | 20 | /** 21 | * IMPORTANT: The defaultValue parameter in each get() method must match 22 | * the corresponding default value defined in package.json configuration. 23 | * This ensures consistency between code defaults and extension settings. 24 | */ 25 | 26 | // Basic configuration items 27 | static get(key: 'alertLevel', defaultValue: 'error'): AlertLevel; 28 | static get(key: 'gitHistoryExtension', defaultValue: GitHistoryExtension.gitGraph): GitHistoryExtension; 29 | 30 | // Worktree picker configuration 31 | static get(key: 'worktreePick.defaultDisplayList', defaultValue: DefaultDisplayList.workspace): DefaultDisplayList; 32 | static get(key: 'worktreePick.showViewHistory', defaultValue: true): boolean; 33 | static get(key: 'worktreePick.showCheckout', defaultValue: true): boolean; 34 | static get(key: 'worktreePick.showAddToWorkspace', defaultValue: false): boolean; 35 | static get(key: 'worktreePick.showCopy', defaultValue: false): boolean; 36 | static get(key: 'worktreePick.showRevealInSystemExplorer', defaultValue: false): boolean; 37 | static get(key: 'worktreePick.showTerminal', defaultValue: false): boolean; 38 | static get(key: 'worktreePick.showExternalTerminal', defaultValue: false): boolean; 39 | static get(key: 'worktreePick.copyTemplate', defaultValue: '$LABEL'): string; 40 | static get(key: 'worktreePick.pinCurrentRepo', defaultValue: false): boolean; 41 | static get(key: 'worktreePick.showOpenRepository', defaultValue: true): boolean; 42 | static get(key: 'worktreePick.showRemoveWorktree', defaultValue: true): boolean; 43 | 44 | // Branch picker configuration 45 | static get(key: 'branchPick.showDeleteBranch', defaultValue: true): boolean; 46 | 47 | // Terminal configuration 48 | static get(key: 'terminalCmdList', defaultValue: []): string[]; 49 | static get(key: 'terminalLocationInEditor', defaultValue: false): boolean; 50 | static get(key: 'terminalNameTemplate', defaultValue: '$LABEL ⇄ $FULL_PATH'): string; 51 | 52 | // Workspace configuration 53 | static get(key: 'openInsideFolder', defaultValue: false): boolean; 54 | static get(key: 'httpProxy', defaultValue: ''): string; 55 | static get(key: 'workspacePathFormat', defaultValue: '$BASE_NAME - $FULL_PATH'): string; 56 | 57 | // Worktree operation configuration 58 | static get(key: 'promptDeleteBranchAfterWorktreeDeletion', defaultValue: false): boolean; 59 | static get(key: 'worktreeCopyPatterns', defaultValue: []): string[]; 60 | static get(key: 'worktreeCopyIgnores', defaultValue: []): string[]; 61 | static get(key: 'checkoutIgnoreOtherWorktree', defaultValue: false): boolean; 62 | 63 | // Tree view configuration 64 | static get(key: 'treeView.showFetchInTreeItem', defaultValue: true): boolean; 65 | static get(key: 'treeView.toSCM', defaultValue: false): boolean; 66 | static get(key: 'treeView.worktreeDescriptionTemplate', defaultValue: '$FULL_PATH'): string; 67 | 68 | // Path template configuration 69 | static get(key: 'worktreePathTemplate', defaultValue: '$BASE_PATH.worktree'): string; 70 | static get(key: 'worktreeSubdirectoryTemplate', defaultValue: 'worktree$INDEX'): string; 71 | 72 | // Post-creation command configuration 73 | static get(key: 'postCreateCmd', defaultValue: ''): string; 74 | /** 75 | * Get configuration value 76 | * @param key Configuration key name 77 | * @param defaultValue Default value 78 | * @returns Configuration value or default value 79 | */ 80 | static get(key: string, defaultValue: T): T { 81 | return vscode.workspace.getConfiguration(APP_NAME).get(key, defaultValue); 82 | } 83 | 84 | /** 85 | * Update configuration value 86 | * @param key Configuration key name 87 | * @param defaultValue New configuration value 88 | * @returns Promise 89 | */ 90 | static update(key: string, defaultValue: T): Thenable { 91 | return vscode.workspace.getConfiguration(APP_NAME).update(key, defaultValue); 92 | } 93 | 94 | /** 95 | * Listen for configuration changes 96 | * @param key Configuration key name 97 | * @param func Callback function when configuration changes 98 | * @returns Disposable event listener 99 | */ 100 | static onChange(key: string, func: () => any) { 101 | const event = vscode.workspace.onDidChangeConfiguration((e) => { 102 | if (e.affectsConfiguration(`${APP_NAME}.${key}`)) func(); 103 | }); 104 | this.disposables.push(event); 105 | return event; 106 | } 107 | 108 | /** 109 | * Dispose all configuration listeners 110 | * Called when extension is deactivated to clean up resources 111 | */ 112 | static dispose() { 113 | this.disposables.forEach((d) => d.dispose()); 114 | this.disposables.length = 0; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/core/event/events.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ViewId, RefreshCacheType } from '@/constants'; 3 | import type { AllViewItem } from '@/core/treeView/items'; 4 | import type { StateKey } from '@/core/state'; 5 | 6 | export const refreshWorktreeCacheEvent = new vscode.EventEmitter(); 7 | export const updateWorktreeCacheEvent = new vscode.EventEmitter(); 8 | export const treeDataEvent = new vscode.EventEmitter(); 9 | export const updateTreeDataEvent = new vscode.EventEmitter(); 10 | export const updateFolderEvent = new vscode.EventEmitter(); 11 | export const globalStateEvent = new vscode.EventEmitter(); 12 | export const workspaceStateEvent = new vscode.EventEmitter(); 13 | export const updateRecentEvent = new vscode.EventEmitter(); 14 | export const updateFavoriteEvent = new vscode.EventEmitter(); 15 | export const toggleGitFolderViewAsEvent = new vscode.EventEmitter(); 16 | export const loadAllTreeDataEvent = new vscode.EventEmitter(); 17 | export const revealTreeItemEvent = new vscode.EventEmitter(); 18 | export const worktreeChangeEvent = new vscode.EventEmitter(); 19 | 20 | export const collectEvent = (context: vscode.ExtensionContext) => { 21 | context.subscriptions.push( 22 | refreshWorktreeCacheEvent, 23 | updateWorktreeCacheEvent, 24 | treeDataEvent, 25 | updateTreeDataEvent, 26 | updateFolderEvent, 27 | globalStateEvent, 28 | workspaceStateEvent, 29 | updateRecentEvent, 30 | updateFavoriteEvent, 31 | toggleGitFolderViewAsEvent, 32 | loadAllTreeDataEvent, 33 | revealTreeItemEvent, 34 | worktreeChangeEvent, 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/core/event/git.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { worktreeChangeEvent } from '@/core/event/events'; 3 | import logger from '@/core/log/logger'; 4 | import fs from 'fs'; 5 | 6 | const worktreeGlob = 'worktrees/*/HEAD,worktrees/*/index,worktrees/*/locked,worktrees/*/gitdir'; 7 | const watcherGlob = `{config,index,refs/remotes/**,worktrees,${worktreeGlob}}`; 8 | 9 | class WorktreeEvent implements vscode.Disposable { 10 | private disposables: vscode.Disposable[] = []; 11 | constructor(readonly uri: vscode.Uri) { 12 | const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(this.uri, watcherGlob)); 13 | this.disposables.push( 14 | watcher, 15 | watcher.onDidChange(this.onChange), 16 | watcher.onDidCreate(this.onChange), 17 | watcher.onDidDelete(this.onChange), 18 | ); 19 | logger.log(`'watching repository' ${this.uri.fsPath}`); 20 | } 21 | onChange(event: vscode.Uri) { 22 | logger.log(`'repository change' ${event.fsPath}`); 23 | worktreeChangeEvent.fire(event); 24 | } 25 | dispose() { 26 | this.disposables.forEach((i) => i.dispose()); 27 | this.disposables.length = 0; 28 | logger.log(`'unwatch repository' ${this.uri.fsPath}`); 29 | } 30 | } 31 | 32 | class WorktreeEventRegister implements vscode.Disposable { 33 | private eventMap: Map = new Map(); 34 | add(uri: vscode.Uri) { 35 | try { 36 | const finalUri = uri.fsPath.endsWith('.git') ? uri : vscode.Uri.joinPath(uri, '.git'); 37 | const folderPath = finalUri.fsPath; 38 | if (this.eventMap.has(folderPath)) return; 39 | if (!fs.existsSync(folderPath)) return; 40 | const worktreeEvent = new WorktreeEvent(finalUri); 41 | this.eventMap.set(folderPath, worktreeEvent); 42 | } catch {} 43 | } 44 | remove(uri: vscode.Uri) { 45 | const finalUri = uri.fsPath.endsWith('.git') ? uri : vscode.Uri.joinPath(uri, '.git'); 46 | const folderPath = finalUri.fsPath; 47 | this.eventMap.get(folderPath)?.dispose(); 48 | this.eventMap.delete(folderPath); 49 | } 50 | dispose() { 51 | this.eventMap.forEach((event) => event.dispose()); 52 | this.eventMap.clear(); 53 | } 54 | } 55 | 56 | const worktreeEventRegister = new WorktreeEventRegister(); 57 | 58 | export { worktreeEventRegister }; 59 | -------------------------------------------------------------------------------- /src/core/folderRoot.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | class WorkspaceFolderRoot implements vscode.Disposable { 4 | private _uri?: vscode.Uri; 5 | private _workspaceWatcher: vscode.Disposable; 6 | private _folderSet: Set = new Set(); 7 | constructor() { 8 | this._workspaceWatcher = vscode.workspace.onDidChangeWorkspaceFolders(() => { 9 | this.checkUri(); 10 | }); 11 | this.checkUri(); 12 | } 13 | private checkUri() { 14 | const folders = vscode.workspace.workspaceFolders || []; 15 | this._folderSet = new Set(folders.map(i => i.uri.fsPath.toLocaleLowerCase().replace(/\\/g, '/')).filter(i => i)); 16 | // TODO Special handling for multiple workspaces 17 | if(folders.length) { 18 | this._uri = folders[0].uri; 19 | } else { 20 | this._uri = void 0; 21 | } 22 | } 23 | get uri() { 24 | return this._uri; 25 | } 26 | get folderPathSet() { 27 | return this._folderSet; 28 | } 29 | dispose() { 30 | this._workspaceWatcher.dispose(); 31 | } 32 | } 33 | 34 | export default new WorkspaceFolderRoot(); 35 | -------------------------------------------------------------------------------- /src/core/git/addWorktree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execAuto } from '@/core/git/exec'; 3 | import { checkoutBranch } from '@/core/git/checkoutBranch'; 4 | import { Alert } from '@/core/ui/message'; 5 | import { WORK_TREE } from '@/constants'; 6 | 7 | export async function addWorktree(path: string, branch: string, isBranch: boolean, cwd?: string) { 8 | try { 9 | await execAuto(cwd, [WORK_TREE, 'add', '-f', '--guess-remote', path, branch]); 10 | await checkoutBranch(path, branch, isBranch); 11 | return true; 12 | } catch (error: any) { 13 | Alert.showErrorMessage(vscode.l10n.t('Failed to create worktree\n{0}', String(error))); 14 | return false; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/core/git/bundleRepo.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec'; 2 | 3 | export const bundleRepo = async (cwd: string, bundlePath: string) => { 4 | return execBase(cwd, ['bundle', 'create', bundlePath, '--all']); 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/git/checkBranchNameValid.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | 3 | export async function checkBranchNameValid(cwd: string, branchName: string) { 4 | try { 5 | await execAuto(cwd, ['check-ref-format', '--branch', branchName]); 6 | return true; 7 | } catch (error) { 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/core/git/checkGitValid.ts: -------------------------------------------------------------------------------- 1 | import folderRoot from '@/core/folderRoot'; 2 | import { execBase } from '@/core/git/exec-base'; 3 | 4 | export async function checkGitValid(folderPath: string = folderRoot.uri?.fsPath || '') { 5 | try { 6 | await execBase(folderPath, ['rev-parse', '--is-inside-work-tree']); 7 | return true; 8 | } catch { 9 | return false; 10 | } 11 | } -------------------------------------------------------------------------------- /src/core/git/checkoutBranch.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { getAllRefList } from '@/core/git/getAllRefList'; 3 | import { Config } from '@/core/config/setting'; 4 | 5 | const refArgs = ['refname', 'upstream:remoteref', 'refname:short', 'upstream:remotename'] as const; 6 | type RefItem = Record<(typeof refArgs)[number], string>; 7 | 8 | export const checkoutBranch = async (cwd: string, branchName: string, isBranch: boolean) => { 9 | const refList = await getAllRefList([...refArgs], cwd, ['--sort=-upstream']); 10 | 11 | const remoteBranchList = refList.filter((i) => /^refs\/remotes/.test(i.refname)); 12 | const ignoreOtherWorktree = Config.get('checkoutIgnoreOtherWorktree', false); 13 | const isRemoteBranch = remoteBranchList.some((i) => i['refname:short'] === branchName); 14 | 15 | if (isRemoteBranch) { 16 | return handleRemoteBranch(refList, branchName, ignoreOtherWorktree, isBranch, cwd); 17 | } 18 | 19 | return handleLocalBranch(branchName, ignoreOtherWorktree, isBranch, cwd); 20 | }; 21 | 22 | const findTrackingBranch = (refList: RefItem[], branchName: string): RefItem | undefined => { 23 | return refList.find((branch) => { 24 | if (!branch['upstream:remoteref']) return false; 25 | const remoteName = branch['upstream:remotename']; 26 | const refname = branch['refname:short']; 27 | return `${remoteName}/${refname}` === branchName; 28 | }); 29 | }; 30 | 31 | const handleRemoteBranch = async ( 32 | refList: RefItem[], 33 | branchName: string, 34 | ignoreOtherWorktree: boolean, 35 | isBranch: boolean, 36 | cwd: string 37 | ) => { 38 | const trackingBranch = findTrackingBranch(refList, branchName); 39 | 40 | if (trackingBranch) { 41 | return execSwitchCommand({ 42 | branchName: trackingBranch['refname:short'], 43 | ignoreOtherWorktree, 44 | isBranch, 45 | cwd, 46 | }); 47 | } 48 | 49 | return execAuto(cwd, ['checkout', '-q', '--track', branchName]).then(r => r.stdout); 50 | }; 51 | 52 | const handleLocalBranch = (branchName: string, ignoreOtherWorktree: boolean, isBranch: boolean, cwd: string) => { 53 | return execSwitchCommand({ 54 | branchName, 55 | ignoreOtherWorktree, 56 | isBranch, 57 | cwd, 58 | }); 59 | }; 60 | 61 | interface SwitchCommandOptions { 62 | branchName: string; 63 | ignoreOtherWorktree: boolean; 64 | isBranch: boolean; 65 | cwd: string; 66 | } 67 | 68 | const execSwitchCommand = ({ branchName, ignoreOtherWorktree, isBranch, cwd }: SwitchCommandOptions) => { 69 | const args: string[] = ['switch']; 70 | if (ignoreOtherWorktree) { 71 | args.push('--ignore-other-worktrees'); 72 | } 73 | if (!isBranch) { 74 | args.push('--detach'); 75 | } 76 | args.push(branchName); 77 | return execAuto(cwd, args); 78 | }; 79 | -------------------------------------------------------------------------------- /src/core/git/createBranch.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | 3 | export const createBranchFrom = async (cwd: string, branchName: string, base?: string) => { 4 | if (!base) return execAuto(cwd, ['branch', '-q', '--no-track', branchName]); 5 | return execAuto(cwd, ['branch', '-q', '--no-track', branchName, base]); 6 | }; 7 | -------------------------------------------------------------------------------- /src/core/git/deleteBranch.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execAuto } from '@/core/git/exec'; 3 | import { actionProgressWrapper } from '@/core/ui/progress'; 4 | 5 | export const deleteBranch = async (cwd: string, branchName: string) => { 6 | const token = new vscode.CancellationTokenSource(); 7 | await actionProgressWrapper( 8 | vscode.l10n.t('Deleting branch {branch}', { branch: branchName }), 9 | () => execAuto(cwd, ['branch', '-d', branchName], token.token), 10 | () => {}, 11 | token 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/core/git/exec-base.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import * as vscode from 'vscode'; 3 | import * as cp from 'child_process'; 4 | import { Config } from '@/core/config/setting'; 5 | import logger from '@/core/log/logger'; 6 | import treeKill = require('tree-kill'); 7 | 8 | export interface ExecResult { 9 | stdout: string; 10 | stderr: string; 11 | code: number | null; 12 | } 13 | 14 | export const execBase = ( 15 | cwd: string, 16 | args?: string[], 17 | token?: vscode.CancellationToken 18 | ): Promise => { 19 | return new Promise((resolve, reject) => { 20 | const gitPath = vscode.workspace.getConfiguration('git').get('path', 'git') || 'git'; 21 | logger.log(`'Running in' ${cwd}`); 22 | logger.log(`> ${[gitPath].concat(args || []).join(' ')}`); 23 | 24 | const env = Object.assign({}, process.env); 25 | 26 | const httpProxy = Config.get('httpProxy', ''); 27 | if (httpProxy) { 28 | env['http_proxy'] = httpProxy; 29 | env['https_proxy'] = httpProxy; 30 | } 31 | 32 | const proc = cp.spawn(gitPath, args, { 33 | cwd, 34 | env: { 35 | ...env, 36 | PATH: env['Path'] || env['PATH'], 37 | GCM_INTERACTIVE: 'NEVER', 38 | GCM_PRESERVE_CREDS: 'TRUE', 39 | LC_ALL: 'C', 40 | }, 41 | }); 42 | 43 | let out: Buffer = Buffer.from('', 'utf-8'); 44 | let err: Buffer = Buffer.from('', 'utf-8'); 45 | 46 | proc.stdout.on('data', (chunk) => { 47 | out = Buffer.concat([out, chunk]); 48 | logger.trace(`[stdout] ${chunk.toString()}`); 49 | }); 50 | 51 | proc.stderr.on('data', (chunk) => { 52 | err = Buffer.concat([err, chunk]); 53 | logger.error(`[stderr] ${chunk.toString()}`); 54 | }); 55 | 56 | token?.onCancellationRequested(() => { 57 | logger.error('[exec] Process cancellation requested'); 58 | proc.kill('SIGTERM'); 59 | if (proc.pid) { 60 | treeKill(proc.pid, 'SIGTERM'); 61 | } 62 | }); 63 | 64 | proc.once('error', (e) => { 65 | logger.error(`[exec error] ${e.message}`); 66 | reject(e); 67 | }); 68 | 69 | proc.once('close', (code, signal) => { 70 | logger.trace('[exec close] ', code, signal); 71 | 72 | if (signal === 'SIGTERM') { 73 | return resolve({ stdout: '', stderr: 'Process cancelled', code }); 74 | } 75 | 76 | if (code === 0) { 77 | resolve({ stdout: out.toString(), stderr: err.toString(), code }); 78 | } else { 79 | reject(Error(err.toString())); 80 | } 81 | }); 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/core/git/exec.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import folderRoot from '@/core/folderRoot'; 3 | import { execBase, type ExecResult } from '@/core/git/exec-base'; 4 | 5 | export const exec = (args?: string[], token?: vscode.CancellationToken): Promise => { 6 | return execBase(folderRoot.uri?.fsPath || '', args, token); 7 | }; 8 | 9 | export const execAuto = (cwd: string = '', args?: string[], token?: vscode.CancellationToken) => { 10 | if (!cwd) return exec(args, token); 11 | return execBase(cwd, args, token); 12 | }; 13 | 14 | export { execBase }; 15 | -------------------------------------------------------------------------------- /src/core/git/fetchRemoteRef.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execAuto } from '@/core/git/exec'; 3 | import { actionProgressWrapper } from '@/core/ui/progress'; 4 | import type { FetchArgs } from '@/types'; 5 | 6 | export const fetchRemoteRef = ({ remote, remoteRef, cwd }: FetchArgs) => { 7 | const token = new vscode.CancellationTokenSource(); 8 | actionProgressWrapper( 9 | vscode.l10n.t('Fetch remote ( {0} ) on {1}', `${remote}/${remoteRef}`, cwd), 10 | () => execAuto(cwd, ['fetch', remote, remoteRef], token.token), 11 | () => {}, 12 | token, 13 | ); 14 | }; -------------------------------------------------------------------------------- /src/core/git/fetchRepo.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execAuto } from '@/core/git/exec'; 3 | import { actionProgressWrapper } from '@/core/ui/progress'; 4 | 5 | export const fetchRepo = (cwd: string) => { 6 | const token = new vscode.CancellationTokenSource(); 7 | actionProgressWrapper( 8 | vscode.l10n.t('Fetch all remote commits on {0}', cwd), 9 | () => execAuto(cwd, ['fetch', '--all'], token.token), 10 | () => {}, 11 | token, 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/core/git/getAheadBehindCommitCount.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec'; 2 | 3 | // Fork from https://github.com/gitkraken/vscode-gitlens/blob/2fd2bbbe328fbe66f879b78a61cab6df65181452/src/env/node/git/git.ts#L1660 4 | export async function getAheadBehindCommitCount(ref1: string, ref2: string, cwd: string) { 5 | try { 6 | let { stdout: data } = await execBase(cwd, ['rev-list', '--left-right', '--count', `${ref1}...${ref2}`, '--']); 7 | if (data.length === 0) return undefined; 8 | const parts = data.split('\t'); 9 | if (parts.length !== 2) return undefined; 10 | const [ahead, behind] = parts; 11 | const result = { 12 | ahead: parseInt(ahead, 10), 13 | behind: parseInt(behind, 10), 14 | }; 15 | if (isNaN(result.ahead) || isNaN(result.behind)) return undefined; 16 | return result; 17 | } catch { 18 | return void 0; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/git/getAllRefList.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { formatQuery, parseOutput } from '@/core/util/parse'; 3 | 4 | export async function getAllRefList(keys: T[], cwd?: string, args?: string[]) { 5 | try { 6 | let { stdout: output} = await execAuto(cwd, [ 7 | 'for-each-ref', 8 | `--format=${formatQuery(keys)}`, 9 | '--sort=-refname:lstrip=2', 10 | '--sort=-committerdate', 11 | ...(args || []), 12 | ]); 13 | return parseOutput(output, keys); 14 | } catch { 15 | return []; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/core/git/getChanges.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec'; 2 | 3 | export interface IChanges { 4 | /** staged area */ 5 | x: string; 6 | /** working directory */ 7 | y: string; 8 | fsPath: string; 9 | raw: string; 10 | } 11 | 12 | export const getChanges = async (cwd: string): Promise => { 13 | const { stdout } = await execBase(cwd, ['status', '--short']).catch(() => ({ stdout: '' })); 14 | if (!stdout) return []; 15 | const lines = stdout.trim().split('\n').filter(Boolean); 16 | return lines.map((line: string) => { 17 | // 前两个字符是状态码,第3个开始是路径(注意可能有多个空格) 18 | const x = line[0]; 19 | const y = line[1]; 20 | const path = line.slice(3).trim(); // 文件路径,去除前导空格 21 | return { x, y, fsPath: path, raw: line }; 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/core/git/getCurrentBranch.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | 3 | export async function getCurrentBranch(cwd: string) { 4 | try { 5 | const { stdout: output } = await execAuto(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']); 6 | const branch = output.trim(); 7 | return branch === 'HEAD' ? '' : branch; 8 | } catch { 9 | return ''; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/core/git/getLashCommitDetail.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { formatSimpleQuery, parseOutput } from '@/core/util/parse'; 3 | 4 | export const getLashCommitDetail = async ( 5 | cwd: string, 6 | keys: T[], 7 | ): Promise> => { 8 | try { 9 | let { stdout: output } = await execAuto(cwd, ['log', '-1', `--pretty=format:${formatSimpleQuery(keys)}`]); 10 | return parseOutput(output, keys)[0]; 11 | } catch { 12 | return {} as Record; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/core/git/getLastCommitHash.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | 3 | export const getLastCommitHash = async (cwd: string, short: boolean = true) => { 4 | try { 5 | const { stdout: output } = await execAuto(cwd, ['rev-parse', short ? '--short' : '', 'HEAD']); 6 | return output.trim(); 7 | } catch { 8 | return ''; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/core/git/getMainFolder.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec-base'; 2 | 3 | export const getMainFolder = async (cwd: string) => { 4 | try { 5 | const { stdout: mainFolderFull} = await execBase(cwd, [ 6 | 'rev-parse', 7 | '--path-format=absolute', 8 | '--git-common-dir', 9 | ]); 10 | return mainFolderFull.trim().replace(/\/.git$/, ''); 11 | } catch { 12 | return ''; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/core/git/getNameRev.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec'; 2 | 3 | export function getNameRev(cwd: string) { 4 | return execBase(cwd, ['describe', '--all']).then(res => res.stdout).catch(() => ''); 5 | } 6 | -------------------------------------------------------------------------------- /src/core/git/getUpstream.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec'; 2 | 3 | export const getUpstream = async (cwd: string) => { 4 | const { stdout: upstream } = await execBase(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); 5 | return upstream.trim(); 6 | }; 7 | -------------------------------------------------------------------------------- /src/core/git/getWorktreeList.ts: -------------------------------------------------------------------------------- 1 | import folderRoot from '@/core/folderRoot'; 2 | import { execBase } from '@/core/git/exec-base'; 3 | import { getNameRev } from '@/core/git/getNameRev'; 4 | import { getMainFolder } from '@/core/git/getMainFolder'; 5 | import type { IWorktreeDetail, IWorktreeOutputItem } from '@/types'; 6 | import logger from '@/core/log/logger'; 7 | 8 | function parseWorktreeOutput(output: string): IWorktreeOutputItem[] { 9 | return output 10 | .split('\n') 11 | .reduce( 12 | (list, textLine) => { 13 | if (textLine) { 14 | list[list.length - 1].push(textLine); 15 | } else { 16 | list.push([]); 17 | } 18 | return list; 19 | }, 20 | [[]], 21 | ) 22 | .filter((lines) => lines.length) 23 | .map((lines) => { 24 | const entries = lines.map((text) => { 25 | const [key, ...values] = text.split(' '); 26 | return [key, values.join(' ')] as [string, string]; 27 | }); 28 | return Object.fromEntries(entries) as unknown as IWorktreeOutputItem; 29 | }); 30 | } 31 | 32 | function checkIsTag(nameRev: string) { 33 | return Boolean( 34 | nameRev && 35 | /^tags\/[^~]+/.test(nameRev) && 36 | // 排除 tags/xxx-<数字>-g<哈希> 37 | !/^tags\/.+-\d+-g[0-9a-f]{7}$/.test(nameRev), 38 | ); 39 | } 40 | 41 | async function buildWorktreeDetail(item: IWorktreeOutputItem, mainFolder: string): Promise { 42 | const branchName = item.branch?.replace('refs/heads/', '') || ''; 43 | 44 | let nameRev = ''; 45 | if (!branchName) nameRev = (await getNameRev(item.worktree)).trim(); 46 | 47 | const isTag = checkIsTag(nameRev); 48 | const isBare = Reflect.has(item, 'bare'); 49 | const locked = Reflect.has(item, 'locked'); 50 | const isMain = item.worktree.trim() === mainFolder; 51 | const isBranch = Boolean(branchName); 52 | const detached = Reflect.has(item, 'detached'); 53 | const prunable = Reflect.has(item, 'prunable'); 54 | 55 | let name = ''; 56 | if (isBare) { 57 | name = ''; 58 | } else if (branchName) { 59 | name = branchName; 60 | } else if (nameRev) { 61 | name = isTag ? nameRev.replace(/^tags\//, '').trim() : item.HEAD?.slice(0, 8); 62 | } 63 | 64 | const hash = item.HEAD || ''; 65 | 66 | return { 67 | name, 68 | path: item.worktree, 69 | isBare, 70 | isBranch, 71 | isTag, 72 | detached, 73 | prunable, 74 | locked, 75 | isMain, 76 | hash, 77 | mainFolder, 78 | }; 79 | } 80 | 81 | export async function getWorktreeList(root?: string): Promise { 82 | const cwd = root || folderRoot.uri?.fsPath || ''; 83 | 84 | try { 85 | const [{ stdout: output }, mainFolder] = await Promise.all([ 86 | execBase(cwd, ['worktree', 'list', '--porcelain']), 87 | getMainFolder(cwd), 88 | ]); 89 | 90 | const worktreeList = parseWorktreeOutput(output); 91 | 92 | return await Promise.all(worktreeList.map((item) => buildWorktreeDetail(item, mainFolder))); 93 | } catch (error) { 94 | logger.error(error); 95 | return []; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/core/git/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackiotyu/git-worktree-manager/881bbfb18519445764359c95afc97f6af0231d4e/src/core/git/index.ts -------------------------------------------------------------------------------- /src/core/git/lockWorktree.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { WORK_TREE } from '@/constants'; 3 | 4 | export function lockWorktree(path: string, cwd?: string) { 5 | return execAuto(cwd, [WORK_TREE, 'lock', path]); 6 | } -------------------------------------------------------------------------------- /src/core/git/moveWorktree.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { WORK_TREE } from '@/constants'; 3 | 4 | export function moveWorktree(oldPath: string, newPath: string, cwd?: string) { 5 | return execAuto(cwd, [WORK_TREE, 'move', oldPath, newPath]); 6 | } -------------------------------------------------------------------------------- /src/core/git/pruneWorktree.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { WORK_TREE } from '@/constants'; 3 | import { getMainFolder } from '@/core/git/getMainFolder'; 4 | import folderRoot from '@/core/folderRoot'; 5 | 6 | import * as path from 'path'; 7 | import * as fs from 'fs/promises'; 8 | 9 | interface WorktreeInfo { 10 | name: string; 11 | metaPath: string; 12 | realPath: string | null; 13 | } 14 | 15 | function parsePruneDryRunOutput(output: string): string[] { 16 | // eg: "Removing worktrees/terst2: gitdir file points to non-existent location" 17 | const regex = /Removing worktrees\/([^\s:]+):/g; 18 | const names: string[] = []; 19 | let match: RegExpExecArray | null; 20 | while ((match = regex.exec(output)) !== null) { 21 | names.push(match[1]); 22 | } 23 | return names; 24 | } 25 | 26 | async function getRealWorktreePath(repoPath: string, gitDir: string, worktreeName: string): Promise { 27 | const metaPath = path.resolve(repoPath, gitDir, 'worktrees', worktreeName); 28 | const gitdirFile = path.join(metaPath, 'gitdir'); 29 | try { 30 | const gitdirContent = await fs.readFile(gitdirFile, 'utf-8'); 31 | const realGitDir = gitdirContent.trim(); 32 | const realPath = path.resolve(realGitDir, '..'); 33 | return realPath; 34 | } catch { 35 | return null; 36 | } 37 | } 38 | 39 | export async function analyzePruneDryRun( 40 | repoPath: string, 41 | pruneDryRunOutput: string, 42 | gitDirRaw: string, 43 | ): Promise { 44 | const gitDir = gitDirRaw.trim(); 45 | const worktreeNames = parsePruneDryRunOutput(pruneDryRunOutput); 46 | 47 | const results: WorktreeInfo[] = []; 48 | for (const name of worktreeNames) { 49 | const metaPath = path.resolve(repoPath, gitDir, 'worktrees', name); 50 | const realPath = await getRealWorktreePath(repoPath, gitDir, name); 51 | results.push({ name, metaPath, realPath }); 52 | } 53 | 54 | return results; 55 | } 56 | 57 | export async function pruneWorktree(dryRun: boolean, cwd: string) { 58 | try { 59 | const res = await execAuto( 60 | cwd, 61 | [WORK_TREE, 'prune', dryRun ? '--dry-run' : '', '-v'].filter((i) => i), 62 | ); 63 | const repoPath = await getMainFolder(cwd || folderRoot.uri?.fsPath || ''); 64 | const gitDirRaw = await execAuto(cwd, ['rev-parse', '--git-dir']); 65 | const worktreeList = await analyzePruneDryRun(repoPath, res.stdout + res.stderr, gitDirRaw.stdout); 66 | return worktreeList.map((worktree) => worktree.realPath); 67 | } catch (error: any) { 68 | return []; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/git/pullBranch.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execAuto } from '@/core/git/exec'; 3 | import { actionProgressWrapper } from '@/core/ui/progress'; 4 | import type { PullPushArgs } from '@/types'; 5 | 6 | export const pullBranch = ({ remote, branch, remoteRef, cwd }: PullPushArgs) => { 7 | const token = new vscode.CancellationTokenSource(); 8 | actionProgressWrapper( 9 | vscode.l10n.t('Pull commit ( {0} → {1} ) on {2}', `${remote}/${remoteRef}`, branch, cwd), 10 | () => execAuto(cwd, ['pull'], token.token), 11 | () => {}, 12 | token, 13 | ); 14 | }; -------------------------------------------------------------------------------- /src/core/git/pushBranch.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execAuto } from '@/core/git/exec'; 3 | import { actionProgressWrapper } from '@/core/ui/progress'; 4 | import type { PullPushArgs } from '@/types'; 5 | 6 | export const pushBranch = ({ remote, branch, remoteRef, cwd }: PullPushArgs) => { 7 | const token = new vscode.CancellationTokenSource(); 8 | actionProgressWrapper( 9 | vscode.l10n.t('Push commit ( {0} → {1} ) on {2}', branch, `${remote}/${remoteRef}`, cwd), 10 | () => execAuto(cwd, ['push'], token.token), 11 | () => {}, 12 | token, 13 | ); 14 | }; -------------------------------------------------------------------------------- /src/core/git/removeWorktree.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { WORK_TREE } from '@/constants'; 3 | 4 | export function removeWorktree(path: string, forceDelete: boolean, cwd?: string) { 5 | let args = [WORK_TREE, 'remove']; 6 | if (forceDelete) args.push('--force'); 7 | args.push(path); 8 | return execAuto(cwd, args); 9 | } 10 | -------------------------------------------------------------------------------- /src/core/git/renameBranch.ts: -------------------------------------------------------------------------------- 1 | import { execBase } from '@/core/git/exec'; 2 | 3 | export function renameBranch(cwd: string, branchName: string, newBranchName: string) { 4 | return execBase(cwd, ['branch', '-m', branchName, newBranchName]); 5 | } 6 | -------------------------------------------------------------------------------- /src/core/git/repairWorktree.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { WORK_TREE } from '@/constants'; 3 | 4 | export function repairWorktree(path: string, cwd?: string) { 5 | return execAuto(cwd, [WORK_TREE, 'repair', path]); 6 | } -------------------------------------------------------------------------------- /src/core/git/unlockWorktree.ts: -------------------------------------------------------------------------------- 1 | import { execAuto } from '@/core/git/exec'; 2 | import { WORK_TREE } from '@/constants'; 3 | 4 | export function unlockWorktree(path: string, cwd?: string) { 5 | return execAuto(cwd, [WORK_TREE, 'unlock', path]); 6 | } -------------------------------------------------------------------------------- /src/core/gitHistory.ts: -------------------------------------------------------------------------------- 1 | import { extensions, commands, Uri, MarkdownString, l10n } from 'vscode'; 2 | import { Alert } from '@/core/ui/message'; 3 | import { Config } from '@/core/config/setting'; 4 | import { GitHistoryExtension } from '@/types'; 5 | 6 | export class GitHistory { 7 | static get extensionName() { 8 | return Config.get('gitHistoryExtension', GitHistoryExtension.gitGraph); 9 | } 10 | static openHistory(uri: Uri) { 11 | try { 12 | this.checkExtension(); 13 | this.openHistoryStrategy(uri); 14 | } catch (error: any) { 15 | Alert.showErrorMessage(error.message); 16 | } 17 | } 18 | private static checkExtension() { 19 | const extension = extensions.getExtension(this.extensionName); 20 | if (!extension) { 21 | const args = encodeURIComponent(JSON.stringify([[this.extensionName]])); 22 | const commandUri = Uri.parse(`command:workbench.extensions.action.showExtensionsWithIds?${args}`); 23 | const tips = l10n.t('Please install the extension, click to search for {0}', `📦 [${this.extensionName}](${commandUri})`); 24 | const contents = new MarkdownString(tips, true); 25 | throw Error(contents.value); 26 | } 27 | if (!extension.isActive) { 28 | extension.activate(); 29 | } 30 | } 31 | private static async openHistoryStrategy(uri: Uri) { 32 | switch (this.extensionName) { 33 | case GitHistoryExtension.gitGraph: 34 | return commands.executeCommand('git-graph.view', { rootUri: uri }); 35 | case GitHistoryExtension.builtinGit: 36 | await commands.executeCommand('git.openRepository', uri.fsPath); 37 | await commands.executeCommand('workbench.scm.history.focus'); 38 | return commands.executeCommand('workbench.scm.action.graph.pickRepository'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core/log/logger.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export class Logger implements vscode.Disposable { 4 | private output = vscode.window.createOutputChannel('Git Worktree Manager', { log: true }); 5 | private visible = false; 6 | log(str: string) { 7 | const lines = str.split(/\r?\n/mg); 8 | while (/^\s*$/.test(lines[lines.length - 1])) { 9 | lines.pop(); 10 | } 11 | this.output.appendLine(lines.join('\n')); 12 | } 13 | toggle = () => { 14 | this.visible = !this.visible; 15 | this.output.show(); 16 | if(this.visible) this.output.show(); 17 | else this.output.hide(); 18 | }; 19 | error(str: any) { 20 | this.output.error(str); 21 | } 22 | trace(str: string, ...args: any[]) { 23 | this.output.trace(str, ...args); 24 | } 25 | dispose() { 26 | this.output.dispose(); 27 | } 28 | } 29 | 30 | export default new Logger(); -------------------------------------------------------------------------------- /src/core/state/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { IFolderItemConfig, IWorktreeCacheItem, RepoRefList, IRecentItemCache, IRecentItem } from '@/types'; 3 | import { globalStateEvent } from '@/core/event/events'; 4 | 5 | type KeyGitRepoRefList = `global.gitRepo.refList.${string}`; 6 | type KeyGitFolderViewAsTree = 'gitFolderViewAsTree'; 7 | type KeyGitFolders = 'gitFolders'; 8 | type KeyWorkTreeCache = 'workTreeCache'; 9 | type KeyMainFolders = 'mainFolders'; 10 | type KeyGlobalRecentItemCache = 'global.recentItemCache'; 11 | type KeyGlobalFavorite = 'global.favorite'; 12 | 13 | export type StateKey = KeyGitRepoRefList | KeyGitFolderViewAsTree | KeyGitFolders | KeyWorkTreeCache | KeyMainFolders | KeyGlobalRecentItemCache | KeyGlobalFavorite; 14 | 15 | export class GlobalState { 16 | static context: vscode.ExtensionContext; 17 | static state: vscode.Memento; 18 | static init(context: vscode.ExtensionContext) { 19 | this.context = context; 20 | this.state = context.globalState; 21 | context.subscriptions.push({ 22 | dispose: () => { 23 | (this.context as any) = null; 24 | (this.state as any) = null; 25 | }, 26 | }); 27 | } 28 | static get(key: KeyGlobalRecentItemCache, defaultValue: IRecentItemCache): IRecentItemCache; 29 | static get(key: KeyGitRepoRefList, defaultValue: RepoRefList): RepoRefList; 30 | static get(key: KeyGitFolderViewAsTree, defaultValue: boolean): boolean; 31 | static get(key: KeyGitFolders, defaultValue: IFolderItemConfig[]): IFolderItemConfig[]; 32 | static get(key: KeyWorkTreeCache, defaultValue: IWorktreeCacheItem[]): IWorktreeCacheItem[]; 33 | static get(key: KeyGlobalFavorite, defaultValue: IRecentItem[]): IRecentItem[]; 34 | static get(key: string, defaultValue: T): T { 35 | return this.state.get(key, defaultValue); 36 | } 37 | 38 | static update(key: KeyGlobalRecentItemCache, defaultValue: IRecentItemCache): Thenable; 39 | static update(key: KeyGitRepoRefList, value: RepoRefList): Thenable; 40 | static update(key: KeyGitFolderViewAsTree, value: boolean): Thenable; 41 | static update(key: KeyGitFolders, value: IFolderItemConfig[]): Thenable; 42 | static update(key: KeyWorkTreeCache, value: IWorktreeCacheItem[]): Thenable; 43 | static update(key: KeyGlobalFavorite, value: IRecentItem[]): Thenable; 44 | static update(key: string, value: any): Thenable { 45 | return this.state.update(key, value).then(() => { 46 | globalStateEvent.fire(key as KeyGitRepoRefList); 47 | }); 48 | } 49 | } 50 | 51 | export class WorkspaceState { 52 | static context: vscode.ExtensionContext; 53 | static state: vscode.Memento; 54 | static init(context: vscode.ExtensionContext) { 55 | this.context = context; 56 | this.state = context.workspaceState; 57 | context.subscriptions.push({ 58 | dispose: () => { 59 | (this.context as any) = null; 60 | (this.state as any) = null; 61 | }, 62 | }); 63 | } 64 | static get(key: KeyWorkTreeCache, defaultValue: IRecentItem[]): IWorktreeCacheItem[]; 65 | static get(key: KeyMainFolders, defaultValue: IFolderItemConfig[]): IFolderItemConfig[]; 66 | static get(key: string, defaultValue: T): T { 67 | return this.state.get(key, defaultValue); 68 | } 69 | static update(key: KeyWorkTreeCache, value: IWorktreeCacheItem[]): Thenable; 70 | static update(key: KeyMainFolders, value: IFolderItemConfig[]): Thenable; 71 | static update(key: string, value: any): Thenable { 72 | return this.state.update(key, value).then(() => { 73 | globalStateEvent.fire(key as KeyGitRepoRefList); 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/core/treeView/items/file.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { TreeItemKind, ViewId } from '@/constants'; 3 | import { IRecentItem, IWorktreeLess } from '@/types'; 4 | 5 | export class FileItem extends vscode.TreeItem implements IWorktreeLess { 6 | fsPath: string = ''; 7 | uriPath: string = ''; 8 | readonly type = TreeItemKind.file; 9 | 10 | constructor(public name: string, collapsible: vscode.TreeItemCollapsibleState, public item: IRecentItem, public readonly from: ViewId) { 11 | super(name, collapsible); 12 | this.setProperties(item); 13 | this.setTooltip(item); 14 | this.setCommand(item); 15 | } 16 | 17 | private setProperties(item: IRecentItem) { 18 | const uri = vscode.Uri.parse(item.path); 19 | this.contextValue = 'git-worktree-manager.fileItem'; 20 | this.uriPath = uri.toString(); 21 | this.fsPath = uri.fsPath; 22 | this.description = uri.fsPath; 23 | this.iconPath = vscode.ThemeIcon.File; 24 | this.resourceUri = uri; 25 | } 26 | 27 | private setTooltip(item: IRecentItem) { 28 | this.tooltip = new vscode.MarkdownString('', true); 29 | this.tooltip.appendMarkdown(vscode.l10n.t('$(file) file {0}\n\n', vscode.Uri.parse(item.path).fsPath)); 30 | } 31 | 32 | private setCommand(item: IRecentItem) { 33 | this.command = { 34 | title: 'open file', 35 | command: 'vscode.openFolder', 36 | arguments: [vscode.Uri.parse(item.path), { forceNewWindow: true }], 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/treeView/items/folder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { TreeItemKind, Commands, ViewId, RecentItemType } from '@/constants'; 3 | import { ILoadMoreItem, IRecentItem, IWorktreeLess } from '@/types'; 4 | import path from 'path'; 5 | 6 | export class FolderItem extends vscode.TreeItem implements IWorktreeLess { 7 | fsPath: string = ''; 8 | uriPath: string = ''; 9 | readonly type = TreeItemKind.folder; 10 | 11 | constructor(public name: string, collapsible: vscode.TreeItemCollapsibleState, public item: IRecentItem, public readonly from: ViewId) { 12 | super(name, collapsible); 13 | this.setProperties(item); 14 | this.setTooltip(item); 15 | this.setCommand(item); 16 | } 17 | 18 | private setProperties(item: IRecentItem) { 19 | const isFolder = item.type === RecentItemType.folder; 20 | const uri = vscode.Uri.parse(item.path); 21 | this.contextValue = isFolder ? 'git-worktree-manager.folderItem' : 'git-worktree-manager.workspaceItem'; 22 | this.uriPath = uri.toString(); 23 | this.fsPath = uri.fsPath; 24 | this.description = uri.fsPath; 25 | this.iconPath = isFolder ? vscode.ThemeIcon.Folder : new vscode.ThemeIcon('layers'); 26 | if (isFolder) this.resourceUri = uri; 27 | } 28 | 29 | private setTooltip(item: IRecentItem) { 30 | this.tooltip = new vscode.MarkdownString('', true); 31 | this.tooltip.appendMarkdown(vscode.l10n.t('$(folder) folder {0}\n\n', vscode.Uri.parse(item.path).fsPath)); 32 | } 33 | 34 | private setCommand(item: IRecentItem) { 35 | this.command = { 36 | title: 'open folder', 37 | command: 'vscode.openFolder', 38 | arguments: [vscode.Uri.parse(item.path), { forceNewWindow: true }], 39 | }; 40 | } 41 | } 42 | 43 | export class FolderLoadMore extends vscode.TreeItem implements ILoadMoreItem { 44 | readonly viewId = ViewId.folderList; 45 | 46 | constructor(public name: string = vscode.l10n.t('Load More...')) { 47 | super(name, vscode.TreeItemCollapsibleState.None); 48 | this.setProperties(); 49 | this.setCommand(); 50 | } 51 | 52 | private setProperties() { 53 | this.contextValue = 'git-worktree-manager.loadMore'; 54 | } 55 | 56 | private setCommand() { 57 | this.command = { 58 | title: vscode.l10n.t('Load More...'), 59 | command: Commands.loadMoreRecentFolder, 60 | }; 61 | } 62 | } 63 | 64 | export class WorkspaceMainGitFolderItem extends vscode.TreeItem implements IWorktreeLess { 65 | readonly type = TreeItemKind.workspaceGitMainFolder; 66 | label?: string; 67 | fsPath: string = ''; 68 | uriPath: string = ''; 69 | name: string = ''; 70 | 71 | constructor(filepath: string, collapsible: vscode.TreeItemCollapsibleState) { 72 | const name = path.basename(filepath); 73 | super(name, collapsible); 74 | this.setProperties(filepath, name); 75 | this.setTooltip(filepath); 76 | } 77 | 78 | private setProperties(filepath: string, name: string) { 79 | const uri = vscode.Uri.file(filepath); 80 | this.fsPath = uri.fsPath; 81 | this.uriPath = uri.toString(); 82 | this.name = name; 83 | this.description = filepath; 84 | this.contextValue = `git-worktree-manager.workspaceGitMainFolder`; 85 | } 86 | 87 | private setTooltip(filepath: string) { 88 | this.tooltip = new vscode.MarkdownString('', true); 89 | this.tooltip.appendMarkdown(vscode.l10n.t('$(folder) folder {0}\n\n', filepath)); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/core/treeView/items/gitFolder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { TreeItemKind } from '@/constants'; 3 | import { IFolderItemConfig, IWorktreeLess } from '@/types'; 4 | 5 | export class GitFolderItem extends vscode.TreeItem implements IWorktreeLess { 6 | readonly type = TreeItemKind.gitFolder; 7 | name: string = ''; 8 | fsPath: string = ''; 9 | uriPath: string = ''; 10 | defaultOpen?: boolean = false; 11 | readonly parent = void 0; 12 | 13 | constructor(item: IFolderItemConfig, collapsible: vscode.TreeItemCollapsibleState) { 14 | super(item.name, collapsible); 15 | this.setProperties(item); 16 | this.setTooltip(item); 17 | } 18 | 19 | private setProperties(item: IFolderItemConfig) { 20 | this.id = `${item.name} ~~ ${item.path}`; 21 | this.name = item.name; 22 | const uri = vscode.Uri.file(item.path); 23 | this.uriPath = uri.toString(); 24 | this.fsPath = uri.fsPath; 25 | this.defaultOpen = !!item.defaultOpen; 26 | this.iconPath = new vscode.ThemeIcon('repo'); 27 | this.contextValue = `git-worktree-manager.gitFolderItem.${this.defaultOpen ? 'defaultOpen' : 'defaultClose'}`; 28 | } 29 | 30 | private setTooltip(item: IFolderItemConfig) { 31 | this.tooltip = new vscode.MarkdownString('', true); 32 | this.tooltip.appendMarkdown(vscode.l10n.t('$(folder) folder {0}\n\n', item.path)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/core/treeView/items/index.ts: -------------------------------------------------------------------------------- 1 | export * from './folder'; 2 | export * from './gitFolder'; 3 | export * from './worktree'; 4 | export * from './file'; 5 | 6 | import { FolderItem } from './folder'; 7 | import { GitFolderItem } from './gitFolder'; 8 | import { WorktreeItem } from './worktree'; 9 | 10 | export type AllViewItem = WorktreeItem | GitFolderItem | FolderItem; -------------------------------------------------------------------------------- /src/core/treeView/treeViewManager.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { 3 | GitFoldersDataProvider, 4 | RecentFoldersDataProvider, 5 | WorktreeDataProvider, 6 | SettingDataProvider, 7 | FavoriteDataProvider, 8 | FavoriteAndDropController, 9 | } from '@/core/treeView/views'; 10 | import { TreeItemKind, ViewId } from '@/constants'; 11 | import { revealTreeItemEvent } from '@/core/event/events'; 12 | import { GitFolderItem, WorktreeItem } from '@/core/treeView/items'; 13 | import { Config } from '@/core/config/setting'; 14 | 15 | export class TreeViewManager { 16 | private static worktreeData?: WorktreeDataProvider; 17 | private static gitFolderData?: GitFoldersDataProvider; 18 | 19 | static register(context: vscode.ExtensionContext) { 20 | const settingView = vscode.window.createTreeView(SettingDataProvider.id, { 21 | treeDataProvider: new SettingDataProvider(), 22 | }); 23 | 24 | this.worktreeData = new WorktreeDataProvider(context); 25 | const worktreeView = vscode.window.createTreeView(ViewId.worktreeList, { 26 | treeDataProvider: this.worktreeData, 27 | showCollapseAll: false, 28 | }); 29 | const worktreeViewSCM = vscode.window.createTreeView(ViewId.worktreeListSCM, { 30 | treeDataProvider: this.worktreeData, 31 | showCollapseAll: false, 32 | }); 33 | 34 | this.gitFolderData = new GitFoldersDataProvider(context); 35 | const gitFolderView = vscode.window.createTreeView(ViewId.gitFolderList, { 36 | treeDataProvider: this.gitFolderData, 37 | showCollapseAll: true, 38 | }); 39 | const gitFolderViewSCM = vscode.window.createTreeView(ViewId.gitFolderListSCM, { 40 | treeDataProvider: this.gitFolderData, 41 | showCollapseAll: true, 42 | }); 43 | 44 | const recentFolderView = vscode.window.createTreeView(RecentFoldersDataProvider.id, { 45 | treeDataProvider: new RecentFoldersDataProvider(context), 46 | }); 47 | 48 | const favoriteView = vscode.window.createTreeView(FavoriteDataProvider.id, { 49 | treeDataProvider: new FavoriteDataProvider(context), 50 | dragAndDropController: new FavoriteAndDropController(), 51 | }); 52 | 53 | // FIXME 需要选中treeItem才能保证`revealFileInOS`和`openInTerminal`成功执行 54 | revealTreeItemEvent.event((item) => { 55 | const viewsToSCM = Config.get('treeView.toSCM', false); 56 | const _gitFolderView = viewsToSCM ? gitFolderViewSCM : gitFolderView; 57 | const _worktreeView = viewsToSCM ? worktreeViewSCM : worktreeView; 58 | 59 | if (item.type === TreeItemKind.folder) { 60 | if (item.from === ViewId.favorite) favoriteView.reveal(item, { focus: true, select: true }); 61 | if (item.from === ViewId.folderList) recentFolderView.reveal(item, { focus: true, select: true }); 62 | return; 63 | } 64 | 65 | if (item.type === TreeItemKind.gitFolder) { 66 | return _gitFolderView.reveal(item, { focus: true, select: true }); 67 | } 68 | 69 | if (item.type === TreeItemKind.worktree) { 70 | if (item.parent?.type === TreeItemKind.gitFolder) { 71 | return _gitFolderView.reveal(item, { focus: true, select: true }); 72 | } 73 | return _worktreeView.reveal(item, { focus: true, select: true }); 74 | } 75 | }); 76 | context.subscriptions.push( 77 | settingView, 78 | worktreeView, 79 | gitFolderView, 80 | recentFolderView, 81 | worktreeViewSCM, 82 | gitFolderViewSCM, 83 | favoriteView, 84 | ); 85 | } 86 | 87 | static updateWorktreeView(item: WorktreeItem) { 88 | return this.worktreeData?.update(item); 89 | } 90 | 91 | static updateGitFolderView(item: GitFolderItem | WorktreeItem) { 92 | return this.gitFolderData?.update(item); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/core/treeView/views/favorite.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ViewId, Commands, RecentItemType } from '@/constants'; 3 | import { globalStateEvent } from '@/core/event/events'; 4 | import throttle from 'lodash-es/throttle'; 5 | import { IRecentItem, IWorktreeLess } from '@/types'; 6 | import { FolderItem, FileItem } from '@/core/treeView/items'; 7 | import { getFavoriteCache } from '@/core/util/cache'; 8 | import logger from '@/core/log/logger'; 9 | import path from 'path'; 10 | 11 | type FavoriteItem = FolderItem | FileItem; 12 | 13 | export class FavoriteDataProvider implements vscode.TreeDataProvider, vscode.Disposable { 14 | static readonly id = ViewId.favorite; 15 | private static readonly refreshThrottle = 1000; // 1s 16 | 17 | private _onDidChangeTreeData = new vscode.EventEmitter(); 18 | private data: IRecentItem[] = getFavoriteCache(); 19 | public readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 20 | 21 | constructor(context: vscode.ExtensionContext) { 22 | this.refresh = throttle(this.refresh, FavoriteDataProvider.refreshThrottle, { 23 | leading: true, 24 | trailing: true, 25 | }); 26 | this.initializeEventListeners(context); 27 | } 28 | 29 | dispose() { 30 | this._onDidChangeTreeData.dispose(); 31 | } 32 | 33 | private initializeEventListeners(context: vscode.ExtensionContext) { 34 | context.subscriptions.push( 35 | globalStateEvent.event((e) => { 36 | if (e === 'global.favorite') this.refresh(); 37 | }), 38 | this, 39 | ); 40 | } 41 | 42 | private refresh = () => { 43 | try { 44 | this.data = getFavoriteCache(); 45 | this._onDidChangeTreeData.fire(); 46 | } catch (error) { 47 | logger.error(`Failed to refresh recent folders:${error}`); 48 | } 49 | }; 50 | 51 | async getChildren(element?: FavoriteItem): Promise { 52 | try { 53 | return this.data 54 | .sort((a, b) => { 55 | return a.label.localeCompare(b.label); 56 | }) 57 | .map((item) => { 58 | if (item.type === RecentItemType.file) { 59 | return new FileItem(item.label, vscode.TreeItemCollapsibleState.None, item, ViewId.favorite); 60 | } else { 61 | return new FolderItem(item.label, vscode.TreeItemCollapsibleState.None, item, ViewId.favorite); 62 | } 63 | }); 64 | } catch (error) { 65 | logger.error(`Failed to get children:${error}`); 66 | return []; 67 | } 68 | } 69 | 70 | getTreeItem(element: FavoriteItem): vscode.TreeItem { 71 | return element; 72 | } 73 | 74 | getParent(): vscode.ProviderResult { 75 | return void 0; 76 | } 77 | } 78 | 79 | export class FavoriteAndDropController implements vscode.TreeDragAndDropController { 80 | readonly dropMimeTypes = ['text/uri-list']; 81 | readonly dragMimeTypes = []; 82 | 83 | async handleDrop( 84 | target: IWorktreeLess | undefined, 85 | dataTransfer: vscode.DataTransfer, 86 | token: vscode.CancellationToken, 87 | ): Promise { 88 | const item = dataTransfer.get('text/uri-list'); 89 | if (!item) return; 90 | 91 | const uriList = await item.asString(); 92 | const uris = uriList 93 | .split('\n') 94 | .map((line) => line.trim()) 95 | .filter((line) => line.length > 0) 96 | .map((line) => vscode.Uri.parse(line)); 97 | 98 | for (const uri of uris) { 99 | await this.processToFavorite(uri); 100 | } 101 | } 102 | 103 | async processToFavorite(uri: vscode.Uri) { 104 | const stat = await vscode.workspace.fs.stat(uri); 105 | if (stat.type & vscode.FileType.Directory) { 106 | vscode.commands.executeCommand(Commands.addToFavorite, this.createItem(uri, RecentItemType.folder)); 107 | } else if(stat.type & vscode.FileType.File) { 108 | vscode.commands.executeCommand(Commands.addToFavorite, this.createItem(uri, RecentItemType.file)); 109 | } 110 | } 111 | 112 | createItem(uri: vscode.Uri, type: RecentItemType): IWorktreeLess { 113 | const label = path.basename(uri.fsPath); 114 | const viewItem: IWorktreeLess = { 115 | fsPath: uri.fsPath, 116 | name: label, 117 | uriPath: uri.toString(), 118 | item: { 119 | label, 120 | path: uri.toString(), 121 | type, 122 | }, 123 | }; 124 | return viewItem; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/core/treeView/views/index.ts: -------------------------------------------------------------------------------- 1 | export * from './gitFolder'; 2 | export * from './recentFolder'; 3 | export * from './worktree'; 4 | export * from './setting'; 5 | export * from './favorite'; -------------------------------------------------------------------------------- /src/core/treeView/views/recentFolder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Commands, ViewId } from '@/constants'; 3 | import { loadAllTreeDataEvent, globalStateEvent } from '@/core/event/events'; 4 | import throttle from 'lodash-es/throttle'; 5 | import { IRecentItemCache } from '@/types'; 6 | import { FolderLoadMore, FolderItem } from '@/core/treeView/items'; 7 | import { getRecentItemCache } from '@/core/util/cache'; 8 | import logger from '@/core/log/logger'; 9 | 10 | type RecentFolderItem = FolderLoadMore | FolderItem; 11 | 12 | export class RecentFoldersDataProvider implements vscode.TreeDataProvider, vscode.Disposable { 13 | static readonly id = ViewId.folderList; 14 | private static readonly defaultPageSize = 20; 15 | private static readonly refreshThrottle = 1000; // 1s 16 | 17 | private pageNo = 1; 18 | private pageSize = RecentFoldersDataProvider.defaultPageSize; 19 | private _onDidChangeTreeData = new vscode.EventEmitter(); 20 | private data: IRecentItemCache = getRecentItemCache(); 21 | public readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 22 | 23 | constructor(context: vscode.ExtensionContext) { 24 | this.refresh = throttle(this.refresh, RecentFoldersDataProvider.refreshThrottle, { 25 | leading: true, 26 | trailing: true, 27 | }); 28 | this.initializeEventListeners(context); 29 | } 30 | 31 | dispose() { 32 | this._onDidChangeTreeData.dispose(); 33 | } 34 | 35 | private initializeEventListeners(context: vscode.ExtensionContext) { 36 | context.subscriptions.push( 37 | globalStateEvent.event((e) => { 38 | if (e === 'global.recentItemCache') this.refresh(); 39 | }), 40 | vscode.commands.registerCommand(Commands.loadMoreRecentFolder, this.loadMoreFolder), 41 | loadAllTreeDataEvent.event(this.loadAllCheck), 42 | this, 43 | ); 44 | } 45 | 46 | private refresh = () => { 47 | try { 48 | this.data = getRecentItemCache(); 49 | this._onDidChangeTreeData.fire(); 50 | } catch (error) { 51 | logger.error(`Failed to refresh recent folders:${error}`); 52 | } 53 | }; 54 | 55 | private loadAllCheck = (viewId: ViewId) => { 56 | if (viewId === RecentFoldersDataProvider.id) { 57 | this.pageSize = Infinity; 58 | this.refresh(); 59 | } 60 | }; 61 | 62 | private loadMoreFolder = () => { 63 | this.pageNo += 1; 64 | this.refresh(); 65 | }; 66 | 67 | async getChildren(element?: RecentFolderItem): Promise { 68 | try { 69 | const start = 0; 70 | const end = this.pageNo * this.pageSize; 71 | const currentItems = this.data.list.slice(start, end); 72 | 73 | const itemList: RecentFolderItem[] = currentItems 74 | .map((config) => { 75 | return new FolderItem(config.label, vscode.TreeItemCollapsibleState.None, config, ViewId.folderList); 76 | }); 77 | 78 | if (itemList.length < this.data.list.length) { 79 | itemList.push(new FolderLoadMore()); 80 | } 81 | 82 | return itemList; 83 | } catch (error) { 84 | logger.error(`Failed to get children:${error}`); 85 | return []; 86 | } 87 | } 88 | 89 | getTreeItem(element: RecentFolderItem): vscode.TreeItem { 90 | return element; 91 | } 92 | 93 | getParent(): vscode.ProviderResult { 94 | return void 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/core/treeView/views/setting.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ViewId, Commands } from '@/constants'; 3 | 4 | interface MenuItemConfig { 5 | label: string; 6 | icon: string; 7 | command: string | vscode.Command; 8 | } 9 | 10 | const menuItems: MenuItemConfig[] = [ 11 | { 12 | label: vscode.l10n.t('Add Worktree'), 13 | icon: 'new-folder', 14 | command: Commands.addWorktree, 15 | }, 16 | { 17 | label: vscode.l10n.t('Find Worktree'), 18 | icon: 'search', 19 | command: Commands.searchAllWorktree, 20 | }, 21 | { 22 | label: vscode.l10n.t('Open Settings'), 23 | icon: 'gear', 24 | command: Commands.openSetting, 25 | }, 26 | { 27 | label: vscode.l10n.t('Report Issue'), 28 | icon: 'issues', 29 | command: { 30 | command: 'vscode.open', 31 | title: '', 32 | arguments: [vscode.Uri.parse('https://github.com/jackiotyu/git-worktree-manager/issues')] 33 | }, 34 | } 35 | ]; 36 | 37 | function createTreeItem(config: MenuItemConfig): vscode.TreeItem { 38 | const item = new vscode.TreeItem(config.label); 39 | item.iconPath = new vscode.ThemeIcon(config.icon); 40 | item.command = typeof config.command === 'string' 41 | ? { command: config.command, title: config.label } 42 | : config.command; 43 | 44 | return item; 45 | } 46 | 47 | export class SettingDataProvider implements vscode.TreeDataProvider { 48 | static readonly id = ViewId.settingList; 49 | private readonly items: MenuItemConfig[] = menuItems; 50 | 51 | getTreeItem(element: MenuItemConfig): vscode.TreeItem { 52 | return createTreeItem(element); 53 | } 54 | 55 | getChildren(): MenuItemConfig[] { 56 | return this.items; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/core/treeView/views/worktree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { WorktreeItem, WorkspaceMainGitFolderItem } from '@/core/treeView/items'; 3 | import { TreeItemKind, ViewId } from '@/constants'; 4 | import { treeDataEvent, updateTreeDataEvent, worktreeChangeEvent } from '@/core/event/events'; 5 | import { getWorktreeList } from '@/core/git/getWorktreeList'; 6 | import { WorkspaceState } from '@/core/state'; 7 | import folderRoot from '@/core/folderRoot'; 8 | import throttle from 'lodash-es/throttle'; 9 | import { IWorktreeDetail, IFolderItemConfig } from '@/types'; 10 | import { findPrefixPath } from '@/core/util/folder'; 11 | 12 | export class WorktreeDataProvider 13 | implements vscode.TreeDataProvider, vscode.Disposable 14 | { 15 | private static readonly refreshThrottle = 150; // 150ms 16 | private worktreeRootMap: Map = new Map(); 17 | private mainFolderPath: string = ''; 18 | 19 | private _onDidChangeTreeData = new vscode.EventEmitter(); 20 | public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 21 | 22 | constructor(context: vscode.ExtensionContext) { 23 | this.refresh = throttle(this.refresh, WorktreeDataProvider.refreshThrottle, { 24 | leading: false, 25 | trailing: true, 26 | }); 27 | this.triggerChangeTreeData = throttle(this.triggerChangeTreeData, WorktreeDataProvider.refreshThrottle, { 28 | leading: false, 29 | trailing: true, 30 | }); 31 | this.initializeEventListeners(context); 32 | } 33 | 34 | dispose() { 35 | this._onDidChangeTreeData.dispose(); 36 | } 37 | 38 | private initializeEventListeners(context: vscode.ExtensionContext) { 39 | context.subscriptions.push( 40 | treeDataEvent.event(() => { 41 | this.triggerChangeTreeData(); 42 | }), 43 | worktreeChangeEvent.event((uri) => { 44 | this.handleWorktreeChange(uri); 45 | }), 46 | this, 47 | ); 48 | } 49 | 50 | private handleWorktreeChange = (uri: vscode.Uri) => { 51 | if (this.checkOnlyOneMainFolder()) { 52 | if (uri.fsPath.startsWith(this.mainFolderPath)) { 53 | this.triggerChangeTreeData(); 54 | } 55 | return; 56 | } 57 | const prefixPath = findPrefixPath(uri.fsPath, [...this.worktreeRootMap.keys()]); 58 | const gitFolderItem = prefixPath ? this.worktreeRootMap.get(prefixPath) : undefined; 59 | if (!gitFolderItem) return; 60 | this.update(gitFolderItem); 61 | }; 62 | 63 | update(item: WorkspaceMainGitFolderItem | WorktreeItem | void) { 64 | this._onDidChangeTreeData.fire(item); 65 | } 66 | 67 | triggerChangeTreeData() { 68 | this._onDidChangeTreeData.fire(); 69 | } 70 | 71 | refresh() { 72 | updateTreeDataEvent.fire(); 73 | } 74 | 75 | private async getWorktreeListWithCache(path: string): Promise { 76 | const data = await getWorktreeList(path); 77 | return data; 78 | } 79 | 80 | getTreeItem(element: WorkspaceMainGitFolderItem | WorktreeItem): vscode.TreeItem { 81 | return element; 82 | } 83 | 84 | async getChildren( 85 | element?: WorkspaceMainGitFolderItem, 86 | ): Promise { 87 | if (!element) { 88 | return this.getRootItems(); 89 | } 90 | 91 | if (element.type === TreeItemKind.workspaceGitMainFolder) { 92 | return this.getWorktreeItems(element); 93 | } 94 | } 95 | 96 | private checkOnlyOneMainFolder() { 97 | const workspaceFolderNum = folderRoot.folderPathSet.size; 98 | const mainFolders = WorkspaceState.get('mainFolders', []); 99 | return workspaceFolderNum === 1 || mainFolders.length === 1; 100 | } 101 | 102 | private async getRootItems(): Promise { 103 | const mainFolders = WorkspaceState.get('mainFolders', []); 104 | if (this.checkOnlyOneMainFolder()) { 105 | const mainFolderPath = mainFolders[0]?.path; 106 | const data = await this.getWorktreeListWithCache(mainFolderPath); 107 | const worktreeItems = data.map((item) => { 108 | return new WorktreeItem(item, vscode.TreeItemCollapsibleState.None); 109 | }); 110 | this.mainFolderPath = vscode.Uri.file(mainFolderPath).fsPath; 111 | return worktreeItems; 112 | } 113 | 114 | return mainFolders.map( 115 | (item) => { 116 | const gitFolderItem = new WorkspaceMainGitFolderItem(item.path, vscode.TreeItemCollapsibleState.Expanded); 117 | this.worktreeRootMap.set(vscode.Uri.file(item.path).fsPath, gitFolderItem); 118 | return gitFolderItem; 119 | }, 120 | ); 121 | } 122 | 123 | private async getWorktreeItems(element: WorkspaceMainGitFolderItem): Promise { 124 | const data = await this.getWorktreeListWithCache(element.fsPath); 125 | const worktreeItems = data.map((item) => { 126 | return new WorktreeItem(item, vscode.TreeItemCollapsibleState.None, element); 127 | }); 128 | return worktreeItems; 129 | } 130 | 131 | getParent(element: WorktreeItem): vscode.ProviderResult { 132 | return element.parent as WorkspaceMainGitFolderItem; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/core/ui/inputNewBranch.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { withResolvers } from '@/core/util/promise'; 3 | import { validateBranchInput } from '@/core/util/branch'; 4 | import { debounce } from 'lodash-es'; 5 | 6 | const backButton = vscode.QuickInputButtons.Back; 7 | 8 | export const inputNewBranch = async (cwd: string, defaultValue?: string) => { 9 | const { promise, resolve, reject } = withResolvers(); 10 | const inputBox = vscode.window.createInputBox(); 11 | inputBox.ignoreFocusOut = true; 12 | inputBox.value = defaultValue || vscode.workspace.getConfiguration('git').get('branchPrefix', ''); 13 | inputBox.valueSelection = [-1, -1]; 14 | inputBox.placeholder = vscode.l10n.t('Please enter branch name'); 15 | inputBox.prompt = vscode.l10n.t('Please enter branch name'); 16 | inputBox.buttons = [backButton]; 17 | inputBox.onDidTriggerButton((event) => { 18 | if (event === backButton) { 19 | resolve(undefined); 20 | inputBox.dispose(); 21 | } 22 | }); 23 | inputBox.onDidAccept(async () => { 24 | const errMsg = await validateBranchInput(cwd, inputBox.value); 25 | if (errMsg) { 26 | inputBox.validationMessage = errMsg; 27 | return; 28 | } 29 | resolve(inputBox.value); 30 | inputBox.hide(); 31 | }); 32 | inputBox.onDidHide(() => { 33 | resolve(false); 34 | }); 35 | inputBox.onDidChangeValue(debounce(async (value) => { 36 | const errMsg = await validateBranchInput(cwd, value); 37 | inputBox.validationMessage = errMsg; 38 | }, 300)); 39 | inputBox.show(); 40 | return promise; 41 | }; 42 | -------------------------------------------------------------------------------- /src/core/ui/inputWorktreeDir.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import path from 'path'; 3 | import { checkExist, isDirEmpty } from '@/core/util/file'; 4 | import { comparePath, getBaseWorktreeDir, getSubDir } from '@/core/util/folder'; 5 | import { Alert } from '@/core/ui/message'; 6 | import { withResolvers } from '@/core/util/promise'; 7 | 8 | export const pickWorktreeDir = async (dir: string, targetDirTip: string) => { 9 | let uriList = await vscode.window.showOpenDialog({ 10 | canSelectFiles: false, 11 | canSelectFolders: true, 12 | canSelectMany: false, 13 | defaultUri: vscode.Uri.file(dir), 14 | openLabel: vscode.l10n.t('Select folder'), 15 | title: targetDirTip, 16 | }); 17 | return uriList?.[0]?.fsPath; 18 | }; 19 | 20 | const verifySameDir = (dir: string, baseDir: string) => { 21 | if (comparePath(path.resolve(dir), path.resolve(baseDir))) { 22 | Alert.showErrorMessage(vscode.l10n.t('Please select a different directory')); 23 | return true; 24 | } 25 | return false; 26 | }; 27 | 28 | interface InputWorktreeDirOptions { 29 | baseDir: string; 30 | baseWorktreeDir?: string; 31 | step?: number; 32 | totalSteps?: number; 33 | targetDirTip?: string; 34 | } 35 | export const inputWorktreeDir = async ({ 36 | baseDir, 37 | baseWorktreeDir, 38 | step, 39 | totalSteps, 40 | targetDirTip = vscode.l10n.t('Select the folder where you want to create the worktree'), 41 | }: InputWorktreeDirOptions) => { 42 | let canClose = true; 43 | const { promise, resolve, reject } = withResolvers(); 44 | // Final path 45 | const workTreeDir = getBaseWorktreeDir(baseDir); 46 | const baseName = path.basename(baseDir); 47 | const dirReg = new RegExp(getSubDir(baseName, '(\\d+)')); 48 | let finalWorktreeDir = path.join(workTreeDir, getSubDir(baseName, 1)); 49 | const inputBox = vscode.window.createInputBox(); 50 | // When the passed baseWorktreeDir has a value and is different from workTreeDir, it indicates switching from a previously selected worktree 51 | if (baseWorktreeDir && !comparePath(workTreeDir, baseWorktreeDir)) { 52 | finalWorktreeDir = baseWorktreeDir; 53 | } else if (await checkExist(workTreeDir)) { 54 | let worktreeDirList = (await vscode.workspace.fs.readDirectory(vscode.Uri.file(workTreeDir))) 55 | .filter((item) => item[1] === vscode.FileType.Directory) 56 | .filter((item) => dirReg.test(item[0])) 57 | .map((item) => item[0]); 58 | if (worktreeDirList.length) { 59 | worktreeDirList.sort((a, b) => Number(b.replace(dirReg, '$1')) - Number(a.replace(dirReg, '$1'))); 60 | const index = worktreeDirList[0].match(dirReg)![1]; 61 | finalWorktreeDir = path.join(workTreeDir, getSubDir(baseName, Number(index) + 1)); 62 | } 63 | } 64 | const selectDirBtn: vscode.QuickInputButton = { 65 | iconPath: new vscode.ThemeIcon('new-folder'), 66 | tooltip: targetDirTip, 67 | }; 68 | inputBox.title = vscode.l10n.t('Enter worktree directory'); 69 | inputBox.value = finalWorktreeDir; 70 | inputBox.valueSelection = [workTreeDir.length + 1, finalWorktreeDir.length]; 71 | inputBox.buttons = [selectDirBtn]; 72 | inputBox.step = step; 73 | inputBox.totalSteps = totalSteps; 74 | const handleTriggerButton = async (event: vscode.QuickInputButton) => { 75 | if (event !== selectDirBtn) return; 76 | canClose = false; 77 | inputBox.hide(); 78 | try { 79 | const dir = await pickWorktreeDir(path.dirname(baseDir), targetDirTip); 80 | if (!dir) return inputBox.show(); 81 | inputBox.value = verifySameDir(dir, workTreeDir) ? finalWorktreeDir : dir; 82 | inputBox.show(); 83 | } catch (err) { 84 | if (err instanceof Error) Alert.showErrorMessage(err.message); 85 | inputBox.dispose(); 86 | reject(err); 87 | } finally { 88 | canClose = true; 89 | } 90 | }; 91 | const handleAccept = async () => { 92 | try { 93 | const input = inputBox.value; 94 | if (!input) return; 95 | if (verifySameDir(input, workTreeDir)) return; 96 | if (!(await isDirEmpty(input))) { 97 | return Alert.showErrorMessage(vscode.l10n.t('The selected folder is not empty')); 98 | } 99 | resolve(input); 100 | inputBox.hide(); 101 | inputBox.dispose(); 102 | } catch (error) { 103 | reject(error); 104 | } 105 | }; 106 | const handleHide = () => canClose && inputBox.dispose(); 107 | inputBox.onDidTriggerButton(handleTriggerButton); 108 | inputBox.onDidHide(handleHide); 109 | inputBox.onDidAccept(handleAccept); 110 | inputBox.show(); 111 | return promise; 112 | }; 113 | -------------------------------------------------------------------------------- /src/core/ui/message.ts: -------------------------------------------------------------------------------- 1 | import { window, ExtensionContext, workspace } from 'vscode'; 2 | import { APP_NAME, AlertLevel } from '@/constants'; 3 | import { Config } from '@/core/config/setting'; 4 | 5 | enum LevelNum { 6 | 'info' = 1, 7 | 'warn' = 2, 8 | 'error' = 3, 9 | } 10 | 11 | export class Alert { 12 | static level: AlertLevel = 'error'; 13 | static init(context: ExtensionContext) { 14 | context.subscriptions.push( 15 | workspace.onDidChangeConfiguration((event) => { 16 | if (event.affectsConfiguration(APP_NAME)) { 17 | this.updateLevel(); 18 | } 19 | }), 20 | ); 21 | this.updateLevel(); 22 | } 23 | static updateLevel() { 24 | this.level = Config.get('alertLevel', 'error'); 25 | } 26 | static get levelNum() { 27 | return LevelNum[this.level]; 28 | } 29 | static showErrorMessage: typeof window.showErrorMessage = (message: any, options: any, ...items: any[]) => { 30 | return window.showErrorMessage(message, options, ...items); 31 | }; 32 | static showInformationMessage: typeof window.showInformationMessage = ( 33 | message: any, 34 | options: any, 35 | ...items: any[] 36 | ) => { 37 | if (this.levelNum > LevelNum.info) { 38 | return Promise.resolve(); 39 | } 40 | return window.showInformationMessage(message, options, ...items); 41 | }; 42 | static showWarningMessage: typeof window.showWarningMessage = (message: any, options: any, ...items: any[]) => { 43 | if (this.levelNum > LevelNum.warn) { 44 | return Promise.resolve(); 45 | } 46 | return window.showWarningMessage(message, options, ...items); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/core/ui/modal.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export const confirmModal = async (title: string, confirmText: string, detail?: string) => { 4 | let confirm = await vscode.window.showWarningMessage(title, { modal: true, detail }, confirmText); 5 | return confirm === confirmText; 6 | }; 7 | -------------------------------------------------------------------------------- /src/core/ui/pickGitFolder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { WorkspaceState } from '@/core/state'; 3 | import path from 'path'; 4 | import { withResolvers } from '@/core/util/promise'; 5 | 6 | export const pickGitFolder = async (title: string): Promise => { 7 | const mainFolders = WorkspaceState.get('mainFolders', []).map((i) => i.path); 8 | if (mainFolders.length === 0) return null; 9 | if (mainFolders.length > 1) { 10 | const items: vscode.QuickPickItem[] = [ 11 | ...mainFolders.map((folderPath) => { 12 | return { 13 | label: path.basename(folderPath), 14 | description: folderPath, 15 | iconPath: new vscode.ThemeIcon('repo'), 16 | }; 17 | }), 18 | ]; 19 | const folderPath = await vscode.window.showQuickPick(items, { 20 | title, 21 | canPickMany: false, 22 | }); 23 | return folderPath?.description; 24 | } else { 25 | return mainFolders[0]; 26 | } 27 | }; 28 | 29 | type ResolveValue = readonly { name: string; path: string }[] | void | null; 30 | const showBaseNameQuickInputButton: vscode.QuickInputButton = { 31 | iconPath: new vscode.ThemeIcon('pass'), 32 | tooltip: vscode.l10n.t('Use folder name'), 33 | }; 34 | const showFullNameQuickInputButton: vscode.QuickInputButton = { 35 | iconPath: new vscode.ThemeIcon('pass-filled'), 36 | tooltip: vscode.l10n.t('Use full path'), 37 | }; 38 | 39 | export const pickMultiFolder = async (gitFolders: string[]): Promise => { 40 | const fullNameOptions: vscode.QuickPickItem[] = gitFolders.map((folderPath) => ({ label: folderPath })); 41 | const baseNameOptions: vscode.QuickPickItem[] = gitFolders.map((folderPath) => ({ 42 | label: path.basename(folderPath), 43 | description: folderPath, 44 | })); 45 | const { resolve, reject, promise } = withResolvers(); 46 | try { 47 | const picker = vscode.window.createQuickPick(); 48 | picker.title = vscode.l10n.t('Select folder(s)'); 49 | picker.items = baseNameOptions; 50 | picker.canSelectMany = true; 51 | picker.buttons = [showFullNameQuickInputButton]; 52 | picker.onDidTriggerButton((event) => { 53 | if (event === showFullNameQuickInputButton) { 54 | picker.buttons = [showBaseNameQuickInputButton]; 55 | picker.items = fullNameOptions; 56 | } else { 57 | picker.buttons = [showFullNameQuickInputButton]; 58 | picker.items = baseNameOptions; 59 | } 60 | }); 61 | picker.onDidHide(() => { 62 | resolve(); 63 | picker.dispose(); 64 | }); 65 | picker.onDidAccept(() => { 66 | resolve(picker.selectedItems.map((item) => ({ name: item.label, path: item.description || item.label }))); 67 | picker.dispose(); 68 | }); 69 | picker.show(); 70 | return promise; 71 | } catch (error) { 72 | return void 0; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/core/ui/progress.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Alert } from '@/core/ui/message'; 3 | 4 | export const actionProgressWrapper = (title: string, action: () => Promise, callback: () => any, cancelToken?: vscode.CancellationTokenSource) => { 5 | vscode.window.withProgress( 6 | { 7 | location: vscode.ProgressLocation.Notification, 8 | cancellable: !!cancelToken, 9 | title, 10 | }, 11 | async (progress, token) => { 12 | cancelToken && token.onCancellationRequested(cancelToken.cancel.bind(cancelToken)); 13 | try { 14 | await action(); 15 | } catch (error: any) { 16 | Alert.showErrorMessage(error.message); 17 | } finally { 18 | callback(); 19 | cancelToken?.dispose(); 20 | progress.report({ increment: 100 }); 21 | } 22 | }, 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/core/ui/pullOrPushAction.ts: -------------------------------------------------------------------------------- 1 | import { pullBranch } from '@/core/git/pullBranch'; 2 | import { pushBranch } from '@/core/git/pushBranch'; 3 | import { PullPushArgs } from '@/types'; 4 | 5 | export const pullOrPushAction = async (action: 'pull' | 'push', options: PullPushArgs) => { 6 | return action === 'pull' ? pullBranch(options) : pushBranch(options); 7 | }; -------------------------------------------------------------------------------- /src/core/util/branch.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { checkBranchNameValid } from '@/core/git/checkBranchNameValid'; 3 | 4 | export const validateBranchInput = async (cwd: string, value: string) => { 5 | try { 6 | if (!value) return vscode.l10n.t('Branch name cannot be empty'); 7 | const isValidBranchName = await checkBranchNameValid(cwd, value); 8 | if (!isValidBranchName) return vscode.l10n.t('Branch name is invalid'); 9 | return ''; 10 | } catch (error) { 11 | return String(error); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/core/util/cache.ts: -------------------------------------------------------------------------------- 1 | import { getWorktreeList } from '@/core/git/getWorktreeList'; 2 | import { getRecentItems, getWorkspaceMainFolders, isRecentWorkspace, isRecentFolder } from '@/core/util/workspace'; 3 | import { comparePath, toSimplePath } from '@/core/util/folder'; 4 | import { WorkspaceState, GlobalState } from '@/core/state'; 5 | import { groupBy } from 'lodash-es'; 6 | import type { IFolderItemConfig, IWorktreeCacheItem, IRecentItemCache, IRecentItem } from '@/types'; 7 | import { RecentItemType } from '@/constants'; 8 | import path from 'path'; 9 | 10 | export const gitFolderToCache = async (item: IFolderItemConfig): Promise => { 11 | const list = await getWorktreeList(item.path); 12 | return list.map((row) => { 13 | return { ...row, label: item.name }; 14 | }); 15 | }; 16 | 17 | export const updateWorkspaceMainFolders = async () => { 18 | const folders = await getWorkspaceMainFolders(); 19 | WorkspaceState.update('mainFolders', folders); 20 | }; 21 | 22 | const getUpdatedWorktreeCache = async ( 23 | repoPath: string, 24 | configList: IFolderItemConfig[], 25 | preWorkTreeCache: IWorktreeCacheItem[], 26 | ) => { 27 | const nextWorkTreeCache: IWorktreeCacheItem[] = []; 28 | const preCacheGroup = groupBy( 29 | preWorkTreeCache 30 | .filter((i) => i.mainFolder) 31 | .map((item) => ({ ...item, mainFolder: toSimplePath(item.mainFolder) })), 32 | 'mainFolder', 33 | ); 34 | for (const item of configList) { 35 | const current = toSimplePath(item.path); 36 | if (comparePath(repoPath, current)) { 37 | nextWorkTreeCache.push(...(await gitFolderToCache(item))); // 更新指定仓库 38 | continue; 39 | } 40 | const caches = preCacheGroup[current]; 41 | if (caches && caches.length > 0) { 42 | nextWorkTreeCache.push(...caches); // 使用旧缓存 43 | } else { 44 | nextWorkTreeCache.push(...(await gitFolderToCache(item))); // 没有旧缓存,直接更新 45 | } 46 | } 47 | return nextWorkTreeCache; 48 | }; 49 | 50 | export const updateWorktreeCache = async (repoPath: string | void) => { 51 | const gitFolders = GlobalState.get('gitFolders', []); 52 | if (repoPath) { 53 | const nextCache = await getUpdatedWorktreeCache(repoPath, gitFolders, GlobalState.get('workTreeCache', [])); 54 | GlobalState.update('workTreeCache', nextCache); 55 | return; 56 | } 57 | 58 | const mainFolderSet = new Set(WorkspaceState.get('mainFolders', []).map((i) => toSimplePath(i.path))); 59 | // TODO 优先更新工作区内的仓库 60 | const sortedFolders = [...gitFolders].sort((a, b) => { 61 | if (mainFolderSet.has(toSimplePath(a.path))) return -1; 62 | return 0; 63 | }); 64 | for (const item of sortedFolders) { 65 | const nextCache = await getUpdatedWorktreeCache(item.path, gitFolders, GlobalState.get('workTreeCache', [])); 66 | GlobalState.update('workTreeCache', nextCache); 67 | } 68 | }; 69 | 70 | export const updateWorkspaceListCache = async (repoPath: string | void) => { 71 | if (WorkspaceState.get('mainFolders', []).length === 0) { 72 | await updateWorkspaceMainFolders(); 73 | } 74 | const mainFolders = WorkspaceState.get('mainFolders', []); 75 | if (repoPath) { 76 | const nextCache = await getUpdatedWorktreeCache(repoPath, mainFolders, WorkspaceState.get('workTreeCache', [])); 77 | WorkspaceState.update('workTreeCache', nextCache); 78 | return; 79 | } 80 | 81 | for (const item of mainFolders) { 82 | const nextCache = await getUpdatedWorktreeCache( 83 | item.path, 84 | mainFolders, 85 | WorkspaceState.get('workTreeCache', []), 86 | ); 87 | WorkspaceState.update('workTreeCache', nextCache); 88 | } 89 | }; 90 | 91 | export const updateRecentItems = async () => { 92 | const list = await getRecentItems(); 93 | GlobalState.update('global.recentItemCache', { 94 | time: +new Date(), 95 | list: list 96 | .filter((item) => isRecentWorkspace(item) || isRecentFolder(item)) 97 | .map((item) => { 98 | if (isRecentFolder(item)) { 99 | return { 100 | path: item.folderUri.toString(), 101 | remoteAuthority: item.remoteAuthority, 102 | type: RecentItemType.folder, 103 | label: item.label || path.basename(item.folderUri.fsPath), 104 | }; 105 | } else { 106 | return { 107 | path: item.workspace.configPath.toString(), 108 | remoteAuthority: item.remoteAuthority, 109 | type: RecentItemType.workspace, 110 | label: item.label || path.basename(item.workspace.configPath.path), 111 | }; 112 | } 113 | }), 114 | }); 115 | }; 116 | 117 | export const getRecentItemCache = (): IRecentItemCache => { 118 | const res = GlobalState.get('global.recentItemCache', { time: -1, list: [] }); 119 | return res; 120 | }; 121 | 122 | export const getFavoriteCache = (): IRecentItem[] => { 123 | return GlobalState.get('global.favorite', []); 124 | }; 125 | 126 | export const updateFavoriteCache = (value: IRecentItem[]) => { 127 | return GlobalState.update('global.favorite', value); 128 | }; 129 | 130 | export const checkRecentFolderCache = () => { 131 | const res = GlobalState.get('global.recentItemCache', { time: -1, list: [] }); 132 | if (+new Date() - res.time > 5000) updateRecentItems(); 133 | }; 134 | -------------------------------------------------------------------------------- /src/core/util/copyWorktreeFiles.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import path from 'path'; 3 | import fs from 'fs/promises'; 4 | import { pipeline } from 'stream/promises'; 5 | import { createReadStream, createWriteStream } from 'fs'; 6 | import { Config } from '@/core/config/setting'; 7 | import { actionProgressWrapper } from '@/core/ui/progress'; 8 | import { withResolvers } from '@/core/util/promise'; 9 | 10 | async function copyFile(source: string, target: string, signal: AbortSignal) { 11 | const targetDir = path.dirname(target); 12 | await fs.mkdir(targetDir, { recursive: true }); 13 | await pipeline( 14 | createReadStream(source), 15 | createWriteStream(target), 16 | { signal } 17 | ); 18 | } 19 | 20 | async function findMatchingFiles(sourceRepo: string, token: vscode.CancellationToken) { 21 | const patterns = Config.get('worktreeCopyPatterns', []).filter(Boolean); 22 | const ignorePatterns = Config.get('worktreeCopyIgnores', []).filter(Boolean); 23 | 24 | if (patterns.length === 0) { 25 | return []; 26 | } 27 | 28 | return vscode.workspace.findFiles( 29 | new vscode.RelativePattern(sourceRepo, `{${patterns.join(',')}}`), 30 | ignorePatterns.length > 0 ? `{${ignorePatterns.join(',')}}` : null, 31 | void 0, 32 | token 33 | ); 34 | } 35 | 36 | export async function copyWorktreeFiles(sourceRepo: string, targetWorktree: string) { 37 | const waitingCopy = withResolvers(); 38 | const tokenSource = new vscode.CancellationTokenSource(); 39 | const abortController = new AbortController(); 40 | let disposeAbortSignal: vscode.Disposable | undefined; 41 | 42 | try { 43 | // Setup cancellation handling 44 | disposeAbortSignal = tokenSource.token.onCancellationRequested(() => { 45 | abortController.abort(); 46 | }); 47 | 48 | // Find and copy files 49 | const files = await findMatchingFiles(sourceRepo, tokenSource.token); 50 | if(files.length === 0) return; 51 | 52 | // Start progress indication 53 | actionProgressWrapper( 54 | vscode.l10n.t('Copying files to worktree {path}', { path: targetWorktree }), 55 | () => waitingCopy.promise, 56 | () => {}, 57 | tokenSource 58 | ); 59 | 60 | for (const file of files) { 61 | if (tokenSource.token.isCancellationRequested) break; 62 | 63 | const relativePath = path.relative(sourceRepo, file.fsPath); 64 | const targetPath = path.join(targetWorktree, relativePath); 65 | 66 | await copyFile(file.fsPath, targetPath, abortController.signal); 67 | } 68 | } catch (error: any) { 69 | if (error.name === 'AbortError') { 70 | // Ignore abort errors 71 | return; 72 | } 73 | vscode.window.showErrorMessage( 74 | vscode.l10n.t('Failed to copy files: {error}', { error: error.message || error }) 75 | ); 76 | } finally { 77 | disposeAbortSignal?.dispose(); 78 | tokenSource.dispose(); 79 | waitingCopy.resolve(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/core/util/external.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { open } from '@/core/util/open'; 3 | 4 | export const openExternalTerminal = (path: string) => { 5 | return vscode.commands.executeCommand('openInTerminal', vscode.Uri.file(path)); 6 | }; 7 | 8 | export const revealFolderInOS = (folder: string) => { 9 | // vscode.env.openExternal(vscode.Uri.file(folder)); // open 'vscode' folder error 10 | return open(folder); 11 | }; -------------------------------------------------------------------------------- /src/core/util/file.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import fs from 'fs/promises'; 3 | import { Alert } from '@/core/ui/message'; 4 | 5 | export const checkExist = (path: string) => { 6 | return fs 7 | .stat(path) 8 | .then(() => true) 9 | .catch(() => false); 10 | }; 11 | 12 | export const verifyFileExistence = async (fsPath: string): Promise => { 13 | let exist = await checkExist(fsPath); 14 | if (!exist) { 15 | Alert.showErrorMessage(vscode.l10n.t('The file does not exist'), { modal: true }); 16 | return false; 17 | } 18 | return true; 19 | }; 20 | 21 | export const verifyDirExistence = async (fsPath: string): Promise => { 22 | let exist = await checkExist(fsPath); 23 | if (!exist) { 24 | Alert.showErrorMessage(vscode.l10n.t('The folder does not exist'), { modal: true }); 25 | return false; 26 | } 27 | let isFolder = await checkIsFolder(fsPath); 28 | if (!isFolder) { 29 | Alert.showErrorMessage(vscode.l10n.t('The path is not a folder'), { modal: true }); 30 | return false; 31 | } 32 | return true; 33 | }; 34 | 35 | export const checkIsFolder = (path: string): Promise => { 36 | return fs.stat(path).then(stat => stat.isDirectory()).catch(() => false); 37 | }; 38 | 39 | export function isDirEmpty(path: string): Promise { 40 | return fs 41 | .readdir(path) 42 | .then((res) => res.length === 0) 43 | .catch(() => true); 44 | } 45 | -------------------------------------------------------------------------------- /src/core/util/folder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import folderRoot from '@/core/folderRoot'; 3 | import path from 'path'; 4 | import { RecentItemType } from '@/constants'; 5 | import { Config } from '@/core/config/setting'; 6 | 7 | export function judgeIsCurrentFolder(path: string) { 8 | return comparePath(folderRoot.uri?.fsPath, path); 9 | } 10 | 11 | export function toSimplePath(path: string) { 12 | return path.toLocaleLowerCase().replace(/\\/g, '/'); 13 | } 14 | 15 | export function judgeIncludeFolder(path: string) { 16 | const normalizePath = toSimplePath(path); 17 | return folderRoot.folderPathSet.has(normalizePath); 18 | } 19 | 20 | export function comparePath(path1: string = '', path2: string = '') { 21 | return toSimplePath(path1) === toSimplePath(path2); 22 | } 23 | 24 | export function getFolderIcon(path: string, color?: vscode.ThemeColor) { 25 | return comparePath(folderRoot.uri?.fsPath, path) 26 | ? new vscode.ThemeIcon('check', color) 27 | : new vscode.ThemeIcon('window', color); 28 | } 29 | 30 | export function getRecentItemIcon(type: RecentItemType): vscode.ThemeIcon { 31 | if (type === RecentItemType.folder) return vscode.ThemeIcon.Folder; 32 | else if (type === RecentItemType.file) return vscode.ThemeIcon.File; 33 | else if (type === RecentItemType.workspace) return new vscode.ThemeIcon('layers'); 34 | return new vscode.ThemeIcon('info'); 35 | } 36 | 37 | export function getGitFolderByUri(uri: vscode.Uri) { 38 | const repoPath = path.dirname(`${uri.fsPath.split('.git')[0]}.git`); 39 | return repoPath; 40 | } 41 | 42 | // get worktree base dir 43 | export const getBaseWorktreeDir = (baseDir: string) => { 44 | const worktreePathTemplate = Config.get('worktreePathTemplate', "$BASE_PATH.worktree"); 45 | return worktreePathTemplate.replace('$BASE_PATH', baseDir); 46 | }; 47 | 48 | // Validate template for invalid path characters 49 | export const validateSubdirectoryTemplate = (template: string): boolean => { 50 | // Check for invalid path characters 51 | const invalidChars = /[/\\:*?"<>|]/; 52 | return !invalidChars.test(template); 53 | }; 54 | 55 | // get worktree subdirectory name with baseName and index 56 | export const getSubDir = (baseName: string, index: string | number) => { 57 | const template = Config.get('worktreeSubdirectoryTemplate', 'worktree$INDEX'); 58 | 59 | // Validate template 60 | if (!validateSubdirectoryTemplate(template)) { 61 | console.warn('Invalid worktree subdirectory template, using default'); 62 | return `worktree${String(index)}`; 63 | } 64 | 65 | return template 66 | .replace('$BASE_NAME', baseName) 67 | .replace('$INDEX', String(index)); 68 | }; 69 | 70 | export const getBaseBundleDir = (baseDir: string) => `${baseDir}.repoBackup`; 71 | 72 | // find prefix path in list 73 | export const findPrefixPath = (fsPath: string, strList: string[]) => { 74 | return strList.find((str) => fsPath.startsWith(str)); 75 | }; 76 | -------------------------------------------------------------------------------- /src/core/util/open.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import * as path from 'path'; 3 | 4 | type OpenCommandFunction = (target: string) => string; 5 | 6 | let openCommand: OpenCommandFunction; 7 | 8 | if (process.platform === 'win32') { 9 | openCommand = (target: string) => `start "" "${path.resolve(target)}"`; 10 | } else if (process.platform === 'darwin') { 11 | openCommand = (target: string) => `open "${path.resolve(target)}"`; 12 | } else { 13 | openCommand = (target: string) => `xdg-open "${path.resolve(target)}"`; 14 | } 15 | 16 | export async function open(target: string): Promise { 17 | const cmd = openCommand(target); 18 | 19 | return new Promise((resolve, reject) => { 20 | exec(cmd, (error) => { 21 | if (error) { 22 | reject(error); 23 | return; 24 | } 25 | resolve(); 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/core/util/parse.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | // 加载dayjs中文语言包 3 | import 'dayjs/locale/zh-cn'; 4 | import 'dayjs/locale/ja'; 5 | import 'dayjs/locale/zh-tw'; 6 | import relativeTime from 'dayjs/plugin/relativeTime'; 7 | import dayjs from 'dayjs'; 8 | 9 | dayjs.extend(relativeTime); 10 | dayjs.locale(vscode.env.language); // 全局使用 11 | 12 | export function formatQuery(keyList: T[]) { 13 | return [...new Set(keyList)].map((key) => `${key}="%(${key})"`).join(' '); 14 | } 15 | 16 | export function formatSimpleQuery(keyList: T[]) { 17 | return [...new Set(keyList)].map((key) => `${key}="%${key}"`).join(' '); 18 | } 19 | 20 | // 转义正则表达式特殊字符 21 | const escapedReg = /[.*+?^${}()|[\]\\]/g; 22 | export function parseOutput(output: string, keyList: T[]): Record[] { 23 | let tokenList = [...new Set(keyList)]; 24 | let regex = tokenList.map((key) => `${key.replace(escapedReg, '\\$&')}="(.*)"`).join(' '); 25 | let workTrees = []; 26 | let matches = output.matchAll(new RegExp(regex, 'g')); 27 | for (const match of matches) { 28 | let item = tokenList.reduce>((obj, key, index) => { 29 | obj[key] = match[index + 1]; 30 | return obj; 31 | }, {}); 32 | workTrees.push(item); 33 | } 34 | return workTrees; 35 | } 36 | 37 | export function formatTime(time: string) { 38 | return dayjs(time).fromNow(); 39 | } 40 | 41 | export function parseObjStr(str: string) { 42 | try { 43 | return JSON.parse(str); 44 | } catch { 45 | return {}; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/util/postCreateWorktree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Config } from '@/core/config/setting'; 3 | import { exec } from 'child_process'; 4 | import { promisify } from 'util'; 5 | import { withResolvers } from '@/core/util/promise'; 6 | import { actionProgressWrapper } from '@/core/ui/progress'; 7 | import logger from '@/core/log/logger'; 8 | 9 | interface IPostCreateWorktreeInfo { 10 | worktreePath: string; 11 | basePath: string; 12 | } 13 | export async function postCreateWorktree(info: IPostCreateWorktreeInfo) { 14 | const { worktreePath, basePath } = info; 15 | const postCreateCmd = Config.get('postCreateCmd', ''); 16 | 17 | if (!postCreateCmd) return; 18 | 19 | const waiting = withResolvers(); 20 | const abortController = new AbortController(); 21 | const tokenSource = new vscode.CancellationTokenSource(); 22 | // Setup cancellation handling 23 | const disposeAbortSignal = tokenSource.token.onCancellationRequested(() => { 24 | waiting.resolve(); 25 | abortController.abort(); 26 | }); 27 | 28 | try { 29 | const cmdStr = postCreateCmd.replace('$BASE_PATH', basePath).replace('$WORKTREE_PATH', worktreePath); 30 | 31 | actionProgressWrapper( 32 | vscode.l10n.t('Running post-create command...'), 33 | () => waiting.promise, 34 | () => {}, 35 | tokenSource, 36 | ); 37 | 38 | const execPromise = promisify(exec); 39 | 40 | const execChild = execPromise(cmdStr, { 41 | cwd: worktreePath, // 默认工作目录是 worktree 目录 42 | env: process.env, 43 | signal: abortController.signal, 44 | encoding: 'buffer', 45 | }); 46 | execChild.child.stdout?.on('data', (data) => { 47 | logger.log(`[postCreateWorktree] ${data.toString()}`); 48 | }); 49 | execChild.child.stderr?.on('data', (data) => { 50 | logger.error(`[postCreateWorktree] ${data.toString()}`); 51 | }); 52 | 53 | await execChild; 54 | waiting.resolve(); 55 | logger.log(`[postCreateWorktree] done`); 56 | } catch (error: any) { 57 | if (error.name === 'AbortError') { 58 | // Ignore abort errors 59 | return; 60 | } 61 | logger.error(`[postCreateWorktree] ${error}`); 62 | } finally { 63 | disposeAbortSignal.dispose(); 64 | tokenSource.dispose(); 65 | waiting.resolve(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/core/util/promise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * shim for Promise.withResolvers 3 | * @returns {Promise} A promise that can be resolved or rejected from outside 4 | */ 5 | export function withResolvers() { 6 | let resolve: (value: T) => void = () => {}; 7 | let reject: (value?: any) => void = () => {}; 8 | let waiting = new Promise((_resolve, _reject) => { 9 | resolve = _resolve; 10 | reject = _reject; 11 | }); 12 | return { resolve, reject, promise: waiting }; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/util/ref.ts: -------------------------------------------------------------------------------- 1 | export const parseUpstream = (upstream: string) => { 2 | const [remote, ...rest] = upstream.split('/'); 3 | const branch = rest.join('/'); 4 | return { 5 | remote, 6 | branch, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/util/state.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { GlobalState } from '@/core/state'; 3 | import { Config } from '@/core/config/setting'; 4 | import { IFolderItemConfig } from '@/types'; 5 | 6 | export function getFolderConfig() { 7 | return GlobalState.get('gitFolders', []); 8 | } 9 | 10 | export function getTerminalLocationConfig() { 11 | return Config.get('terminalLocationInEditor', false) 12 | ? vscode.TerminalLocation.Editor 13 | : vscode.TerminalLocation.Panel; 14 | } 15 | 16 | export function getTerminalCmdListConfig() { 17 | return Config.get('terminalCmdList', []); 18 | } 19 | 20 | export function getTerminalNameTemplateConfig() { 21 | return Config.get('terminalNameTemplate', '$LABEL ⇄ $FULL_PATH'); 22 | } 23 | 24 | export function updateFolderConfig(value: IFolderItemConfig[]) { 25 | return GlobalState.update('gitFolders', value); 26 | } -------------------------------------------------------------------------------- /src/core/util/tree.ts: -------------------------------------------------------------------------------- 1 | import { AllViewItem } from '@/core/treeView/items'; 2 | import { revealTreeItemEvent } from '@/core/event/events'; 3 | 4 | export const revealTreeItem = (item: AllViewItem) => { 5 | revealTreeItemEvent.fire(item); 6 | return new Promise((r) => process.nextTick(r)); 7 | }; -------------------------------------------------------------------------------- /src/core/util/workspace.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import folderRoot from '@/core/folderRoot'; 3 | import { treeDataEvent } from '@/core/event/events'; 4 | import { comparePath } from '@/core/util/folder'; 5 | import { getMainFolder } from '@/core/git/getMainFolder'; 6 | import type { IRecentlyOpened, IFolderItemConfig, IRecentFolder, IRecentWorkspace } from '@/types'; 7 | import { ContextKey } from '@/constants'; 8 | import { WorkspaceState } from '@/core/state'; 9 | import { getFolderConfig } from '@/core/util/state'; 10 | import { Config } from '@/core/config/setting'; 11 | import { toSimplePath } from '@/core/util/folder'; 12 | import { updateWorkspaceMainFolders, updateWorkspaceListCache, updateWorktreeCache } from '@/core/util/cache'; 13 | import path from 'path'; 14 | import { debounce } from 'lodash-es'; 15 | import logger from '@/core/log/logger'; 16 | 17 | export const formatWorkspacePath = (folder: string): string => { 18 | const baseName = path.basename(folder); 19 | const fullPath = folder; 20 | const templateStr = Config.get('workspacePathFormat', '$BASE_NAME - $FULL_PATH'); 21 | return templateStr.replace(/\$FULL_PATH/g, fullPath).replace(/\$BASE_NAME/g, baseName); 22 | }; 23 | 24 | export const addToWorkspace = (folder: string) => { 25 | return vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders?.length || 0, 0, { 26 | uri: vscode.Uri.file(folder), 27 | name: formatWorkspacePath(folder), 28 | }); 29 | }; 30 | 31 | export const removeFromWorkspace = (path: string) => { 32 | if (!vscode.workspace.workspaceFolders) return; 33 | let index = vscode.workspace.workspaceFolders.findIndex((item) => comparePath(item.uri.fsPath, path)); 34 | if (index >= 0) vscode.workspace.updateWorkspaceFolders(index, 1); 35 | }; 36 | 37 | export const isRecentFolder = (item: IRecentFolder | IRecentWorkspace): item is IRecentFolder => { 38 | const value = item as IRecentFolder; 39 | return value && value.folderUri && value.folderUri.scheme === 'file'; 40 | }; 41 | 42 | export const isRecentWorkspace = (item: IRecentFolder | IRecentWorkspace): item is IRecentWorkspace => { 43 | const value = item as IRecentWorkspace; 44 | return value && value.workspace && !!value.workspace.configPath; 45 | }; 46 | 47 | export const getRecentItems = async (): Promise> => { 48 | let data = (await vscode.commands.executeCommand('_workbench.getRecentlyOpened')) as IRecentlyOpened; 49 | return data.workspaces; 50 | }; 51 | 52 | export const getWorkspaceMainFolders = async (): Promise => { 53 | let list: string[] = []; 54 | for (const folder of folderRoot.folderPathSet) { 55 | const mainFolder = await getMainFolder(folder); 56 | list.push(mainFolder); 57 | } 58 | const folders = [...new Set(list.filter((i) => i))].map((folder) => ({ 59 | name: path.basename(folder), 60 | path: folder, 61 | })); 62 | return folders; 63 | }; 64 | 65 | export const updateAddDirsContext = () => { 66 | let canAdd = false; 67 | try { 68 | const dirs = WorkspaceState.get('mainFolders', []).map((i) => i.path); 69 | const distinctFolders = [...new Set(dirs.filter((i) => i))]; 70 | if (!dirs.length) return; 71 | const existFolders = getFolderConfig(); 72 | const existFoldersMap = new Map(existFolders.map((i) => [toSimplePath(i.path), true])); 73 | const gitFolders = distinctFolders.filter((i) => i && !existFoldersMap.has(toSimplePath(i))) as string[]; 74 | if (gitFolders.length) canAdd = true; 75 | } catch (error) { 76 | logger.error(String(error)); 77 | } finally { 78 | vscode.commands.executeCommand('setContext', ContextKey.addRootsToRepo, canAdd); 79 | } 80 | }; 81 | 82 | export const checkRoots = debounce( 83 | async () => { 84 | await new Promise((resolve) => process.nextTick(resolve)); 85 | await updateWorkspaceMainFolders(); 86 | await Promise.all([ 87 | Promise.resolve(updateAddDirsContext()).finally(() => { 88 | treeDataEvent.fire(); 89 | }), 90 | updateWorkspaceListCache(), 91 | updateWorktreeCache(), 92 | ]); 93 | }, 94 | 300, 95 | { leading: true }, 96 | ); 97 | -------------------------------------------------------------------------------- /src/core/util/worktree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { AheadBehindInfo } from '@/types'; 3 | import { WORK_TREE_SCHEME } from '@/constants'; 4 | 5 | export const getWorktreeStatus = (item: AheadBehindInfo) => { 6 | if (item.ahead && item.behind) return 'diverged'; 7 | if (item.ahead) return 'ahead'; 8 | if (item.behind) return 'behind'; 9 | return 'upToDate'; 10 | }; 11 | 12 | /** 13 | * Fork from https://github.com/gitkraken/vscode-gitlens/blob/main/src/views/viewDecorationProvider.ts#L149 14 | */ 15 | export class WorktreeDecorator implements vscode.FileDecorationProvider { 16 | provideFileDecoration(uri: vscode.Uri, token: vscode.CancellationToken) { 17 | if (uri.scheme !== WORK_TREE_SCHEME) return undefined; 18 | const [, , status] = uri.path.split('/'); 19 | 20 | switch (status) { 21 | case 'ahead': 22 | return { 23 | badge: '▲', 24 | color: new vscode.ThemeColor('charts.green'), 25 | tooltip: 'Ahead', 26 | }; 27 | case 'behind': 28 | return { 29 | badge: '▼', 30 | color: new vscode.ThemeColor('charts.orange'), 31 | tooltip: 'Behind', 32 | }; 33 | case 'diverged': 34 | return { 35 | badge: '▼▲', 36 | color: new vscode.ThemeColor('charts.yellow'), 37 | tooltip: 'Diverged', 38 | }; 39 | default: 40 | return undefined; 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import logger from '@/core/log/logger'; 3 | import { Commands } from '@/constants'; 4 | import { bootstrap } from '@/core/bootstrap'; 5 | 6 | export function activate(context: vscode.ExtensionContext) { 7 | logger.log('git-worktree-manager is now active!'); 8 | bootstrap(context); 9 | } 10 | 11 | export function deactivate() { 12 | vscode.commands.executeCommand(Commands.unwatchWorktreeEvent); 13 | } 14 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Uri as URI, TreeItem } from 'vscode'; 2 | import { Commands, ViewId, refArgList, RecentItemType } from '@/constants'; 3 | import * as vscode from 'vscode'; 4 | 5 | /* eslint-disable @typescript-eslint/naming-convention */ 6 | export interface IWorktreeDetail { 7 | name: string; 8 | path: string; 9 | hash: string; 10 | detached: boolean; 11 | prunable: boolean; 12 | isBare: boolean; 13 | isBranch: boolean; 14 | isTag: boolean; 15 | locked: boolean; 16 | isMain: boolean; 17 | folderName?: string; 18 | mainFolder: string; 19 | } 20 | 21 | export interface AheadBehindInfo { 22 | ahead?: number; 23 | behind?: number; 24 | } 25 | 26 | export interface IWorktreeOutputItem { 27 | worktree: string; 28 | HEAD: string; 29 | detached: void; 30 | prunable: string; 31 | branch?: string; 32 | bare?: string; 33 | locked?: string; 34 | } 35 | 36 | export interface IWorktreeCacheItem { 37 | label: string; 38 | path: string; 39 | name: string; 40 | isMain: boolean; 41 | isBare: boolean; 42 | mainFolder: string; 43 | } 44 | 45 | export interface IRecentFolder { 46 | readonly folderUri: URI; 47 | label?: string; 48 | readonly remoteAuthority?: string; 49 | } 50 | 51 | export interface IBaseWorkspaceIdentifier { 52 | /** 53 | * Every workspace (multi-root, single folder or empty) 54 | * has a unique identifier. It is not possible to open 55 | * a workspace with the same `id` in multiple windows 56 | */ 57 | readonly id: string; 58 | } 59 | 60 | /** 61 | * A multi-root workspace identifier is a path to a workspace file + id. 62 | */ 63 | export interface IWorkspaceIdentifier extends IBaseWorkspaceIdentifier { 64 | /** 65 | * Workspace config file path as `URI`. 66 | */ 67 | configPath: URI; 68 | } 69 | 70 | export interface IRecentWorkspace { 71 | readonly workspace: IWorkspaceIdentifier; 72 | label?: string; 73 | readonly remoteAuthority?: string; 74 | } 75 | 76 | export interface IRecentItem { 77 | label: string; 78 | path: string; 79 | remoteAuthority?: string; 80 | type: RecentItemType; 81 | } 82 | 83 | export interface IRecentItemCache { 84 | time: number; 85 | list: IRecentItem[]; 86 | } 87 | 88 | export type IFavoriteCache = IRecentItem[]; 89 | 90 | export interface IRecentlyOpened { 91 | workspaces: Array; 92 | } 93 | 94 | export interface ILoadMoreItem extends TreeItem { 95 | viewId: ViewId; 96 | } 97 | 98 | export interface IFolderItemConfig { 99 | name: string; 100 | path: string; 101 | // 默认展开 102 | defaultOpen?: boolean; 103 | // TODO 添加标签 104 | tags?: []; 105 | } 106 | 107 | export interface IRecentFolderConfig extends Pick { 108 | uri: URI; 109 | } 110 | 111 | export type RefItem = Record<(typeof refArgList)[number], string>; 112 | export type RefList = RefItem[]; 113 | export type RepoRefList = { 114 | branchList: RefList; 115 | remoteBranchList: RefList; 116 | tagList: RefList; 117 | }; 118 | 119 | export interface IWorktreeLess { 120 | name: string; 121 | fsPath: string; 122 | uriPath: string; 123 | item?: IRecentItem; 124 | } 125 | 126 | export enum DefaultDisplayList { 127 | favorites = 'favorites', 128 | recentlyOpened = 'recentlyOpened', 129 | workspace = 'workspace', 130 | all = 'all', 131 | } 132 | 133 | export enum GitHistoryExtension { 134 | gitHistory = 'donjayamanne.githistory', 135 | gitGraph = 'mhutchie.git-graph', 136 | builtinGit = 'vscode.git', 137 | } 138 | 139 | export interface QuickPickAction extends vscode.QuickPickItem { 140 | action: 141 | | 'copy' 142 | | Commands.openTerminal 143 | | Commands.openExternalTerminalContext 144 | | Commands.revealInSystemExplorerContext 145 | | Commands.addToWorkspace 146 | | Commands.removeFromWorkspace 147 | | Commands.viewHistory 148 | | Commands.openRepository 149 | | Commands.removeWorktree; 150 | hide?: boolean; 151 | } 152 | 153 | export type PullPushArgs = { 154 | remote: string; 155 | branch: string; 156 | remoteRef: string; 157 | cwd: string; 158 | }; 159 | 160 | export type FetchArgs = { 161 | remote: string; 162 | remoteRef: string; 163 | cwd: string; 164 | }; 165 | export type IBranchForWorktree = { branch?: string; hash?: string; mainFolder?: string }; 166 | export type BranchForWorktree = vscode.QuickPickItem & IBranchForWorktree; 167 | export type IPickBranchResolveValue = IBranchForWorktree | void | false; 168 | export type IPickBranchParams = { 169 | title: string; 170 | placeholder: string; 171 | mainFolder: string; 172 | cwd: string; 173 | step?: number; 174 | totalSteps?: number; 175 | showCreate: boolean; 176 | }; 177 | export type IPickBranch = (params: IPickBranchParams) => Promise; 178 | 179 | export interface ICreateWorktreeInfo { 180 | folderPath: string; 181 | name: string; 182 | label: string; 183 | isBranch: boolean; 184 | cwd: string; 185 | } 186 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true, 4 | "module": "commonjs", 5 | "target": "ES2020", 6 | "lib": [ 7 | "ES2020" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "paths": { 12 | "@/*": ["./src/*"], 13 | }, 14 | "esModuleInterop": true, 15 | "strict": true /* enable all strict type-checking options */ 16 | /* Additional Checks */ 17 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 18 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 19 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 20 | } 21 | } 22 | --------------------------------------------------------------------------------