Browse Source

Adds worktree support

Adds worktree open, create, & delete palette command
main
Eric Amodio 2 years ago
parent
commit
5ad4d74fa0
38 changed files with 2944 additions and 87 deletions
  1. +3
    -0
      images/views/worktrees.svg
  2. +394
    -7
      package.json
  3. +640
    -0
      src/commands/git/worktree.ts
  4. +34
    -1
      src/commands/gitCommands.actions.ts
  5. +82
    -35
      src/commands/gitCommands.ts
  6. +4
    -0
      src/commands/gitCommands.utils.ts
  7. +5
    -0
      src/commands/quickCommand.buttons.ts
  8. +157
    -0
      src/commands/quickCommand.steps.ts
  9. +44
    -12
      src/commands/quickCommand.ts
  10. +3
    -0
      src/commands/showView.ts
  11. +17
    -0
      src/config.ts
  12. +3
    -0
      src/constants.ts
  13. +11
    -0
      src/container.ts
  14. +44
    -0
      src/env/node/git/git.ts
  15. +109
    -13
      src/env/node/git/localGitProvider.ts
  16. +90
    -5
      src/git/errors.ts
  17. +16
    -2
      src/git/gitProvider.ts
  18. +28
    -1
      src/git/gitProviderService.ts
  19. +1
    -0
      src/git/models.ts
  20. +2
    -2
      src/git/models/remote.ts
  21. +27
    -1
      src/git/models/repository.ts
  22. +66
    -0
      src/git/models/worktree.ts
  23. +1
    -0
      src/git/parsers.ts
  24. +88
    -0
      src/git/parsers/worktreeParser.ts
  25. +70
    -0
      src/quickpicks/items/gitCommands.ts
  26. +2
    -0
      src/views/nodes.ts
  27. +123
    -0
      src/views/nodes/UncommittedFileNode.ts
  28. +143
    -0
      src/views/nodes/UncommittedFilesNode.ts
  29. +5
    -0
      src/views/nodes/repositoryNode.ts
  30. +9
    -4
      src/views/nodes/statusFilesNode.ts
  31. +3
    -0
      src/views/nodes/viewNode.ts
  32. +256
    -0
      src/views/nodes/worktreeNode.ts
  33. +63
    -0
      src/views/nodes/worktreesNode.ts
  34. +99
    -2
      src/views/repositoriesView.ts
  35. +6
    -2
      src/views/viewBase.ts
  36. +32
    -0
      src/views/viewCommands.ts
  37. +263
    -0
      src/views/worktreesView.ts
  38. +1
    -0
      src/webviews/settings/settingsWebview.ts

+ 3
- 0
images/views/worktrees.svg View File

@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#fff" d="M1.5 14h11l.48-.37 2.63-7-.48-.63H14V3.5l-.5-.5H7.71l-.86-.85L6.5 2h-5l-.5.5v11l.5.5zM2 3h4.29l.86.85.35.15H13v2H8.5l-.35.15-.86.85H3.5l-.47.34-1 3.08L2 3zm10.13 10H2.19l1.67-5H7.5l.35-.15.86-.85h5.79l-2.37 6z"/>
</svg>

+ 394
- 7
package.json View File

@ -61,6 +61,7 @@
"onView:gitlens.views.tags",
"onView:gitlens.views.contributors",
"onView:gitlens.views.searchAndCompare",
"onView:gitlens.views.worktrees",
"onWebviewPanel:gitlens.welcome",
"onWebviewPanel:gitlens.settings",
"onCommand:gitlens.premium.login",
@ -82,6 +83,7 @@
"onCommand:gitlens.showSettingsPage#search-compare-view",
"onCommand:gitlens.showSettingsPage#stashes-view",
"onCommand:gitlens.showSettingsPage#tags-view",
"onCommand:gitlens.showSettingsPage#worktrees-view",
"onCommand:gitlens.showWelcomePage",
"onCommand:gitlens.showBranchesView",
"onCommand:gitlens.showCommitsView",
@ -93,6 +95,7 @@
"onCommand:gitlens.showSearchAndCompareView",
"onCommand:gitlens.showStashesView",
"onCommand:gitlens.showTagsView",
"onCommand:gitlens.showWorktreesView",
"onCommand:gitlens.showWelcomeView",
"onCommand:gitlens.closeWelcomeView",
"onCommand:gitlens.compareWith",
@ -1018,12 +1021,19 @@
"scope": "window",
"order": 35
},
"gitlens.views.repositories.showWorktrees": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to show the worktrees for each repository in the _Repositories_ view",
"scope": "window",
"order": 36
},
"gitlens.views.repositories.showIncomingActivity": {
"type": "boolean",
"default": false,
"markdownDescription": "Specifies whether to show the experimental incoming activity for each repository in the _Repositories_ view",
"scope": "window",
"order": 36
"order": 37
},
"gitlens.views.repositories.autoRefresh": {
"type": "boolean",
@ -1545,9 +1555,81 @@
}
},
{
"id": "worktrees-view",
"title": "Worktrees View",
"order": 29,
"properties": {
"gitlens.worktrees.defaultLocation": {
"type": "string",
"default": null,
"markdownDescription": "Specifies the default path in which new worktrees will be created",
"scope": "resource"
},
"gitlens.worktrees.promptForLocation": {
"type": "boolean",
"default": false,
"markdownDescription": "Specifies whether to prompt for a path when creating new worktrees",
"scope": "resource"
},
"gitlens.views.worktrees.pullRequests.enabled": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)",
"scope": "window"
},
"gitlens.views.worktrees.pullRequests.showForCommits": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)",
"scope": "window"
},
"gitlens.views.worktrees.files.layout": {
"type": "string",
"default": "auto",
"enum": [
"auto",
"list",
"tree"
],
"enumDescriptions": [
"Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.worktrees.files.threshold#` value and the number of files at each nesting level",
"Displays files as a list",
"Displays files as a tree"
],
"markdownDescription": "Specifies how the _Worktrees_ view will display files",
"scope": "window"
},
"gitlens.views.worktrees.files.threshold": {
"type": "number",
"default": 5,
"markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `auto`",
"scope": "window"
},
"gitlens.views.worktrees.files.compact": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `tree` or `auto`",
"scope": "window"
},
"gitlens.views.worktrees.avatars": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Worktrees_ view",
"scope": "window"
},
"gitlens.views.worktrees.reveal": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to reveal worktrees in the _Worktrees_ view, otherwise they revealed in the _Repositories_ view",
"scope": "window",
"order": 20
}
}
},
{
"id": "contributors-view",
"title": "Contributors View",
"order": 29,
"order": 30,
"properties": {
"gitlens.views.contributors.showAllBranches": {
"type": "boolean",
@ -1650,7 +1732,7 @@
{
"id": "search-compare-view",
"title": "Search & Compare View",
"order": 29,
"order": 31,
"properties": {
"gitlens.views.searchAndCompare.pullRequests.enabled": {
"type": "boolean",
@ -3462,6 +3544,15 @@
"dark": "#c74e39",
"highContrast": "#c74e39"
}
},
{
"id": "gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor",
"description": "Specifies the decoration foreground color for worktrees that have uncommitted changes",
"defaults": {
"light": "#895503",
"dark": "#E2C08D",
"highContrast": "#E2C08D"
}
}
],
"commands": [
@ -3573,6 +3664,12 @@
"icon": "$(gear)"
},
{
"command": "gitlens.showSettingsPage#worktrees-view",
"title": "Open View Settings",
"category": "GitLens",
"icon": "$(gear)"
},
{
"command": "gitlens.showWelcomePage",
"title": "Welcome (Quick Setup)",
"category": "GitLens"
@ -3628,6 +3725,11 @@
"category": "GitLens"
},
{
"command": "gitlens.showWorktreesView",
"title": "Show Worktrees View",
"category": "GitLens"
},
{
"command": "gitlens.showWelcomeView",
"title": "Show Welcome View",
"category": "GitLens"
@ -3930,6 +4032,11 @@
"category": "GitLens"
},
{
"command": "gitlens.gitCommands.worktree",
"title": "Git Worktree...",
"category": "GitLens"
},
{
"command": "gitlens.switchMode",
"title": "Switch Mode",
"category": "GitLens"
@ -4730,6 +4837,30 @@
"icon": "$(person-add)"
},
{
"command": "gitlens.views.createWorktree",
"title": "Create Worktree...",
"category": "GitLens",
"icon": "$(add)"
},
{
"command": "gitlens.views.deleteWorktree",
"title": "Delete Worktree...",
"category": "GitLens",
"icon": "$(trash)"
},
{
"command": "gitlens.views.openWorktree",
"title": "Open Worktree",
"category": "GitLens",
"icon": "$(window)"
},
{
"command": "gitlens.views.openWorktreeInNewWindow",
"title": "Open Worktree in New Window",
"category": "GitLens",
"icon": "$(empty-window)"
},
{
"command": "gitlens.views.cherryPick",
"title": "Cherry Pick Commit...",
"category": "GitLens"
@ -5425,6 +5556,16 @@
"category": "GitLens"
},
{
"command": "gitlens.views.repositories.setShowWorktreesOn",
"title": "Show Worktrees",
"category": "GitLens"
},
{
"command": "gitlens.views.repositories.setShowWorktreesOff",
"title": "Hide Worktrees",
"category": "GitLens"
},
{
"command": "gitlens.views.repositories.setShowUpstreamStatusOn",
"title": "Show Current Branch Status",
"category": "GitLens"
@ -5633,6 +5774,48 @@
"category": "GitLens"
},
{
"command": "gitlens.views.worktrees.copy",
"title": "Copy",
"category": "GitLens"
},
{
"command": "gitlens.views.worktrees.refresh",
"title": "Refresh",
"category": "GitLens",
"icon": "$(refresh)"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToAuto",
"title": "Toggle Files View: Tree",
"category": "GitLens",
"icon": "$(list-tree)"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToList",
"title": "Toggle Files View: Auto",
"category": "GitLens",
"icon": {
"dark": "images/dark/icon-view-auto.svg",
"light": "images/light/icon-view-auto.svg"
}
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToTree",
"title": "Toggle Files View: List",
"category": "GitLens",
"icon": "$(list-flat)"
},
{
"command": "gitlens.views.worktrees.setShowAvatarsOn",
"title": "Show Avatars",
"category": "GitLens"
},
{
"command": "gitlens.views.worktrees.setShowAvatarsOff",
"title": "Hide Avatars",
"category": "GitLens"
},
{
"command": "gitlens.enableDebugLogging",
"title": "Enable Debug Logging",
"category": "GitLens"
@ -5717,6 +5900,10 @@
"when": "false"
},
{
"command": "gitlens.showSettingsPage#worktrees-view",
"when": "false"
},
{
"command": "gitlens.showBranchesView",
"when": "gitlens:enabled"
},
@ -5757,6 +5944,10 @@
"when": "gitlens:enabled"
},
{
"command": "gitlens.showWorktreesView",
"when": "gitlens:enabled"
},
{
"command": "gitlens.showWelcomeView",
"when": "gitlens:enabled"
},
@ -5937,6 +6128,10 @@
"when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
},
{
"command": "gitlens.gitCommands.worktree",
"when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
},
{
"command": "gitlens.switchMode",
"when": "gitlens:enabled"
},
@ -6441,6 +6636,22 @@
"when": "false"
},
{
"command": "gitlens.views.createWorktree",
"when": "false"
},
{
"command": "gitlens.views.deleteWorktree",
"when": "false"
},
{
"command": "gitlens.views.openWorktree",
"when": "false"
},
{
"command": "gitlens.views.openWorktreeInNewWindow",
"when": "false"
},
{
"command": "gitlens.views.createBranch",
"when": "false"
},
@ -6937,6 +7148,14 @@
"when": "false"
},
{
"command": "gitlens.views.repositories.setShowWorktreesOn",
"when": "false"
},
{
"command": "gitlens.views.repositories.setShowWorktreesOff",
"when": "false"
},
{
"command": "gitlens.views.repositories.setShowUpstreamStatusOn",
"when": "false"
},
@ -7077,6 +7296,34 @@
"when": "false"
},
{
"command": "gitlens.views.worktrees.copy",
"when": "false"
},
{
"command": "gitlens.views.worktrees.refresh",
"when": "false"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToAuto",
"when": "false"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToList",
"when": "false"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToTree",
"when": "false"
},
{
"command": "gitlens.views.worktrees.setShowAvatarsOn",
"when": "false"
},
{
"command": "gitlens.views.worktrees.setShowAvatarsOff",
"when": "false"
},
{
"command": "gitlens.enableDebugLogging",
"when": "config.gitlens.outputLevel != debug"
},
@ -8025,6 +8272,41 @@
"group": "5_gitlens@0"
},
{
"command": "gitlens.views.createWorktree",
"when": "view =~ /^gitlens\\.views\\.worktrees/",
"group": "navigation@10"
},
{
"command": "gitlens.views.worktrees.refresh",
"when": "view =~ /^gitlens\\.views\\.worktrees/",
"group": "navigation@99"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToAuto",
"when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.files.layout == tree",
"group": "3_gitlens@1"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToList",
"when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.files.layout == auto",
"group": "3_gitlens@1"
},
{
"command": "gitlens.views.worktrees.setFilesLayoutToTree",
"when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.files.layout == list",
"group": "3_gitlens@1"
},
{
"command": "gitlens.views.worktrees.setShowAvatarsOn",
"when": "view =~ /^gitlens\\.views\\.worktrees/ && !config.gitlens.views.worktrees.avatars",
"group": "5_gitlens@0"
},
{
"command": "gitlens.views.worktrees.setShowAvatarsOff",
"when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.avatars",
"group": "5_gitlens@0"
},
{
"command": "gitlens.views.setShowRelativeDateMarkersOn",
"when": "view =~ /^gitlens\\.views\\.(branches|commits|fileHistory|lineHistory|remotes|repositories|tags)/ && !config.gitlens.views.showRelativeDateMarkers",
"group": "5_gitlens@3"
@ -8083,6 +8365,11 @@
"command": "gitlens.showSettingsPage#tags-view",
"when": "view =~ /^gitlens\\.views\\.tags/",
"group": "9_gitlens@1"
},
{
"command": "gitlens.showSettingsPage#worktrees-view",
"when": "view =~ /^gitlens\\.views\\.worktrees/",
"group": "9_gitlens@1"
}
],
"view/item/context": [
@ -8261,9 +8548,14 @@
"group": "1_gitlens_actions_@8"
},
{
"command": "gitlens.views.createWorktree",
"when": "!gitlens:readonly && viewItem =~ /gitlens:branch\\b/",
"group": "1_gitlens_actions_@9"
},
{
"command": "gitlens.views.createPullRequest",
"when": "gitlens:hasRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/",
"group": "1_gitlens_actions_@9"
"group": "1_gitlens_actions_@10"
},
{
"command": "gitlens.openBranchOnRemote",
@ -9208,6 +9500,53 @@
"group": "1_gitlens_actions@3"
},
{
"command": "gitlens.views.createWorktree",
"when": "!gitlens:readonly && viewItem =~ /gitlens:worktrees\\b/",
"group": "inline@1"
},
{
"command": "gitlens.views.createWorktree",
"when": "!gitlens:readonly && viewItem =~ /gitlens:worktrees\\b/",
"group": "1_gitlens_actions@1"
},
{
"command": "gitlens.views.openWorktree",
"when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
"group": "inline@1",
"alt": "gitlens.views.openWorktreeInNewWindow"
},
{
"command": "gitlens.views.openWorktree",
"when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1",
"group": "inline@1",
"alt": "gitlens.views.openWorktreeInNewWindow"
},
{
"command": "gitlens.views.openWorktree",
"when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
"group": "2_gitlens_quickopen@1"
},
{
"command": "gitlens.views.openWorktree",
"when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1",
"group": "2_gitlens_quickopen@1"
},
{
"command": "gitlens.views.openWorktreeInNewWindow",
"when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
"group": "2_gitlens_quickopen@2"
},
{
"command": "gitlens.views.openWorktreeInNewWindow",
"when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1",
"group": "2_gitlens_quickopen@2"
},
{
"command": "gitlens.views.deleteWorktree",
"when": "!gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
"group": "6_gitlens_actions@1"
},
{
"command": "gitlens.views.stageDirectory",
"when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/",
"group": "inline@1"
@ -9229,7 +9568,7 @@
},
{
"command": "gitlens.views.copy",
"when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag)\\b)/",
"when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag|worktree)\\b)/",
"group": "7_gitlens_cutcopypaste@1"
},
{
@ -9650,14 +9989,24 @@
"group": "2_gitlens@6"
},
{
"command": "gitlens.views.repositories.setShowWorktreesOn",
"when": "!config.gitlens.views.repositories.showWorktrees",
"group": "2_gitlens@7"
},
{
"command": "gitlens.views.repositories.setShowWorktreesOff",
"when": "config.gitlens.views.repositories.showWorktrees",
"group": "2_gitlens@7"
},
{
"command": "gitlens.views.repositories.setShowContributorsOn",
"when": "!config.gitlens.views.repositories.showContributors",
"group": "2_gitlens@7"
"group": "2_gitlens@8"
},
{
"command": "gitlens.views.repositories.setShowContributorsOff",
"when": "config.gitlens.views.repositories.showContributors",
"group": "2_gitlens@7"
"group": "2_gitlens@8"
}
],
"gitlens/view/searchAndCompare/new": [
@ -10036,6 +10385,12 @@
"key": "ctrl+c",
"mac": "cmd+c",
"when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.tags/"
},
{
"command": "gitlens.views.worktrees.copy",
"key": "ctrl+c",
"mac": "cmd+c",
"when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.worktrees/"
}
],
"customEditors": [
@ -10097,6 +10452,30 @@
"view": "gitlens.views.searchAndCompare",
"contents": "Compare a <branch, tag, or ref> with another <branch, tag, or ref>\n\n[Compare References...](command:gitlens.views.searchAndCompare.selectForCompare)",
"when": "!gitlens:hasVirtualFolders"
},
{
"view": "gitlens.views.worktrees",
"contents": "Worktrees allow you to easily work on different branches of a repository simultaneously. You can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace."
},
{
"view": "gitlens.views.worktrees",
"contents": "[Create Worktree...](command:gitlens.views.createWorktree)",
"when": "!gitlens:premium:upgradeRequired"
},
{
"view": "gitlens.views.worktrees",
"contents": "Worktrees are a premium feature, which require a free account for public code. [Learn more](https://dev.gitkraken.com/gitlens/premium-features).",
"when": "gitlens:premium:upgradeRequired == free+"
},
{
"view": "gitlens.views.worktrees",
"contents": "Worktrees are a premium feature, which require at least a Pro subscription for private code. [Learn more](https://dev.gitkraken.com/gitlens/premium-features).",
"when": "gitlens:premium:upgradeRequired == paid"
},
{
"view": "gitlens.views.worktrees",
"contents": "[Unlock Premium Features](command:gitlens.showHomeView)",
"when": "gitlens:premium:upgradeRequired"
}
],
"views": {
@ -10185,6 +10564,14 @@
"visibility": "collapsed"
},
{
"id": "gitlens.views.worktrees",
"name": "Worktrees",
"when": "!gitlens:disabled && !gitlens:hasVirtualFolders",
"contextualTitle": "GitLens",
"icon": "images/views/worktrees.svg",
"visibility": "collapsed"
},
{
"id": "gitlens.views.contributors",
"name": "Contributors",
"when": "!gitlens:disabled",

+ 640
- 0
src/commands/git/worktree.ts View File

@ -0,0 +1,640 @@
import { MessageItem, QuickInputButtons, Uri, window } from 'vscode';
import { configuration } from '../../configuration';
import { Container } from '../../container';
import {
WorktreeCreateError,
WorktreeCreateErrorReason,
WorktreeDeleteError,
WorktreeDeleteErrorReason,
} from '../../git/errors';
import { PremiumFeatures } from '../../git/gitProvider';
import { GitReference, GitWorktree, Repository } from '../../git/models';
import { Messages } from '../../messages';
import { QuickPickItemOfT } from '../../quickpicks/items/common';
import { Directive } from '../../quickpicks/items/directive';
import { FlagsQuickPickItem } from '../../quickpicks/items/flags';
import { pad, pluralize } from '../../system/string';
import { OpenWorkspaceLocation } from '../../system/utils';
import { ViewsWithRepositoryFolders } from '../../views/viewBase';
import { GitActions } from '../gitCommands.actions';
import {
appendReposToTitle,
AsyncStepResultGenerator,
CustomStep,
ensureAccessStep,
inputBranchNameStep,
PartialStepState,
pickBranchOrTagStep,
pickRepositoryStep,
pickWorktreesStep,
pickWorktreeStep,
QuickCommand,
QuickPickStep,
StepGenerator,
StepResult,
StepResultGenerator,
StepSelection,
StepState,
} from '../quickCommand';
interface Context {
repos: Repository[];
associatedView: ViewsWithRepositoryFolders;
defaultUri?: Uri;
showTags: boolean;
title: string;
worktrees?: GitWorktree[];
}
type CreateFlags = '--force' | '-b' | '--detach';
interface CreateState {
subcommand: 'create';
repo: string | Repository;
uri: Uri;
reference?: GitReference;
createBranch: string;
flags: CreateFlags[];
}
type DeleteFlags = '--force';
interface DeleteState {
subcommand: 'delete';
repo: string | Repository;
uris: Uri[];
flags: DeleteFlags[];
}
type OpenFlags = '--new-window';
interface OpenState {
subcommand: 'open';
repo: string | Repository;
uri: Uri;
flags: OpenFlags[];
}
type State = CreateState | DeleteState | OpenState;
type WorktreeStepState<T extends State> = SomeNonNullable<StepState<T>, 'subcommand'>;
type CreateStepState<T extends CreateState = CreateState> = WorktreeStepState<ExcludeSome<T, 'repo', string>>;
type DeleteStepState<T extends DeleteState = DeleteState> = WorktreeStepState<ExcludeSome<T, 'repo', string>>;
type OpenStepState<T extends OpenState = OpenState> = WorktreeStepState<ExcludeSome<T, 'repo', string>>;
const subcommandToTitleMap = new Map<State['subcommand'], string>([
['create', 'Create'],
['delete', 'Delete'],
['open', 'Open'],
]);
function getTitle(title: string, subcommand: State['subcommand'] | undefined) {
return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`;
}
export interface WorktreeGitCommandArgs {
readonly command: 'worktree';
confirm?: boolean;
state?: Partial<State>;
}
export class WorktreeGitCommand extends QuickCommand<State> {
private subcommand: State['subcommand'] | undefined;
private overrideCanConfirm: boolean | undefined;
constructor(container: Container, args?: WorktreeGitCommandArgs) {
super(container, 'worktree', 'worktree', 'Worktree', {
description: 'open, create, or delete worktrees',
});
let counter = 0;
if (args?.state?.subcommand != null) {
counter++;
switch (args.state.subcommand) {
case 'create':
if (args.state.uri != null) {
counter++;
}
if (args.state.reference != null) {
counter++;
}
break;
case 'delete':
if (args.state.uris != null && (!Array.isArray(args.state.uris) || args.state.uris.length !== 0)) {
counter++;
}
break;
case 'open':
if (args.state.uri != null) {
counter++;
}
break;
}
}
if (args?.state?.repo != null) {
counter++;
}
this.initialState = {
counter: counter,
confirm: args?.confirm,
...args?.state,
};
}
override get canConfirm(): boolean {
return this.overrideCanConfirm != null ? this.overrideCanConfirm : this.subcommand != null;
}
override get canSkipConfirm(): boolean {
return this.subcommand === 'delete' ? false : super.canSkipConfirm;
}
override get skipConfirmKey() {
return `${this.key}${this.subcommand == null ? '' : `-${this.subcommand}`}:${this.pickedVia}`;
}
protected async *steps(state: PartialStepState<State>): StepGenerator {
const context: Context = {
repos: Container.instance.git.openRepositories,
associatedView: Container.instance.worktreesView,
showTags: false,
title: this.title,
};
let skippedStepTwo = false;
while (this.canStepsContinue(state)) {
context.title = this.title;
if (state.counter < 1 || state.subcommand == null) {
this.subcommand = undefined;
const result = yield* this.pickSubcommandStep(state);
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.subcommand = result;
}
this.subcommand = state.subcommand;
if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') {
skippedStepTwo = false;
if (context.repos.length === 1) {
skippedStepTwo = true;
state.counter++;
state.repo = context.repos[0];
} else {
const result = yield* pickRepositoryStep(state, context);
if (result === StepResult.Break) continue;
state.repo = result;
}
}
const result = yield* ensureAccessStep(state as any, context, PremiumFeatures.Worktrees);
if (result === StepResult.Break) break;
context.title = getTitle(state.subcommand === 'delete' ? 'Worktrees' : this.title, state.subcommand);
switch (state.subcommand) {
case 'create': {
yield* this.createCommandSteps(state as CreateStepState, context);
// Clear any chosen path, since we are exiting this subcommand
state.uri = undefined;
break;
}
case 'delete': {
if (state.uris != null && !Array.isArray(state.uris)) {
state.uris = [state.uris];
}
yield* this.deleteCommandSteps(state as DeleteStepState, context);
break;
}
case 'open': {
yield* this.openCommandSteps(state as OpenStepState, context);
break;
}
default:
QuickCommand.endSteps(state);
break;
}
// If we skipped the previous step, make sure we back up past it
if (skippedStepTwo) {
state.counter--;
}
}
return state.counter < 0 ? StepResult.Break : undefined;
}
private *pickSubcommandStep(state: PartialStepState<State>): StepResultGenerator<State['subcommand']> {
const step = QuickCommand.createPickStep<QuickPickItemOfT<State['subcommand']>>({
title: this.title,
placeholder: `Choose a ${this.label} command`,
items: [
{
label: 'open',
description: 'opens the specified worktree',
picked: state.subcommand === 'open',
item: 'open',
},
{
label: 'create',
description: 'creates a new worktree',
picked: state.subcommand === 'create',
item: 'create',
},
{
label: 'delete',
description: 'deletes the specified worktrees',
picked: state.subcommand === 'delete',
item: 'delete',
},
],
buttons: [QuickInputButtons.Back],
});
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
private async *createCommandSteps(state: CreateStepState, context: Context): AsyncStepResultGenerator<void> {
if (context.defaultUri == null) {
context.defaultUri = await state.repo.getWorktreesDefaultUri();
}
if (state.flags == null) {
state.flags = [];
}
while (this.canStepsContinue(state)) {
this.overrideCanConfirm = undefined;
if (state.counter < 3 || state.reference == null) {
const result = yield* pickBranchOrTagStep(state, context, {
placeholder: context =>
`Choose a branch${context.showTags ? ' or tag' : ''} to create the new worktree for`,
picked: state.reference?.ref ?? (await state.repo.getBranch())?.ref,
titleContext: ' for',
value: GitReference.isRevision(state.reference) ? state.reference.ref : undefined,
});
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.reference = result;
}
if (state.counter < 4 || state.uri == null) {
if (
state.reference != null &&
!configuration.get('worktrees.promptForLocation', state.repo.folder) &&
context.defaultUri != null
) {
state.uri = Uri.joinPath(context.defaultUri, state.reference.name);
} else {
const result = yield* this.createCommandChoosePathStep(state, context, {
titleContext: ` for ${GitReference.toString(state.reference, {
capitalize: true,
icon: false,
label: state.reference.refType !== 'branch',
})}`,
});
if (result === StepResult.Break) continue;
state.uri = result;
}
}
// Clear the flags, since we can backup after the confirm step below (which is non-standard)
state.flags = [];
if (this.confirm(state.confirm)) {
const result = yield* this.createCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
state.flags = result;
}
if (state.flags.includes('-b') && state.createBranch == null) {
this.overrideCanConfirm = false;
const result = yield* inputBranchNameStep(state, context, {
placeholder: 'Please provide a name for the new branch',
titleContext: ` from ${GitReference.toString(state.reference, {
capitalize: true,
icon: false,
label: state.reference.refType !== 'branch',
})}`,
value: state.createBranch ?? GitReference.getNameWithoutRemote(state.reference),
});
if (result === StepResult.Break) continue;
state.createBranch = result;
}
QuickCommand.endSteps(state);
let retry = false;
do {
retry = false;
const force = state.flags.includes('--force');
const friendlyPath = GitWorktree.getFriendlyPath(state.uri);
try {
await state.repo.createWorktree(state.uri, {
commitish: state.reference?.name,
createBranch: state.flags.includes('-b') ? state.createBranch : undefined,
detach: state.flags.includes('--detach'),
force: force,
});
} catch (ex) {
if (
!force &&
ex instanceof WorktreeCreateError &&
ex.reason === WorktreeCreateErrorReason.AlreadyCheckedOut
) {
const confirm: MessageItem = { title: 'Create Anyway' };
const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showWarningMessage(
`Unable to create a new worktree in '${friendlyPath}' because ${GitReference.toString(
state.reference,
{ icon: false, quoted: true },
)} is already checked out.\n\nWould you like to create this worktree anyway?`,
{ modal: true },
confirm,
cancel,
);
if (result === confirm) {
state.flags.push('--force');
retry = true;
}
} else if (
ex instanceof WorktreeCreateError &&
ex.reason === WorktreeCreateErrorReason.AlreadyExists
) {
void Messages.showGenericErrorMessage(
`Unable to create a new worktree in '${friendlyPath} because that folder already exists.`,
);
} else {
void Messages.showGenericErrorMessage(`Unable to create a new worktree in '${friendlyPath}.`);
}
}
} while (retry);
}
}
private async *createCommandChoosePathStep(
state: CreateStepState,
context: Context,
options?: { titleContext?: string },
): AsyncStepResultGenerator<Uri> {
const step = QuickCommand.createCustomStep<Uri>({
show: async (_step: CustomStep<Uri>) => {
const uris = await window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
defaultUri: state.uri ?? context.defaultUri,
openLabel: 'Select Worktree Location',
title: appendReposToTitle(`${context.title}${options?.titleContext ?? ''}`, state, context),
});
if (uris == null || uris.length === 0) return Directive.Back;
return uris[0];
},
});
const value: StepSelection<typeof step> = yield step;
if (
!QuickCommand.canStepContinue(step, state, value) ||
!(await QuickCommand.canInputStepContinue(step, state, value))
) {
return StepResult.Break;
}
return value;
}
private *createCommandConfirmStep(state: CreateStepState, context: Context): StepResultGenerator<CreateFlags[]> {
const friendlyPath = GitWorktree.getFriendlyPath(state.uri);
const step: QuickPickStep<FlagsQuickPickItem<CreateFlags>> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
FlagsQuickPickItem.create<CreateFlags>(state.flags, [], {
label: context.title,
detail: `Will create a new worktree for ${GitReference.toString(state.reference)} in${pad(
'$(folder)',
2,
2,
)}${friendlyPath}`,
}),
FlagsQuickPickItem.create<CreateFlags>(state.flags, ['-b'], {
label: 'Create Branch and Worktree', //context.title,
description: `-b`,
detail: `Will create a new branch and worktree for ${GitReference.toString(
state.reference,
)} in${pad('$(folder)', 2, 2)}${friendlyPath}`,
}),
FlagsQuickPickItem.create<CreateFlags>(state.flags, ['--force'], {
label: `Force ${context.title}`,
description: `--force`,
detail: `Will forcibly create a new worktree for ${GitReference.toString(state.reference)} in${pad(
'$(folder)',
2,
2,
)}${friendlyPath}`,
}),
],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
private async *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator {
context.worktrees = await state.repo.getWorktrees();
if (state.flags == null) {
state.flags = [];
}
while (this.canStepsContinue(state)) {
if (state.counter < 3 || state.uris == null || state.uris.length === 0) {
context.title = getTitle('Worktrees', state.subcommand);
const result = yield* pickWorktreesStep(state, context, {
filter: wt => !wt.opened, // Can't delete an open worktree
includeStatus: true,
picked: state.uris?.map(uri => uri.toString()),
placeholder: 'Choose worktrees to delete',
});
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.uris = result.map(w => w.uri);
}
context.title = getTitle(pluralize('Worktree', state.uris.length, { only: true }), state.subcommand);
const result = yield* this.deleteCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
state.flags = result;
QuickCommand.endSteps(state);
for (const uri of state.uris) {
let retry = false;
do {
retry = false;
const force = state.flags.includes('--force');
try {
if (force) {
const worktree = context.worktrees.find(wt => wt.uri.toString() === uri.toString());
const status = await worktree?.getStatus();
if (status?.hasChanges ?? false) {
const confirm: MessageItem = { title: 'Force Delete' };
const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showWarningMessage(
`The worktree in '${uri.fsPath}' has uncommitted changes.\n\nDeleting it will cause those changes to be FOREVER LOST.\nThis is IRREVERSIBLE!\n\nAre you sure you still want to delete it?`,
{ modal: true },
confirm,
cancel,
);
if (result !== confirm) return;
}
}
await state.repo.deleteWorktree(uri, { force: force });
} catch (ex) {
if (!force && ex instanceof WorktreeDeleteError) {
const confirm: MessageItem = { title: 'Force Delete' };
const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showErrorMessage(
ex.reason === WorktreeDeleteErrorReason.HasChanges
? `Unable to delete worktree because there are UNCOMMITTED changes in '${uri.fsPath}'.\n\nForcibly deleting it will cause those changes to be FOREVER LOST.\nThis is IRREVERSIBLE!\n\nWould you like to forcibly delete it?`
: `Unable to delete worktree in '${uri.fsPath}'.\n\nWould you like to try to forcibly delete it?`,
{ modal: true },
confirm,
cancel,
);
if (result === confirm) {
state.flags.push('--force');
retry = true;
}
} else {
void Messages.showGenericErrorMessage(`Unable to delete worktree in '${uri.fsPath}.`);
}
}
} while (retry);
}
}
}
private *deleteCommandConfirmStep(state: DeleteStepState, context: Context): StepResultGenerator<DeleteFlags[]> {
const step: QuickPickStep<FlagsQuickPickItem<DeleteFlags>> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
FlagsQuickPickItem.create<DeleteFlags>(state.flags, [], {
label: context.title,
detail: `Will delete ${pluralize('worktree', state.uris.length, {
only: state.uris.length === 1,
})}${
state.uris.length === 1
? ` in${pad('$(folder)', 2, 2)}${GitWorktree.getFriendlyPath(state.uris[0])}`
: ''
}`,
}),
FlagsQuickPickItem.create<DeleteFlags>(state.flags, ['--force'], {
label: `Force ${context.title}`,
detail: `Will forcibly delete ${pluralize('worktree', state.uris.length, {
only: state.uris.length === 1,
})} even with UNCOMMITTED changes${
state.uris.length === 1
? ` in${pad('$(folder)', 2, 2)}${GitWorktree.getFriendlyPath(state.uris[0])}`
: ''
}`,
}),
],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
private async *openCommandSteps(state: OpenStepState, context: Context): StepGenerator {
context.worktrees = await state.repo.getWorktrees();
if (state.flags == null) {
state.flags = [];
}
while (this.canStepsContinue(state)) {
if (state.counter < 3 || state.uri == null) {
context.title = getTitle('Worktree', state.subcommand);
const result = yield* pickWorktreeStep(state, context, {
includeStatus: true,
picked: state.uri?.toString(),
placeholder: 'Choose worktree to open',
});
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.uri = result.uri;
}
context.title = getTitle('Worktree', state.subcommand);
const result = yield* this.openCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
state.flags = result;
QuickCommand.endSteps(state);
const worktree = context.worktrees.find(wt => wt.uri.toString() === state.uri.toString())!;
GitActions.Worktree.open(worktree, {
location: state.flags.includes('--new-window')
? OpenWorkspaceLocation.NewWindow
: OpenWorkspaceLocation.CurrentWindow,
});
}
}
private *openCommandConfirmStep(state: OpenStepState, context: Context): StepResultGenerator<OpenFlags[]> {
const step: QuickPickStep<FlagsQuickPickItem<OpenFlags>> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
FlagsQuickPickItem.create<OpenFlags>(state.flags, [], {
label: context.title,
detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in the current window`,
}),
FlagsQuickPickItem.create<OpenFlags>(state.flags, ['--new-window'], {
label: `${context.title} in New Window`,
detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in a new window`,
}),
],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
}

+ 34
- 1
src/commands/gitCommands.actions.ts View File

@ -21,11 +21,13 @@ import {
GitRevisionReference,
GitStashReference,
GitTagReference,
GitWorktree,
Repository,
} from '../git/models';
import { RepositoryPicker } from '../quickpicks/repositoryPicker';
import { ensure } from '../system/array';
import { executeCommand, executeEditorCommand } from '../system/command';
import { findOrOpenEditor, findOrOpenEditors } from '../system/utils';
import { findOrOpenEditor, findOrOpenEditors, openWorkspace, OpenWorkspaceLocation } from '../system/utils';
import { ViewsWithRepositoryFolders } from '../views/viewBase';
import { ResetGitCommandArgs } from './git/reset';
@ -883,4 +885,35 @@ export namespace GitActions {
return node;
}
}
export namespace Worktree {
export function create(repo?: string | Repository, uri?: Uri, ref?: GitReference) {
return executeGitCommand({
command: 'worktree',
state: { subcommand: 'create', repo: repo, uri: uri, reference: ref },
});
}
export function open(worktree: GitWorktree, options?: { location?: OpenWorkspaceLocation }) {
return openWorkspace(worktree.uri, options);
}
export function remove(repo?: string | Repository, uri?: Uri) {
return executeGitCommand({
command: 'worktree',
state: { subcommand: 'delete', repo: repo, uris: ensure(uri) },
});
}
export async function reveal(
worktree: GitWorktree,
options?: { select?: boolean; focus?: boolean; expand?: boolean | number },
) {
const view = Container.instance.worktreesView;
const node = view.canReveal
? await view.revealWorktree(worktree, options)
: await Container.instance.repositoriesView.revealWorktree(worktree, options);
return node;
}
}
}

+ 82
- 35
src/commands/gitCommands.ts View File

@ -25,13 +25,17 @@ import type { StashGitCommandArgs } from './git/stash';
import type { StatusGitCommandArgs } from './git/status';
import type { SwitchGitCommandArgs } from './git/switch';
import type { TagGitCommandArgs } from './git/tag';
import type { WorktreeGitCommandArgs } from './git/worktree';
import { PickCommandStep } from './gitCommands.utils';
import {
CustomStep,
isCustomStep,
isQuickInputStep,
isQuickPickStep,
QuickCommand,
QuickInputStep,
QuickPickStep,
StepResult,
StepSelection,
} from './quickCommand';
import { QuickCommandButtons, ToggleQuickInputButton } from './quickCommand.buttons';
@ -56,7 +60,8 @@ export type GitCommandsCommandArgs =
| StashGitCommandArgs
| StatusGitCommandArgs
| SwitchGitCommandArgs
| TagGitCommandArgs;
| TagGitCommandArgs
| WorktreeGitCommandArgs;
@command()
export class GitCommandsCommand extends Command {
@ -73,6 +78,7 @@ export class GitCommandsCommand extends Command {
Commands.GitCommandsRevert,
Commands.GitCommandsSwitch,
Commands.GitCommandsTag,
Commands.GitCommandsWorktree,
]);
}
@ -102,6 +108,9 @@ export class GitCommandsCommand extends Command {
case Commands.GitCommandsTag:
args = { command: 'tag' };
break;
case Commands.GitCommandsWorktree:
args = { command: 'worktree' };
break;
}
return this.execute(args);
@ -116,7 +125,7 @@ export class GitCommandsCommand extends Command {
let ignoreFocusOut;
let step;
let step: QuickPickStep<QuickPickItem> | QuickInputStep | CustomStep | undefined;
if (command == null) {
step = commandsStep;
} else {
@ -157,14 +166,23 @@ export class GitCommandsCommand extends Command {
continue;
}
if (isCustomStep(step)) {
step = await this.showCustomStep(step, commandsStep);
if (step?.ignoreFocusOut === true) {
ignoreFocusOut = true;
}
continue;
}
break;
}
}
private async showLoadingIfNeeded(
command: QuickCommand<any>,
stepPromise: Promise<QuickPickStep<QuickPickItem> | QuickInputStep | undefined>,
): Promise<QuickPickStep<QuickPickItem> | QuickInputStep | undefined> {
stepPromise: Promise<QuickPickStep<QuickPickItem> | QuickInputStep | CustomStep | undefined>,
): Promise<QuickPickStep<QuickPickItem> | QuickInputStep | CustomStep | undefined> {
const stepOrTimeout = await Promise.race([
stepPromise,
new Promise<typeof showLoadingSymbol>(resolve => setTimeout(() => resolve(showLoadingSymbol), 250)),
@ -179,23 +197,25 @@ export class GitCommandsCommand extends Command {
const disposables: Disposable[] = [];
let step: QuickPickStep<QuickPickItem> | QuickInputStep | undefined;
let step: QuickPickStep<QuickPickItem> | QuickInputStep | CustomStep | undefined;
try {
// eslint-disable-next-line no-async-promise-executor
return await new Promise<QuickPickStep<QuickPickItem> | QuickInputStep | undefined>(async resolve => {
disposables.push(quickpick.onDidHide(() => resolve(step)));
return await new Promise<QuickPickStep<QuickPickItem> | QuickInputStep | CustomStep | undefined>(
// eslint-disable-next-line no-async-promise-executor
async resolve => {
disposables.push(quickpick.onDidHide(() => resolve(step)));
quickpick.title = command.title;
quickpick.placeholder = 'Loading...';
quickpick.busy = true;
quickpick.enabled = false;
quickpick.title = command.title;
quickpick.placeholder = 'Loading...';
quickpick.busy = true;
quickpick.enabled = false;
quickpick.show();
quickpick.show();
step = await stepPromise;
step = await stepPromise;
quickpick.hide();
});
quickpick.hide();
},
);
} finally {
quickpick.dispose();
disposables.forEach(d => d.dispose());
@ -253,20 +273,55 @@ export class GitCommandsCommand extends Command {
}
private async nextStep(
quickInput: InputBox | QuickPick<QuickPickItem>,
command: QuickCommand,
value: StepSelection<any> | undefined,
quickInput?: InputBox | QuickPick<QuickPickItem>,
) {
quickInput.busy = true;
// quickInput.enabled = false;
if (quickInput != null) {
quickInput.busy = true;
// quickInput.enabled = false;
}
const next = await command.next(value);
if (next.done) return undefined;
quickInput.value = '';
if (quickInput != null) {
quickInput.value = '';
}
return next.value;
}
private async showCustomStep(step: CustomStep, commandsStep: PickCommandStep) {
const result = await step.show(step);
if (result === StepResult.Break) return undefined;
if (Directive.is(result)) {
switch (result) {
case Directive.Back:
return (await commandsStep?.command?.previous()) ?? commandsStep;
case Directive.Noop:
return commandsStep.command?.retry();
case Directive.Cancel:
default:
return undefined;
}
} else {
return this.nextStep(commandsStep.command!, result);
}
// switch (result.directive) {
// case 'back':
// return (await commandsStep?.command?.previous()) ?? commandsStep;
// case 'cancel':
// return undefined;
// case 'next':
// return this.nextStep(commandsStep.command!, result.value);
// case 'retry':
// return commandsStep.command?.retry();
// default:
// return undefined;
// }
}
private async showInputStep(step: QuickInputStep, commandsStep: PickCommandStep) {
const input = window.createInputBox();
input.ignoreFocusOut = !configuration.get('gitCommands.closeOnFocusOut') ? true : step.ignoreFocusOut ?? false;
@ -349,7 +404,7 @@ export class GitCommandsCommand extends Command {
input.validationMessage = message;
}),
input.onDidAccept(async () => {
resolve(await this.nextStep(input, commandsStep.command!, input.value));
resolve(await this.nextStep(commandsStep.command!, input.value, input));
}),
);
@ -453,7 +508,7 @@ export class GitCommandsCommand extends Command {
quickpick.onDidHide(() => resolve(undefined)),
quickpick.onDidTriggerItemButton(async e => {
if ((await step.onDidClickItemButton?.(quickpick, e.button, e.item)) === true) {
resolve(await this.nextStep(quickpick, commandsStep.command!, [e.item]));
resolve(await this.nextStep(commandsStep.command!, [e.item], quickpick));
}
}),
quickpick.onDidTriggerButton(async e => {
@ -564,7 +619,7 @@ export class GitCommandsCommand extends Command {
items = [item];
}
resolve(await this.nextStep(quickpick, commandsStep.command!, items));
resolve(await this.nextStep(commandsStep.command!, items, quickpick));
return;
}
}
@ -619,7 +674,7 @@ export class GitCommandsCommand extends Command {
if (step.onDidAccept == null) {
if (step.allowEmpty) {
resolve(await this.nextStep(quickpick, commandsStep.command!, []));
resolve(await this.nextStep(commandsStep.command!, [], quickpick));
}
return;
@ -628,7 +683,7 @@ export class GitCommandsCommand extends Command {
quickpick.busy = true;
if (await step.onDidAccept(quickpick)) {
resolve(await this.nextStep(quickpick, commandsStep.command!, value));
resolve(await this.nextStep(commandsStep.command!, value, quickpick));
}
quickpick.busy = false;
@ -655,17 +710,9 @@ export class GitCommandsCommand extends Command {
return;
case Directive.RequiresVerification:
void Container.instance.subscription.resendVerification();
resolve(undefined);
return;
case Directive.RequiresFreeSubscription:
void Container.instance.subscription.loginOrSignUp();
resolve(undefined);
return;
case Directive.RequiresPaidSubscription:
void Container.instance.subscription.purchase();
void Container.instance.subscription.showHomeView();
resolve(undefined);
return;
}
@ -691,7 +738,7 @@ export class GitCommandsCommand extends Command {
}
}
resolve(await this.nextStep(quickpick, commandsStep.command!, items as QuickPickItem[]));
resolve(await this.nextStep(commandsStep.command!, items as QuickPickItem[], quickpick));
}),
);

+ 4
- 0
src/commands/gitCommands.utils.ts View File

@ -20,6 +20,7 @@ import { StashGitCommand } from './git/stash';
import { StatusGitCommand } from './git/status';
import { SwitchGitCommand } from './git/switch';
import { TagGitCommand } from './git/tag';
import { WorktreeGitCommand } from './git/worktree';
import type { GitCommandsCommandArgs } from './gitCommands';
import type { QuickCommand, QuickPickStep, StepGenerator } from './quickCommand';
@ -90,6 +91,9 @@ export class PickCommandStep implements QuickPickStep {
args?.command === 'switch' || args?.command === 'checkout' ? args : undefined,
),
readonly ? undefined : new TagGitCommand(container, args?.command === 'tag' ? args : undefined),
hasVirtualFolders
? undefined
: new WorktreeGitCommand(container, args?.command === 'worktree' ? args : undefined),
].filter(<T>(i: T | undefined): i is T => i != null);
if (this.container.config.gitCommands.sortBy === GitCommandSorting.Usage) {

+ 5
- 0
src/commands/quickCommand.buttons.ts View File

@ -107,6 +107,11 @@ export namespace QuickCommandButtons {
}
};
export const OpenInNewWindow: QuickInputButton = {
iconPath: new ThemeIcon('empty-window'),
tooltip: 'Open in New Window',
};
export const RevealInSideBar: QuickInputButton = {
iconPath: new ThemeIcon('eye'),
tooltip: 'Reveal in Side Bar',

+ 157
- 0
src/commands/quickCommand.steps.ts View File

@ -19,6 +19,7 @@ import {
GitStatus,
GitTag,
GitTagReference,
GitWorktree,
Repository,
TagSortOptions,
} from '../git/models';
@ -58,6 +59,7 @@ import {
RefQuickPickItem,
RepositoryQuickPickItem,
TagQuickPickItem,
WorktreeQuickPickItem,
} from '../quickpicks/items/gitCommands';
import { ReferencesQuickPickItem } from '../quickpicks/referencePicker';
import {
@ -69,6 +71,7 @@ import { filterMap, intersection, isStringArray } from '../system/array';
import { formatPath } from '../system/formatPath';
import { map } from '../system/iterable';
import { pad, pluralize, truncate } from '../system/string';
import { OpenWorkspaceLocation } from '../system/utils';
import { ViewsWithRepositoryFolders } from '../views/viewBase';
import { GitActions } from './gitCommands.actions';
import {
@ -141,6 +144,39 @@ export async function getTags(
}) as Promise<TagQuickPickItem[]>;
}
export async function getWorktrees(
repoOrWorktrees: Repository | GitWorktree[],
{
buttons,
filter,
includeStatus,
picked,
}: {
buttons?: QuickInputButton[];
filter?: (t: GitWorktree) => boolean;
includeStatus?: boolean;
picked?: string | string[];
},
): Promise<WorktreeQuickPickItem[]> {
const worktrees = repoOrWorktrees instanceof Repository ? await repoOrWorktrees.getWorktrees() : repoOrWorktrees;
return Promise.all<WorktreeQuickPickItem>([
...worktrees
.filter(w => filter == null || filter(w))
.map(async w =>
WorktreeQuickPickItem.create(
w,
picked != null &&
(typeof picked === 'string' ? w.uri.toString() === picked : picked.includes(w.uri.toString())),
{
buttons: buttons,
ref: true,
status: includeStatus ? await w.getStatus() : undefined,
},
),
),
]);
}
export async function getBranchesAndOrTags(
repos: Repository | Repository[] | undefined,
include: ('tags' | 'branches')[],
@ -1395,6 +1431,127 @@ export async function* pickTagsStep<
return QuickCommand.canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResult.Break;
}
export async function* pickWorktreeStep<
State extends PartialStepState & { repo: Repository },
Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] },
>(
state: State,
context: Context,
{
filter,
includeStatus,
picked,
placeholder,
titleContext,
}: {
filter?: (b: GitWorktree) => boolean;
includeStatus?: boolean;
picked?: string | string[];
placeholder: string;
titleContext?: string;
},
): AsyncStepResultGenerator<GitWorktree> {
const worktrees = await getWorktrees(context.worktrees ?? state.repo, {
buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar],
filter: filter,
includeStatus: includeStatus,
picked: picked,
});
const step = QuickCommand.createPickStep<WorktreeQuickPickItem>({
title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context),
placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder,
matchOnDetail: true,
items:
worktrees.length === 0
? [DirectiveQuickPickItem.create(Directive.Back, true), DirectiveQuickPickItem.create(Directive.Cancel)]
: worktrees,
onDidClickItemButton: (quickpick, button, { item }) => {
switch (button) {
case QuickCommandButtons.OpenInNewWindow:
void GitActions.Worktree.open(item, { location: OpenWorkspaceLocation.NewWindow });
break;
case QuickCommandButtons.RevealInSideBar:
void GitActions.Worktree.reveal(item, { select: true, focus: false, expand: true });
break;
}
},
keys: ['right', 'alt+right', 'ctrl+right'],
onDidPressKey: async quickpick => {
if (quickpick.activeItems.length === 0) return;
await GitActions.Worktree.reveal(quickpick.activeItems[0].item, {
select: true,
focus: false,
expand: true,
});
},
});
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
export async function* pickWorktreesStep<
State extends PartialStepState & { repo: Repository },
Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] },
>(
state: State,
context: Context,
{
filter,
includeStatus,
picked,
placeholder,
titleContext,
}: {
filter?: (b: GitWorktree) => boolean;
includeStatus?: boolean;
picked?: string | string[];
placeholder: string;
titleContext?: string;
},
): AsyncStepResultGenerator<GitWorktree[]> {
const worktrees = await getWorktrees(context.worktrees ?? state.repo, {
buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar],
filter: filter,
includeStatus: includeStatus,
picked: picked,
});
const step = QuickCommand.createPickStep<WorktreeQuickPickItem>({
multiselect: worktrees.length !== 0,
title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context),
placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder,
matchOnDetail: true,
items:
worktrees.length === 0
? [DirectiveQuickPickItem.create(Directive.Back, true), DirectiveQuickPickItem.create(Directive.Cancel)]
: worktrees,
onDidClickItemButton: (quickpick, button, { item }) => {
switch (button) {
case QuickCommandButtons.OpenInNewWindow:
void GitActions.Worktree.open(item, { location: OpenWorkspaceLocation.NewWindow });
break;
case QuickCommandButtons.RevealInSideBar:
void GitActions.Worktree.reveal(item, { select: true, focus: false, expand: true });
break;
}
},
keys: ['right', 'alt+right', 'ctrl+right'],
onDidPressKey: async quickpick => {
if (quickpick.activeItems.length === 0) return;
await GitActions.Worktree.reveal(quickpick.activeItems[0].item, {
select: true,
focus: false,
expand: true,
});
},
});
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResult.Break;
}
export async function* showCommitOrStashStep<
State extends PartialStepState & { repo: Repository; reference: GitCommit | GitStashCommit },
Context extends { repos: Repository[]; title: string },

+ 44
- 12
src/commands/quickCommand.ts View File

@ -6,6 +6,18 @@ import { Directive, DirectiveQuickPickItem } from '../quickpicks/items/directive
export * from './quickCommand.buttons';
export * from './quickCommand.steps';
export interface CustomStep<T = unknown> {
ignoreFocusOut?: boolean;
show(step: CustomStep<T>): Promise<StepResult<Directive | T>>;
}
export function isCustomStep(
step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResult.Break,
): step is CustomStep {
return typeof step === 'object' && (step as CustomStep).show != null;
}
export interface QuickInputStep {
additionalButtons?: QuickInputButton[];
buttons?: QuickInputButton[];
@ -24,7 +36,7 @@ export interface QuickInputStep {
export function isQuickInputStep(
step: QuickPickStep | QuickInputStep | typeof StepResult.Break,
): step is QuickInputStep {
return typeof step === 'object' && (step as QuickPickStep).items == null;
return typeof step === 'object' && (step as QuickPickStep).items == null && (step as CustomStep).show == null;
}
export interface QuickPickStep<T extends QuickPickItem = QuickPickItem> {
@ -59,23 +71,35 @@ export interface QuickPickStep {
validate?(selection: T[]): boolean;
}
export function isQuickPickStep(step: QuickPickStep | QuickInputStep | typeof StepResult.Break): step is QuickPickStep {
export function isQuickPickStep(
step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResult.Break,
): step is QuickPickStep {
return typeof step === 'object' && (step as QuickPickStep).items != null;
}
export type StepGenerator =
| Generator<QuickPickStep | QuickInputStep, StepResult<void | undefined>, any | undefined>
| AsyncGenerator<QuickPickStep | QuickInputStep, StepResult<void | undefined>, any | undefined>;
| Generator<QuickPickStep | QuickInputStep | CustomStep, StepResult<void | undefined>, any | undefined>
| AsyncGenerator<QuickPickStep | QuickInputStep | CustomStep, StepResult<void | undefined>, any | undefined>;
export type StepItemType<T> = T extends QuickPickStep<infer U> ? U[] : T extends QuickInputStep ? string : never;
export type StepItemType<T> = T extends CustomStep<infer U>
? U
: T extends QuickPickStep<infer U>
? U[]
: T extends QuickInputStep
? string
: never;
export type StepNavigationKeys = Exclude<Keys, 'left' | 'alt+left' | 'ctrl+left'>;
export namespace StepResult {
export const Break = Symbol('BreakStep');
}
export type StepResult<T> = typeof StepResult.Break | T;
export type StepResultGenerator<T> = Generator<QuickPickStep | QuickInputStep, StepResult<T>, any | undefined>;
export type StepResultGenerator<T> = Generator<
QuickPickStep | QuickInputStep | CustomStep,
StepResult<T>,
any | undefined
>;
export type AsyncStepResultGenerator<T> = AsyncGenerator<
QuickPickStep | QuickInputStep,
QuickPickStep | QuickInputStep | CustomStep,
StepResult<T>,
any | undefined
>;
@ -83,7 +107,9 @@ export type AsyncStepResultGenerator = AsyncGenerator<
// export type StepResultGenerator<T> =
// | Generator<QuickPickStep | QuickInputStep, StepResult<T>, any | undefined>
// | AsyncGenerator<QuickPickStep | QuickInputStep, StepResult<T>, any | undefined>;
export type StepSelection<T> = T extends QuickPickStep<infer U>
export type StepSelection<T> = T extends CustomStep<infer U>
? U | Directive
: T extends QuickPickStep<infer U>
? U[] | Directive
: T extends QuickInputStep
? string | Directive
@ -97,7 +123,7 @@ export abstract class QuickCommand implements QuickPickItem {
protected initialState: PartialStepState<State> | undefined;
private _currentStep: QuickPickStep | QuickInputStep | undefined;
private _currentStep: QuickPickStep | QuickInputStep | CustomStep | undefined;
private _stepsIterator: StepGenerator | undefined;
constructor(
@ -145,7 +171,7 @@ export abstract class QuickCommand implements QuickPickItem {
return `${this.key}:${this.pickedVia}`;
}
get value(): QuickPickStep | QuickInputStep | undefined {
get value(): QuickPickStep | QuickInputStep | CustomStep | undefined {
return this._currentStep;
}
@ -176,7 +202,9 @@ export abstract class QuickCommand implements QuickPickItem {
return (await this.next(Directive.Back)).value;
}
async next(value?: StepSelection<any>): Promise<IteratorResult<QuickPickStep | QuickInputStep | undefined>> {
async next(
value?: StepSelection<any>,
): Promise<IteratorResult<QuickPickStep | QuickInputStep | CustomStep | undefined>> {
if (this._stepsIterator == null) {
this._stepsIterator = this.steps(this.getStepState(false));
}
@ -196,7 +224,7 @@ export abstract class QuickCommand implements QuickPickItem {
return result;
}
async retry(): Promise<QuickPickStep | QuickInputStep | undefined> {
async retry(): Promise<QuickPickStep | QuickInputStep | CustomStep | undefined> {
await this.next(Directive.Noop);
return this.value;
}
@ -317,6 +345,10 @@ export namespace QuickCommand {
return step;
}
export function createCustomStep<T>(step: CustomStep<T>): CustomStep<T> {
return step;
}
export function endSteps(state: PartialStepState) {
state.counter = -1;
}

+ 3
- 0
src/commands/showView.ts View File

@ -19,6 +19,7 @@ export class ShowViewCommand extends Command {
Commands.ShowSearchAndCompareView,
Commands.ShowStashesView,
Commands.ShowTagsView,
Commands.ShowWorktreesView,
Commands.ShowWelcomeView,
Commands.ShowHomeView,
]);
@ -50,6 +51,8 @@ export class ShowViewCommand extends Command {
return this.container.stashesView.show();
case Commands.ShowTagsView:
return this.container.tagsView.show();
case Commands.ShowWorktreesView:
return this.container.worktreesView.show();
case Commands.ShowWelcomeView:
await setContext(ContextKeys.ViewsWelcomeVisible, true);
void this.container.storage.store(SyncedStorageKeys.WelcomeViewVisible, true);

+ 17
- 0
src/config.ts View File

@ -148,6 +148,10 @@ export interface Config {
enabled: boolean;
};
views: ViewsConfig;
worktrees: {
defaultLocation: string | null;
promptForLocation: boolean;
};
advanced: AdvancedConfig;
}
@ -518,6 +522,7 @@ interface ViewsConfigs {
searchAndCompare: SearchAndCompareViewConfig;
stashes: StashesViewConfig;
tags: TagsViewConfig;
worktrees: WorktreesViewConfig;
}
export type ViewsConfigKeys = keyof ViewsConfigs;
@ -532,6 +537,7 @@ export const viewsConfigKeys: ViewsConfigKeys[] = [
'tags',
'contributors',
'searchAndCompare',
'worktrees',
];
export type ViewsConfig = ViewsCommonConfig & ViewsConfigs;
@ -624,6 +630,7 @@ export interface RepositoriesViewConfig {
showStashes: boolean;
showTags: boolean;
showUpstreamStatus: boolean;
showWorktrees: boolean;
}
export interface SearchAndCompareViewConfig {
@ -649,6 +656,16 @@ export interface TagsViewConfig {
reveal: boolean;
}
export interface WorktreesViewConfig {
avatars: boolean;
files: ViewsFilesConfig;
pullRequests: {
enabled: boolean;
showForCommits: boolean;
};
reveal: boolean;
}
export interface ViewsFilesConfig {
compact: boolean;
layout: ViewFilesLayout;

+ 3
- 0
src/constants.ts View File

@ -134,6 +134,7 @@ export const enum Commands {
GitCommandsRevert = 'gitlens.gitCommands.revert',
GitCommandsSwitch = 'gitlens.gitCommands.switch',
GitCommandsTag = 'gitlens.gitCommands.tag',
GitCommandsWorktree = 'gitlens.gitCommands.worktree',
QuickOpenFileHistory = 'gitlens.quickOpenFileHistory',
RefreshHover = 'gitlens.refreshHover',
ResetAvatarCache = 'gitlens.resetAvatarCache',
@ -175,9 +176,11 @@ export const enum Commands {
ShowSettingsPageAndJumpToSearchAndCompareView = 'gitlens.showSettingsPage#search-compare-view',
ShowSettingsPageAndJumpToStashesView = 'gitlens.showSettingsPage#stashes-view',
ShowSettingsPageAndJumpToTagsView = 'gitlens.showSettingsPage#tags-view',
ShowSettingsPageAndJumpToWorkTreesView = 'gitlens.showSettingsPage#worktrees-view',
ShowSettingsPageAndJumpToViews = 'gitlens.showSettingsPage#views',
ShowStashesView = 'gitlens.showStashesView',
ShowTagsView = 'gitlens.showTagsView',
ShowWorktreesView = 'gitlens.showWorktreesView',
ShowWelcomePage = 'gitlens.showWelcomePage',
ShowWelcomeView = 'gitlens.showWelcomeView',
StashApply = 'gitlens.stashApply',

+ 11
- 0
src/container.ts View File

@ -50,6 +50,7 @@ import { StashesView } from './views/stashesView';
import { TagsView } from './views/tagsView';
import { ViewCommands } from './views/viewCommands';
import { ViewFileDecorationProvider } from './views/viewDecorationProvider';
import { WorktreesView } from './views/worktreesView';
import { VslsController } from './vsls/vsls';
import { HomeWebviewView } from './webviews/premium/home/homeWebviewView';
import { RebaseEditorProvider } from './webviews/rebase/rebaseEditor';
@ -182,6 +183,7 @@ export class Container {
context.subscriptions.push((this._remotesView = new RemotesView(this)));
context.subscriptions.push((this._stashesView = new StashesView(this)));
context.subscriptions.push((this._tagsView = new TagsView(this)));
context.subscriptions.push((this._worktreesView = new WorktreesView(this)));
context.subscriptions.push((this._contributorsView = new ContributorsView(this)));
context.subscriptions.push((this._searchAndCompareView = new SearchAndCompareView(this)));
@ -507,6 +509,15 @@ export class Container {
return this._welcomeWebview;
}
private _worktreesView: WorktreesView | undefined;
get worktreesView() {
if (this._worktreesView == null) {
this._context.subscriptions.push((this._worktreesView = new WorktreesView(this)));
}
return this._worktreesView;
}
private applyMode(config: Config) {
if (!config.mode.active) return config;

+ 44
- 0
src/env/node/git/git.ts View File

@ -27,6 +27,9 @@ export const GitErrors = {
noMergeBase: /no merge base/i,
notAValidObjectName: /Not a valid object name/i,
invalidLineCount: /file .+? has only \d+ lines/i,
uncommittedChanges: /contains modified or untracked files/i,
alreadyExists: /already exists/i,
alreadyCheckedOut: /already checked out/i,
};
const GitWarnings = {
@ -1492,6 +1495,47 @@ export class Git {
return this.git<string>({ cwd: repoPath }, 'tag', '-l', `--format=${GitTagParser.defaultFormat}`);
}
worktree__add(
repoPath: string,
path: string,
{
commitish,
createBranch,
detach,
force,
}: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean } = {},
) {
const params = ['worktree', 'add'];
if (force) {
params.push('--force');
}
if (createBranch) {
params.push('-b', createBranch);
}
if (detach) {
params.push('--detach');
}
params.push(path);
if (commitish) {
params.push(commitish);
}
return this.git<string>({ cwd: repoPath }, ...params);
}
worktree__list(repoPath: string) {
return this.git<string>({ cwd: repoPath }, 'worktree', 'list', '--porcelain');
}
worktree__remove(repoPath: string, worktree: string, { force }: { force?: boolean } = {}) {
const params = ['worktree', 'remove'];
if (force) {
params.push('--force');
}
params.push(worktree);
return this.git<string>({ cwd: repoPath, errors: GitErrorHandling.Throw }, ...params);
}
async readDotGitFile(
repoPath: string,
paths: string[],

+ 109
- 13
src/env/node/git/localGitProvider.ts View File

@ -1,5 +1,5 @@
import { readdir, realpath } from 'fs';
import { hostname, userInfo } from 'os';
import { homedir, hostname, userInfo } from 'os';
import { resolve as resolvePath } from 'path';
import {
Disposable,
@ -7,6 +7,8 @@ import {
Event,
EventEmitter,
extensions,
FileStat,
FileSystemError,
FileType,
Range,
TextDocument,
@ -26,7 +28,14 @@ import type {
import { configuration } from '../../../configuration';
import { CoreGitConfiguration, GlyphChars, Schemes } from '../../../constants';
import type { Container } from '../../../container';
import { StashApplyError, StashApplyErrorReason } from '../../../git/errors';
import {
StashApplyError,
StashApplyErrorReason,
WorktreeCreateError,
WorktreeCreateErrorReason,
WorktreeDeleteError,
WorktreeDeleteErrorReason,
} from '../../../git/errors';
import {
Features,
GitProvider,
@ -74,6 +83,7 @@ import {
GitTag,
GitTreeEntry,
GitUser,
GitWorktree,
isUserMatch,
Repository,
RepositoryChange,
@ -92,6 +102,7 @@ import {
GitStatusParser,
GitTagParser,
GitTreeParser,
GitWorktreeParser,
LogType,
} from '../../../git/parsers';
import { RemoteProviderFactory, RemoteProviders } from '../../../git/remotes/factory';
@ -110,6 +121,7 @@ import {
getBestPath,
isAbsolute,
isFolderGlob,
joinPaths,
maybeUri,
normalizePath,
relative,
@ -3675,11 +3687,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
if (ex instanceof Error) {
const msg: string = ex.message ?? '';
if (msg.includes('Your local changes to the following files would be overwritten by merge')) {
throw new StashApplyError(
'Unable to apply stash. Your working tree changes would be overwritten. Please commit or stash your changes before trying again',
StashApplyErrorReason.WorkingChanges,
ex,
);
throw new StashApplyError(StashApplyErrorReason.WorkingChanges, ex);
}
if (
@ -3693,14 +3701,10 @@ export class LocalGitProvider implements GitProvider, Disposable {
return;
}
throw new StashApplyError(
`Unable to apply stash \u2014 ${msg.trim().replace(/\n+?/g, '; ')}`,
undefined,
ex,
);
throw new StashApplyError(`Unable to apply stash \u2014 ${msg.trim().replace(/\n+?/g, '; ')}`, ex);
}
throw new StashApplyError(`Unable to apply stash \u2014 ${String(ex)}`, undefined, ex);
throw new StashApplyError(`Unable to apply stash \u2014 ${String(ex)}`, ex);
}
}
@ -3744,6 +3748,98 @@ export class LocalGitProvider implements GitProvider, Disposable {
});
}
@log()
async createWorktree(
repoPath: string,
path: string,
options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
) {
const uri = Uri.file(path);
let stat: FileStat | undefined;
try {
stat = await workspace.fs.stat(uri);
} catch (ex) {
if (!(ex instanceof FileSystemError && ex.code === FileSystemError.FileNotFound().code)) {
throw ex;
}
}
if (stat?.type !== FileType.Directory) {
await workspace.fs.createDirectory(uri);
}
try {
await this.git.worktree__add(repoPath, path, options);
} catch (ex) {
Logger.error(ex);
const msg = String(ex);
if (GitErrors.alreadyCheckedOut.test(msg)) {
throw new WorktreeCreateError(WorktreeCreateErrorReason.AlreadyCheckedOut, ex);
}
if (GitErrors.alreadyExists.test(msg)) {
throw new WorktreeCreateError(WorktreeCreateErrorReason.AlreadyExists, ex);
}
throw new WorktreeCreateError(undefined, ex);
}
}
@gate()
@log()
async getWorktrees(repoPath: string): Promise<GitWorktree[]> {
await this.ensureGitVersion(
'2.7.6',
'Displaying worktrees',
' Please install a more recent version of Git and try again.',
);
const data = await this.git.worktree__list(repoPath);
return GitWorktreeParser.parse(data, repoPath);
}
@log()
async getWorktreesDefaultUri(repoPath: string): Promise<Uri> {
let location = configuration.get(
'worktrees.defaultLocation',
workspace.getWorkspaceFolder(this.getAbsoluteUri(repoPath, repoPath)),
);
if (location == null) {
const dotGit = await this.getGitDir(repoPath);
return Uri.joinPath(Uri.file(dotGit), '.worktrees');
}
if (location.startsWith('~')) {
location = joinPaths(homedir(), location.slice(1));
}
return this.getAbsoluteUri(location, repoPath); //isAbsolute(location) ? GitUri.file(location) : ;
}
@log()
async deleteWorktree(repoPath: string, path: string, options?: { force?: boolean }) {
await this.ensureGitVersion(
'2.17.0',
'Deleting worktrees',
' Please install a more recent version of Git and try again.',
);
try {
await this.git.worktree__remove(repoPath, path, options);
} catch (ex) {
Logger.error(ex);
const msg = String(ex);
if (GitErrors.uncommittedChanges.test(msg)) {
throw new WorktreeDeleteError(WorktreeDeleteErrorReason.HasChanges, ex);
}
throw new WorktreeDeleteError(undefined, ex);
}
}
private _scmGitApi: Promise<BuiltInGitApi | undefined> | undefined;
private async getScmGitApi(): Promise<BuiltInGitApi | undefined> {
return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore());

+ 90
- 5
src/git/errors.ts View File

@ -3,13 +3,98 @@ export const enum StashApplyErrorReason {
}
export class StashApplyError extends Error {
constructor(
message: string,
public readonly reason: StashApplyErrorReason | undefined,
public readonly original?: Error,
) {
readonly original?: Error;
readonly reason: StashApplyErrorReason | undefined;
constructor(reason?: StashApplyErrorReason, original?: Error);
constructor(message?: string, original?: Error);
constructor(messageOrReason: string | StashApplyErrorReason | undefined, original?: Error) {
let message;
let reason: StashApplyErrorReason | undefined;
if (messageOrReason == null) {
message = 'Unable to apply stash';
} else if (typeof messageOrReason === 'string') {
message = messageOrReason;
reason = undefined;
} else {
reason = messageOrReason;
message =
'Unable to apply stash. Your working tree changes would be overwritten. Please commit or stash your changes before trying again';
}
super(message);
this.original = original;
this.reason = reason;
Error.captureStackTrace?.(this, StashApplyError);
}
}
export const enum WorktreeCreateErrorReason {
AlreadyCheckedOut = 1,
AlreadyExists = 2,
}
export class WorktreeCreateError extends Error {
readonly original?: Error;
readonly reason: WorktreeCreateErrorReason | undefined;
constructor(reason?: WorktreeCreateErrorReason, original?: Error);
constructor(message?: string, original?: Error);
constructor(messageOrReason: string | WorktreeCreateErrorReason | undefined, original?: Error) {
let message;
let reason: WorktreeCreateErrorReason | undefined;
if (messageOrReason == null) {
message = 'Unable to create worktree';
} else if (typeof messageOrReason === 'string') {
message = messageOrReason;
reason = undefined;
} else {
reason = messageOrReason;
switch (reason) {
case WorktreeCreateErrorReason.AlreadyCheckedOut:
message = 'Unable to create worktree because it is already checked out';
break;
case WorktreeCreateErrorReason.AlreadyExists:
message = 'Unable to create worktree because it already exists';
break;
}
}
super(message);
this.original = original;
this.reason = reason;
Error.captureStackTrace?.(this, WorktreeCreateError);
}
}
export const enum WorktreeDeleteErrorReason {
HasChanges = 1,
}
export class WorktreeDeleteError extends Error {
readonly original?: Error;
readonly reason: WorktreeDeleteErrorReason | undefined;
constructor(reason?: WorktreeDeleteErrorReason, original?: Error);
constructor(message?: string, original?: Error);
constructor(messageOrReason: string | WorktreeDeleteErrorReason | undefined, original?: Error) {
let message;
let reason: WorktreeDeleteErrorReason | undefined;
if (messageOrReason == null) {
message = 'Unable to delete worktree';
} else if (typeof messageOrReason === 'string') {
message = messageOrReason;
reason = undefined;
} else {
reason = messageOrReason;
if (reason === WorktreeDeleteErrorReason.HasChanges) {
message = 'Unable to delete worktree because there are uncommitted changes';
}
}
super(message);
this.original = original;
this.reason = reason;
Error.captureStackTrace?.(this, WorktreeDeleteError);
}
}

+ 16
- 2
src/git/gitProvider.ts View File

@ -27,6 +27,7 @@ import {
GitTag,
GitTreeEntry,
GitUser,
GitWorktree,
Repository,
RepositoryChangeEvent,
TagSortOptions,
@ -89,9 +90,13 @@ export interface RepositoryOpenEvent {
readonly uri: Uri;
}
export const enum Features {}
export const enum Features {
Worktrees = 'worktrees',
}
export const enum PremiumFeatures {}
export const enum PremiumFeatures {
Worktrees = 'worktrees',
}
export const enum RepositoryVisibility {
Private = 'private',
@ -426,6 +431,15 @@ export interface GitProvider extends Disposable {
uris?: Uri[],
options?: { includeUntracked?: boolean | undefined; keepIndex?: boolean | undefined },
): Promise<void>;
createWorktree?(
repoPath: string,
path: string,
options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
): Promise<void>;
getWorktrees?(repoPath: string): Promise<GitWorktree[]>;
getWorktreesDefaultUri?(repoPath: string): Promise<Uri>;
deleteWorktree?(repoPath: string, path: string, options?: { force?: boolean }): Promise<void>;
}
export interface RevisionUriData {

+ 28
- 1
src/git/gitProviderService.ts View File

@ -81,6 +81,7 @@ import {
GitTag,
GitTreeEntry,
GitUser,
GitWorktree,
PullRequest,
PullRequestState,
Repository,
@ -1798,7 +1799,6 @@ export class GitProviderService implements Disposable {
getBestRepository(uri?: Uri): Repository | undefined;
getBestRepository(editor?: TextEditor): Repository | undefined;
getBestRepository(uri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined;
@log<GitProviderService['getBestRepository']>({ exit: r => `returned ${r?.path}` })
getBestRepository(editorOrUri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined {
if (this.repositoryCount === 0) return undefined;
@ -2157,6 +2157,33 @@ export class GitProviderService implements Disposable {
}
@log()
createWorktree(
repoPath: string | Uri,
path: string,
options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
): Promise<void> {
const { provider, path: rp } = this.getProvider(repoPath);
return Promise.resolve(provider.createWorktree?.(rp, path, options));
}
@log()
async getWorktrees(repoPath: string | Uri): Promise<GitWorktree[]> {
const { provider, path } = this.getProvider(repoPath);
return (await provider.getWorktrees?.(path)) ?? [];
}
@log()
async getWorktreesDefaultUri(path: string | Uri): Promise<Uri | undefined> {
const { provider, path: rp } = this.getProvider(path);
return provider.getWorktreesDefaultUri?.(rp);
}
@log()
deleteWorktree(repoPath: string | Uri, path: string, options?: { force?: boolean }): Promise<void> {
const { provider, path: rp } = this.getProvider(repoPath);
return Promise.resolve(provider.deleteWorktree?.(rp, path, options));
}
@log()
async getOpenScmRepositories(): Promise<ScmRepository[]> {
const results = await Promise.allSettled([...this._providers.values()].map(p => p.getOpenScmRepositories()));
const repositories = flatMap<PromiseFulfilledResult<ScmRepository[]>, ScmRepository>(

+ 1
- 0
src/git/models.ts View File

@ -22,3 +22,4 @@ export * from './models/status';
export * from './models/tag';
export * from './models/tree';
export * from './models/user';
export * from './models/worktree';

+ 2
- 2
src/git/models/remote.ts View File

@ -9,7 +9,7 @@ export const enum GitRemoteType {
}
export class GitRemote<TProvider extends RemoteProvider | undefined = RemoteProvider | RichRemoteProvider | undefined> {
static getHighlanderProviders(remotes: GitRemote<RemoteProvider>[]) {
static getHighlanderProviders(remotes: GitRemote<RemoteProvider | RichRemoteProvider>[]) {
if (remotes.length === 0) return undefined;
const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default);
@ -21,7 +21,7 @@ export class GitRemote
return undefined;
}
static getHighlanderProviderName(remotes: GitRemote<RemoteProvider>[]) {
static getHighlanderProviderName(remotes: GitRemote<RemoteProvider | RichRemoteProvider>[]) {
if (remotes.length === 0) return undefined;
const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default);

+ 27
- 1
src/git/models/repository.ts View File

@ -43,6 +43,7 @@ import { GitRemote } from './remote';
import { GitStash } from './stash';
import { GitStatus } from './status';
import { GitTag, TagSortOptions } from './tag';
import { GitWorktree } from './worktree';
const millisecondsPerMinute = 60 * 1000;
const millisecondsPerHour = 60 * 60 * 1000;
@ -72,6 +73,7 @@ export const enum RepositoryChange {
*/
Status = 'status',
Tags = 'tags',
Worktrees = 'worktrees',
}
export const enum RepositoryChangeComparisonMode {
@ -247,6 +249,7 @@ export class Repository implements Disposable {
**/.git/refs/**,\
**/.git/rebase-merge/**,\
**/.git/sequencer/**,\
**/.git/worktrees/**,\
**/.gitignore\
}',
),
@ -306,7 +309,7 @@ export class Repository implements Disposable {
const match =
uri != null
? /(?<ignore>\/\.gitignore)|\.git\/(?<type>config|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|refs\/(?:heads|remotes|stash|tags))/.exec(
? /(?<ignore>\/\.gitignore)|\.git\/(?<type>config|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|refs\/(?:heads|remotes|stash|tags)|worktrees)/.exec(
uri.path,
)
: undefined;
@ -368,6 +371,10 @@ export class Repository implements Disposable {
case 'refs/tags':
this.fireChange(RepositoryChange.Tags);
return;
case 'worktrees':
this.fireChange(RepositoryChange.Worktrees);
return;
}
}
@ -603,6 +610,25 @@ export class Repository implements Disposable {
return this.container.git.getTags(this.path, options);
}
createWorktree(
uri: Uri,
options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
): Promise<void> {
return this.container.git.createWorktree(this.path, uri.fsPath, options);
}
getWorktrees(): Promise<GitWorktree[]> {
return this.container.git.getWorktrees(this.path);
}
async getWorktreesDefaultUri(): Promise<Uri | undefined> {
return this.container.git.getWorktreesDefaultUri(this.path);
}
deleteWorktree(uri: Uri, options?: { force?: boolean }): Promise<void> {
return this.container.git.deleteWorktree(this.path, uri.fsPath, options);
}
async hasRemotes(): Promise<boolean> {
const remotes = await this.getRemotes();
return remotes?.length > 0;

+ 66
- 0
src/git/models/worktree.ts View File

@ -0,0 +1,66 @@
import { Uri, workspace, WorkspaceFolder } from 'vscode';
import { Container } from '../../container';
import { memoize } from '../../system/decorators/memoize';
import { normalizePath, relative } from '../../system/path';
import { GitRevision } from './reference';
import type { GitStatus } from './status';
export class GitWorktree {
static is(worktree: any): worktree is GitWorktree {
return worktree instanceof GitWorktree;
}
constructor(
public readonly type: 'bare' | 'branch' | 'detached',
public readonly repoPath: string,
public readonly uri: Uri,
public readonly locked: boolean | string,
public readonly prunable: boolean | string,
public readonly sha?: string,
public readonly branch?: string,
) {}
get opened(): boolean {
return this.workspaceFolder?.uri.toString() === this.uri.toString();
}
get name(): string {
switch (this.type) {
case 'bare':
return '(bare)';
case 'detached':
return GitRevision.shorten(this.sha);
default:
return this.branch || this.friendlyPath;
}
}
@memoize()
get friendlyPath(): string {
const path = GitWorktree.getFriendlyPath(this.uri);
return path;
}
@memoize()
get workspaceFolder(): WorkspaceFolder | undefined {
return workspace.getWorkspaceFolder(this.uri);
}
private _status: Promise<GitStatus | undefined> | undefined;
getStatus(options?: { force?: boolean }): Promise<GitStatus | undefined> {
if (this.type === 'bare') return Promise.resolve(undefined);
if (this._status == null || options?.force) {
this._status = Container.instance.git.getStatusForRepo(this.uri.fsPath);
}
return this._status;
}
static getFriendlyPath(uri: Uri): string {
const folder = workspace.getWorkspaceFolder(uri);
if (folder == null) return normalizePath(uri.fsPath);
const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath));
return relativePath.length === 0 ? folder.name : relativePath;
}
}

+ 1
- 0
src/git/parsers.ts View File

@ -8,3 +8,4 @@ export * from './parsers/stashParser';
export * from './parsers/statusParser';
export * from './parsers/tagParser';
export * from './parsers/treeParser';
export * from './parsers/worktreeParser';

+ 88
- 0
src/git/parsers/worktreeParser.ts View File

@ -0,0 +1,88 @@
import { Uri } from 'vscode';
import { debug } from '../../system/decorators/log';
import { normalizePath } from '../../system/path';
import { getLines } from '../../system/string';
import { GitWorktree } from '../models/worktree';
interface WorktreeEntry {
path: string;
sha?: string;
branch?: string;
bare: boolean;
detached: boolean;
locked?: boolean | string;
prunable?: boolean | string;
}
export class GitWorktreeParser {
@debug({ args: false, singleLine: true })
static parse(data: string, repoPath: string): GitWorktree[] {
if (!data) return [];
if (repoPath !== undefined) {
repoPath = normalizePath(repoPath);
}
const worktrees: GitWorktree[] = [];
let entry: Partial<WorktreeEntry> | undefined = undefined;
let line: string;
let key: string;
let value: string;
let locked: string;
let prunable: string;
for (line of getLines(data)) {
[key, value] = line.split(' ', 2);
if (key.length === 0 && entry !== undefined) {
worktrees.push(
new GitWorktree(
entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch',
repoPath,
Uri.file(entry.path!),
entry.locked ?? false,
entry.prunable ?? false,
entry.sha,
entry.branch,
),
);
entry = undefined;
continue;
}
if (entry === undefined) {
entry = {};
}
switch (key) {
case 'worktree':
entry.path = value;
break;
case 'bare':
entry.bare = true;
break;
case 'HEAD':
entry.sha = value;
break;
case 'branch':
// Strip off refs/heads/
entry.branch = value.substr(11);
break;
case 'detached':
entry.detached = true;
break;
case 'locked':
[, locked] = value.split(' ', 2);
entry.locked = locked?.trim() || true;
break;
case 'prunable':
[, prunable] = value.split(' ', 2);
entry.prunable = prunable?.trim() || true;
break;
}
}
return worktrees;
}
}

+ 70
- 0
src/quickpicks/items/gitCommands.ts View File

@ -11,7 +11,9 @@ import {
GitReference,
GitRemoteType,
GitRevision,
GitStatus,
GitTag,
GitWorktree,
Repository,
} from '../../git/models';
import { fromNow } from '../../system/date';
@ -438,3 +440,71 @@ export namespace TagQuickPickItem {
return item;
}
}
export interface WorktreeQuickPickItem extends QuickPickItemOfT<GitWorktree> {
readonly opened: boolean;
readonly hasChanges: boolean | undefined;
}
export namespace WorktreeQuickPickItem {
export function create(
worktree: GitWorktree,
picked?: boolean,
options: {
alwaysShow?: boolean;
buttons?: QuickInputButton[];
checked?: boolean;
message?: boolean;
ref?: boolean;
type?: boolean;
status?: GitStatus;
} = {},
) {
let description = '';
if (options.type) {
description = 'worktree';
}
if (options.status != null) {
description += options.status.hasChanges
? pad(`Uncommited Changes (${options.status.getFormattedDiffStatus()})`, description ? 2 : 0, 0)
: pad('No Changes', description ? 2 : 0, 0);
}
if (options.ref) {
description += `${description ? pad(GlyphChars.Dot, 2, 2) : ''}${worktree.friendlyPath}`;
}
let icon;
let label;
switch (worktree.type) {
case 'bare':
label = '(bare)';
icon = '$(folder)';
break;
case 'branch':
label = worktree.branch!;
icon = '$(git-branch)';
break;
case 'detached':
label = GitRevision.shorten(worktree.sha);
icon = '$(git-commit)';
break;
}
const item: WorktreeQuickPickItem = {
label: `${pad(icon, 0, 2)}${label}${
options.checked ? `${GlyphChars.Space.repeat(2)}$(check)${GlyphChars.Space}` : ''
}`,
description: description,
alwaysShow: options.alwaysShow,
buttons: options.buttons,
picked: picked,
item: worktree,
opened: worktree.opened,
hasChanges: options.status?.hasChanges,
};
return item;
}
}

+ 2
- 0
src/views/nodes.ts View File

@ -42,3 +42,5 @@ export * from './nodes/statusFileNode';
export * from './nodes/statusFilesNode';
export * from './nodes/tagsNode';
export * from './nodes/tagNode';
export * from './nodes/worktreeNode';
export * from './nodes/worktreesNode';

+ 123
- 0
src/views/nodes/UncommittedFileNode.ts View File

@ -0,0 +1,123 @@
import { Command, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { DiffWithPreviousCommandArgs } from '../../commands';
import { Commands } from '../../constants';
import { StatusFileFormatter } from '../../git/formatters';
import { GitUri } from '../../git/gitUri';
import { GitFile } from '../../git/models';
import { dirname, joinPaths } from '../../system/path';
import { ViewsWithCommits } from '../viewBase';
import { FileNode } from './folderNode';
import { ContextValues, ViewNode } from './viewNode';
export class UncommittedFileNode extends ViewNode<ViewsWithCommits> implements FileNode {
public readonly file: GitFile;
public readonly repoPath: string;
constructor(view: ViewsWithCommits, parent: ViewNode, repoPath: string, file: GitFile) {
super(GitUri.fromFile(file, repoPath), view, parent);
this.repoPath = repoPath;
this.file = file;
}
override toClipboard(): string {
return this.path;
}
get path(): string {
return this.file.path;
}
getChildren(): ViewNode[] {
return [];
}
getTreeItem(): TreeItem {
const item = new TreeItem(this.label, TreeItemCollapsibleState.None);
item.contextValue = ContextValues.File;
item.description = this.description;
// Use the file icon and decorations
item.resourceUri = this.view.container.git.getAbsoluteUri(this.file.path, this.repoPath);
const icon = GitFile.getStatusIcon(this.file.status);
item.iconPath = {
dark: this.view.container.context.asAbsolutePath(joinPaths('images', 'dark', icon)),
light: this.view.container.context.asAbsolutePath(joinPaths('images', 'light', icon)),
};
item.tooltip = StatusFileFormatter.fromTemplate(
`\${file}\n\${directory}/\n\n\${status}\${ (originalPath)}`,
this.file,
);
item.command = this.getCommand();
// Only cache the label/description for a single refresh
this._label = undefined;
this._description = undefined;
return item;
}
private _description: string | undefined;
get description() {
if (this._description == null) {
this._description = StatusFileFormatter.fromTemplate(
this.view.config.formats.files.description,
{ ...this.file },
{ relativePath: this.relativePath },
);
}
return this._description;
}
private _folderName: string | undefined;
get folderName() {
if (this._folderName == null) {
this._folderName = dirname(this.uri.relativePath);
}
return this._folderName;
}
private _label: string | undefined;
get label() {
if (this._label == null) {
this._label = StatusFileFormatter.fromTemplate(
`\${file}`,
{ ...this.file },
{ relativePath: this.relativePath },
);
}
return this._label;
}
get priority(): number {
return 0;
}
private _relativePath: string | undefined;
get relativePath(): string | undefined {
return this._relativePath;
}
set relativePath(value: string | undefined) {
this._relativePath = value;
this._label = undefined;
this._description = undefined;
}
override getCommand(): Command | undefined {
const commandArgs: DiffWithPreviousCommandArgs = {
uri: GitUri.fromFile(this.file, this.repoPath),
line: 0,
showOptions: {
preserveFocus: true,
preview: true,
},
};
return {
title: 'Open Changes with Previous Revision',
command: Commands.DiffWithPrevious,
arguments: [undefined, commandArgs],
};
}
}

+ 143
- 0
src/views/nodes/UncommittedFilesNode.ts View File

@ -0,0 +1,143 @@
'use strict';
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { ViewFilesLayout } from '../../config';
import { GitUri } from '../../git/gitUri';
import {
GitCommit,
GitCommitIdentity,
GitFileChange,
GitFileWithCommit,
GitRevision,
GitStatus,
GitStatusFile,
GitTrackingState,
} from '../../git/models';
import { groupBy, makeHierarchical } from '../../system/array';
import { flatMap } from '../../system/iterable';
import { joinPaths, normalizePath } from '../../system/path';
import { RepositoriesView } from '../repositoriesView';
import { WorktreesView } from '../worktreesView';
import { FileNode, FolderNode } from './folderNode';
import { RepositoryNode } from './repositoryNode';
import { UncommittedFileNode } from './UncommittedFileNode';
import { ContextValues, ViewNode } from './viewNode';
export class UncommittedFilesNode extends ViewNode<RepositoriesView | WorktreesView> {
static key = ':uncommitted-files';
static getId(repoPath: string): string {
return `${RepositoryNode.getId(repoPath)}${this.key}`;
}
readonly repoPath: string;
constructor(
view: RepositoriesView | WorktreesView,
parent: ViewNode,
public readonly status:
| GitStatus
| {
readonly repoPath: string;
readonly files: GitStatusFile[];
readonly state: GitTrackingState;
readonly upstream?: string;
},
public readonly range: string | undefined,
) {
super(GitUri.fromRepoPath(status.repoPath), view, parent);
this.repoPath = status.repoPath;
}
override get id(): string {
return UncommittedFilesNode.getId(this.repoPath);
}
getChildren(): ViewNode[] {
const repoPath = this.repoPath;
const files: GitFileWithCommit[] = [
...flatMap(this.status.files, f => {
if (f.workingTreeStatus != null && f.indexStatus != null) {
// Decrements the date to guarantee this entry will be sorted after the previous entry (most recent first)
const older = new Date();
older.setMilliseconds(older.getMilliseconds() - 1);
return [
this.getFileWithPseudoCommit(f, GitRevision.uncommitted, GitRevision.uncommittedStaged),
this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD', older),
];
} else if (f.indexStatus != null) {
return [this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD')];
}
return [this.getFileWithPseudoCommit(f, GitRevision.uncommitted, 'HEAD')];
}),
];
files.sort((a, b) => b.commit.date.getTime() - a.commit.date.getTime());
const groups = groupBy(files, f => f.path);
let children: FileNode[] = Object.values(groups).map(
files => new UncommittedFileNode(this.view, this, repoPath, files[files.length - 1]),
);
if (this.view.config.files.layout !== ViewFilesLayout.List) {
const hierarchy = makeHierarchical(
children,
n => n.uri.relativePath.split('/'),
(...parts: string[]) => normalizePath(joinPaths(...parts)),
this.view.config.files.compact,
);
const root = new FolderNode(this.view, this, repoPath, '', hierarchy, true);
children = root.getChildren() as FileNode[];
} else {
children.sort(
(a, b) =>
a.priority - b.priority ||
a.label!.localeCompare(b.label!, undefined, { numeric: true, sensitivity: 'base' }),
);
}
return children;
}
getTreeItem(): TreeItem {
const item = new TreeItem('Uncommitted changes', TreeItemCollapsibleState.Collapsed);
item.id = this.id;
item.contextValue = ContextValues.UncommittedFiles;
item.iconPath = new ThemeIcon('folder');
return item;
}
private getFileWithPseudoCommit(
file: GitStatusFile,
ref: string,
previousRef: string,
date?: Date,
): GitFileWithCommit {
date = date ?? new Date();
return {
status: file.status,
repoPath: file.repoPath,
indexStatus: file.indexStatus,
workingTreeStatus: file.workingTreeStatus,
path: file.path,
originalPath: file.originalPath,
commit: new GitCommit(
this.view.container,
file.repoPath,
ref,
new GitCommitIdentity('You', undefined, date),
new GitCommitIdentity('You', undefined, date),
'Uncommitted changes',
[previousRef],
'Uncommitted changes',
new GitFileChange(file.repoPath, file.path, file.status, file.originalPath, previousRef),
undefined,
[],
),
};
}
}

+ 5
- 0
src/views/nodes/repositoryNode.ts View File

@ -31,6 +31,7 @@ import { StashesNode } from './stashesNode';
import { StatusFilesNode } from './statusFilesNode';
import { TagsNode } from './tagsNode';
import { ContextValues, SubscribeableViewNode, ViewNode } from './viewNode';
import { WorktreesNode } from './worktreesNode';
export class RepositoryNode extends SubscribeableViewNode<RepositoriesView> {
static key = ':repository';
@ -159,6 +160,10 @@ export class RepositoryNode extends SubscribeableViewNode {
children.push(new TagsNode(this.uri, this.view, this, this.repo));
}
if (this.view.config.showWorktrees) {
children.push(new WorktreesNode(this.uri, this.view, this, this.repo));
}
if (this.view.config.showContributors) {
children.push(new ContributorsNode(this.uri, this.view, this, this.repo));
}

+ 9
- 4
src/views/nodes/statusFilesNode.ts View File

@ -7,12 +7,13 @@ import { filter, flatMap, map } from '../../system/iterable';
import { joinPaths, normalizePath } from '../../system/path';
import { pluralize, sortCompare } from '../../system/string';
import { RepositoriesView } from '../repositoriesView';
import { WorktreesView } from '../worktreesView';
import { FileNode, FolderNode } from './folderNode';
import { RepositoryNode } from './repositoryNode';
import { StatusFileNode } from './statusFileNode';
import { ContextValues, ViewNode } from './viewNode';
export class StatusFilesNode extends ViewNode<RepositoriesView> {
export class StatusFilesNode extends ViewNode<RepositoriesView | WorktreesView> {
static key = ':status-files';
static getId(repoPath: string): string {
return `${RepositoryNode.getId(repoPath)}${this.key}`;
@ -21,7 +22,7 @@ export class StatusFilesNode extends ViewNode {
readonly repoPath: string;
constructor(
view: RepositoriesView,
view: RepositoriesView | WorktreesView,
parent: ViewNode,
public readonly status:
| GitStatus
@ -66,7 +67,10 @@ export class StatusFilesNode extends ViewNode {
}
}
if (this.view.config.includeWorkingTree && this.status.files.length !== 0) {
if (
(this.view instanceof WorktreesView || this.view.config.includeWorkingTree) &&
this.status.files.length !== 0
) {
files.splice(
0,
0,
@ -109,7 +113,8 @@ export class StatusFilesNode extends ViewNode {
}
async getTreeItem(): Promise<TreeItem> {
let files = this.view.config.includeWorkingTree ? this.status.files.length : 0;
let files =
this.view instanceof WorktreesView || this.view.config.includeWorkingTree ? this.status.files.length : 0;
if (this.range != null) {
if (this.status.upstream != null && this.status.state.ahead > 0) {

+ 3
- 0
src/views/nodes/viewNode.ts View File

@ -84,6 +84,9 @@ export const enum ContextValues {
StatusSameAsUpstream = 'gitlens:status:upstream:same',
Tag = 'gitlens:tag',
Tags = 'gitlens:tags',
UncommittedFiles = 'gitlens:uncommitted:files',
Worktree = 'gitlens:worktree',
Worktrees = 'gitlens:worktrees',
}
export interface ViewNode {

+ 256
- 0
src/views/nodes/worktreeNode.ts View File

@ -0,0 +1,256 @@
import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode';
import { GlyphChars } from '../../constants';
import { GitUri } from '../../git/gitUri';
import { GitLog, GitRemote, GitRemoteType, GitRevision, GitWorktree } from '../../git/models';
import { gate } from '../../system/decorators/gate';
import { debug } from '../../system/decorators/log';
import { map } from '../../system/iterable';
import { pad } from '../../system/string';
import { RepositoriesView } from '../repositoriesView';
import { WorktreesView } from '../worktreesView';
import { CommitNode } from './commitNode';
import { LoadMoreNode, MessageNode } from './common';
import { insertDateMarkers } from './helpers';
import { RepositoryNode } from './repositoryNode';
import { UncommittedFilesNode } from './UncommittedFilesNode';
import { ContextValues, ViewNode } from './viewNode';
export class WorktreeNode extends ViewNode<WorktreesView | RepositoriesView> {
static key = ':worktree';
static getId(repoPath: string, uri: Uri): string {
return `${RepositoryNode.getId(repoPath)}${this.key}(${uri.path})`;
}
constructor(
uri: GitUri,
view: WorktreesView | RepositoriesView,
parent: ViewNode,
public readonly worktree: GitWorktree,
) {
super(uri, view, parent);
}
override toClipboard(): string {
return this.worktree.uri.fsPath;
}
override get id(): string {
return WorktreeNode.getId(this.worktree.repoPath, this.worktree.uri);
}
get repoPath(): string {
return this.uri.repoPath!;
}
async getChildren(): Promise<ViewNode[]> {
const log = await this.getLog();
if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')];
const getBranchAndTagTips = await this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath);
const children = [
...insertDateMarkers(
map(
log.commits.values(),
c => new CommitNode(this.view, this, c, undefined, undefined, getBranchAndTagTips),
),
this,
),
];
if (log.hasMore) {
children.push(new LoadMoreNode(this.view, this, children[children.length - 1]));
}
const status = await this.worktree.getStatus();
if (status?.hasChanges) {
children.splice(0, 0, new UncommittedFilesNode(this.view, this, status, undefined));
}
return children;
}
async getTreeItem(): Promise<TreeItem> {
this.splatted = false;
let description = '';
const tooltip = new MarkdownString('', true);
let icon: ThemeIcon | undefined;
let hasChanges = false;
switch (this.worktree.type) {
case 'bare':
icon = new ThemeIcon('folder');
tooltip.appendMarkdown(`Bare Worktree\\\n\`${this.worktree.friendlyPath}\``);
break;
case 'branch': {
const [branch, status] = await Promise.all([
this.worktree.branch
? this.view.container.git
.getBranches(this.uri.repoPath, { filter: b => b.name === this.worktree.branch })
.then(b => b.values[0])
: undefined,
this.worktree.getStatus(),
]);
tooltip.appendMarkdown(
`Worktree for Branch $(git-branch) ${branch?.getNameWithoutRemote() ?? this.worktree.branch}${
this.worktree.opened ? `${pad(GlyphChars.Dash, 2, 2)} _Active_ ` : ''
}\\\n\`${this.worktree.friendlyPath}\``,
);
icon = new ThemeIcon('git-branch');
if (status != null) {
hasChanges = status.hasChanges;
tooltip.appendMarkdown(
`\n\n${status.getFormattedDiffStatus({
prefix: 'Has Uncommitted Changes\\\n',
empty: 'No Uncommitted Changes',
expand: true,
})}`,
);
}
if (branch != null) {
tooltip.appendMarkdown(`\n\nBranch $(git-branch) ${branch.getNameWithoutRemote()}`);
if (!branch.remote) {
if (branch.upstream != null) {
let arrows = GlyphChars.Dash;
const remote = await branch.getRemote();
if (!branch.upstream.missing) {
if (remote != null) {
let left;
let right;
for (const { type } of remote.urls) {
if (type === GitRemoteType.Fetch) {
left = true;
if (right) break;
} else if (type === GitRemoteType.Push) {
right = true;
if (left) break;
}
}
if (left && right) {
arrows = GlyphChars.ArrowsRightLeft;
} else if (right) {
arrows = GlyphChars.ArrowRight;
} else if (left) {
arrows = GlyphChars.ArrowLeft;
}
}
} else {
arrows = GlyphChars.Warning;
}
description = `${branch.getTrackingStatus({
empty: pad(arrows, 0, 2),
suffix: pad(arrows, 2, 2),
})}${branch.upstream.name}`;
tooltip.appendMarkdown(
` is ${branch.getTrackingStatus({
empty: branch.upstream.missing
? `missing upstream $(git-branch) ${branch.upstream.name}`
: `up to date with $(git-branch) ${branch.upstream.name}${
remote?.provider?.name ? ` on ${remote.provider.name}` : ''
}`,
expand: true,
icons: true,
separator: ', ',
suffix: ` $(git-branch) ${branch.upstream.name}${
remote?.provider?.name ? ` on ${remote.provider.name}` : ''
}`,
})}`,
);
} else {
const providerName = GitRemote.getHighlanderProviderName(
await this.view.container.git.getRemotesWithProviders(branch.repoPath),
);
tooltip.appendMarkdown(` hasn't been published to ${providerName ?? 'a remote'}`);
}
}
}
break;
}
case 'detached': {
icon = new ThemeIcon('git-commit');
tooltip.appendMarkdown(
`Detached Worktree at $(git-commit) ${GitRevision.shorten(this.worktree.sha)}${
this.worktree.opened ? `${pad(GlyphChars.Dash, 2, 2)} _Active_` : ''
}\\\n\`${this.worktree.friendlyPath}\``,
);
const status = await this.worktree.getStatus();
if (status != null) {
hasChanges = status.hasChanges;
tooltip.appendMarkdown(
`\n\n${status.getFormattedDiffStatus({
prefix: 'Has Uncommitted Changes',
empty: 'No Uncommitted Changes',
expand: true,
})}`,
);
}
break;
}
}
const item = new TreeItem(this.worktree.name, TreeItemCollapsibleState.Collapsed);
item.id = this.id;
item.description = description;
item.contextValue = `${ContextValues.Worktree}${this.worktree.opened ? '+active' : ''}`;
item.iconPath = this.worktree.opened ? new ThemeIcon('check') : icon;
item.tooltip = tooltip;
item.resourceUri = hasChanges ? Uri.parse('gitlens-view://worktree/changes') : undefined;
return item;
}
@gate()
@debug()
override refresh(reset?: boolean) {
if (reset) {
this._log = undefined;
}
}
private _log: GitLog | undefined;
private async getLog() {
if (this._log == null) {
this._log = await this.view.container.git.getLog(this.uri.repoPath!, {
ref: this.worktree.sha,
limit: this.limit ?? this.view.config.defaultItemLimit,
});
}
return this._log;
}
get hasMore() {
return this._log?.hasMore ?? true;
}
limit: number | undefined = this.view.getNodeLastKnownLimit(this);
@gate()
async loadMore(limit?: number | { until?: any }) {
let log = await window.withProgress(
{
location: { viewId: this.view.id },
},
() => this.getLog(),
);
if (log == null || !log.hasMore) return;
log = await log.more?.(limit ?? this.view.config.pageItemLimit);
if (this._log === log) return;
this._log = log;
this.limit = log?.count;
void this.triggerChange(false);
}
}

+ 63
- 0
src/views/nodes/worktreesNode.ts View File

@ -0,0 +1,63 @@
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { GitUri } from '../../git/gitUri';
import { Repository } from '../../git/models';
import { gate } from '../../system/decorators/gate';
import { debug } from '../../system/decorators/log';
import { RepositoriesView } from '../repositoriesView';
import { WorktreesView } from '../worktreesView';
import { MessageNode } from './common';
import { RepositoryNode } from './repositoryNode';
import { ContextValues, ViewNode } from './viewNode';
import { WorktreeNode } from './worktreeNode';
export class WorktreesNode extends ViewNode<WorktreesView | RepositoriesView> {
static key = ':worktrees';
static getId(repoPath: string): string {
return `${RepositoryNode.getId(repoPath)}${this.key}`;
}
private _children: WorktreeNode[] | undefined;
constructor(
uri: GitUri,
view: WorktreesView | RepositoriesView,
parent: ViewNode,
public readonly repo: Repository,
) {
super(uri, view, parent);
}
override get id(): string {
return WorktreesNode.getId(this.repo.path);
}
get repoPath(): string {
return this.repo.path;
}
async getChildren(): Promise<ViewNode[]> {
if (this._children == null) {
const worktrees = await this.repo.getWorktrees();
if (worktrees.length === 0) return [new MessageNode(this.view, this, 'No worktrees could be found.')];
this._children = worktrees.map(c => new WorktreeNode(this.uri, this.view, this, c));
}
return this._children;
}
getTreeItem(): TreeItem {
const item = new TreeItem('Worktrees', TreeItemCollapsibleState.Collapsed);
item.id = this.id;
item.contextValue = ContextValues.Worktrees;
// TODO@eamodio `folder` icon won't work here for some reason
item.iconPath = new ThemeIcon('folder-opened');
return item;
}
@gate()
@debug()
override refresh() {
this._children = undefined;
}
}

+ 99
- 2
src/views/repositoriesView.ts View File

@ -28,6 +28,7 @@ import {
GitRevisionReference,
GitStashReference,
GitTagReference,
GitWorktree,
} from '../git/models';
import { WorkspaceStorageKeys } from '../storage';
import { executeCommand } from '../system/command';
@ -48,6 +49,8 @@ import {
StashesNode,
StashNode,
TagsNode,
WorktreeNode,
WorktreesNode,
} from './nodes';
import { ViewBase } from './viewBase';
@ -217,6 +220,16 @@ export class RepositoriesView extends ViewBase
),
commands.registerCommand(
this.getQualifiedCommand('setShowWorktreesOn'),
() => this.toggleSection('showWorktrees', true),
this,
),
commands.registerCommand(
this.getQualifiedCommand('setShowWorktreesOff'),
() => this.toggleSection('showWorktrees', false),
this,
),
commands.registerCommand(
this.getQualifiedCommand('setShowUpstreamStatusOn'),
() => this.toggleSection('showUpstreamStatus', true),
this,
@ -239,7 +252,8 @@ export class RepositoriesView extends ViewBase
| ReflogNode
| RemotesNode
| StashesNode
| TagsNode,
| TagsNode
| WorktreesNode,
) => this.toggleSectionByNode(node, false),
this,
),
@ -483,6 +497,25 @@ export class RepositoriesView extends ViewBase
});
}
findWorktree(worktree: GitWorktree, token?: CancellationToken) {
const repoNodeId = RepositoryNode.getId(worktree.repoPath);
return this.findNode(WorktreeNode.getId(worktree.repoPath, worktree.uri), {
maxDepth: 2,
canTraverse: n => {
// Only search for worktree nodes in the same repo within WorktreesNode
if (n instanceof RepositoriesNode) return true;
if (n instanceof RepositoryNode || n instanceof WorktreesNode) {
return n.id.startsWith(repoNodeId);
}
return false;
},
token: token,
});
}
@gate(() => '')
revealBranch(
branch: GitBranchReference,
@ -770,6 +803,64 @@ export class RepositoriesView extends ViewBase
return node;
}
@gate(() => '')
revealWorktree(
worktree: GitWorktree,
options?: {
select?: boolean;
focus?: boolean;
expand?: boolean | number;
},
) {
return window.withProgress(
{
location: ProgressLocation.Notification,
title: `Revealing worktree '${worktree.name}' in the side bar...`,
cancellable: true,
},
async (progress, token) => {
const node = await this.findWorktree(worktree, token);
if (node == null) return undefined;
await this.ensureRevealNode(node, options);
return node;
},
);
}
@gate(() => '')
async revealWorktrees(
repoPath: string,
options?: {
select?: boolean;
focus?: boolean;
expand?: boolean | number;
},
) {
const repoNodeId = RepositoryNode.getId(repoPath);
const node = await this.findNode(WorktreesNode.getId(repoPath), {
maxDepth: 2,
canTraverse: n => {
// Only search for worktrees nodes in the same repo
if (n instanceof RepositoriesNode) return true;
if (n instanceof RepositoryNode) {
return n.id.startsWith(repoNodeId);
}
return false;
},
});
if (node !== undefined) {
await this.reveal(node, options);
}
return node;
}
private async setAutoRefresh(enabled: boolean, workspaceEnabled?: boolean) {
if (enabled) {
if (workspaceEnabled === undefined) {
@ -825,6 +916,7 @@ export class RepositoriesView extends ViewBase
| 'showRemotes'
| 'showStashes'
| 'showTags'
| 'showWorktrees'
| 'showUpstreamStatus',
enabled: boolean,
) {
@ -841,7 +933,8 @@ export class RepositoriesView extends ViewBase
| ReflogNode
| RemotesNode
| StashesNode
| TagsNode,
| TagsNode
| WorktreesNode,
enabled: boolean,
) {
if (node instanceof BranchesNode) {
@ -880,6 +973,10 @@ export class RepositoriesView extends ViewBase
return configuration.updateEffective(`views.${this.configKey}.showTags` as const, enabled);
}
if (node instanceof WorktreesNode) {
return configuration.updateEffective(`views.${this.configKey}.showWorktrees` as const, enabled);
}
return Promise.resolve();
}
}

+ 6
- 2
src/views/viewBase.ts View File

@ -29,6 +29,7 @@ import {
viewsCommonConfigKeys,
viewsConfigKeys,
ViewsConfigKeys,
WorktreesViewConfig,
} from '../configuration';
import { Container } from '../container';
import { Logger } from '../logger';
@ -48,6 +49,7 @@ import { RepositoriesView } from './repositoriesView';
import { SearchAndCompareView } from './searchAndCompareView';
import { StashesView } from './stashesView';
import { TagsView } from './tagsView';
import { WorktreesView } from './worktreesView';
export type View =
| BranchesView
@ -59,7 +61,8 @@ export type View =
| RepositoriesView
| SearchAndCompareView
| StashesView
| TagsView;
| TagsView
| WorktreesView;
export type ViewsWithCommits = Exclude<View, FileHistoryView | LineHistoryView | StashesView>;
export type ViewsWithRepositoryFolders = Exclude<View, RepositoriesView | FileHistoryView | LineHistoryView>;
@ -79,7 +82,8 @@ export abstract class ViewBase<
| RepositoriesViewConfig
| SearchAndCompareViewConfig
| StashesViewConfig
| TagsViewConfig,
| TagsViewConfig
| WorktreesViewConfig,
> implements TreeDataProvider<ViewNode>, Disposable
{
protected _onDidChangeTreeData = new EventEmitter<ViewNode | undefined>();

+ 32
- 0
src/views/viewCommands.ts View File

@ -21,6 +21,7 @@ import {
executeEditorCommand,
} from '../system/command';
import { debug } from '../system/decorators/log';
import { OpenWorkspaceLocation } from '../system/utils';
import { runGitCommandInTerminal } from '../terminal';
import {
BranchesNode,
@ -56,6 +57,7 @@ import {
ViewNode,
ViewRefFileNode,
ViewRefNode,
WorktreeNode,
} from './nodes';
interface CompareSelectedInfo {
@ -226,6 +228,15 @@ export class ViewCommands {
commands.registerCommand('gitlens.views.createPullRequest', this.createPullRequest, this);
commands.registerCommand('gitlens.views.openPullRequest', this.openPullRequest, this);
commands.registerCommand('gitlens.views.createWorktree', this.createWorktree, this);
commands.registerCommand('gitlens.views.deleteWorktree', this.deleteWorktree, this);
commands.registerCommand('gitlens.views.openWorktree', this.openWorktree, this);
commands.registerCommand(
'gitlens.views.openWorktreeInNewWindow',
n => this.openWorktree(n, { location: OpenWorkspaceLocation.NewWindow }),
this,
);
}
@debug()
@ -304,6 +315,27 @@ export class ViewCommands {
}
@debug()
private async createWorktree(node?: BranchNode) {
if (node !== undefined && !(node instanceof BranchNode)) return undefined;
return GitActions.Worktree.create(node?.repoPath, undefined, node?.ref);
}
@debug()
private openWorktree(node: WorktreeNode, options?: { location?: OpenWorkspaceLocation }) {
if (!(node instanceof WorktreeNode)) return undefined;
return GitActions.Worktree.open(node.worktree, options);
}
@debug()
private async deleteWorktree(node: WorktreeNode) {
if (!(node instanceof WorktreeNode)) return undefined;
return GitActions.Worktree.remove(node.repoPath, node.worktree.uri);
}
@debug()
private async createPullRequest(node: BranchNode | BranchTrackingStatusNode) {
if (!(node instanceof BranchNode) && !(node instanceof BranchTrackingStatusNode)) {
return Promise.resolve();

+ 263
- 0
src/views/worktreesView.ts View File

@ -0,0 +1,263 @@
import {
CancellationToken,
commands,
ConfigurationChangeEvent,
Disposable,
ProgressLocation,
ThemeColor,
TreeItem,
TreeItemCollapsibleState,
window,
} from 'vscode';
import { configuration, ViewFilesLayout, WorktreesViewConfig } from '../configuration';
import { Container } from '../container';
import { PremiumFeatures } from '../git/gitProvider';
import { GitUri } from '../git/gitUri';
import { GitWorktree, RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../git/models';
import { gate } from '../system/decorators/gate';
import {
RepositoriesSubscribeableNode,
RepositoryFolderNode,
RepositoryNode,
ViewNode,
WorktreeNode,
WorktreesNode,
} from './nodes';
import { ViewBase } from './viewBase';
export class WorktreesRepositoryNode extends RepositoryFolderNode<WorktreesView, WorktreesNode> {
getChildren(): Promise<ViewNode[]> {
if (this.child == null) {
this.child = new WorktreesNode(this.uri, this.view, this, this.repo);
}
return this.child.getChildren();
}
protected changed(e: RepositoryChangeEvent) {
return e.changed(
RepositoryChange.Config,
RepositoryChange.Worktrees,
RepositoryChange.Unknown,
RepositoryChangeComparisonMode.Any,
);
}
}
export class WorktreesViewNode extends RepositoriesSubscribeableNode<WorktreesView, WorktreesRepositoryNode> {
async getChildren(): Promise<ViewNode[]> {
const access = await this.view.container.git.access(PremiumFeatures.Worktrees);
if (!access.allowed) return [];
if (this.children == null) {
const repositories = this.view.container.git.openRepositories;
if (repositories.length === 0) {
this.view.message = 'No worktrees could be found.';
return [];
}
this.view.message = undefined;
const splat = repositories.length === 1;
this.children = repositories.map(
r => new WorktreesRepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r, splat),
);
}
if (this.children.length === 1) {
const [child] = this.children;
const children = await child.getChildren();
if (children.length <= 1) {
this.view.message = undefined;
this.view.title = 'Worktrees';
void child.ensureSubscription();
return [];
}
this.view.message = undefined;
this.view.title = `Worktrees (${children.length})`;
return children;
}
return this.children;
}
getTreeItem(): TreeItem {
const item = new TreeItem('Worktrees', TreeItemCollapsibleState.Expanded);
return item;
}
}
export class WorktreesView extends ViewBase<WorktreesViewNode, WorktreesViewConfig> {
protected readonly configKey = 'worktrees';
constructor(container: Container) {
super('gitlens.views.worktrees', 'Worktrees', container);
this.disposables.push(
window.registerFileDecorationProvider({
provideFileDecoration: (uri, _token) => {
if (
uri.scheme !== 'gitlens-view' ||
uri.authority !== 'worktree' ||
!uri.path.includes('/changes')
) {
return undefined;
}
return {
badge: '●',
color: new ThemeColor('gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor'),
tooltip: 'Has Uncommitted Changes',
};
},
}),
);
}
override get canReveal(): boolean {
return this.config.reveal || !configuration.get('views.repositories.showWorktrees');
}
protected getRoot() {
return new WorktreesViewNode(this);
}
protected registerCommands(): Disposable[] {
void this.container.viewCommands;
return [
commands.registerCommand(
this.getQualifiedCommand('copy'),
() => commands.executeCommand('gitlens.views.copy', this.selection),
this,
),
commands.registerCommand(
this.getQualifiedCommand('refresh'),
async () => {
// this.container.git.resetCaches('worktrees');
return this.refresh(true);
},
this,
),
commands.registerCommand(
this.getQualifiedCommand('setFilesLayoutToAuto'),
() => this.setFilesLayout(ViewFilesLayout.Auto),
this,
),
commands.registerCommand(
this.getQualifiedCommand('setFilesLayoutToList'),
() => this.setFilesLayout(ViewFilesLayout.List),
this,
),
commands.registerCommand(
this.getQualifiedCommand('setFilesLayoutToTree'),
() => this.setFilesLayout(ViewFilesLayout.Tree),
this,
),
commands.registerCommand(
this.getQualifiedCommand('setShowAvatarsOn'),
() => this.setShowAvatars(true),
this,
),
commands.registerCommand(
this.getQualifiedCommand('setShowAvatarsOff'),
() => this.setShowAvatars(false),
this,
),
];
}
protected override filterConfigurationChanged(e: ConfigurationChangeEvent) {
const changed = super.filterConfigurationChanged(e);
if (
!changed &&
!configuration.changed(e, 'defaultDateFormat') &&
!configuration.changed(e, 'defaultDateShortFormat') &&
!configuration.changed(e, 'defaultDateSource') &&
!configuration.changed(e, 'defaultDateStyle') &&
!configuration.changed(e, 'defaultGravatarsStyle') &&
!configuration.changed(e, 'defaultTimeFormat')
// !configuration.changed(e, 'sortWorktreesBy')
) {
return false;
}
return true;
}
findWorktree(worktree: GitWorktree, token?: CancellationToken) {
const repoNodeId = RepositoryNode.getId(worktree.repoPath);
return this.findNode(WorktreeNode.getId(worktree.repoPath, worktree.uri), {
maxDepth: 2,
canTraverse: n => {
if (n instanceof WorktreesViewNode) return true;
if (n instanceof WorktreesRepositoryNode) {
return n.id.startsWith(repoNodeId);
}
return false;
},
token: token,
});
}
@gate(() => '')
async revealRepository(
repoPath: string,
options?: { select?: boolean; focus?: boolean; expand?: boolean | number },
) {
const node = await this.findNode(RepositoryFolderNode.getId(repoPath), {
maxDepth: 1,
canTraverse: n => n instanceof WorktreesViewNode || n instanceof RepositoryFolderNode,
});
if (node !== undefined) {
await this.reveal(node, options);
}
return node;
}
@gate(() => '')
revealWorktree(
worktree: GitWorktree,
options?: {
select?: boolean;
focus?: boolean;
expand?: boolean | number;
},
) {
return window.withProgress(
{
location: ProgressLocation.Notification,
title: `Revealing worktree '${worktree.name}' in the side bar...`,
cancellable: true,
},
async (progress, token) => {
const node = await this.findWorktree(worktree, token);
if (node == null) return undefined;
await this.ensureRevealNode(node, options);
return node;
},
);
}
private setFilesLayout(layout: ViewFilesLayout) {
return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout);
}
private setShowAvatars(enabled: boolean) {
return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled);
}
}

+ 1
- 0
src/webviews/settings/settingsWebview.ts View File

@ -32,6 +32,7 @@ export class SettingsWebview extends WebviewWithConfigBase {
Commands.ShowSettingsPageAndJumpToSearchAndCompareView,
Commands.ShowSettingsPageAndJumpToStashesView,
Commands.ShowSettingsPageAndJumpToTagsView,
Commands.ShowSettingsPageAndJumpToWorkTreesView,
Commands.ShowSettingsPageAndJumpToViews,
].map(c => {
// The show and jump commands are structured to have a # separating the base command from the anchor

Loading…
Cancel
Save