diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 94e642c2b6e..1dc87fedc4a 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -41,6 +41,7 @@ import { IStashEntry } from '../models/stash-entry' import { TutorialStep } from '../models/tutorial-step' import { UncommittedChangesStrategy } from '../models/uncommitted-changes-strategy' import { ShowBranchNameInRepoListSetting } from '../models/show-branch-name-in-repo-list' +import { BranchSortOrder } from '../models/branch-sort-order' import { DragElement } from '../models/drag-drop' import { ILastThankYou } from '../models/last-thank-you' import { @@ -398,6 +399,9 @@ export interface IAppState { /** Controls when to show the current branch name next to each repository in the repository list */ readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting + /** Controls the sort order for branch lists in branch-selection views */ + readonly branchSortOrder: BranchSortOrder + /** * Cached repo rulesets. Used to prevent repeatedly querying the same * rulesets to check their bypass status. diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index c014f9722c1..8dcf31d256d 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -182,6 +182,10 @@ import { defaultShowBranchNameInRepoListSetting, ShowBranchNameInRepoListSetting, } from '../../models/show-branch-name-in-repo-list' +import { + BranchSortOrder, + defaultBranchSortOrder, +} from '../../models/branch-sort-order' import { WorkflowPreferences } from '../../models/workflow-preferences' import { TrashNameLabel } from '../../ui/lib/context-menu' import { getDefaultDir } from '../../ui/lib/default-dir' @@ -494,6 +498,7 @@ export const showDiffCheckMarksDefault = true export const showDiffCheckMarksKey = 'diff-check-marks-visible' export const showBranchNameInRepoListKey = 'show-branch-name-in-repo-list' +const branchSortOrderKey = 'branch-sort-order' const commitMessageGenerationDisclaimerLastSeenKey = 'commit-message-generation-disclaimer-last-seen' @@ -663,6 +668,8 @@ export class AppStore extends TypedBaseStore { private showBranchNameInRepoList: ShowBranchNameInRepoListSetting = defaultShowBranchNameInRepoListSetting + private branchSortOrder: BranchSortOrder = defaultBranchSortOrder + private cachedRepoRulesets = new Map() private underlineLinks: boolean = underlineLinksDefault @@ -1212,6 +1219,7 @@ export class AppStore extends TypedBaseStore { underlineLinks: this.underlineLinks, showDiffCheckMarks: this.showDiffCheckMarks, showBranchNameInRepoList: this.showBranchNameInRepoList, + branchSortOrder: this.branchSortOrder, updateState: updateStore.state, commitMessageGenerationDisclaimerLastSeen: this.commitMessageGenerationDisclaimerLastSeen, @@ -2619,6 +2627,9 @@ export class AppStore extends TypedBaseStore { getEnum(showBranchNameInRepoListKey, ShowBranchNameInRepoListSetting) ?? defaultShowBranchNameInRepoListSetting + this.branchSortOrder = + getEnum(branchSortOrderKey, BranchSortOrder) ?? defaultBranchSortOrder + this.commitMessageGenerationDisclaimerLastSeen = getNumber(commitMessageGenerationDisclaimerLastSeenKey) ?? null @@ -3929,7 +3940,9 @@ export class AppStore extends TypedBaseStore { // loadBranches needs the default remote to determine the default branch await gitStore.loadRemotes() - await gitStore.loadBranches() + await gitStore.loadBranches( + gitStore.getRecentBranchesLimit(this.branchSortOrder) + ) await gitStore.loadWorktrees() const section = state.selectedSection @@ -9202,6 +9215,23 @@ export class AppStore extends TypedBaseStore { } } + public _updateBranchSortOrder(branchSortOrder: BranchSortOrder) { + if (branchSortOrder !== this.branchSortOrder) { + this.branchSortOrder = branchSortOrder + localStorage.setItem(branchSortOrderKey, branchSortOrder) + this.emitUpdate() + + if (this.selectedRepository instanceof Repository) { + const gitStore = this.gitStoreCache.get(this.selectedRepository) + if (gitStore) { + void gitStore.loadBranches( + gitStore.getRecentBranchesLimit(branchSortOrder) + ) + } + } + } + } + public _updateFileListFilter( repository: Repository, filterUpdate: Partial diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index 4358632e393..9dd73f0ac2d 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -105,6 +105,7 @@ import { findDefaultBranch } from '../find-default-branch' import { cleanUntrackedFiles } from '../git/clean' import { dotGitPath } from '../helpers/git-dir' import { normalizePath } from '../helpers/path' +import { BranchSortOrder } from '../../models/branch-sort-order' /** The number of commits to load from history per batch. */ const CommitBatchSize = 100 @@ -112,8 +113,14 @@ const CommitBatchSizeSearch = 500 const LoadingHistoryRequestKey = 'history' -/** The max number of recent branches to find. */ -const RecentBranchesLimit = 5 +/** + * The max number of recent branches to include for non-alphabetic + * sorts where full "recent" list ordering is required. + */ +const RecentOptionsBranchLimit = 2500 + +/** The max number of recent branches to include for alphabetic sort mode. */ +const AlphabetBranchRecentLimit = 5 /** The store for a repository's git data. */ export class GitStore extends BaseStore { @@ -178,6 +185,12 @@ export class GitStore extends BaseStore { this._tagsToPush = getTagsToPush(repository) } + public getRecentBranchesLimit(branchSortOrder: BranchSortOrder): number { + return branchSortOrder === BranchSortOrder.Alphabet + ? AlphabetBranchRecentLimit + : RecentOptionsBranchLimit + } + /** * Reconcile the local history view with the repository state * after a pull has completed, to include merged remote commits. @@ -398,14 +411,16 @@ export class GitStore extends BaseStore { } /** Load all the branches. */ - public async loadBranches() { + public async loadBranches( + recentBranchesLimit: number = AlphabetBranchRecentLimit + ) { const [localAndRemoteBranches, recentBranchNames] = await Promise.all([ this.performFailableOperation(() => getBranches(this.repository)) || [], this.performFailableOperation(() => // Chances are that the recent branches list will contain the default // branch which we filter out in refreshRecentBranches. So grab one // more than we need to account for that. - getRecentBranches(this.repository, RecentBranchesLimit + 1) + getRecentBranches(this.repository, recentBranchesLimit + 1) ), ]) @@ -417,7 +432,7 @@ export class GitStore extends BaseStore { // refreshRecentBranches is dependent on having a default branch await this.refreshDefaultBranch() - this.refreshRecentBranches(recentBranchNames) + this.refreshRecentBranches(recentBranchNames, recentBranchesLimit) await this.checkPullWithRebase() @@ -558,7 +573,8 @@ export class GitStore extends BaseStore { } private refreshRecentBranches( - recentBranchNames: ReadonlyArray | undefined + recentBranchNames: ReadonlyArray | undefined, + recentBranchesLimit: number = RecentOptionsBranchLimit ) { if (!recentBranchNames || !recentBranchNames.length) { this._recentBranches = [] @@ -591,7 +607,7 @@ export class GitStore extends BaseStore { recentBranches.push(branch) - if (recentBranches.length >= RecentBranchesLimit) { + if (recentBranches.length >= recentBranchesLimit) { break } } diff --git a/app/src/models/branch-sort-order.ts b/app/src/models/branch-sort-order.ts new file mode 100644 index 00000000000..5cca32c2abc --- /dev/null +++ b/app/src/models/branch-sort-order.ts @@ -0,0 +1,7 @@ +export enum BranchSortOrder { + Alphabet = 'Alphabet', + RecentlyAdded = 'RecentlyAdded', + RecentlyChanged = 'RecentlyChanged', +} + +export const defaultBranchSortOrder = BranchSortOrder.Alphabet diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 1f95c31870e..bb86c8c2d45 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1659,6 +1659,7 @@ export class App extends React.Component { underlineLinks={this.state.underlineLinks} showDiffCheckMarks={this.state.showDiffCheckMarks} showBranchNameInRepoList={this.state.showBranchNameInRepoList} + branchSortOrder={this.state.branchSortOrder} /> ) case PopupType.RepositorySettings: { @@ -3457,6 +3458,7 @@ export class App extends React.Component { dispatcher={this.props.dispatcher} isOpen={isOpen} branchDropdownWidth={this.state.branchDropdownWidth} + branchSortOrder={this.state.branchSortOrder} onDropDownStateChanged={this.onBranchDropdownStateChanged} repository={repository} repositoryState={selection.state} @@ -3635,6 +3637,7 @@ export class App extends React.Component { stashedFilesWidth={state.stashedFilesWidth} issuesStore={this.props.issuesStore} gitHubUserStore={this.props.gitHubUserStore} + branchSortOrder={state.branchSortOrder} onViewCommitOnGitHub={this.onViewCommitOnGitHub} imageDiffType={state.imageDiffType} hideWhitespaceInChangesDiff={state.hideWhitespaceInChangesDiff} diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 5015ae64fb7..88de3e2e1d9 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -10,6 +10,7 @@ import { SelectionSource } from '../lib/filter-list' import { IMatches } from '../../lib/fuzzy-find' import { Button } from '../lib/button' import { TextBox } from '../lib/text-box' +import { BranchSortOrder } from '../../models/branch-sort-order' import { groupBranches, @@ -53,6 +54,8 @@ interface IBranchListProps { */ readonly recentBranches: ReadonlyArray + readonly branchSortOrder?: BranchSortOrder + /** * All worktrees in the repository. */ @@ -195,7 +198,9 @@ export class BranchList extends React.Component< this.props.currentBranch, this.props.allBranches, this.props.recentBranches, - this.props.allWorktrees + this.props.allWorktrees, + this.props.branchSortOrder, + this.state.commitAuthorDates ) } diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index c783632e69e..f3f3b20596b 100644 --- a/app/src/ui/branches/branches-container.tsx +++ b/app/src/ui/branches/branches-container.tsx @@ -24,6 +24,7 @@ import { Button } from '../lib/button' import { BranchList } from './branch-list' import { PullRequestList } from './pull-request-list' import { IBranchListItem } from './group-branches' +import { BranchSortOrder } from '../../models/branch-sort-order' import { getDefaultAriaLabelForBranch, renderDefaultBranch, @@ -53,6 +54,8 @@ interface IBranchesContainerProps { readonly onSetAsDefaultBranch: (branchName: string) => void readonly onDeleteBranch: (branchName: string) => void + readonly branchSortOrder: BranchSortOrder + /** All worktrees in the repository. */ readonly allWorktrees: ReadonlyArray @@ -280,6 +283,7 @@ export class BranchesContainer extends React.Component< currentBranch={this.props.currentBranch} allBranches={this.props.allBranches} recentBranches={this.props.recentBranches} + branchSortOrder={this.props.branchSortOrder} allWorktrees={this.props.allWorktrees} onItemClick={this.onBranchItemClick} filterText={this.state.branchFilterText} diff --git a/app/src/ui/branches/group-branches.ts b/app/src/ui/branches/group-branches.ts index 9ec92b001e8..0b5e58f454e 100644 --- a/app/src/ui/branches/group-branches.ts +++ b/app/src/ui/branches/group-branches.ts @@ -1,6 +1,8 @@ import { Branch } from '../../models/branch' import { WorktreeEntry } from '../../models/worktree' import { IFilterListGroup, IFilterListItem } from '../lib/filter-list' +import { BranchSortOrder } from '../../models/branch-sort-order' +import { caseInsensitiveCompare } from '../../lib/compare' export type BranchGroupIdentifier = 'default' | 'recent' | 'other' @@ -38,7 +40,9 @@ export function groupBranches( currentBranch: Branch | null, allBranches: ReadonlyArray, recentBranches: ReadonlyArray, - allWorktrees: ReadonlyArray + allWorktrees: ReadonlyArray, + branchSortOrder: BranchSortOrder = BranchSortOrder.Alphabet, + commitAuthorDates?: ReadonlyMap ): ReadonlyArray> { const groups = new Array>() @@ -60,51 +64,133 @@ export function groupBranches( }) } - const recentBranchNames = new Set() const defaultBranchName = defaultBranch ? defaultBranch.name : null const recentBranchesWithoutDefault = recentBranches.filter( b => b.name !== defaultBranchName ) - if (recentBranchesWithoutDefault.length > 0) { - const recentBranches = new Array() - - for (const branch of recentBranchesWithoutDefault) { - const worktreeInUse = findWorktreeForBranch(branch.name, allWorktrees) - recentBranches.push({ - text: [branch.name], - id: branch.name, - branch, - worktreeInUse, + const recentBranchesWithoutDefaultForSort = recentBranchesWithoutDefault + const remainingBranches = allBranches.filter( + b => b.name !== defaultBranchName && !b.isDesktopForkRemoteBranch + ) + const isAlphabeticSort = branchSortOrder === BranchSortOrder.Alphabet + + if (isAlphabeticSort) { + const recentBranchNames = new Set( + recentBranchesWithoutDefaultForSort.map(branch => branch.name) + ) + const branchesWithMetadata = remainingBranches.map(branch => ({ + text: [branch.name], + id: branch.name, + branch, + worktreeInUse: findWorktreeForBranch(branch.name, allWorktrees), + })) + const sortedBranches = branchesWithMetadata.sort((left, right) => + caseInsensitiveCompare(left.branch.name, right.branch.name) + ) + const recentBranchesToShow = new Array() + const remainingBranchesToShow = new Array() + + for (const branch of sortedBranches) { + if (recentBranchNames.has(branch.branch.name)) { + recentBranchesToShow.push(branch) + } else { + remainingBranchesToShow.push(branch) + } + } + + if (recentBranchesToShow.length > 0) { + groups.push({ + identifier: 'recent', + items: recentBranchesToShow, }) - recentBranchNames.add(branch.name) } groups.push({ - identifier: 'recent', - items: recentBranches, + identifier: 'other', + items: remainingBranchesToShow, }) + + return groups } - const remainingBranches = allBranches.filter( - b => - b.name !== defaultBranchName && - !recentBranchNames.has(b.name) && - !b.isDesktopForkRemoteBranch - ) + const sortByName = (left: IBranchListItem, right: IBranchListItem) => + caseInsensitiveCompare(left.branch.name, right.branch.name) - const remainingItems = remainingBranches.map(b => { - const worktreeInUse = findWorktreeForBranch(b.name, allWorktrees) - return { - text: [b.name], - id: b.name, - branch: b, - worktreeInUse, + const sortByMostRecentCommit = ( + left: IBranchListItem, + right: IBranchListItem + ) => { + const leftCommitDate = + commitAuthorDates?.get(left.branch.tip.sha)?.getTime() ?? -Infinity + const rightCommitDate = + commitAuthorDates?.get(right.branch.tip.sha)?.getTime() ?? -Infinity + + if (leftCommitDate === rightCommitDate) { + return sortByName(left, right) } + + return rightCommitDate - leftCommitDate + } + + const recentBranchNameToRank = new Map() + recentBranchesWithoutDefaultForSort.forEach((branch, index) => { + recentBranchNameToRank.set(branch.name, index) }) - groups.push({ - identifier: 'other', - items: remainingItems, - }) + const remainingBranchesForFilter = remainingBranches + .map(branch => ({ + text: [branch.name], + id: branch.name, + branch, + worktreeInUse: findWorktreeForBranch(branch.name, allWorktrees), + })) + .sort((left, right) => { + if (branchSortOrder === BranchSortOrder.RecentlyChanged) { + return sortByMostRecentCommit(left, right) + } + + const leftRank = recentBranchNameToRank.get(left.branch.name) + const rightRank = recentBranchNameToRank.get(right.branch.name) + + if (leftRank !== undefined && rightRank !== undefined) { + return leftRank - rightRank + } + + if (leftRank !== undefined) { + return -1 + } + + if (rightRank !== undefined) { + return 1 + } + + return sortByName(left, right) + }) + const useRecentBranchesOnly = + branchSortOrder === BranchSortOrder.RecentlyChanged + const recentBranchesToShow = useRecentBranchesOnly + ? remainingBranchesForFilter + : remainingBranchesForFilter.filter(branch => + recentBranchNameToRank.has(branch.branch.name) + ) + const remainingBranchesToShow = useRecentBranchesOnly + ? [] + : remainingBranchesForFilter.filter( + branch => !recentBranchNameToRank.has(branch.branch.name) + ) + + if (recentBranchesToShow.length > 0) { + groups.push({ + identifier: 'recent', + items: recentBranchesToShow, + }) + } + + if (!useRecentBranchesOnly) { + groups.push({ + identifier: 'other', + items: remainingBranchesToShow, + }) + } return groups } diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 56c88701c8b..24b9ae8581e 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -103,6 +103,7 @@ import { } from '../../lib/stores/commit-status-store' import { MergeTreeResult } from '../../models/merge' import { UncommittedChangesStrategy } from '../../models/uncommitted-changes-strategy' +import { BranchSortOrder } from '../../models/branch-sort-order' import { ShowBranchNameInRepoListSetting } from '../../models/show-branch-name-in-repo-list' import { IStashEntry } from '../../models/stash-entry' import { WorkflowPreferences } from '../../models/workflow-preferences' @@ -4203,6 +4204,10 @@ export class Dispatcher { ) } + public setBranchSortOrder(branchSortOrder: BranchSortOrder) { + return this.appStore._updateBranchSortOrder(branchSortOrder) + } + public testPruneBranches() { return this.appStore._testPruneBranches() } diff --git a/app/src/ui/history/compare.tsx b/app/src/ui/history/compare.tsx index d8ec3508aba..a8dacca1d6e 100644 --- a/app/src/ui/history/compare.tsx +++ b/app/src/ui/history/compare.tsx @@ -29,6 +29,7 @@ import { DragType } from '../../models/drag-drop' import { PopupType } from '../../models/popup' import { getUniqueCoauthorsAsAuthors } from '../../lib/unique-coauthors-as-authors' import { getSquashedCommitDescription } from '../../lib/squash/squashed-commit-description' +import { BranchSortOrder } from '../../models/branch-sort-order' import { doMergeCommitsExistAfterCommit } from '../../lib/git' import { KeyboardInsertionData } from '../lib/list' import { Account } from '../../models/account' @@ -47,6 +48,7 @@ interface ICompareSidebarProps { readonly dispatcher: Dispatcher readonly currentBranch: Branch | null readonly selectedCommitShas: ReadonlyArray + readonly branchSortOrder: BranchSortOrder readonly onRevertCommit: (commit: Commit) => void readonly onAmendCommit: (commit: Commit, isLocalCommit: boolean) => void readonly onViewCommitOnGitHub: (sha: string) => void @@ -413,6 +415,7 @@ export class CompareSidebar extends React.Component< allBranches={branches} recentBranches={recentBranches} allWorktrees={[]} + branchSortOrder={this.props.branchSortOrder} filterText={filterText} textbox={this.textbox!} selectedBranch={this.state.focusedBranch} diff --git a/app/src/ui/preferences/appearance.tsx b/app/src/ui/preferences/appearance.tsx index 657cb25f0f0..d9e8756b8c8 100644 --- a/app/src/ui/preferences/appearance.tsx +++ b/app/src/ui/preferences/appearance.tsx @@ -14,6 +14,8 @@ import { tabSizeDefault } from '../../lib/stores/app-store' import { Checkbox, CheckboxValue } from '../lib/checkbox' import { ShowBranchNameInRepoListSetting } from '../../models/show-branch-name-in-repo-list' import { parseEnumValue } from '../../lib/enum' +import { assertNever } from '../../lib/fatal-error' +import { BranchSortOrder } from '../../models/branch-sort-order' interface IAppearanceProps { readonly selectedTheme: ApplicationTheme @@ -30,6 +32,8 @@ interface IAppearanceProps { readonly onShowBranchNameInRepoListChanged: ( value: ShowBranchNameInRepoListSetting ) => void + readonly branchSortOrder: BranchSortOrder + readonly onBranchSortOrderChanged: (sortOrder: BranchSortOrder) => void } interface IAppearanceState { @@ -231,6 +235,48 @@ export class Appearance extends React.Component< } } + private onBranchSortOrderChanged = (branchSortOrder: BranchSortOrder) => { + this.props.onBranchSortOrderChanged(branchSortOrder) + } + + private renderBranchSortOrder() { + const { branchSortOrder } = this.props + + return ( +
+

Sort branches

+ + + ariaLabelledBy="branch-sort-order-heading" + selectedKey={branchSortOrder} + radioButtonKeys={[ + BranchSortOrder.Alphabet, + BranchSortOrder.RecentlyAdded, + BranchSortOrder.RecentlyChanged, + ]} + onSelectionChanged={this.onBranchSortOrderChanged} + renderRadioButtonLabelContents={this.renderBranchSortOptionLabel} + /> +
+ ) + } + + private renderBranchSortOptionLabel = (branchSortOrder: BranchSortOrder) => { + switch (branchSortOrder) { + case BranchSortOrder.Alphabet: + return 'Alphabet' + case BranchSortOrder.RecentlyAdded: + return 'Recently added' + case BranchSortOrder.RecentlyChanged: + return 'Recently changed' + default: + return assertNever( + branchSortOrder, + `Unknown branch sort order: ${branchSortOrder}` + ) + } + } + private renderRepositoryList() { return (
@@ -303,6 +349,7 @@ export class Appearance extends React.Component< {this.renderSelectedTheme()} {this.renderRepositoryList()} + {this.renderBranchSortOrder()} {this.renderWorktreeVisibility()} {this.renderSelectedTabSize()} {this.renderTitleBarStyleDropdown()} diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index cb12a610783..76857837e5d 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -24,6 +24,7 @@ import { ApplicationTheme } from '../lib/application-theme' import { TitleBarStyle } from '../lib/title-bar-style' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { Integrations } from './integrations' +import { BranchSortOrder } from '../../models/branch-sort-order' import { UncommittedChangesStrategy, defaultUncommittedChangesStrategy, @@ -95,6 +96,7 @@ interface IPreferencesProps { readonly showWorktrees: boolean readonly repositoryIndicatorsEnabled: boolean readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting + readonly branchSortOrder: BranchSortOrder readonly hideWindowOnQuit: boolean readonly onEditGlobalGitConfig: () => void readonly underlineLinks: boolean @@ -147,6 +149,7 @@ interface IPreferencesState { readonly existingLockFilePath?: string readonly repositoryIndicatorsEnabled: boolean readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting + readonly branchSortOrder: BranchSortOrder readonly hideWindowOnQuit: boolean readonly initiallySelectedTheme: ApplicationTheme @@ -223,6 +226,7 @@ export class Preferences extends React.Component< showWorktrees: this.props.showWorktrees, repositoryIndicatorsEnabled: this.props.repositoryIndicatorsEnabled, showBranchNameInRepoList: this.props.showBranchNameInRepoList, + branchSortOrder: this.props.branchSortOrder, hideWindowOnQuit: this.props.hideWindowOnQuit, initiallySelectedTheme: this.props.selectedTheme, initiallySelectedTabSize: this.props.selectedTabSize, @@ -560,6 +564,8 @@ export class Preferences extends React.Component< onShowBranchNameInRepoListChanged={ this.onShowBranchNameInRepoListChanged } + branchSortOrder={this.state.branchSortOrder} + onBranchSortOrderChanged={this.onBranchSortOrderChanged} /> ) break @@ -811,6 +817,10 @@ export class Preferences extends React.Component< this.setState({ showBranchNameInRepoList }) } + private onBranchSortOrderChanged = (branchSortOrder: BranchSortOrder) => { + this.setState({ branchSortOrder }) + } + private onSelectedTabSizeChanged = (tabSize: number) => { this.props.dispatcher.setSelectedTabSize(tabSize) } @@ -1017,6 +1027,7 @@ export class Preferences extends React.Component< dispatcher.setDiffCheckMarksSetting(this.state.showDiffCheckMarks) dispatcher.setShowBranchNameInRepoList(this.state.showBranchNameInRepoList) + dispatcher.setBranchSortOrder(this.state.branchSortOrder) this.props.onDismissed() } diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 647f0251290..fc1aafb003d 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -38,6 +38,7 @@ import { clamp } from '../lib/clamp' import { Emoji } from '../lib/emoji' import { PopupType } from '../models/popup' import { Branch } from '../models/branch' +import { BranchSortOrder } from '../models/branch-sort-order' interface IRepositoryViewProps { readonly repository: Repository @@ -113,6 +114,8 @@ interface IRepositoryViewProps { sourceBranch?: Branch ) => void + readonly branchSortOrder: BranchSortOrder + /** The user's preference of pull request suggested next action to use **/ readonly pullRequestSuggestedNextAction?: PullRequestSuggestedNextAction @@ -417,6 +420,7 @@ export class RepositoryView extends React.Component< repository={repository} isLocalRepository={remote === null} compareState={compareState} + branchSortOrder={this.props.branchSortOrder} selectedCommitShas={shas} shasToHighlight={compareState.shasToHighlight} currentBranch={currentBranch} @@ -473,6 +477,7 @@ export class RepositoryView extends React.Component< repository={repository} isLocalRepository={remote === null} compareState={compareState} + branchSortOrder={this.props.branchSortOrder} selectedCommitShas={shas} shasToHighlight={compareState.shasToHighlight} currentBranch={currentBranch} diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 6ebdfce21b2..1617d8b9157 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -29,6 +29,7 @@ import { PopupType } from '../../models/popup' import { generateBranchContextMenuItems } from '../branches/branch-list-item-context-menu' import { showContextualMenu } from '../../lib/menu-item' import { Emoji } from '../../lib/emoji' +import { BranchSortOrder } from '../../models/branch-sort-order' import { enableResizingToolbarButtons } from '../../lib/feature-flag' interface IBranchDropdownProps { @@ -46,6 +47,8 @@ interface IBranchDropdownProps { /** Whether or not the branch dropdown is currently open */ readonly isOpen: boolean + readonly branchSortOrder: BranchSortOrder + /** * An event handler for when the drop down is opened, or closed, by a pointer * event or by pressing the space or enter key while focused. @@ -112,6 +115,7 @@ export class BranchDropdown extends React.Component { pullRequests={this.props.pullRequests} currentPullRequest={this.props.currentPullRequest} isLoadingPullRequests={this.props.isLoadingPullRequests} + branchSortOrder={this.props.branchSortOrder} emoji={this.props.emoji} onDeleteBranch={this.onDeleteBranch} onRenameBranch={this.onRenameBranch} diff --git a/app/test/unit/group-branches-test.ts b/app/test/unit/group-branches-test.ts index aabc6f4d957..37870ea81c7 100644 --- a/app/test/unit/group-branches-test.ts +++ b/app/test/unit/group-branches-test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert' import { groupBranches } from '../../src/ui/branches' import { Branch, BranchType } from '../../src/models/branch' import { CommitIdentity } from '../../src/models/commit-identity' +import { BranchSortOrder } from '../../src/models/branch-sort-order' describe('Branches grouping', () => { const author = new CommitIdentity('Hubot', 'hubot@github.com', new Date()) @@ -71,4 +72,127 @@ describe('Branches grouping', () => { items = groups[2].items assert.equal(items[0].branch, otherBranch) }) + + it('should sort by recent branch order with "recently added"', () => { + const allBranches = [ + currentBranch, + new Branch('alpha', null, branchTip, BranchType.Local, '', false), + new Branch('zeta', null, branchTip, BranchType.Local, '', false), + new Branch('gamma', null, branchTip, BranchType.Local, '', false), + ] + const recentBranches = [allBranches[2], allBranches[1], allBranches[3]] + const groups = groupBranches( + defaultBranch, + currentBranch, + allBranches, + recentBranches, + [], + BranchSortOrder.RecentlyAdded + ) + + assert.equal(groups.length, 3) + assert.equal(groups[1].identifier, 'recent') + assert.deepStrictEqual( + groups[1].items.map(item => item.branch.name), + ['zeta', 'alpha', 'gamma'] + ) + assert.equal(groups[2].identifier, 'other') + assert.equal(groups[2].items.length, 0) + }) + + it('should cap recent branches to 5 when sorting alphabetically', () => { + const allBranches = [ + currentBranch, + new Branch('alpha', null, branchTip, BranchType.Local, '', false), + new Branch('beta', null, branchTip, BranchType.Local, '', false), + new Branch('charlie', null, branchTip, BranchType.Local, '', false), + new Branch('delta', null, branchTip, BranchType.Local, '', false), + new Branch('echo', null, branchTip, BranchType.Local, '', false), + new Branch('foxtrot', null, branchTip, BranchType.Local, '', false), + ] + const recentBranches = [ + allBranches[1], + allBranches[2], + allBranches[3], + allBranches[4], + allBranches[5], + ] + const groups = groupBranches( + defaultBranch, + currentBranch, + allBranches, + recentBranches, + [], + BranchSortOrder.Alphabet + ) + + assert.equal(groups.length, 3) + assert.equal(groups[1].identifier, 'recent') + assert.equal(groups[1].items.length, 5) + assert.deepStrictEqual( + groups[1].items.map(item => item.branch.name), + ['alpha', 'beta', 'charlie', 'delta', 'echo'] + ) + assert.equal(groups[2].identifier, 'other') + assert.equal(groups[2].items.length, 1) + assert.deepStrictEqual( + groups[2].items.map(item => item.branch.name), + ['foxtrot'] + ) + }) + + it('should sort by commit date with "recently changed"', () => { + const branchDateA = new Date('2026-01-01T00:00:00.000Z') + const branchDateB = new Date('2026-01-15T00:00:00.000Z') + const branchDateC = new Date('2026-01-10T00:00:00.000Z') + + const branchA = new Branch( + 'alpha', + null, + { sha: 'a-date-branch' }, + BranchType.Local, + '', + false + ) + const branchB = new Branch( + 'beta', + null, + { sha: 'b-date-branch' }, + BranchType.Local, + '', + false + ) + const branchC = new Branch( + 'gamma', + null, + { sha: 'c-date-branch' }, + BranchType.Local, + '', + false + ) + const allBranches = [currentBranch, branchA, branchB, branchC] + const commitAuthorDates = new Map([ + [branchA.tip.sha, branchDateA], + [branchB.tip.sha, branchDateB], + [branchC.tip.sha, branchDateC], + ]) + const recentBranches = [branchA] + + const groups = groupBranches( + defaultBranch, + currentBranch, + allBranches, + recentBranches, + [], + BranchSortOrder.RecentlyChanged, + commitAuthorDates + ) + + assert.equal(groups.length, 2) + assert.equal(groups[1].identifier, 'recent') + assert.deepStrictEqual( + groups[1].items.map(item => item.branch.name), + ['beta', 'gamma', 'alpha'] + ) + }) })