diff --git a/images/views/worktrees.svg b/images/views/worktrees.svg new file mode 100644 index 0000000..5536776 --- /dev/null +++ b/images/views/worktrees.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index 279e23a..d4ce411 100644 --- a/package.json +++ b/package.json @@ -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 with another \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", diff --git a/src/commands/git/worktree.ts b/src/commands/git/worktree.ts new file mode 100644 index 0000000..f32c2e2 --- /dev/null +++ b/src/commands/git/worktree.ts @@ -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 = SomeNonNullable, 'subcommand'>; +type CreateStepState = WorktreeStepState>; +type DeleteStepState = WorktreeStepState>; +type OpenStepState = WorktreeStepState>; + +const subcommandToTitleMap = new Map([ + ['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; +} + +export class WorktreeGitCommand extends QuickCommand { + 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): 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): StepResultGenerator { + const step = QuickCommand.createPickStep>({ + 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 = yield step; + return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break; + } + + private async *createCommandSteps(state: CreateStepState, context: Context): AsyncStepResultGenerator { + 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 { + const step = QuickCommand.createCustomStep({ + show: async (_step: CustomStep) => { + 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 = 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 { + const friendlyPath = GitWorktree.getFriendlyPath(state.uri); + + const step: QuickPickStep> = QuickCommand.createConfirmStep( + appendReposToTitle(`Confirm ${context.title}`, state, context), + [ + FlagsQuickPickItem.create(state.flags, [], { + label: context.title, + detail: `Will create a new worktree for ${GitReference.toString(state.reference)} in${pad( + '$(folder)', + 2, + 2, + )}${friendlyPath}`, + }), + FlagsQuickPickItem.create(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(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 = 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 { + const step: QuickPickStep> = QuickCommand.createConfirmStep( + appendReposToTitle(`Confirm ${context.title}`, state, context), + [ + FlagsQuickPickItem.create(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(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 = 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 { + const step: QuickPickStep> = QuickCommand.createConfirmStep( + appendReposToTitle(`Confirm ${context.title}`, state, context), + [ + FlagsQuickPickItem.create(state.flags, [], { + label: context.title, + detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in the current window`, + }), + FlagsQuickPickItem.create(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 = yield step; + return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break; + } +} diff --git a/src/commands/gitCommands.actions.ts b/src/commands/gitCommands.actions.ts index d2f35b5..9027d49 100644 --- a/src/commands/gitCommands.actions.ts +++ b/src/commands/gitCommands.actions.ts @@ -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; + } + } } diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts index e7fe73c..496bfed 100644 --- a/src/commands/gitCommands.ts +++ b/src/commands/gitCommands.ts @@ -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 | 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, - stepPromise: Promise | QuickInputStep | undefined>, - ): Promise | QuickInputStep | undefined> { + stepPromise: Promise | QuickInputStep | CustomStep | undefined>, + ): Promise | QuickInputStep | CustomStep | undefined> { const stepOrTimeout = await Promise.race([ stepPromise, new Promise(resolve => setTimeout(() => resolve(showLoadingSymbol), 250)), @@ -179,23 +197,25 @@ export class GitCommandsCommand extends Command { const disposables: Disposable[] = []; - let step: QuickPickStep | QuickInputStep | undefined; + let step: QuickPickStep | QuickInputStep | CustomStep | undefined; try { - // eslint-disable-next-line no-async-promise-executor - return await new Promise | QuickInputStep | undefined>(async resolve => { - disposables.push(quickpick.onDidHide(() => resolve(step))); + return await new Promise | 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, command: QuickCommand, value: StepSelection | undefined, + quickInput?: InputBox | QuickPick, ) { - 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)); }), ); diff --git a/src/commands/gitCommands.utils.ts b/src/commands/gitCommands.utils.ts index 9d0b937..b1a5b4e 100644 --- a/src/commands/gitCommands.utils.ts +++ b/src/commands/gitCommands.utils.ts @@ -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((i: T | undefined): i is T => i != null); if (this.container.config.gitCommands.sortBy === GitCommandSorting.Usage) { diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index 9f90b36..b91f950 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -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', diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index 68f39ad..19712da 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -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; } +export async function getWorktrees( + repoOrWorktrees: Repository | GitWorktree[], + { + buttons, + filter, + includeStatus, + picked, + }: { + buttons?: QuickInputButton[]; + filter?: (t: GitWorktree) => boolean; + includeStatus?: boolean; + picked?: string | string[]; + }, +): Promise { + const worktrees = repoOrWorktrees instanceof Repository ? await repoOrWorktrees.getWorktrees() : repoOrWorktrees; + return Promise.all([ + ...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 { + const worktrees = await getWorktrees(context.worktrees ?? state.repo, { + buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar], + filter: filter, + includeStatus: includeStatus, + picked: picked, + }); + + const step = QuickCommand.createPickStep({ + 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 = 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 { + const worktrees = await getWorktrees(context.worktrees ?? state.repo, { + buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar], + filter: filter, + includeStatus: includeStatus, + picked: picked, + }); + + const step = QuickCommand.createPickStep({ + 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 = 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 }, diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts index 5e53e09..a575ea5 100644 --- a/src/commands/quickCommand.ts +++ b/src/commands/quickCommand.ts @@ -6,6 +6,18 @@ import { Directive, DirectiveQuickPickItem } from '../quickpicks/items/directive export * from './quickCommand.buttons'; export * from './quickCommand.steps'; +export interface CustomStep { + ignoreFocusOut?: boolean; + + show(step: CustomStep): Promise>; +} + +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 { @@ -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, any | undefined> - | AsyncGenerator, any | undefined>; + | Generator, any | undefined> + | AsyncGenerator, any | undefined>; -export type StepItemType = T extends QuickPickStep ? U[] : T extends QuickInputStep ? string : never; +export type StepItemType = T extends CustomStep + ? U + : T extends QuickPickStep + ? U[] + : T extends QuickInputStep + ? string + : never; export type StepNavigationKeys = Exclude; export namespace StepResult { export const Break = Symbol('BreakStep'); } export type StepResult = typeof StepResult.Break | T; -export type StepResultGenerator = Generator, any | undefined>; +export type StepResultGenerator = Generator< + QuickPickStep | QuickInputStep | CustomStep, + StepResult, + any | undefined +>; export type AsyncStepResultGenerator = AsyncGenerator< - QuickPickStep | QuickInputStep, + QuickPickStep | QuickInputStep | CustomStep, StepResult, any | undefined >; @@ -83,7 +107,9 @@ export type AsyncStepResultGenerator = AsyncGenerator< // export type StepResultGenerator = // | Generator, any | undefined> // | AsyncGenerator, any | undefined>; -export type StepSelection = T extends QuickPickStep +export type StepSelection = T extends CustomStep + ? U | Directive + : T extends QuickPickStep ? U[] | Directive : T extends QuickInputStep ? string | Directive @@ -97,7 +123,7 @@ export abstract class QuickCommand implements QuickPickItem { protected initialState: PartialStepState | 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): Promise> { + async next( + value?: StepSelection, + ): Promise> { 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 { + async retry(): Promise { await this.next(Directive.Noop); return this.value; } @@ -317,6 +345,10 @@ export namespace QuickCommand { return step; } + export function createCustomStep(step: CustomStep): CustomStep { + return step; + } + export function endSteps(state: PartialStepState) { state.counter = -1; } diff --git a/src/commands/showView.ts b/src/commands/showView.ts index 7306e65..99e6d21 100644 --- a/src/commands/showView.ts +++ b/src/commands/showView.ts @@ -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); diff --git a/src/config.ts b/src/config.ts index 5a63612..26a8fc2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/constants.ts b/src/constants.ts index 7c761da..08c0496 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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', diff --git a/src/container.ts b/src/container.ts index 81770c3..9e4c773 100644 --- a/src/container.ts +++ b/src/container.ts @@ -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; diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 83d69c0..1e632d2 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -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({ 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({ cwd: repoPath }, ...params); + } + + worktree__list(repoPath: string) { + return this.git({ 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({ cwd: repoPath, errors: GitErrorHandling.Throw }, ...params); + } + async readDotGitFile( repoPath: string, paths: string[], diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index a6b21c2..46bf91c 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -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 { + 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 { + 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 | undefined; private async getScmGitApi(): Promise { return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore()); diff --git a/src/git/errors.ts b/src/git/errors.ts index 93a2dc6..61c9105 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -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); + } +} diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index cc0646a..25dc021 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -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; + + createWorktree?( + repoPath: string, + path: string, + options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean }, + ): Promise; + getWorktrees?(repoPath: string): Promise; + getWorktreesDefaultUri?(repoPath: string): Promise; + deleteWorktree?(repoPath: string, path: string, options?: { force?: boolean }): Promise; } export interface RevisionUriData { diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 73291f7..d63947d 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -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({ 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 { + const { provider, path: rp } = this.getProvider(repoPath); + return Promise.resolve(provider.createWorktree?.(rp, path, options)); + } + + @log() + async getWorktrees(repoPath: string | Uri): Promise { + const { provider, path } = this.getProvider(repoPath); + return (await provider.getWorktrees?.(path)) ?? []; + } + + @log() + async getWorktreesDefaultUri(path: string | Uri): Promise { + const { provider, path: rp } = this.getProvider(path); + return provider.getWorktreesDefaultUri?.(rp); + } + + @log() + deleteWorktree(repoPath: string | Uri, path: string, options?: { force?: boolean }): Promise { + const { provider, path: rp } = this.getProvider(repoPath); + return Promise.resolve(provider.deleteWorktree?.(rp, path, options)); + } + @log() async getOpenScmRepositories(): Promise { const results = await Promise.allSettled([...this._providers.values()].map(p => p.getOpenScmRepositories())); const repositories = flatMap, ScmRepository>( diff --git a/src/git/models.ts b/src/git/models.ts index 1f997d9..512bb59 100644 --- a/src/git/models.ts +++ b/src/git/models.ts @@ -22,3 +22,4 @@ export * from './models/status'; export * from './models/tag'; export * from './models/tree'; export * from './models/user'; +export * from './models/worktree'; diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index ff20bdc..c69218b 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -9,7 +9,7 @@ export const enum GitRemoteType { } export class GitRemote { - static getHighlanderProviders(remotes: GitRemote[]) { + static getHighlanderProviders(remotes: GitRemote[]) { 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[]) { + static getHighlanderProviderName(remotes: GitRemote[]) { if (remotes.length === 0) return undefined; const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 4eee5ec..d097311 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -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 - ? /(?\/\.gitignore)|\.git\/(?config|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|refs\/(?:heads|remotes|stash|tags))/.exec( + ? /(?\/\.gitignore)|\.git\/(?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 { + return this.container.git.createWorktree(this.path, uri.fsPath, options); + } + + getWorktrees(): Promise { + return this.container.git.getWorktrees(this.path); + } + + async getWorktreesDefaultUri(): Promise { + return this.container.git.getWorktreesDefaultUri(this.path); + } + + deleteWorktree(uri: Uri, options?: { force?: boolean }): Promise { + return this.container.git.deleteWorktree(this.path, uri.fsPath, options); + } + async hasRemotes(): Promise { const remotes = await this.getRemotes(); return remotes?.length > 0; diff --git a/src/git/models/worktree.ts b/src/git/models/worktree.ts new file mode 100644 index 0000000..f07cc40 --- /dev/null +++ b/src/git/models/worktree.ts @@ -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 | undefined; + getStatus(options?: { force?: boolean }): Promise { + 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; + } +} diff --git a/src/git/parsers.ts b/src/git/parsers.ts index 5e2bbba..b470c6b 100644 --- a/src/git/parsers.ts +++ b/src/git/parsers.ts @@ -8,3 +8,4 @@ export * from './parsers/stashParser'; export * from './parsers/statusParser'; export * from './parsers/tagParser'; export * from './parsers/treeParser'; +export * from './parsers/worktreeParser'; diff --git a/src/git/parsers/worktreeParser.ts b/src/git/parsers/worktreeParser.ts new file mode 100644 index 0000000..52ec69b --- /dev/null +++ b/src/git/parsers/worktreeParser.ts @@ -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 | 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; + } +} diff --git a/src/quickpicks/items/gitCommands.ts b/src/quickpicks/items/gitCommands.ts index c8aec7e..b2a6791 100644 --- a/src/quickpicks/items/gitCommands.ts +++ b/src/quickpicks/items/gitCommands.ts @@ -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 { + 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; + } +} diff --git a/src/views/nodes.ts b/src/views/nodes.ts index 6bb01c1..cad7ac1 100644 --- a/src/views/nodes.ts +++ b/src/views/nodes.ts @@ -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'; diff --git a/src/views/nodes/UncommittedFileNode.ts b/src/views/nodes/UncommittedFileNode.ts new file mode 100644 index 0000000..e7106f0 --- /dev/null +++ b/src/views/nodes/UncommittedFileNode.ts @@ -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 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], + }; + } +} diff --git a/src/views/nodes/UncommittedFilesNode.ts b/src/views/nodes/UncommittedFilesNode.ts new file mode 100644 index 0000000..485ae5d --- /dev/null +++ b/src/views/nodes/UncommittedFilesNode.ts @@ -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 { + 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, + [], + ), + }; + } +} diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index e4e2f7b..256a279 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -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 { 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)); } diff --git a/src/views/nodes/statusFilesNode.ts b/src/views/nodes/statusFilesNode.ts index 4255ee2..e8fd34b 100644 --- a/src/views/nodes/statusFilesNode.ts +++ b/src/views/nodes/statusFilesNode.ts @@ -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 { +export class StatusFilesNode extends ViewNode { 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 { - 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) { diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index b45a519..5434c2f 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -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 { diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts new file mode 100644 index 0000000..5c48627 --- /dev/null +++ b/src/views/nodes/worktreeNode.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/views/nodes/worktreesNode.ts b/src/views/nodes/worktreesNode.ts new file mode 100644 index 0000000..0e3bd5a --- /dev/null +++ b/src/views/nodes/worktreesNode.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/views/repositoriesView.ts b/src/views/repositoriesView.ts index f8b6cdb..e89caf7 100644 --- a/src/views/repositoriesView.ts +++ b/src/views/repositoriesView.ts @@ -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 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 this.toggleSectionByNode(node, false), this, ), @@ -483,6 +497,25 @@ export class RepositoriesView extends ViewBase { + // 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 '') + 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; export type ViewsWithRepositoryFolders = Exclude; @@ -79,7 +82,8 @@ export abstract class ViewBase< | RepositoriesViewConfig | SearchAndCompareViewConfig | StashesViewConfig - | TagsViewConfig, + | TagsViewConfig + | WorktreesViewConfig, > implements TreeDataProvider, Disposable { protected _onDidChangeTreeData = new EventEmitter(); diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 05bfd5c..4a0df22 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -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(); diff --git a/src/views/worktreesView.ts b/src/views/worktreesView.ts new file mode 100644 index 0000000..bf74c6e --- /dev/null +++ b/src/views/worktreesView.ts @@ -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 { + getChildren(): Promise { + 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 { + async getChildren(): Promise { + 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 { + 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); + } +} diff --git a/src/webviews/settings/settingsWebview.ts b/src/webviews/settings/settingsWebview.ts index 9b7982a..31af35a 100644 --- a/src/webviews/settings/settingsWebview.ts +++ b/src/webviews/settings/settingsWebview.ts @@ -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