diff --git a/contributions.json b/contributions.json index 472e670cdb10f..b39a00b3950b7 100644 --- a/contributions.json +++ b/contributions.json @@ -5056,6 +5056,44 @@ ] } }, + "gitlens.views.branches.setShowRemoteBranchesOff": { + "label": "Hide Remote Branches", + "menus": { + "gitlens/views/grouped/more": [ + { + "when": "view == gitlens.views.scm.grouped && gitlens:views:scm:grouped:view == branches && config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens", + "order": 11 + } + ], + "view/title": [ + { + "when": "view == gitlens.views.branches && config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens", + "order": 11 + } + ] + } + }, + "gitlens.views.branches.setShowRemoteBranchesOn": { + "label": "Show Remote Branches", + "menus": { + "gitlens/views/grouped/more": [ + { + "when": "view == gitlens.views.scm.grouped && gitlens:views:scm:grouped:view == branches && !config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens", + "order": 11 + } + ], + "view/title": [ + { + "when": "view == gitlens.views.branches && !config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens", + "order": 11 + } + ] + } + }, "gitlens.views.branches.setShowStashesOff": { "label": "Hide Stashes", "menus": { diff --git a/package.json b/package.json index 2da5db95af5ad..5d5825a272d6f 100644 --- a/package.json +++ b/package.json @@ -2369,6 +2369,13 @@ "title": "Branches View", "order": 170, "properties": { + "gitlens.views.branches.showRemoteBranches": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show remote branches for the default remote in the _Branches_ view", + "scope": "window", + "order": 8 + }, "gitlens.views.branches.showStashes": { "type": "boolean", "default": false, @@ -7612,6 +7619,14 @@ "command": "gitlens.views.branches.setShowBranchPullRequestOn", "title": "Show Branch Pull Requests" }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOff", + "title": "Hide Remote Branches" + }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOn", + "title": "Show Remote Branches" + }, { "command": "gitlens.views.branches.setShowStashesOff", "title": "Hide Stashes" @@ -11239,6 +11254,14 @@ "command": "gitlens.views.branches.setShowBranchPullRequestOn", "when": "false" }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOff", + "when": "false" + }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOn", + "when": "false" + }, { "command": "gitlens.views.branches.setShowStashesOff", "when": "false" @@ -14556,6 +14579,16 @@ "when": "view == gitlens.views.scm.grouped && gitlens:views:scm:grouped:view =~ /(branches|commits|contributors|remotes|repositories|tags|worktrees)/ && !config.gitlens.views.showRelativeDateMarkers", "group": "5_gitlens@10" }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOff", + "when": "view == gitlens.views.scm.grouped && gitlens:views:scm:grouped:view == branches && config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens@11" + }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOn", + "when": "view == gitlens.views.scm.grouped && gitlens:views:scm:grouped:view == branches && !config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens@11" + }, { "command": "gitlens.views.branches.setShowStashesOff", "when": "view == gitlens.views.scm.grouped && gitlens:views:scm:grouped:view == branches && config.gitlens.views.branches.showStashes && !gitlens:hasVirtualFolders", @@ -16799,6 +16832,16 @@ "when": "view =~ /^gitlens\\.views\\.(branches|commits|contributors|fileHistory|lineHistory|remotes|repositories|tags|worktrees)/ && !config.gitlens.views.showRelativeDateMarkers", "group": "5_gitlens@10" }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOff", + "when": "view == gitlens.views.branches && config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens@11" + }, + { + "command": "gitlens.views.branches.setShowRemoteBranchesOn", + "when": "view == gitlens.views.branches && !config.gitlens.views.branches.showRemoteBranches && !gitlens:hasVirtualFolders", + "group": "5_gitlens@11" + }, { "command": "gitlens.views.branches.setShowStashesOff", "when": "view == gitlens.views.branches && config.gitlens.views.branches.showStashes && !gitlens:hasVirtualFolders", diff --git a/src/config.ts b/src/config.ts index a63ee97043998..bef75097850ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -745,6 +745,7 @@ export interface BranchesViewConfig { }; readonly reveal: boolean; readonly showBranchComparison: false | Extract; + readonly showRemoteBranches: boolean; readonly showStashes: boolean; } diff --git a/src/constants.commands.ts b/src/constants.commands.ts index 1f604ea6be657..622d1a87a8a18 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -349,6 +349,7 @@ export type TreeViewCommands = `gitlens.views.${ | `setShowAvatars${'On' | 'Off'}` | `setShowBranchComparison${'On' | 'Off'}` | `setShowBranchPullRequest${'On' | 'Off'}` + | `setShowRemoteBranches${'On' | 'Off'}` | `setShowStashes${'On' | 'Off'}`}` | `commits.${ | 'copy' diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 3ca9fa58a7882..8274052c48735 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -76,7 +76,7 @@ import type { GitBranchReference, GitReference, GitRevisionRange } from './model import { createRevisionRange, isSha, isUncommitted, isUncommittedParent } from './models/reference'; import type { GitReflog } from './models/reflog'; import type { GitRemote } from './models/remote'; -import { getRemoteThemeIconString, getVisibilityCacheKey } from './models/remote'; +import { getDefaultRemoteOrHighlander, getRemoteThemeIconString, getVisibilityCacheKey } from './models/remote'; import type { RepositoryChangeEvent } from './models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from './models/repository'; import type { GitStash } from './models/stash'; @@ -2319,6 +2319,12 @@ export class GitProviderService implements Disposable { return undefined; } + @log() + async getDefaultRemote(repoPath: string | Uri, _cancellation?: CancellationToken): Promise { + const remotes = await this.getRemotes(repoPath, undefined, _cancellation); + return getDefaultRemoteOrHighlander(remotes); + } + @log() async getRemote( repoPath: string | Uri, diff --git a/src/git/models/branch.ts b/src/git/models/branch.ts index 2f3c379fc7714..c08985f24da52 100644 --- a/src/git/models/branch.ts +++ b/src/git/models/branch.ts @@ -38,6 +38,7 @@ export type GitBranchStatus = export interface BranchSortOptions { current?: boolean; + groupByType?: boolean; missingUpstream?: boolean; orderBy?: BranchSorting; openedWorktreesByBranch?: Set; @@ -311,7 +312,7 @@ export function isOfBranchRefType(branch: GitReference | undefined) { } export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) { - options = { current: true, orderBy: configuration.get('sortBranchesBy'), ...options }; + options = { current: true, groupByType: true, orderBy: configuration.get('sortBranchesBy'), ...options }; switch (options.orderBy) { case 'date:asc': @@ -324,7 +325,7 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) (options.openedWorktreesByBranch.has(b.id) ? -1 : 1) : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || - (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || + (options.groupByType ? (b.remote ? -1 : 1) - (a.remote ? -1 : 1) : 0) || (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()) || sortCompare(a.name, b.name), ); @@ -341,7 +342,7 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || (a.name === 'develop' ? -1 : 1) - (b.name === 'develop' ? -1 : 1) || - (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || + (options.groupByType ? (b.remote ? -1 : 1) - (a.remote ? -1 : 1) : 0) || sortCompare(a.name, b.name), ); case 'name:desc': @@ -357,7 +358,7 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || (a.name === 'develop' ? -1 : 1) - (b.name === 'develop' ? -1 : 1) || - (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || + (options.groupByType ? (b.remote ? -1 : 1) - (a.remote ? -1 : 1) : 0) || sortCompare(b.name, a.name), ); case 'date:desc': @@ -371,7 +372,7 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) (options.openedWorktreesByBranch.has(b.id) ? -1 : 1) : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || - (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || + (options.groupByType ? (b.remote ? -1 : 1) - (a.remote ? -1 : 1) : 0) || (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()) || sortCompare(b.name, a.name), ); @@ -414,3 +415,15 @@ export async function getLocalBranchByUpstream( return undefined; } + +export async function getLocalBranchUpstreamNames(branches: PageableResult): Promise> { + const remoteBranches = new Set(); + + for await (const branch of branches.values()) { + if (!branch.remote && branch.upstream?.name != null) { + remoteBranches.add(branch.upstream.name); + } + } + + return remoteBranches; +} diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index f73f39dbb2e8f..6262fccad5e4a 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -1,7 +1,7 @@ import type { ColorTheme } from 'vscode'; import { Uri, window } from 'vscode'; import { GlyphChars } from '../../constants'; -import { Container } from '../../container'; +import type { Container } from '../../container'; import type { HostingIntegration } from '../../plus/integrations/integration'; import { memoize } from '../../system/decorators/memoize'; import { equalsIgnoreCase, sortCompare } from '../../system/string'; @@ -25,7 +25,7 @@ export class GitRemote(remotes: T[]): T | undefined { + return remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); +} + export function getHighlanderProviders(remotes: GitRemote[]) { if (remotes.length === 0) return undefined; - const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); + const remote = getDefaultRemoteOrHighlander(remotes); if (remote != null) return [remote.provider]; const providerName = remotes[0].provider.name; @@ -110,7 +114,7 @@ export function getHighlanderProviders(remotes: GitRemote[]) { export function getHighlanderProviderName(remotes: GitRemote[]) { if (remotes.length === 0) return undefined; - const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); + const remote = getDefaultRemoteOrHighlander(remotes); if (remote != null) return remote.provider.name; const providerName = remotes[0].provider.name; diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index a536ad7b622fe..49c0a3c4a972c 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -65,6 +65,7 @@ export type RepoGitProviderService = Pick< | 'getBestRemotesWithProviders' | 'getBestRemoteWithIntegration' | 'getBranch' + | 'getDefaultRemote' | 'getRemote' | 'getTag' | 'getWorktree' diff --git a/src/git/utils/remote-utils.ts b/src/git/utils/remote-utils.ts new file mode 100644 index 0000000000000..8ecbaf4f2d935 --- /dev/null +++ b/src/git/utils/remote-utils.ts @@ -0,0 +1,18 @@ +import { ThemeIcon } from 'vscode'; +import type { IconPath } from '../../@types/vscode.iconpath'; +import type { Container } from '../../container'; +import { getIconPathUris } from '../../system/vscode/vscode'; +import type { GitRemote } from '../models/remote'; +import { getRemoteThemeIconString } from '../models/remote'; + +export function getRemoteIconPath( + container: Container, + remote: GitRemote | undefined, + options?: { avatars?: boolean }, +): IconPath { + if (options?.avatars && remote?.provider?.icon != null) { + return getIconPathUris(container, `icon-${remote.provider.icon}.svg`); + } + + return new ThemeIcon(getRemoteThemeIconString(remote)); +} diff --git a/src/system/paging.ts b/src/system/paging.ts index c7a448c2cdbb9..e41cc54d54728 100644 --- a/src/system/paging.ts +++ b/src/system/paging.ts @@ -3,7 +3,12 @@ import type { PagedResult } from '../git/gitProvider'; export class PageableResult { private cached: Mutable> | undefined; - constructor(private readonly fetch: (paging: PagedResult['paging']) => Promise>) {} + constructor( + private readonly fetch: (paging: PagedResult['paging']) => Promise>, + seed?: PagedResult, + ) { + this.cached = seed; + } async *values(): AsyncIterable> { if (this.cached != null) { diff --git a/src/views/branchesView.ts b/src/views/branchesView.ts index f95bec7da55e5..aaa39cbd8a1f2 100644 --- a/src/views/branchesView.ts +++ b/src/views/branchesView.ts @@ -83,7 +83,13 @@ export class BranchesViewNode extends RepositoriesSubscribeableNode !b.remote }); + const { showRemoteBranches } = this.view.config; + const defaultRemote = showRemoteBranches ? (await child.repo.git.getDefaultRemote())?.name : undefined; + + const branches = await child.repo.git.getBranches({ + filter: b => + !b.remote || (showRemoteBranches && defaultRemote != null && b.getRemoteName() === defaultRemote), + }); if (branches.values.length === 0) { this.view.message = 'No branches could be found.'; void child.ensureSubscription(); @@ -178,6 +184,16 @@ export class BranchesView extends ViewBase<'branches', BranchesViewNode, Branche () => this.setShowBranchPullRequest(false), this, ), + registerViewCommand( + this.getQualifiedCommand('setShowRemoteBranchesOn'), + () => this.setShowRemoteBranches(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowRemoteBranchesOff'), + () => this.setShowRemoteBranches(false), + this, + ), registerViewCommand(this.getQualifiedCommand('setShowStashesOn'), () => this.setShowStashes(true), this), registerViewCommand(this.getQualifiedCommand('setShowStashesOff'), () => this.setShowStashes(false), this), ]; @@ -356,6 +372,10 @@ export class BranchesView extends ViewBase<'branches', BranchesViewNode, Branche await configuration.updateEffective(`views.${this.configKey}.pullRequests.enabled` as const, enabled); } + private setShowRemoteBranches(enabled: boolean) { + return configuration.updateEffective(`views.${this.configKey}.showRemoteBranches` as const, enabled); + } + private setShowStashes(enabled: boolean) { return configuration.updateEffective(`views.${this.configKey}.showStashes` as const, enabled); } diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index bc4ef3d4bb346..1ad334fe44446 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -18,6 +18,7 @@ import { Repository } from '../../git/models/repository'; import type { GitUser } from '../../git/models/user'; import type { GitWorktree } from '../../git/models/worktree'; import { getBranchIconPath } from '../../git/utils/branch-utils'; +import { getRemoteIconPath } from '../../git/utils/remote-utils'; import { getWorktreeBranchIconPath } from '../../git/utils/worktree-utils'; import { fromNow } from '../../system/date'; import { gate } from '../../system/decorators/gate'; @@ -425,8 +426,10 @@ export class BranchNode this.splatted = false; const parts = await getBranchNodeParts(this.view.container, this.branch, this.current, { + avatars: this.view.config.avatars, pendingPullRequest: this.getState('pendingPullRequest'), showAsCommits: this.options.showAsCommits, + showingLocalAndRemoteBranches: this.view.type === 'branches' && this.view.config.showRemoteBranches, showStatusDecorationOnly: this.options.showStatusDecorationOnly, useBaseNameOnly: !(this.view.config.branches?.layout !== 'tree' || this.compacted || this.avoidCompacting), worktree: this.worktree, @@ -551,8 +554,10 @@ export async function getBranchNodeParts( branch: GitBranch, current: boolean, options?: { + avatars?: boolean; pendingPullRequest?: Promise | undefined; showAsCommits?: boolean; + showingLocalAndRemoteBranches?: boolean; showStatusDecorationOnly?: boolean; useBaseNameOnly: boolean; worktree?: GitWorktree; @@ -740,19 +745,26 @@ export async function getBranchNodeParts( } } + let iconPath: IconPath; + if (options?.pendingPullRequest != null) { + iconPath = new ThemeIcon('loading~spin'); + } else if (options?.showAsCommits) { + iconPath = new ThemeIcon('git-commit', iconColor); + } else if (options?.worktree != null) { + iconPath = getWorktreeBranchIconPath(container, branch); + } else if (branch.remote && options?.showingLocalAndRemoteBranches) { + const remote = await branch.getRemote(); + iconPath = getRemoteIconPath(container, remote, { avatars: options?.avatars }); + } else { + iconPath = getBranchIconPath(container, branch); + } + return { label: label, description: description, tooltip: tooltip, contextValue: contextValue, - iconPath: - options?.pendingPullRequest != null - ? new ThemeIcon('loading~spin') - : options?.showAsCommits - ? new ThemeIcon('git-commit', iconColor) - : options?.worktree != null - ? getWorktreeBranchIconPath(container, branch) - : getBranchIconPath(container, branch), + iconPath: iconPath, resourceUri: createViewDecorationUri('branch', { status: localUnpublished ? 'unpublished' : status, current: current, diff --git a/src/views/nodes/branchesNode.ts b/src/views/nodes/branchesNode.ts index 032d869142d61..60b3178b48ba2 100644 --- a/src/views/nodes/branchesNode.ts +++ b/src/views/nodes/branchesNode.ts @@ -1,9 +1,12 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; +import type { GitBranch } from '../../git/models/branch'; +import { getLocalBranchUpstreamNames } from '../../git/models/branch'; import type { Repository } from '../../git/models/repository'; import { getOpenedWorktreesByBranch } from '../../git/models/worktree'; import { makeHierarchical } from '../../system/array'; import { debug } from '../../system/decorators/log'; +import { PageableResult } from '../../system/paging'; import type { ViewsWithBranchesNode } from '../viewBase'; import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; import type { ViewNode } from './abstract/viewNode'; @@ -35,27 +38,42 @@ export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWit async getChildren(): Promise { if (this.children == null) { - const branches = await this.repo.git.getBranches({ - // only show local branches - filter: b => !b.remote, + const showRemoteBranches = this.view.type === 'branches' && this.view.config.showRemoteBranches; + const defaultRemote = showRemoteBranches ? (await this.repo.git.getDefaultRemote())?.name : undefined; + + const options: Parameters['0'] = { + // only show local branches or remote branches for the default remote + filter: b => + !b.remote || (showRemoteBranches && defaultRemote != null && b.getRemoteName() === defaultRemote), sort: this.view.config.showCurrentBranchOnTop ? { current: true, + groupByType: defaultRemote == null, openedWorktreesByBranch: getOpenedWorktreesByBranch(this.context.worktreesByBranch), } - : { current: false }, - }); - if (branches.values.length === 0) return [new MessageNode(this.view, this, 'No branches could be found.')]; + : { current: false, groupByType: defaultRemote == null }, + }; + + const branches = new PageableResult(p => this.repo.git.getBranches({ ...options, paging: p })); + + let localUpstreamNames: Set | undefined; + // Filter out remote branches that have a local branch + if (defaultRemote != null) { + localUpstreamNames = await getLocalBranchUpstreamNames(branches); + } + + const branchNodes: BranchNode[] = []; - // TODO@eamodio handle paging - const branchNodes = branches.values.map( - b => + for await (const branch of branches.values()) { + if (branch.remote && localUpstreamNames?.has(branch.name)) continue; + + branchNodes.push( new BranchNode( - GitUri.fromRepoPath(this.uri.repoPath!, b.ref), + GitUri.fromRepoPath(this.uri.repoPath!, branch.ref), this.view, this, this.repo, - b, + branch, false, { showComparison: @@ -65,7 +83,10 @@ export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWit showStashes: this.view.config.showStashes, }, ), - ); + ); + } + + if (branchNodes.length === 0) return [new MessageNode(this.view, this, 'No branches could be found.')]; if (this.view.config.branches.layout === 'list') return branchNodes; const hierarchy = makeHierarchical(