├── .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 | [](https://marketplace.visualstudio.com/items?itemName=jackiotyu.git-worktree-manager)
5 | [](https://github.com/jackiotyu/git-worktree-manager/releases)
6 | [](https://github.com/jackiotyu/git-worktree-manager/issues)
7 | [](https://github.com/jackiotyu/git-worktree-manager/blob/main/LICENSE)
8 | [](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 |
22 |
23 | PayPal
24 |
25 |
26 |
27 | 微信
28 |
29 |
30 |
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 | [](https://marketplace.visualstudio.com/items?itemName=jackiotyu.git-worktree-manager)
6 | [](https://github.com/jackiotyu/git-worktree-manager/releases)
7 | [](https://github.com/jackiotyu/git-worktree-manager/issues)
8 | [](https://github.com/jackiotyu/git-worktree-manager/blob/main/LICENSE)
9 | [](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 |
24 |
25 | PayPal
26 |
27 |
28 |
29 | 微信
30 |
31 |
32 |
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 |
--------------------------------------------------------------------------------