From aeb7af146aeffa998db73643a12067361baf8426 Mon Sep 17 00:00:00 2001 From: kingdo10 <275526951+kingdo10@users.noreply.github.com> Date: Tue, 12 May 2026 13:47:11 +0100 Subject: [PATCH 01/10] Commit Graph History Tree --- app/src/lib/app-state.ts | 18 + app/src/lib/stores/app-store.ts | 291 +++- app/src/lib/stores/commit-graph-state.ts | 155 ++ app/src/lib/stores/git-store.ts | 41 + app/src/lib/stores/repository-state-cache.ts | 4 + app/src/ui/app.tsx | 1 + app/src/ui/dispatcher/dispatcher.ts | 41 + .../history/commit-graph-commit-list-item.tsx | 530 ++++++ app/src/ui/history/commit-graph-model.ts | 297 ++++ app/src/ui/history/commit-graph-sidebar.tsx | 1546 +++++++++++++++++ app/src/ui/history/commit-list.tsx | 68 +- app/src/ui/history/index.ts | 1 + app/src/ui/lib/list/list-row.tsx | 9 + app/src/ui/repository.tsx | 24 +- app/styles/ui/_history.scss | 1 + app/styles/ui/history/_commit-graph.scss | 440 +++++ 16 files changed, 3452 insertions(+), 15 deletions(-) create mode 100644 app/src/lib/stores/commit-graph-state.ts create mode 100644 app/src/ui/history/commit-graph-commit-list-item.tsx create mode 100644 app/src/ui/history/commit-graph-model.ts create mode 100644 app/src/ui/history/commit-graph-sidebar.tsx create mode 100644 app/styles/ui/history/_commit-graph.scss diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 9fd77682cb9..e2553cf2126 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -200,6 +200,9 @@ export interface IAppState { /** The width of the commit summary column in the history view */ readonly commitSummaryWidth: IConstrainedValue + /** The width of the branch list column in the commit graph view */ + readonly commitGraphBranchListWidth: IConstrainedValue + /** The width of the files list in the stash view */ readonly stashedFilesWidth: IConstrainedValue @@ -1014,6 +1017,21 @@ export interface ICompareState { readonly allHistoryCommitSHAs: ReadonlyArray + /** The branch refs used to build the current commit graph. */ + readonly commitGraphRefs: ReadonlyArray + + /** + * The branch refs hidden from the commit graph for this repository, or null + * before they load. + */ + readonly commitGraphHiddenBranchRefs: ReadonlyArray | null + + /** The branch groups collapsed in the commit graph branch list. */ + readonly commitGraphCollapsedBranchGroups: ReadonlyArray + + /** The SHAs of commits to render in the commit graph. */ + readonly commitGraphCommitSHAs: ReadonlyArray + readonly compareCommitSHAs: ReadonlyArray /** The SHAs of commits to highlight in the compare list */ diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index d4207498b4d..122065825dc 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -297,6 +297,16 @@ import { MergeTreeResult } from '../../models/merge' import { promiseWithMinimumTimeout } from '../promise' import { BackgroundFetcher } from './helpers/background-fetcher' import { RepositoryStateCache } from './repository-state-cache' +import { + commitGraph_DefaultBranchListWidth, + commitGraph_getCommitSelectionCandidates, + commitGraph_getStoredCollapsedBranchGroups, + commitGraph_getStoredHiddenBranchRefs, + commitGraph_BranchListWidthConfigKey, + commitGraph_setStoredCollapsedBranchGroups, + commitGraph_setStoredHiddenBranchRefs, + commitGraph_setStoredViewMode, +} from './commit-graph-state' import { readEmoji } from '../read-emoji' import { Emoji } from '../emoji' import { GitStoreCache } from './git-store-cache' @@ -631,6 +641,9 @@ export class AppStore extends TypedBaseStore { private sidebarWidth = constrain(defaultSidebarWidth) private commitSummaryWidth = constrain(defaultCommitSummaryWidth) + private commitGraphBranchListWidth = constrain( + commitGraph_DefaultBranchListWidth + ) private stashedFilesWidth = constrain(defaultStashedFilesWidth) private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth) private branchDropdownWidth = constrain(defaultBranchDropdownWidth) @@ -1267,6 +1280,7 @@ export class AppStore extends TypedBaseStore { worktreeDropdownWidth: this.worktreeDropdownWidth, pushPullButtonWidth: this.pushPullButtonWidth, commitSummaryWidth: this.commitSummaryWidth, + commitGraphBranchListWidth: this.commitGraphBranchListWidth, stashedFilesWidth: this.stashedFilesWidth, pullRequestFilesListWidth: this.pullRequestFileListWidth, appMenuState: this.appMenu ? this.appMenu.openMenus : [], @@ -1768,6 +1782,15 @@ export class AppStore extends TypedBaseStore { branches, recentBranches, defaultBranch, + commitGraphHiddenBranchRefs: commitGraph_getStoredHiddenBranchRefs( + repository, + branchesState.allBranches, + currentBranch, + cachedDefaultBranch, + state.localTags + ), + commitGraphCollapsedBranchGroups: + commitGraph_getStoredCollapsedBranchGroups(repository), })) const cachedState = compareState.formState @@ -2045,6 +2068,210 @@ export class AppStore extends TypedBaseStore { return } + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _commitGraph_load( + repository: Repository, + refs: ReadonlyArray + ): Promise { + // Preserve selections that were already visible before this graph reload. + const stateBeforeLoad = this.repositoryStateCache.get(repository) + const commitGraphPreviousCommitCount = + stateBeforeLoad.compareState.commitGraphCommitSHAs.length + + this.repositoryStateCache.updateCompareState(repository, () => ({ + commitGraphRefs: refs, + commitGraphCommitSHAs: [], + })) + this.emitUpdate() + + if (refs.length === 0) { + return + } + + const gitStore = this.gitStoreCache.get(repository) + let commits: ReadonlyArray | null = + await gitStore.commitGraph_loadCommitBatch(refs, 0, false) + + if (commits === null) { + return + } + + const stateAfterLoad = this.repositoryStateCache.get(repository) + + if (!arrayEquals(stateAfterLoad.compareState.commitGraphRefs, refs)) { + return + } + + commits = await this.commitGraph_loadToPreviousSelection( + repository, + refs, + commits, + commitGraphPreviousCommitCount + ) + + if (commits === null) { + return + } + + this.repositoryStateCache.updateCompareState(repository, () => ({ + commitGraphCommitSHAs: commits, + })) + + this.updateOrSelectFirstCommit(repository, commits) + this.emitUpdate() + } + + private async commitGraph_loadToPreviousSelection( + repository: Repository, + refs: ReadonlyArray, + initialCommits: ReadonlyArray, + commitGraphPreviousCommitCount: number + ): Promise | null> { + const selectedSHA = + this.repositoryStateCache.get(repository).commitSelection.shas[0] + + if ( + selectedSHA === undefined || + initialCommits.includes(selectedSHA) || + initialCommits.length >= commitGraphPreviousCommitCount + ) { + return initialCommits + } + + const gitStore = this.gitStoreCache.get(repository) + let commits = initialCommits + + while ( + commits.length < commitGraphPreviousCommitCount && + !commits.includes(selectedSHA) + ) { + const nextCommits = await gitStore.commitGraph_loadCommitBatch( + refs, + commits.length, + false + ) + + if (nextCommits === null || nextCommits.length === 0) { + return commits + } + + const stateAfterLoad = this.repositoryStateCache.get(repository) + + if (!arrayEquals(stateAfterLoad.compareState.commitGraphRefs, refs)) { + return null + } + + commits = commits.concat(nextCommits) + } + + return commits + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _commitGraph_setHiddenBranchRefs( + repository: Repository, + hiddenBranchRefs: ReadonlyArray + ) { + commitGraph_setStoredHiddenBranchRefs(repository, hiddenBranchRefs) + + this.repositoryStateCache.updateCompareState(repository, () => ({ + commitGraphHiddenBranchRefs: hiddenBranchRefs, + })) + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _commitGraph_setCollapsedBranchGroups( + repository: Repository, + collapsedBranchGroups: ReadonlyArray + ) { + commitGraph_setStoredCollapsedBranchGroups( + repository, + collapsedBranchGroups + ) + + this.repositoryStateCache.updateCompareState(repository, () => ({ + commitGraphCollapsedBranchGroups: collapsedBranchGroups, + })) + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _commitGraph_loadNextCommitBatch( + repository: Repository + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const state = this.repositoryStateCache.get(repository) + const { commitGraphRefs, commitGraphCommitSHAs } = state.compareState + + if (commitGraphRefs.length === 0) { + return + } + + const queryTextLowercase = + state.compareState.commitSearchQuery.toLowerCase() + + if (queryTextLowercase.length > 0) { + // Graph search filters in memory, so continue paging until the loaded + // graph has enough matches or Git reports no more commits. + const commitGraphFilteredCommitCount = commitGraphCommitSHAs.filter(sha => + this.commitIsIncluded( + gitStore.commitLookup.get(sha), + queryTextLowercase + ) + ).length + + if (commitGraphFilteredCommitCount >= MinimumFilteredCommitsToLoad) { + return + } + } + + const newCommits = await gitStore.commitGraph_loadCommitBatch( + commitGraphRefs, + commitGraphCommitSHAs.length, + !!queryTextLowercase + ) + + if (!newCommits || newCommits.length === 0) { + return + } + + const stateAfterLoad = this.repositoryStateCache.get(repository) + + if ( + !arrayEquals(stateAfterLoad.compareState.commitGraphRefs, commitGraphRefs) + ) { + return + } + + this.repositoryStateCache.updateCompareState(repository, () => ({ + commitGraphCommitSHAs: + stateAfterLoad.compareState.commitGraphCommitSHAs.concat(newCommits), + })) + + this.emitUpdate() + + const latestState = this.repositoryStateCache.get(repository) + const latestQueryTextLowercase = + latestState.compareState.commitSearchQuery.toLowerCase() + + if (latestQueryTextLowercase.length > 0) { + const commitGraphFilteredCommitCount = + latestState.compareState.commitGraphCommitSHAs.filter(sha => + this.commitIsIncluded( + gitStore.commitLookup.get(sha), + latestQueryTextLowercase + ) + ).length + + if (commitGraphFilteredCommitCount < MinimumFilteredCommitsToLoad) { + return this._commitGraph_loadNextCommitBatch(repository) + } + } + } + private commitIsIncluded( commit: Commit | undefined, filterTextLowerCase: string @@ -2304,6 +2531,14 @@ export class AppStore extends TypedBaseStore { } } + if ( + previouslySelectedRepository instanceof Repository && + previouslySelectedRepository.hash !== repository.hash + ) { + // Keep the graph/tree choice from carrying across repositories. + commitGraph_setStoredViewMode('list') + } + this.emitUpdate() if (persistSelection) { @@ -2614,6 +2849,12 @@ export class AppStore extends TypedBaseStore { this.commitSummaryWidth = constrain( getNumber(commitSummaryWidthConfigKey, defaultCommitSummaryWidth) ) + this.commitGraphBranchListWidth = constrain( + getNumber( + commitGraph_BranchListWidthConfigKey, + commitGraph_DefaultBranchListWidth + ) + ) this.stashedFilesWidth = constrain( getNumber(stashedFilesWidthConfigKey, defaultStashedFilesWidth) ) @@ -2885,6 +3126,11 @@ export class AppStore extends TypedBaseStore { const filesMax = available - diffPaneMinWidth this.commitSummaryWidth = constrain(this.commitSummaryWidth, 100, filesMax) + this.commitGraphBranchListWidth = constrain( + this.commitGraphBranchListWidth, + 120, + filesMax + ) this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax) // Allocate worktree first (highest priority), then branch, then @@ -4210,6 +4456,7 @@ export class AppStore extends TypedBaseStore { ]) await gitStore.refreshTags() + this.updateLocalTags(repository) // this promise is fire-and-forget, so no need to await it this.updateStashEntryCountMetric( @@ -4605,9 +4852,16 @@ export class AppStore extends TypedBaseStore { await gitStore.loadLocalCommits(tip.branch) } + const latestState = this.repositoryStateCache.get(repository) + const { commitGraphCommitSHAs, commitGraphRefs } = latestState.compareState + const commitGraphSelectionCandidates = + commitGraphRefs.length > 0 || commitGraphCommitSHAs.length > 0 + ? commitGraph_getCommitSelectionCandidates(latestState) + : state.compareState.allHistoryCommitSHAs + return this.updateOrSelectFirstCommit( repository, - state.compareState.allHistoryCommitSHAs + commitGraphSelectionCandidates ) } @@ -4777,6 +5031,17 @@ export class AppStore extends TypedBaseStore { await gitStore.deleteTag(name) } + private updateLocalTags(repository: Repository) { + const gitStore = this.gitStoreCache.get(repository) + + this.repositoryStateCache.update(repository, () => ({ + localTags: gitStore.localTags, + tagsToPush: gitStore.tagsToPush, + })) + + this.emitUpdate() + } + private updateCheckoutProgress( repository: Repository, checkoutProgress: ICheckoutProgress | null @@ -6525,6 +6790,30 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _commitGraph_setBranchListWidth(width: number): Promise { + this.commitGraphBranchListWidth = { + ...this.commitGraphBranchListWidth, + value: width, + } + setNumber(commitGraph_BranchListWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _commitGraph_resetBranchListWidth(): Promise { + this.commitGraphBranchListWidth = { + ...this.commitGraphBranchListWidth, + value: commitGraph_DefaultBranchListWidth, + } + localStorage.removeItem(commitGraph_BranchListWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + public _setCommitMessage( repository: Repository, message: ICommitMessage diff --git a/app/src/lib/stores/commit-graph-state.ts b/app/src/lib/stores/commit-graph-state.ts new file mode 100644 index 00000000000..3cfb132008f --- /dev/null +++ b/app/src/lib/stores/commit-graph-state.ts @@ -0,0 +1,155 @@ +import { Branch, BranchType } from '../../models/branch' +import { Repository } from '../../models/repository' +import type { IRepositoryState } from '../app-state' +import { getStringArray, setStringArray } from '../local-storage' + +const commitGraph_HiddenBranchRefsKeyPrefix = 'commitGraph-hidden-branch-refs' +const commitGraph_CollapsedBranchGroupsKeyPrefix = + 'commitGraph-collapsed-branch-groups' +const commitGraph_ViewModeKey = 'commitGraph-view-mode' +const commitGraph_DefaultCollapsedBranchGroups = ['origin', 'upstream', 'tags'] +const commitGraph_DefaultViewMode: CommitGraphViewModePreference = 'tree' + +export const commitGraph_DefaultBranchListWidth = 180 +export const commitGraph_BranchListWidthConfigKey = + 'commitGraph-branch-list-width' + +export type CommitGraphViewModePreference = 'list' | 'tree' + +export function commitGraph_getStoredViewMode(): CommitGraphViewModePreference { + const value = localStorage.getItem(commitGraph_ViewModeKey) + + return value === 'list' || value === 'tree' + ? value + : commitGraph_DefaultViewMode +} + +export function commitGraph_setStoredViewMode( + viewMode: CommitGraphViewModePreference +) { + localStorage.setItem(commitGraph_ViewModeKey, viewMode) +} + +export function commitGraph_getStoredHiddenBranchRefs( + repository: Repository, + branches: ReadonlyArray, + currentBranch: Branch | null, + defaultBranch: Branch | null, + tags: Map | null +): ReadonlyArray | null { + if (branches.length === 0 || tags === null) { + return null + } + + const key = commitGraph_GetHiddenBranchRefsKey(repository) + + return localStorage.getItem(key) !== null + ? getStringArray(key) + : commitGraph_GetInitialHiddenBranchRefs( + branches, + currentBranch, + defaultBranch, + tags + ) +} + +export function commitGraph_setStoredHiddenBranchRefs( + repository: Repository, + hiddenBranchRefs: ReadonlyArray +) { + setStringArray( + commitGraph_GetHiddenBranchRefsKey(repository), + hiddenBranchRefs + ) +} + +export function commitGraph_getStoredCollapsedBranchGroups( + repository: Repository +): ReadonlyArray { + const key = commitGraph_GetCollapsedBranchGroupsKey(repository) + + return localStorage.getItem(key) === null + ? commitGraph_DefaultCollapsedBranchGroups + : getStringArray(key) +} + +export function commitGraph_setStoredCollapsedBranchGroups( + repository: Repository, + collapsedBranchGroups: ReadonlyArray +) { + setStringArray( + commitGraph_GetCollapsedBranchGroupsKey(repository), + collapsedBranchGroups + ) +} + +export function commitGraph_getCommitSelectionCandidates( + state: IRepositoryState +): ReadonlyArray { + const { allHistoryCommitSHAs, commitGraphCommitSHAs, commitGraphRefs } = + state.compareState + + if (commitGraphCommitSHAs.length === 0) { + if (commitGraphRefs.length > 0 && state.commitSelection.shas.length > 0) { + return [...allHistoryCommitSHAs, ...state.commitSelection.shas] + } + + return allHistoryCommitSHAs + } + + return Array.from( + new Set([...allHistoryCommitSHAs, ...commitGraphCommitSHAs]) + ) +} + +function commitGraph_GetHiddenBranchRefsKey(repository: Repository) { + return `${commitGraph_HiddenBranchRefsKeyPrefix}-${repository.hash}` +} + +function commitGraph_GetCollapsedBranchGroupsKey(repository: Repository) { + return `${commitGraph_CollapsedBranchGroupsKeyPrefix}-${repository.hash}` +} + +function commitGraph_GetTagRef(tagName: string) { + return `refs/tags/${tagName}` +} + +function commitGraph_GetInitialHiddenBranchRefs( + branches: ReadonlyArray, + currentBranch: Branch | null, + defaultBranch: Branch | null, + tags: Map +): ReadonlyArray { + const selectedBranchRefs = new Set() + + if (currentBranch !== null) { + selectedBranchRefs.add(currentBranch.ref) + } + + const mainBranch = + defaultBranch ?? + branches.find( + branch => + branch.type === BranchType.Local && + (branch.name === 'main' || branch.name === 'master') + ) ?? + branches.find( + branch => + branch.type === BranchType.Remote && + (branch.nameWithoutRemote === 'main' || + branch.nameWithoutRemote === 'master') + ) ?? + null + + if (mainBranch !== null) { + selectedBranchRefs.add(mainBranch.ref) + } + + return branches + .filter( + branch => + !branch.isDesktopForkRemoteBranch && !selectedBranchRefs.has(branch.ref) + ) + .map(branch => branch.ref) + .concat(Array.from(tags.keys(), commitGraph_GetTagRef)) +} diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index adae0befb9f..37455cbd583 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -108,6 +108,9 @@ import { normalizePath } from '../helpers/path' /** The number of commits to load from history per batch. */ const CommitBatchSize = 100 + +/** The commit graph needs more parent context to avoid lane reflow. */ +const commitGraph_CommitBatchSize = 500 const CommitBatchSizeSearch = 500 const LoadingHistoryRequestKey = 'history' @@ -254,6 +257,44 @@ export class GitStore extends BaseStore { return commits.map(c => c.sha) } + /** Load a batch of commits reachable from a set of branch refs. */ + public async commitGraph_loadCommitBatch( + refs: ReadonlyArray, + skip: number, + isSearching: boolean + ) { + if (this.requestsInFight.has(LoadingHistoryRequestKey)) { + return null + } + + const refsKey = refs.join('\0') + const requestKey = `history/graph/${refsKey}/skip/${skip}` + if (this.requestsInFight.has(requestKey)) { + return null + } + + this.requestsInFight.add(requestKey) + + const batchSize = isSearching + ? CommitBatchSizeSearch + : commitGraph_CommitBatchSize + const revisionArgs = refs.length > 0 ? refs : ['--all'] + const commits = await this.performFailableOperation(() => + getCommits(this.repository, undefined, batchSize, skip, [ + '--topo-order', + ...revisionArgs, + ]) + ) + + this.requestsInFight.delete(requestKey) + if (!commits) { + return null + } + + this.storeCommits(commits) + return commits.map(c => c.sha) + } + public async refreshTags() { const previousTags = this._localTags const newTags = await this.performFailableOperation(() => diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index e5c33a4b872..ed345b808f6 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -367,6 +367,10 @@ function getInitialRepositoryState(): IRepositoryState { commitSearchQuery: '', allHistoryCommitSHAs: [], filteredHistoryCommitSHAs: [], + commitGraphRefs: [], + commitGraphHiddenBranchRefs: null, + commitGraphCollapsedBranchGroups: [], + commitGraphCommitSHAs: [], compareCommitSHAs: [], shasToHighlight: [], branches: new Array(), diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index a36feb52a00..53eec6cd401 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -3840,6 +3840,7 @@ export class App extends React.Component { emoji={state.emoji} sidebarWidth={state.sidebarWidth} commitSummaryWidth={state.commitSummaryWidth} + commitGraphBranchListWidth={state.commitGraphBranchListWidth} stashedFilesWidth={state.stashedFilesWidth} issuesStore={this.props.issuesStore} gitHubUserStore={this.props.gitHubUserStore} diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index a76f5d62817..d1b4dbd7ba5 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -260,6 +260,39 @@ export class Dispatcher { return this.appStore._loadNextCommitBatch(repository, 0) } + public commitGraph_load( + repository: Repository, + refs: ReadonlyArray + ): Promise { + return this.appStore._commitGraph_load(repository, refs) + } + + public commitGraph_setHiddenBranchRefs( + repository: Repository, + hiddenBranchRefs: ReadonlyArray + ) { + return this.appStore._commitGraph_setHiddenBranchRefs( + repository, + hiddenBranchRefs + ) + } + + public commitGraph_setCollapsedBranchGroups( + repository: Repository, + collapsedBranchGroups: ReadonlyArray + ) { + return this.appStore._commitGraph_setCollapsedBranchGroups( + repository, + collapsedBranchGroups + ) + } + + public commitGraph_loadNextCommitBatch( + repository: Repository + ): Promise { + return this.appStore._commitGraph_loadNextCommitBatch(repository) + } + /** Update the commit search filter text. */ public setCommitSearchQuery( repository: Repository, @@ -1188,6 +1221,14 @@ export class Dispatcher { return this.appStore._resetCommitSummaryWidth() } + public commitGraph_setBranchListWidth(width: number): Promise { + return this.appStore._commitGraph_setBranchListWidth(width) + } + + public commitGraph_resetBranchListWidth(): Promise { + return this.appStore._commitGraph_resetBranchListWidth() + } + /** Update the repository's issues from GitHub. */ public refreshIssues(repository: GitHubRepository): Promise { return this.appStore._refreshIssues(repository) diff --git a/app/src/ui/history/commit-graph-commit-list-item.tsx b/app/src/ui/history/commit-graph-commit-list-item.tsx new file mode 100644 index 00000000000..12d5fb2af23 --- /dev/null +++ b/app/src/ui/history/commit-graph-commit-list-item.tsx @@ -0,0 +1,530 @@ +import * as React from 'react' +import classNames from 'classnames' +import { Commit } from '../../models/commit' +import { Branch, BranchType } from '../../models/branch' +import { Emoji } from '../../lib/emoji' +import { formatDate } from '../../lib/format-date' +import { TooltippedContent } from '../lib/tooltipped-content' +import { TooltipDirection } from '../lib/tooltip' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { commitGraph_RowHeight, ICommitGraphRow } from './commit-graph-model' +import { Tokenizer, TokenType } from '../../lib/text-token-parser' +import { assertNever } from '../../lib/fatal-error' +import { getAvatarUsersForCommit, IAvatarUser } from '../../models/avatar' +import { AvatarStack } from '../lib/avatar-stack' +import { Account } from '../../models/account' +import { GitHubRepository } from '../../models/github-repository' +import { Avatar } from '../lib/avatar' + +interface ICommitGraphCommitListItemProps { + readonly commit: Commit + readonly commitGraphRow: ICommitGraphRow + readonly branches: ReadonlyArray + readonly branchColors: Map + readonly emoji: Map + readonly showUnpushedIndicator: boolean + readonly unpushedIndicatorTitle?: string + readonly preferAbsoluteDates: boolean + readonly currentBranch: Branch | null + readonly currentTipSha: string | null + readonly gitHubRepository: GitHubRepository | null + readonly accounts: ReadonlyArray +} + +// Graph spacing follows the visible lanes in each row, which keeps commit text +// close to the lane it belongs to while preserving fixed-height virtualization. +const commitGraph_LaneGap = 18 +const commitGraph_LeadingPadding = 8 +const commitGraph_MessageGap = 16 +const commitGraph_DotRadius = 4 +const commitGraph_RecentCommitWeekdayThreshold = 6 +const commitGraph_ShortRefLabelLength = 12 + +const commitGraph_CommitWeekdayFormatter = new Intl.DateTimeFormat('en-US', { + weekday: 'long', +}) + +interface ICommitGraphSummaryProps { + readonly className: string + readonly emoji: Map + readonly text: string +} + +class CommitGraphSummary extends React.PureComponent { + public render() { + const { className, emoji, text } = this.props + + return ( + + {commitGraph_renderSummaryTokens(emoji, text)} + + ) + } +} + +export class CommitGraphCommitListItem extends React.PureComponent { + public render() { + const { commit } = this.props + const avatarUsers = getAvatarUsersForCommit( + this.props.gitHubRepository, + commit + ) + const commitSummary = commitGraph_getCommitSummary(commit) + const hasEmptySummary = commit.summary.length === 0 + const commitClassNames = classNames('commit', 'commitGraph-commit', { + 'merge-commit': commit.isMergeCommit, + }) + const summaryClassNames = classNames('commitGraph-summary', { + 'empty-summary': hasEmptySummary, + }) + + return ( +
+ {this.commitGraph_renderGraph()} +
+
+ {this.commitGraph_renderBranchLabels()} + {this.commitGraph_renderCurrentCommitIndicator()} + +
+ {this.commitGraph_renderCommitterBadge(avatarUsers)} + + {this.commitGraph_renderCommitTime(commit.author.date)} + + {this.commitGraph_renderUnpushedIndicator()} +
+
+ ) + } + + private commitGraph_renderGraph() { + const { commitGraphRow } = this.props + const height = commitGraph_RowHeight + const width = + commitGraph_LeadingPadding + + commitGraphRow.maxColumn * commitGraph_LaneGap + + commitGraph_MessageGap + const centerY = height / 2 + const xForColumn = (column: number) => + commitGraph_LeadingPadding + column * commitGraph_LaneGap + const shiftedLaneColumns = new Set( + commitGraphRow.shifts.map(shift => shift.fromColumn) + ) + + return ( + + {commitGraphRow.lanes.map(lane => ( + + ))} + {commitGraphRow.hasTopLine && ( + + )} + {commitGraphRow.shifts.map((shift, index) => { + const fromX = xForColumn(shift.fromColumn) + const toX = xForColumn(shift.toColumn) + const path = `M ${fromX} ${centerY} C ${fromX} ${ + centerY + 8 + }, ${toX} ${height - 8}, ${toX} ${height}` + + return ( + + ) + })} + {commitGraphRow.connections.map((connection, index) => { + const fromX = xForColumn(connection.fromColumn) + const toX = xForColumn(connection.toColumn) + const path = + fromX === toX + ? `M ${fromX} ${ + centerY + commitGraph_DotRadius + } L ${toX} ${height}` + : `M ${fromX} ${centerY} C ${fromX} ${centerY + 8}, ${toX} ${ + height - 8 + }, ${toX} ${height}` + + return ( + + ) + })} + + + ) + } + + private commitGraph_renderCurrentCommitIndicator() { + if ( + this.props.currentBranch !== null || + this.props.currentTipSha !== this.props.commit.sha + ) { + return null + } + + return ( + + ) + } + + private commitGraph_renderBranchLabels() { + const tags = this.props.commit.tags + const labels = this.props.branches.map(branch => + this.commitGraph_renderBranchLabel(branch) + ) + const refNames = [ + ...this.props.branches.map(branch => branch.name), + ...tags, + ] + const className = classNames('commitGraph-ref-labels', { + compact: commitGraph_isCompactRefLabelGroup(refNames), + }) + + if (labels.length === 0 && tags.length === 0) { + return null + } + + return ( + + {labels} + {tags.map(tag => this.commitGraph_renderTagLabel(tag))} + + ) + } + + private commitGraph_renderTagLabel(tag: string) { + const className = classNames( + 'commitGraph-ref-label', + 'commitGraph-ref-tag', + { + short: commitGraph_isShortRefLabel(tag), + } + ) + + return ( + + {tag} + + ) + } + + private commitGraph_renderBranchLabel(branch: Branch) { + const isCurrentBranch = branch.ref === this.props.currentBranch?.ref + const isRemoteBranch = branch.type !== BranchType.Local + const isPullRequestLabel = commitGraph_isPullRequestRefLabel(branch.name) + const color = this.props.branchColors.get(branch.ref) + const className = classNames('commitGraph-ref-label', { + current: isCurrentBranch, + remote: isRemoteBranch, + 'pull-request': isPullRequestLabel, + short: commitGraph_isShortRefLabel(branch.name), + }) + const content = ( + <> + {!isRemoteBranch && color !== undefined ? ( + + ) : null} + {isCurrentBranch ? ( + + ) : null} + {branch.name} + + ) + + if (!isPullRequestLabel) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) + } + + private commitGraph_renderPullRequestLabelTooltip(label: string) { + const body = this.props.commit.body.trim() + + return ( +
+
{label}
+
+ {commitGraph_getCommitSummary(this.props.commit)} +
+ {body.length > 0 ? ( +
{body}
+ ) : null} +
+ ) + } + + private commitGraph_renderCommitterBadge( + avatarUsers: ReadonlyArray + ) { + return ( + + + + ) + } + + private commitGraph_renderCommitterTooltip( + avatarUsers: ReadonlyArray + ) { + const absoluteDate = formatDate(this.props.commit.author.date, { + dateStyle: 'full', + timeStyle: 'short', + }) + + return ( +
+ {avatarUsers.map((user, i) => ( +
+
+ +
+
{commitGraph_renderExpandedAuthor(user)}
+
+ ))} +
+
Date:
+ {absoluteDate} +
+ {this.props.showUnpushedIndicator ? ( +
+
+ + + +
+
{this.props.unpushedIndicatorTitle ?? 'Unpushed commit'}
+
+ ) : null} +
+ ) + } + + private commitGraph_renderCommitTime(date: Date) { + return commitGraph_formatDate(date, this.props.preferAbsoluteDates) + } + + private commitGraph_renderUnpushedIndicator() { + if (!this.props.showUnpushedIndicator) { + return null + } + + return ( + + + + ) + } +} + +function commitGraph_formatDate(date: Date, preferAbsoluteDates: boolean) { + const now = new Date() + const unpaddedTime = formatDate(date, { + date: false, + timeStyle: 'short', + }) + const time = commitGraph_padTimeHour(unpaddedTime) + + if (!preferAbsoluteDates && commitGraph_isSameDay(date, now)) { + return time + } + + const ageInDays = commitGraph_getDayDifference(now, date) + + if ( + !preferAbsoluteDates && + ageInDays > 0 && + ageInDays <= commitGraph_RecentCommitWeekdayThreshold + ) { + return `${commitGraph_CommitWeekdayFormatter.format(date)} ${time}` + } + + const dateTime = formatDate(date, { + dateStyle: 'short', + timeStyle: 'short', + }) + + return dateTime.replace(unpaddedTime, time) +} + +function commitGraph_padTimeHour(time: string) { + return time.replace(/^(\d)(?=[:.]\d{2})/, '0$1') +} + +function commitGraph_isSameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +function commitGraph_getDayDifference(newerDate: Date, olderDate: Date) { + const newerDay = new Date( + newerDate.getFullYear(), + newerDate.getMonth(), + newerDate.getDate() + ) + const olderDay = new Date( + olderDate.getFullYear(), + olderDate.getMonth(), + olderDate.getDate() + ) + + return Math.round((newerDay.getTime() - olderDay.getTime()) / 86400000) +} + +function commitGraph_getCommitSummary(commit: Commit) { + return commit.summary.length === 0 ? 'Empty commit message' : commit.summary +} + +function commitGraph_renderSummaryTokens( + emoji: Map, + text: string +) { + const tokenizer = new Tokenizer(emoji) + + return tokenizer.tokenize(text).map((token, index) => { + switch (token.kind) { + case TokenType.Emoji: + return token.emoji ? ( + {token.emoji} + ) : ( + {token.description + ) + case TokenType.Link: + case TokenType.Text: + return {token.text} + default: + return assertNever(token, `Unknown token type: ${token}`) + } + }) +} + +function commitGraph_isPullRequestRefLabel(name: string) { + return /(^|\/)pr[-/]\d+$/i.test(name) || /(^|\/)pull\/\d+\/head$/i.test(name) +} + +function commitGraph_isShortRefLabel(name: string) { + return name.length <= commitGraph_ShortRefLabelLength +} + +function commitGraph_isCompactRefLabelGroup(names: ReadonlyArray) { + return names.length === 1 && commitGraph_isShortRefLabel(names[0]!) +} + +function commitGraph_renderExpandedAuthor( + user: IAvatarUser +): string | JSX.Element { + if (!user) { + return 'Unknown user' + } + + if (user.name) { + return ( + <> +
{user.name}
+
{user.email}
+ + ) + } + + return user.email +} diff --git a/app/src/ui/history/commit-graph-model.ts b/app/src/ui/history/commit-graph-model.ts new file mode 100644 index 00000000000..f0682a63e05 --- /dev/null +++ b/app/src/ui/history/commit-graph-model.ts @@ -0,0 +1,297 @@ +import { Commit } from '../../models/commit' + +const commitGraph_Colors = [ + '#2f9e44', + '#1c7ed6', + '#ae3ec9', + '#f08c00', + '#0ca678', + '#e64980', + '#5c7cfa', + '#868e96', + '#e03131', + '#087f5b', + '#1971c2', + '#9c36b5', + '#e8590c', + '#0b7285', + '#c2255c', + '#5f3dc4', + '#66a80f', + '#d9480f', + '#1864ab', + '#862e9c', + '#2b8a3e', + '#c92a2a', + '#364fc7', + '#099268', +] + +const commitGraph_BackgroundColor = '#9ea4aa' + +// SmartGit caps connector length at 100 rows. Avoid reserving merge-parent +// lanes indefinitely, which can make busy histories much wider than needed. +const commitGraph_MaxMergeConnectorRows = 100 + +interface ICommitGraphActiveLane { + readonly sha: string + readonly color: string +} + +export interface ICommitGraphRefColor { + readonly sha: string + readonly color: string +} + +export interface ICommitGraphLane { + readonly column: number + readonly color: string +} + +export interface ICommitGraphConnection { + readonly fromColumn: number + readonly toColumn: number + readonly color: string +} + +export interface ICommitGraphLaneShift { + readonly fromColumn: number + readonly toColumn: number + readonly color: string +} + +export interface ICommitGraphRow { + readonly sha: string + readonly column: number + readonly color: string + readonly hasTopLine: boolean + readonly lanes: ReadonlyArray + readonly connections: ReadonlyArray + readonly shifts: ReadonlyArray + /** + * Largest graph column touched by this row, for sizing the row SVG. + */ + readonly maxColumn: number +} + +export const commitGraph_RowHeight = 32 + +export function commitGraph_getColor(index: number) { + if (index < commitGraph_Colors.length) { + return commitGraph_Colors[index] + } + + const hue = Math.round((index * 137.508) % 360) + return `hsl(${hue}, 72%, 42%)` +} + +export function commitGraph_buildRows( + commits: ReadonlyArray, + refColors: ReadonlyArray = [], + primaryLaneSha?: string +): ReadonlyArray { + const rowIndexBySha = new Map() + const colorsBySha = new Map() + const seededColorsBySha = new Map() + const usedColors = new Set() + const useBackgroundForUnseededLanes = refColors.length > 0 + let nextColor = 0 + let lanes = new Array() + + for (let rowIndex = 0; rowIndex < commits.length; rowIndex++) { + const commit = commits[rowIndex] + + rowIndexBySha.set(commit.sha, rowIndex) + } + + for (const refColor of refColors) { + if (seededColorsBySha.has(refColor.sha)) { + continue + } + + seededColorsBySha.set(refColor.sha, refColor.color) + colorsBySha.set(refColor.sha, refColor.color) + usedColors.add(refColor.color) + } + + const colorForSha = (sha: string) => { + let color = colorsBySha.get(sha) + + if (color === undefined) { + if (useBackgroundForUnseededLanes) { + color = commitGraph_BackgroundColor + } else { + do { + color = commitGraph_getColor(nextColor) + nextColor++ + } while (usedColors.has(color)) + + usedColors.add(color) + } + + colorsBySha.set(sha, color) + } + + return color + } + + // Seed the current branch lane first so it remains the left-most lane when + // its tip is part of the rendered graph. + if (primaryLaneSha !== undefined && rowIndexBySha.has(primaryLaneSha)) { + lanes = [{ sha: primaryLaneSha, color: colorForSha(primaryLaneSha) }] + } + + const rows = commits.map((commit, rowIndex) => { + let column = lanes.findIndex(l => l.sha === commit.sha) + const hasTopLine = column >= 0 + + if (column < 0) { + column = lanes.length + lanes.push({ sha: commit.sha, color: colorForSha(commit.sha) }) + } + + const seededColor = seededColorsBySha.get(commit.sha) + + if (seededColor !== undefined && lanes[column].color !== seededColor) { + lanes[column] = { ...lanes[column], color: seededColor } + } + + const currentLane = lanes[column] + const lanesToContinue = new Array() + for (let laneColumn = 0; laneColumn < lanes.length; laneColumn++) { + if (laneColumn !== column) { + const lane = lanes[laneColumn] + lanesToContinue.push({ column: laneColumn, color: lane.color }) + } + } + + const parents = new Array() + for (let i = 0; i < commit.parentSHAs.length; i++) { + const sha = commit.parentSHAs[i] + const parentRowIndex = rowIndexBySha.get(sha) + + if (parentRowIndex === undefined) { + continue + } + + const isLongMergeConnector = + i > 0 && + !lanes.some(lane => lane.sha === sha) && + parentRowIndex - rowIndex > commitGraph_MaxMergeConnectorRows + + if (!isLongMergeConnector) { + parents.push(sha) + } + } + + // The first parent continues the current lane. Additional merge parents get + // temporary lanes until their commits are reached lower in the list. + let nextLanes = lanes.slice() + + if (parents.length > 0) { + nextLanes[column] = { sha: parents[0], color: currentLane.color } + } else { + nextLanes.splice(column, 1) + } + + for (let i = 1; i < parents.length; i++) { + const parent = parents[i] + if (!nextLanes.some(lane => lane.sha === parent)) { + nextLanes.splice(Math.min(column + 1, nextLanes.length), 0, { + sha: parent, + color: colorForSha(parent), + }) + } + } + + nextLanes = commitGraph_dedupeLanes(nextLanes) + + const columnsByParentSha = new Map() + for (let laneColumn = 0; laneColumn < nextLanes.length; laneColumn++) { + columnsByParentSha.set(nextLanes[laneColumn].sha, laneColumn) + } + + const shifts = new Array() + for (let laneColumn = 0; laneColumn < lanes.length; laneColumn++) { + if (laneColumn === column) { + continue + } + + const lane = lanes[laneColumn] + const nextColumn = columnsByParentSha.get(lane.sha) + + if (nextColumn === undefined || nextColumn === laneColumn) { + continue + } + + const nextLane = nextLanes[nextColumn] + + shifts.push({ + fromColumn: laneColumn, + toColumn: nextColumn, + color: nextLane?.color ?? lane.color, + }) + } + + const connections = new Array() + for (const parent of parents) { + const toColumn = columnsByParentSha.get(parent) ?? column + const parentLane = nextLanes[toColumn] + + connections.push({ + fromColumn: column, + toColumn, + color: parentLane?.color ?? currentLane.color, + }) + } + + lanes = nextLanes + + return { + sha: commit.sha, + column, + color: currentLane.color, + hasTopLine, + lanes: lanesToContinue, + connections, + shifts, + } + }) + + return rows.map(row => ({ + ...row, + maxColumn: commitGraph_getRowMaxColumn(row), + })) +} + +function commitGraph_getRowMaxColumn( + row: Pick +) { + return Math.max( + row.column, + ...row.lanes.map(lane => lane.column), + ...row.shifts.flatMap(shift => [shift.fromColumn, shift.toColumn]), + ...row.connections.flatMap(connection => [ + connection.fromColumn, + connection.toColumn, + ]) + ) +} + +function commitGraph_dedupeLanes( + lanes: ReadonlyArray +): Array { + const seen = new Set() + const deduped = new Array() + + for (const lane of lanes) { + if (seen.has(lane.sha)) { + continue + } + + seen.add(lane.sha) + deduped.push(lane) + } + + return deduped +} diff --git a/app/src/ui/history/commit-graph-sidebar.tsx b/app/src/ui/history/commit-graph-sidebar.tsx new file mode 100644 index 00000000000..17c3c9c5edf --- /dev/null +++ b/app/src/ui/history/commit-graph-sidebar.tsx @@ -0,0 +1,1546 @@ +import * as React from 'react' + +import { Commit, CommitOneLine, ICommitContext } from '../../models/commit' +import { ICompareState, IConstrainedValue } from '../../lib/app-state' +import { + commitGraph_getStoredViewMode, + commitGraph_setStoredViewMode, +} from '../../lib/stores/commit-graph-state' +import { Repository } from '../../models/repository' +import { Branch, BranchType } from '../../models/branch' +import { Dispatcher, defaultErrorHandler } from '../dispatcher' +import { CommitList } from './commit-list' +import type { ICommitListItemRenderProps } from './commit-list' +import { FancyTextBox } from '../lib/fancy-text-box' +import { Button } from '../lib/button' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { Resizable } from '../resizable' +import { Account } from '../../models/account' +import { Emoji } from '../../lib/emoji' +import { KeyboardInsertionData } from '../lib/list' +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 { doMergeCommitsExistAfterCommit } from '../../lib/git' +import { Octicon, syncClockwise } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import classNames from 'classnames' +import memoizeOne from 'memoize-one' +import { ThrottledScheduler } from '../lib/throttled-scheduler' +import { startTimer } from '../lib/timing' +import { + commitGraph_buildRows, + commitGraph_getColor, + commitGraph_RowHeight, + ICommitGraphRow, +} from './commit-graph-model' +import { CommitGraphCommitListItem } from './commit-graph-commit-list-item' + +enum CommitGraphViewMode { + List = 'list', + Tree = 'tree', +} + +function commitGraph_getInitialViewMode() { + return commitGraph_getStoredViewMode() === 'list' + ? CommitGraphViewMode.List + : CommitGraphViewMode.Tree +} + +type CommitGraphBranchGroup = + | 'local' + | 'origin' + | 'upstream' + | 'remote' + | 'tags' + +interface ICommitGraphSidebarProps { + readonly repository: Repository + readonly isLocalRepository: boolean + readonly compareState: ICompareState + readonly commitGraphBranchListWidth: IConstrainedValue + readonly emoji: Map + readonly commitLookup: Map + readonly localCommitSHAs: ReadonlyArray + readonly askForConfirmationOnCheckoutCommit: boolean + readonly dispatcher: Dispatcher + readonly currentBranch: Branch | null + readonly currentTipSha: string | null + readonly allBranches: ReadonlyArray + readonly selectedCommitShas: ReadonlyArray + readonly onRevertCommit: (commit: Commit) => void + readonly onAmendCommit: (commit: Commit, isLocalCommit: boolean) => void + readonly onViewCommitOnGitHub: (sha: string) => void + readonly onCompareListScrolled: (scrollTop: number) => void + readonly onCherryPick: ( + repository: Repository, + commits: ReadonlyArray, + sourceBranch?: Branch + ) => void + readonly compareListScrollTop?: number + readonly localTags: Map | null + readonly tagsToPush: ReadonlyArray | null + readonly isMultiCommitOperationInProgress?: boolean + readonly shasToHighlight: ReadonlyArray + readonly accounts: ReadonlyArray + readonly preferAbsoluteDates: boolean +} + +interface ICommitGraphSidebarState { + readonly keyboardReorderData?: KeyboardInsertionData + readonly isSearching: boolean + readonly commitGraphViewMode: CommitGraphViewMode + readonly commitGraphSelectedBranchRef: string | null +} + +interface ICommitGraphBranches { + readonly allBranches: ReadonlyArray + readonly visibleBranches: ReadonlyArray +} + +/** If we're within this many rows from the bottom, load the next history batch. */ +const CloseToBottomThreshold = 30 +const commitGraph_CloseToBottomThreshold = 200 + +interface ICommitGraphBranchCheckboxProps { + readonly branch: Branch + readonly checked: boolean + readonly color: string + readonly currentBranch: Branch | null + readonly selected: boolean + readonly onToggle: (branch: Branch) => void + readonly onSelect: (branch: Branch) => void + readonly onCheckout?: (branch: Branch) => void +} + +class CommitGraphBranchCheckbox extends React.PureComponent { + private onChange = () => { + this.props.onToggle(this.props.branch) + } + + private onLabelMouseDown = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + this.props.onSelect(this.props.branch) + event.currentTarget.focus() + } + + private onLabelClick = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + } + + private onLabelDoubleClick = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + this.props.onCheckout?.(this.props.branch) + } + + private onLabelKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + + event.preventDefault() + event.stopPropagation() + this.props.onSelect(this.props.branch) + } + + public render() { + const { branch, checked, color, currentBranch, selected } = this.props + const isCurrentBranch = branch.ref === currentBranch?.ref + const label = ( + + {checked ? ( + + ) : null} + {isCurrentBranch ? ( + + ) : null} + + {branch.nameWithoutRemote} + + + ) + + return ( + + ) + } +} + +interface ICommitGraphBranchGroupRowProps { + readonly group: CommitGraphBranchGroup + readonly label: string + readonly collapsed: boolean + readonly checkboxValue: CheckboxValue + readonly onToggleSelection: (group: CommitGraphBranchGroup) => void + readonly onToggleCollapsed: (group: CommitGraphBranchGroup) => void +} + +class CommitGraphBranchGroupRow extends React.PureComponent { + private onToggleSelection = () => { + this.props.onToggleSelection(this.props.group) + } + + private onToggleCollapsed = () => { + this.props.onToggleCollapsed(this.props.group) + } + + public render() { + const { label, collapsed, checkboxValue } = this.props + const disclosureSymbol = collapsed + ? octicons.triangleRight + : octicons.triangleDown + + return ( +
+ + +
+ ) + } +} + +export class CommitGraphSidebar extends React.Component< + ICommitGraphSidebarProps, + ICommitGraphSidebarState +> { + private readonly loadChangedFilesScheduler = new ThrottledScheduler(200) + private commitListRef = React.createRef() + private loadingMoreCommitsPromise: Promise | null = null + private commitGraph_loadingMoreCommitsPromise: Promise | null = null + private commitGraph_loadingRefsKey: string | null = null + + private readonly commitGraph_getAllBranchesForState = memoizeOne( + ( + allBranches: ReadonlyArray, + currentBranch: Branch | null, + localTags: Map | null + ): ReadonlyArray => { + const branches = + allBranches.length > 0 + ? allBranches + : currentBranch !== null + ? [currentBranch] + : [] + + return branches + .filter(branch => !branch.isDesktopForkRemoteBranch) + .toSorted((a, b) => { + if (a.type !== b.type) { + return a.type - b.type + } + + return a.name.localeCompare(b.name) + }) + .concat(this.commitGraph_getTagsForState(localTags)) + } + ) + + private readonly commitGraph_getTagsForState = memoizeOne( + (localTags: Map | null): ReadonlyArray => { + if (localTags === null) { + return [] + } + + return Array.from(localTags, ([tagName, sha]) => + this.commitGraph_createTag(tagName, sha) + ).toSorted((a, b) => a.name.localeCompare(b.name)) + } + ) + + private readonly commitGraph_getVisibleBranchesForState = memoizeOne( + ( + branches: ReadonlyArray, + hiddenBranchRefs: ReadonlyArray, + currentBranch: Branch | null + ): ReadonlyArray => { + const hiddenBranchRefsSet = new Set(hiddenBranchRefs) + const visibleBranches = branches.filter( + branch => !hiddenBranchRefsSet.has(branch.ref) + ) + const currentBranchIndex = + currentBranch === null + ? -1 + : visibleBranches.findIndex( + branch => branch.ref === currentBranch.ref + ) + + if (currentBranchIndex <= 0) { + return visibleBranches + } + + return [ + visibleBranches[currentBranchIndex], + ...visibleBranches.slice(0, currentBranchIndex), + ...visibleBranches.slice(currentBranchIndex + 1), + ] + } + ) + + private readonly commitGraph_getSelectedRefsForState = memoizeOne( + (visibleBranches: ReadonlyArray) => + visibleBranches.map(branch => branch.ref) + ) + + private readonly commitGraph_getHiddenBranchRefsSetForState = memoizeOne( + ( + hiddenBranchRefs: ReadonlyArray | null + ): ReadonlySet | null => + hiddenBranchRefs === null ? null : new Set(hiddenBranchRefs) + ) + + private readonly commitGraph_getBranchesForState = memoizeOne( + ( + allBranches: ReadonlyArray, + currentBranch: Branch | null, + localTags: Map | null, + hiddenBranchRefs: ReadonlyArray | null + ): ICommitGraphBranches => { + const commitGraphBranches = this.commitGraph_getAllBranchesForState( + allBranches, + currentBranch, + localTags + ) + const visibleBranches = + hiddenBranchRefs !== null + ? this.commitGraph_getVisibleBranchesForState( + commitGraphBranches, + hiddenBranchRefs, + currentBranch + ) + : [] + + return { allBranches: commitGraphBranches, visibleBranches } + } + ) + + private readonly commitGraph_getFilteredCommitSHAsForState = memoizeOne( + ( + commitSHAs: ReadonlyArray, + commitSearchQuery: string, + commitLookup: Map + ): ReadonlyArray => { + const query = commitSearchQuery.toLowerCase() + + if (!query) { + return commitSHAs + } + + return commitSHAs.filter(sha => + this.commitIsIncluded(commitLookup.get(sha), query) + ) + } + ) + + private readonly commitGraph_getPrioritizedCommitSHAsForState = memoizeOne( + ( + commitSHAs: ReadonlyArray, + commitLookup: Map, + visibleBranches: ReadonlyArray, + primaryLaneSha?: string + ): ReadonlyArray => { + if (primaryLaneSha === undefined) { + return commitSHAs + } + + const commitsBySha = new Map() + for (const sha of commitSHAs) { + const commit = commitLookup.get(sha) + + if (commit !== undefined) { + commitsBySha.set(sha, commit) + } + } + + if (!commitsBySha.has(primaryLaneSha)) { + return commitSHAs + } + + const otherTipShas = visibleBranches.flatMap(branch => + !commitGraph_isTag(branch) && branch.tip.sha !== primaryLaneSha + ? [branch.tip.sha] + : [] + ) + + if (otherTipShas.length === 0) { + return commitSHAs + } + + const primaryReachableShas = commitGraph_getReachableCommitSHAs( + [primaryLaneSha], + commitsBySha + ) + const otherReachableShas = commitGraph_getReachableCommitSHAs( + otherTipShas, + commitsBySha + ) + const primaryOnlyShas = new Set( + Array.from(primaryReachableShas).filter( + sha => !otherReachableShas.has(sha) + ) + ) + + if (primaryOnlyShas.size === 0) { + return commitSHAs + } + + const primaryCommits = new Array() + const otherCommits = new Array() + + for (const sha of commitSHAs) { + if (primaryOnlyShas.has(sha)) { + primaryCommits.push(sha) + } else { + otherCommits.push(sha) + } + } + + return primaryCommits.length > 0 + ? primaryCommits.concat(otherCommits) + : commitSHAs + } + ) + + private readonly commitGraph_lookupCommitsForState = memoizeOne( + ( + commitLookup: Map, + commitSHAs: ReadonlyArray + ): ReadonlyArray => { + const commits = new Array() + + for (const sha of commitSHAs) { + const commit = commitLookup.get(sha) + + if (commit !== undefined) { + commits.push(commit) + } + } + + return commits + } + ) + + private readonly commitGraph_getBranchColorsForState = memoizeOne( + (branches: ReadonlyArray): Map => { + const colors = new Map() + const colorsByTipSha = new Map() + let nextColor = 0 + + for (const branch of branches) { + let color = colorsByTipSha.get(branch.tip.sha) + + if (color === undefined) { + color = commitGraph_getColor(nextColor) + colorsByTipSha.set(branch.tip.sha, color) + nextColor++ + } + + colors.set(branch.ref, color) + } + + return colors + } + ) + + private readonly commitGraph_getRefColorsForState = memoizeOne( + ( + visibleBranches: ReadonlyArray, + branchColors: Map + ) => + visibleBranches.map(branch => ({ + sha: branch.tip.sha, + color: branchColors.get(branch.ref) ?? commitGraph_getColor(0), + })) + ) + + private readonly commitGraph_getBranchesByCommitShaForState = memoizeOne( + (visibleBranches: ReadonlyArray) => { + const commitGraphBranchesByCommitSha = new Map>() + + for (const branch of visibleBranches) { + if (commitGraph_isTag(branch)) { + continue + } + + const branches = + commitGraphBranchesByCommitSha.get(branch.tip.sha) ?? [] + branches.push(branch) + commitGraphBranchesByCommitSha.set(branch.tip.sha, branches) + } + + return commitGraphBranchesByCommitSha + } + ) + + private readonly commitGraph_buildRowsForState = memoizeOne( + ( + commits: ReadonlyArray, + refColors: ReadonlyArray<{ + readonly sha: string + readonly color: string + }>, + primaryLaneSha?: string + ): ReadonlyArray => + commitGraph_buildRows(commits, refColors, primaryLaneSha) + ) + + public constructor(props: ICommitGraphSidebarProps) { + super(props) + + this.state = { + isSearching: false, + commitGraphViewMode: commitGraph_getInitialViewMode(), + commitGraphSelectedBranchRef: null, + } + } + + public componentWillMount() { + this.props.dispatcher.initializeCompare(this.props.repository) + } + + public componentDidMount() { + this.commitGraph_ensureLoaded() + } + + public componentDidUpdate() { + this.commitGraph_ensureLoaded() + } + + public focusHistory() { + this.commitListRef.current?.focus() + } + + public render() { + const { commitSearchQuery } = this.props.compareState + + return ( +
+
+
+ +
+ {this.commitGraph_renderViewModeSwitch()} +
+ + {this.state.commitGraphViewMode === CommitGraphViewMode.Tree + ? this.commitGraph_renderView() + : this.renderCommitList(false)} +
+ ) + } + + private commitGraph_renderViewModeSwitch() { + return ( +
+ + +
+ ) + } + + private commitGraph_renderView() { + const commitGraphIsBranchSelectionResolved = + this.commitGraph_getHiddenBranchRefs() !== null + + return ( +
+ {this.commitGraph_renderBranchPane()} +
+ {commitGraphIsBranchSelectionResolved + ? this.renderCommitList(true) + : null} +
+
+ ) + } + + private commitGraph_renderBranchPane() { + const groups = + this.commitGraph_getHiddenBranchRefs() === null + ? [] + : this.commitGraph_getBranchGroups() + const { commitGraphBranchListWidth } = this.props + + return ( + +
+
+ {groups.map(group => this.commitGraph_renderBranchGroup(group))} +
+
+
+ ) + } + + private commitGraph_renderBranchGroup(group: CommitGraphBranchGroup) { + const branches = this.commitGraph_getBranchesForGroup(group) + const collapsed = this.commitGraph_isBranchGroupCollapsed(group) + const label = this.commitGraph_getBranchGroupLabel(group) + + return ( +
+ + {collapsed + ? null + : branches.map(branch => this.commitGraph_renderBranch(branch))} +
+ ) + } + + private commitGraph_renderBranch(branch: Branch) { + const hiddenBranchRefs = this.commitGraph_getHiddenBranchRefsSet() + const checked = + hiddenBranchRefs !== null && !hiddenBranchRefs.has(branch.ref) + const color = this.commitGraph_getBranchColor(branch) + + return ( + + ) + } + + private renderCommitList(commitGraphIsTreeMode: boolean) { + const { + filteredHistoryCommitSHAs, + commitGraphCommitSHAs, + commitSearchQuery, + } = this.props.compareState + + const commitGraphCommitSHAsForList = commitGraphIsTreeMode + ? this.commitGraph_getFilteredCommitSHAs() + : [] + const commitGraphBranchColors = commitGraphIsTreeMode + ? this.commitGraph_getBranchColors() + : undefined + const commitGraphRows = commitGraphIsTreeMode + ? this.commitGraph_getRows() + : undefined + const commitGraphBranchesByCommitSha = commitGraphIsTreeMode + ? this.commitGraph_getBranchesByCommitSha() + : undefined + + const emptyListMessage = commitGraphIsTreeMode + ? this.commitGraph_getSelectedRefs().length === 0 + ? 'No branches selected' + : commitSearchQuery + ? 'No results found' + : 'No history' + : commitSearchQuery + ? 'No results found' + : 'No history' + + const commitSHAs = commitGraphIsTreeMode + ? commitGraphCommitSHAsForList + : filteredHistoryCommitSHAs + + return ( + + ) + } + + private commitGraph_renderCommitItem = ( + props: ICommitListItemRenderProps + ) => { + const commitGraphRow = this.commitGraph_getRows()[props.row] + + if (commitGraphRow === undefined) { + return null + } + + return ( + + ) + } + + private commitGraph_getBranches() { + return this.commitGraph_getBranchesForState( + this.props.allBranches, + this.props.currentBranch, + this.props.localTags, + this.commitGraph_getHiddenBranchRefs() + ).allBranches + } + + private commitGraph_getVisibleBranches() { + return this.commitGraph_getBranchesForState( + this.props.allBranches, + this.props.currentBranch, + this.props.localTags, + this.commitGraph_getHiddenBranchRefs() + ).visibleBranches + } + + private commitGraph_getHiddenBranchRefs() { + return this.props.compareState.commitGraphHiddenBranchRefs + } + + private commitGraph_getHiddenBranchRefsSet() { + return this.commitGraph_getHiddenBranchRefsSetForState( + this.commitGraph_getHiddenBranchRefs() + ) + } + + private commitGraph_setHiddenBranchRefs( + hiddenBranchRefs: ReadonlyArray + ) { + this.props.dispatcher.commitGraph_setHiddenBranchRefs( + this.props.repository, + hiddenBranchRefs + ) + } + + private commitGraph_getSelectedRefs() { + return this.commitGraph_getSelectedRefsForState( + this.commitGraph_getVisibleBranches() + ) + } + + private commitGraph_getPrimaryLaneSha() { + const currentBranch = this.props.currentBranch + + if (currentBranch === null) { + return undefined + } + + return this.commitGraph_getVisibleBranches().some( + branch => branch.ref === currentBranch.ref + ) + ? currentBranch.tip.sha + : undefined + } + + private commitGraph_getFilteredCommitSHAs() { + const commitSHAs = this.commitGraph_getFilteredCommitSHAsForState( + this.props.compareState.commitGraphCommitSHAs, + this.props.compareState.commitSearchQuery, + this.props.commitLookup + ) + + return this.commitGraph_getPrioritizedCommitSHAsForState( + commitSHAs, + this.props.commitLookup, + this.commitGraph_getVisibleBranches(), + this.commitGraph_getPrimaryLaneSha() + ) + } + + private commitGraph_lookupCommits(commitSHAs: ReadonlyArray) { + return this.commitGraph_lookupCommitsForState( + this.props.commitLookup, + commitSHAs + ) + } + + private commitGraph_getRows() { + return this.commitGraph_buildRowsForState( + this.commitGraph_lookupCommits(this.commitGraph_getFilteredCommitSHAs()), + this.commitGraph_getRefColors(), + this.commitGraph_getPrimaryLaneSha() + ) + } + + private commitIsIncluded( + commit: Commit | undefined, + filterTextLowerCase: string + ) { + if (commit === undefined) { + return false + } + + return ( + !filterTextLowerCase || + commit.summary.toLowerCase().includes(filterTextLowerCase) || + commit.body.toLowerCase().includes(filterTextLowerCase) || + commit.tags.some(tag => + tag.toLowerCase().startsWith(filterTextLowerCase) + ) || + commit.sha.toLowerCase().startsWith(filterTextLowerCase) + ) + } + + private commitGraph_getBranchesByCommitSha() { + return this.commitGraph_getBranchesByCommitShaForState( + this.commitGraph_getBranches() + ) + } + + private commitGraph_getBranchColors() { + return this.commitGraph_getBranchColorsForState( + this.commitGraph_getBranches() + ) + } + + private commitGraph_getBranchColor(branch: Branch) { + return ( + this.commitGraph_getBranchColors().get(branch.ref) ?? + commitGraph_getColor(0) + ) + } + + private commitGraph_createTag(tagName: string, sha: string) { + return new Branch( + tagName, + null, + { sha, author: { date: new Date(0) } }, + BranchType.Local, + commitGraph_GetTagRef(tagName), + false + ) + } + + private commitGraph_getRefColors() { + const branchColors = this.commitGraph_getBranchColors() + + return this.commitGraph_getRefColorsForState( + this.commitGraph_getVisibleBranches(), + branchColors + ) + } + + private commitGraph_getBranchGroups() { + const availableGroups = new Set( + this.commitGraph_getBranches().map(branch => + commitGraph_getBranchGroup(branch) + ) + ) + + return ( + [ + 'local', + 'origin', + 'upstream', + 'remote', + 'tags', + ] as ReadonlyArray + ).filter(group => availableGroups.has(group)) + } + + private commitGraph_getBranchesForGroup(group: CommitGraphBranchGroup) { + return this.commitGraph_getBranches().filter( + branch => commitGraph_getBranchGroup(branch) === group + ) + } + + private commitGraph_getBranchGroupLabel(group: CommitGraphBranchGroup) { + const count = this.commitGraph_getBranchesForGroup(group).length + + switch (group) { + case 'local': + return `Local Branches (${count})` + case 'origin': + return `origin (${count})` + case 'upstream': + return `upstream (${count})` + case 'remote': + return `Other Remotes (${count})` + case 'tags': + return `Tags (${count})` + } + } + + private commitGraph_getBranchGroupCheckboxValue( + group: CommitGraphBranchGroup + ) { + const hiddenBranchRefs = this.commitGraph_getHiddenBranchRefsSet() + + if (hiddenBranchRefs === null) { + return CheckboxValue.Off + } + + const branches = this.commitGraph_getBranchesForGroup(group) + + if (branches.length === 0) { + return CheckboxValue.Off + } + + const selectedBranchCount = branches.filter( + branch => !hiddenBranchRefs.has(branch.ref) + ).length + + if (selectedBranchCount === 0) { + return CheckboxValue.Off + } + + if (selectedBranchCount === branches.length) { + return CheckboxValue.On + } + + return CheckboxValue.Mixed + } + + private commitGraph_isBranchGroupCollapsed(group: CommitGraphBranchGroup) { + return this.props.compareState.commitGraphCollapsedBranchGroups.includes( + group + ) + } + + private commitGraph_onBranchListResize = (width: number) => { + this.props.dispatcher.commitGraph_setBranchListWidth(width) + } + + private commitGraph_onBranchListReset = () => { + this.props.dispatcher.commitGraph_resetBranchListWidth() + } + + private commitGraph_onBranchGroupSelectionToggled = ( + group: CommitGraphBranchGroup + ) => { + const hiddenBranchRefs = this.commitGraph_getHiddenBranchRefs() + + if (hiddenBranchRefs === null) { + return + } + + const branches = this.commitGraph_getBranchesForGroup(group) + const hiddenBranchRefsSet = new Set(hiddenBranchRefs) + const allSelected = branches.every( + branch => !hiddenBranchRefsSet.has(branch.ref) + ) + + for (const branch of branches) { + if (allSelected) { + hiddenBranchRefsSet.add(branch.ref) + } else { + hiddenBranchRefsSet.delete(branch.ref) + } + } + + this.commitGraph_setHiddenBranchRefs(Array.from(hiddenBranchRefsSet)) + } + + private commitGraph_onBranchGroupCollapsedToggled = ( + group: CommitGraphBranchGroup + ) => { + const collapsedGroups = new Set( + this.props.compareState.commitGraphCollapsedBranchGroups + ) + + if (collapsedGroups.has(group)) { + collapsedGroups.delete(group) + } else { + collapsedGroups.add(group) + } + + this.props.dispatcher.commitGraph_setCollapsedBranchGroups( + this.props.repository, + Array.from(collapsedGroups) + ) + } + + private commitGraph_onBranchToggled = (branch: Branch) => { + const hiddenBranchRefs = this.commitGraph_getHiddenBranchRefs() + + if (hiddenBranchRefs === null) { + return + } + + const hiddenBranchRefsSet = new Set(hiddenBranchRefs) + + if (hiddenBranchRefsSet.has(branch.ref)) { + hiddenBranchRefsSet.delete(branch.ref) + } else { + hiddenBranchRefsSet.add(branch.ref) + } + + this.commitGraph_setHiddenBranchRefs(Array.from(hiddenBranchRefsSet)) + } + + private commitGraph_onBranchSelected = (branch: Branch) => { + this.setState({ commitGraphSelectedBranchRef: branch.ref }) + } + + private commitGraph_onBranchCheckout = (branch: Branch) => { + const { repository, dispatcher } = this.props + const timer = startTimer('checkout branch from list', repository) + dispatcher.checkoutBranch(repository, branch).then(() => timer.done()) + } + + private commitGraph_getCheckoutBranchForCommit(commitSha: string) { + const branches = + this.commitGraph_getBranchesByCommitSha().get(commitSha) ?? [] + const localBranches = branches.filter( + branch => branch.type === BranchType.Local && !commitGraph_isTag(branch) + ) + const selectedBranch = localBranches.find( + branch => branch.ref === this.state.commitGraphSelectedBranchRef + ) + + if (selectedBranch !== undefined) { + return selectedBranch + } + + return localBranches.length === 1 ? localBranches[0] : null + } + + private commitGraph_onListModeClicked = () => { + commitGraph_setStoredViewMode('list') + this.setState({ commitGraphViewMode: CommitGraphViewMode.List }) + void this.props.dispatcher.setCommitSearchQuery( + this.props.repository, + this.props.compareState.commitSearchQuery + ) + } + + private commitGraph_onTreeModeClicked = () => { + commitGraph_setStoredViewMode('tree') + this.setState({ commitGraphViewMode: CommitGraphViewMode.Tree }, () => + this.commitGraph_ensureLoaded() + ) + } + + private commitGraph_ensureLoaded() { + if ( + this.state.commitGraphViewMode !== CommitGraphViewMode.Tree || + this.commitGraph_getHiddenBranchRefs() === null + ) { + return + } + + const refs = this.commitGraph_getSelectedRefs() + const refsKey = refs.join('\0') + + if ( + refsKey === this.props.compareState.commitGraphRefs.join('\0') || + refsKey === this.commitGraph_loadingRefsKey + ) { + return + } + + this.commitGraph_loadingRefsKey = refsKey + void this.props.dispatcher + .commitGraph_load(this.props.repository, refs) + .finally(() => { + if (this.commitGraph_loadingRefsKey === refsKey) { + this.commitGraph_loadingRefsKey = null + } + }) + } + + private onCancelKeyboardReorder = () => { + this.setState({ keyboardReorderData: undefined }) + } + + private onDropCommitInsertion = async ( + baseCommit: Commit | null, + commitsToInsert: ReadonlyArray, + lastRetainedCommitRef: string | null + ) => { + this.setState({ keyboardReorderData: undefined }) + + if ( + await doMergeCommitsExistAfterCommit( + this.props.repository, + lastRetainedCommitRef + ) + ) { + defaultErrorHandler( + new Error( + `Unable to reorder. Reordering replays all commits up to the last one required for the reorder. A merge commit cannot exist among those commits.` + ), + this.props.dispatcher + ) + return + } + + return this.props.dispatcher.reorderCommits( + this.props.repository, + commitsToInsert, + baseCommit, + lastRetainedCommitRef + ) + } + + private onRenderCommitDragElement = ( + commit: Commit, + selectedCommits: ReadonlyArray + ) => { + this.props.dispatcher.setDragElement({ + type: DragType.Commit, + commit, + selectedCommits, + gitHubRepository: this.props.repository.gitHubRepository, + }) + } + + private onRemoveCommitDragElement = () => { + this.props.dispatcher.clearDragElement() + } + + private onCommitsSelected = ( + commits: ReadonlyArray, + isContiguous: boolean + ) => { + this.props.dispatcher.changeCommitSelection( + this.props.repository, + commits.map(c => c.sha), + isContiguous + ) + + this.loadChangedFilesScheduler.queue(() => { + this.props.dispatcher.loadChangedFilesForCurrentSelection( + this.props.repository + ) + }) + } + + private onScroll = (start: number, end: number) => { + const commitGraphIsTreeMode = + this.state.commitGraphViewMode === CommitGraphViewMode.Tree + const commits = commitGraphIsTreeMode + ? this.commitGraph_getFilteredCommitSHAs() + : this.props.compareState.filteredHistoryCommitSHAs + const closeToBottomThreshold = commitGraphIsTreeMode + ? commitGraph_CloseToBottomThreshold + : CloseToBottomThreshold + + if (commits.length - end > closeToBottomThreshold) { + return + } + + if ( + commitGraphIsTreeMode + ? this.commitGraph_loadingMoreCommitsPromise !== null + : this.loadingMoreCommitsPromise !== null + ) { + // This callback fires for any scroll event, so guard against re-entrant batch loads. + return + } + + const promise = commitGraphIsTreeMode + ? this.props.dispatcher.commitGraph_loadNextCommitBatch( + this.props.repository + ) + : this.props.dispatcher.loadNextCommitBatch(this.props.repository) + + if (commitGraphIsTreeMode) { + this.commitGraph_loadingMoreCommitsPromise = promise + } else { + this.loadingMoreCommitsPromise = promise + } + + promise.then(() => { + // Defer until after commits append so eager scroll events do not immediately reload. + window.setTimeout(() => { + if (commitGraphIsTreeMode) { + this.commitGraph_loadingMoreCommitsPromise = null + } else { + this.loadingMoreCommitsPromise = null + } + }, 500) + }) + } + + private onCommitSearchQueryChanged = async (text: string) => { + if (this.state.commitGraphViewMode === CommitGraphViewMode.Tree) { + this.props.dispatcher.updateCompareForm(this.props.repository, { + commitSearchQuery: text, + }) + + if (text.length > 0) { + void this.props.dispatcher.commitGraph_loadNextCommitBatch( + this.props.repository + ) + } + + return + } + + this.setState({ isSearching: true }) + await this.props.dispatcher.setCommitSearchQuery( + this.props.repository, + text + ) + this.setState({ isSearching: false }) + } + + private onCreateTag = (targetCommitSha: string) => { + this.props.dispatcher.showCreateTagDialog( + this.props.repository, + targetCommitSha, + this.props.localTags + ) + } + + private onUndoCommit = (commit: Commit) => { + this.props.dispatcher.undoCommit(this.props.repository, commit) + } + + private onResetToCommit = (commit: Commit) => { + this.props.dispatcher.resetToCommit(this.props.repository, commit) + } + + private onCreateBranch = (commit: CommitOneLine) => { + const { repository, dispatcher } = this.props + + dispatcher.showPopup({ + type: PopupType.CreateBranch, + repository, + targetCommit: commit, + }) + } + + private onCheckoutCommit = (commit: CommitOneLine) => { + const { repository, dispatcher, askForConfirmationOnCheckoutCommit } = + this.props + const checkoutBranch = this.commitGraph_getCheckoutBranchForCommit( + commit.sha + ) + + if (this.props.currentTipSha === commit.sha) { + return + } + + if (checkoutBranch !== null) { + const timer = startTimer('checkout branch from commit graph', repository) + dispatcher + .checkoutBranch(repository, checkoutBranch) + .then(() => timer.done()) + return + } + + if (!askForConfirmationOnCheckoutCommit) { + dispatcher.checkoutCommit(repository, commit) + } else { + dispatcher.showPopup({ + type: PopupType.ConfirmCheckoutCommit, + commit: commit, + repository, + }) + } + } + + private onDeleteTag = (tagName: string, unpushed: boolean) => { + const { repository, dispatcher } = this.props + if (unpushed) { + dispatcher.showDeleteTagDialog(this.props.repository, tagName) + } else { + dispatcher.showPopup({ + type: PopupType.ConfirmDeletePushedTag, + tagName: tagName, + repository, + }) + } + } + + private onCherryPick = (commits: ReadonlyArray) => { + this.props.onCherryPick(this.props.repository, commits) + } + + private onKeyboardReorder = (toReorder: ReadonlyArray) => { + const { allHistoryCommitSHAs } = this.props.compareState + + this.setState({ + keyboardReorderData: { + type: DragType.Commit, + commits: toReorder, + itemIndices: toReorder.map(c => allHistoryCommitSHAs.indexOf(c.sha)), + }, + }) + } + + private onSquash = async ( + toSquash: ReadonlyArray, + squashOnto: Commit, + lastRetainedCommitRef: string | null, + isInvokedByContextMenu: boolean + ) => { + const toSquashSansSquashOnto = toSquash.filter( + c => c.sha !== squashOnto.sha + ) + + const allCommitsInSquash = [...toSquashSansSquashOnto, squashOnto] + const coAuthors = getUniqueCoauthorsAsAuthors(allCommitsInSquash) + + const squashedDescription = getSquashedCommitDescription( + toSquashSansSquashOnto, + squashOnto + ) + + if ( + await doMergeCommitsExistAfterCommit( + this.props.repository, + lastRetainedCommitRef + ) + ) { + defaultErrorHandler( + new Error( + `Unable to squash. Squashing replays all commits up to the last one required for the squash. A merge commit cannot exist among those commits.` + ), + this.props.dispatcher + ) + return + } + + this.props.dispatcher.recordSquashInvoked(isInvokedByContextMenu) + + this.props.dispatcher.showPopup({ + type: PopupType.CommitMessage, + repository: this.props.repository, + coAuthors, + showCoAuthoredBy: coAuthors.length > 0, + commitMessage: { + summary: squashOnto.summary, + description: squashedDescription, + timestamp: Date.now(), + }, + dialogTitle: `Squash ${allCommitsInSquash.length} Commits`, + dialogButtonText: `Squash ${allCommitsInSquash.length} Commits`, + prepopulateCommitSummary: true, + onSubmitCommitMessage: async (context: ICommitContext) => { + this.props.dispatcher.closePopup(PopupType.CommitMessage) + + this.props.dispatcher.squash( + this.props.repository, + toSquashSansSquashOnto, + squashOnto, + lastRetainedCommitRef, + context + ) + return true + }, + }) + } +} + +function commitGraph_getBranchGroup(branch: Branch): CommitGraphBranchGroup { + if (commitGraph_isTag(branch)) { + return 'tags' + } + + if (branch.type === BranchType.Local) { + return 'local' + } + + if (branch.remoteName === 'origin') { + return 'origin' + } + + if (branch.remoteName === 'upstream') { + return 'upstream' + } + + return 'remote' +} + +function commitGraph_GetTagRef(tagName: string) { + return `refs/tags/${tagName}` +} + +function commitGraph_isTag(branch: Branch) { + return branch.ref.startsWith('refs/tags/') +} + +function commitGraph_getReachableCommitSHAs( + tips: ReadonlyArray, + commitsBySha: ReadonlyMap +): Set { + const reachable = new Set() + const pending = tips.slice() + + while (pending.length > 0) { + const sha = pending.pop() + + if (sha === undefined || reachable.has(sha)) { + continue + } + + const commit = commitsBySha.get(sha) + + if (commit === undefined) { + continue + } + + reachable.add(sha) + pending.push(...commit.parentSHAs) + } + + return reachable +} diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx index 2832f0dd938..9db56a2db10 100644 --- a/app/src/ui/history/commit-list.tsx +++ b/app/src/ui/history/commit-list.tsx @@ -37,6 +37,13 @@ import * as octicons from '../octicons/octicons.generated' const RowHeight = 50 +export interface ICommitListItemRenderProps { + readonly row: number + readonly commit: Commit + readonly showUnpushedIndicator: boolean + readonly unpushedIndicatorTitle?: string +} + interface ICommitListProps { /** The Repository associated with this commit (if found) */ readonly repository: Repository | null @@ -204,6 +211,23 @@ interface ICommitListProps { /** This will make the list semantics friendly to screen reader users in browse mode. */ readonly isInformationalView?: boolean + + /** Optional fixed row height override for the commitGraph renderer. */ + readonly commitGraphRowHeight?: number + + /** Optional class name for list-specific styling. */ + readonly className?: string + + /** Optional custom renderer for commit rows. */ + readonly renderCommitItem?: ( + props: ICommitListItemRenderProps + ) => JSX.Element | null + + /** Extra invalidation data for custom commit row renderers. */ + readonly additionalInvalidationProps?: unknown + + /** Whether to suppress the default row focus tooltip. */ + readonly disableRowFocusTooltip?: boolean } interface ICommitListState { @@ -308,6 +332,17 @@ export class CommitList extends React.Component< const showUnpushedIndicator = (isLocal || unpushedTags.length > 0) && this.props.isLocalRepository === false + if (this.props.renderCommitItem !== undefined) { + return this.props.renderCommitItem({ + row, + commit, + showUnpushedIndicator, + unpushedIndicatorTitle: this.getUnpushedIndicatorTitle( + isLocal, + unpushedTags.length + ), + }) + } return ( { + const commitGraphRowHeight = this.props.commitGraphRowHeight + + if (commitGraphRowHeight !== undefined) { + const numberOfRows = Math.ceil(clientHeight / commitGraphRowHeight) + const top = Math.floor(scrollTop / commitGraphRowHeight) + const bottom = top + numberOfRows + this.props.onScroll?.(top, bottom) + + // Pass new scroll value so the scroll position will be remembered (if the callback has been supplied). + this.props.onCompareListScrolled?.(scrollTop) + return + } + const numberOfRows = Math.ceil(clientHeight / RowHeight) const top = Math.floor(scrollTop / RowHeight) const bottom = top + numberOfRows @@ -624,10 +672,13 @@ export class CommitList extends React.Component< ) } - const classes = classNames({ - 'has-highlighted-commits': - shasToHighlight !== undefined && shasToHighlight.length > 0, - }) + const classes = classNames( + { + 'has-highlighted-commits': + shasToHighlight !== undefined && shasToHighlight.length > 0, + }, + this.props.className + ) const selectedRows = selectedSHAs .map(sha => this.rowForSHA(sha)) @@ -641,7 +692,7 @@ export class CommitList extends React.Component< role={this.props.isInformationalView === true ? 'list' : 'listbox'} ref={this.listRef} rowCount={commitSHAs.length} - rowHeight={RowHeight} + rowHeight={this.props.commitGraphRowHeight ?? RowHeight} selectedRows={selectedRows} rowRenderer={this.renderCommit} onDropDataInsertion={this.onDropDataInsertion} @@ -671,10 +722,15 @@ export class CommitList extends React.Component< tagsToPush: this.props.tagsToPush, shasToHighlight: this.props.shasToHighlight, preferAbsoluteDates: this.props.preferAbsoluteDates, + additionalInvalidationProps: this.props.additionalInvalidationProps, }} setScrollTop={this.props.compareListScrollTop} rowCustomClassNameMap={this.getRowCustomClassMap()} - renderRowFocusTooltip={this.renderRowFocusTooltip} + renderRowFocusTooltip={ + this.props.disableRowFocusTooltip === true + ? undefined + : this.renderRowFocusTooltip + } /> diff --git a/app/src/ui/history/index.ts b/app/src/ui/history/index.ts index f4fabb6e82f..3d98662cd32 100644 --- a/app/src/ui/history/index.ts +++ b/app/src/ui/history/index.ts @@ -1,2 +1,3 @@ export { SelectedCommits } from './selected-commits' export { CompareSidebar } from './compare' +export { CommitGraphSidebar } from './commit-graph-sidebar' diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index ae72e9bd3e4..53cd2646144 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -182,6 +182,15 @@ export class ListRow extends React.Component { private onRowRef = (elem: HTMLDivElement | null) => { if (elem) { this.listItemRef = createObservableRef(elem) + + if ( + this.props.renderRowFocusTooltip !== undefined && + enableAccessibleListToolTips() + ) { + // Tree/List switches remount rows after the first render, so re-render + // once the row ref exists for tooltip hover listeners and positioning. + this.forceUpdate() + } } this.props.onRowRef?.(this.props.rowIndex, elem) } diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 9d8d59f0537..68f9aa41b75 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -7,7 +7,7 @@ import { Changes, ChangesSidebar } from './changes' import { NoChanges } from './changes/no-changes' import { MultipleSelection } from './changes/multiple-selection' import { FilesChangedBadge } from './changes/files-changed-badge' -import { SelectedCommits, CompareSidebar } from './history' +import { SelectedCommits, CompareSidebar, CommitGraphSidebar } from './history' import { Resizable } from './resizable' import { TabBar } from './tab-bar' import { @@ -47,6 +47,7 @@ interface IRepositoryViewProps { readonly emoji: Map readonly sidebarWidth: IConstrainedValue readonly commitSummaryWidth: IConstrainedValue + readonly commitGraphBranchListWidth: IConstrainedValue readonly stashedFilesWidth: IConstrainedValue readonly issuesStore: IssuesStore readonly gitHubUserStore: GitHubUserStore @@ -183,6 +184,7 @@ export class RepositoryView extends React.Component< private forceCompareListScrollTop: boolean = false private readonly historySidebarRef = React.createRef() + private readonly commitGraphSidebarRef = React.createRef() private readonly changesSidebarRef = React.createRef() private readonly compareSidebarRef = React.createRef() @@ -401,8 +403,7 @@ export class RepositoryView extends React.Component< } private renderHistorySidebar(): JSX.Element { - const { repository, dispatcher, state, aheadBehindStore, emoji } = - this.props + const { repository, dispatcher, state, emoji } = this.props const { remote, compareState, @@ -416,6 +417,12 @@ export class RepositoryView extends React.Component< } = state const { tip } = branchesState const currentBranch = tip.kind === TipState.Valid ? tip.branch : null + const currentTipSha = + tip.kind === TipState.Valid + ? tip.branch.tip.sha + : tip.kind === TipState.Detached + ? tip.currentSha + : null const scrollTop = this.forceCompareListScrollTop || this.previousSection !== RepositorySectionTab.History @@ -425,16 +432,17 @@ export class RepositoryView extends React.Component< this.forceCompareListScrollTop = false return ( - Date: Sat, 16 May 2026 17:23:16 +0200 Subject: [PATCH 02/10] Preserve user choice of history view mode across repos --- app/src/lib/stores/app-store.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index c83c30da6f8..a34d47434ea 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -305,7 +305,6 @@ import { commitGraph_BranchListWidthConfigKey, commitGraph_setStoredCollapsedBranchGroups, commitGraph_setStoredHiddenBranchRefs, - commitGraph_setStoredViewMode, } from './commit-graph-state' import { readEmoji } from '../read-emoji' import { Emoji } from '../emoji' @@ -2531,14 +2530,6 @@ export class AppStore extends TypedBaseStore { } } - if ( - previouslySelectedRepository instanceof Repository && - previouslySelectedRepository.hash !== repository.hash - ) { - // Keep the graph/tree choice from carrying across repositories. - commitGraph_setStoredViewMode('list') - } - this.emitUpdate() if (persistSelection) { From 84cbfe8bc909fa8273b67510beaf682c55d5a37f Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 16 May 2026 17:54:18 +0200 Subject: [PATCH 03/10] Avoid hardcoded widths for history graph Maximize the horizontal space available for the commit title --- app/src/ui/history/commit-graph-commit-list-item.tsx | 2 +- app/styles/ui/history/_commit-graph.scss | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/src/ui/history/commit-graph-commit-list-item.tsx b/app/src/ui/history/commit-graph-commit-list-item.tsx index 12d5fb2af23..6904143fbf4 100644 --- a/app/src/ui/history/commit-graph-commit-list-item.tsx +++ b/app/src/ui/history/commit-graph-commit-list-item.tsx @@ -97,10 +97,10 @@ export class CommitGraphCommitListItem extends React.PureComponent - {this.commitGraph_renderCommitterBadge(avatarUsers)} {this.commitGraph_renderCommitTime(commit.author.date)} + {this.commitGraph_renderCommitterBadge(avatarUsers)} {this.commitGraph_renderUnpushedIndicator()} diff --git a/app/styles/ui/history/_commit-graph.scss b/app/styles/ui/history/_commit-graph.scss index e636dfa070a..15120e58cc4 100644 --- a/app/styles/ui/history/_commit-graph.scss +++ b/app/styles/ui/history/_commit-graph.scss @@ -211,17 +211,17 @@ } .commitGraph-commit-content { - display: grid; - grid-template-columns: minmax(0, 1fr) 30px 136px 18px; + display: flex; align-items: center; flex: 1 1 auto; min-width: 0; - column-gap: var(--spacing-half); + gap: var(--spacing-half); line-height: 18px; } .commitGraph-message { display: flex; + flex: 1 1 0; align-items: center; min-width: 0; gap: var(--spacing-half); @@ -329,11 +329,10 @@ .commitGraph-committer-badge { display: flex; + flex: 0 0 30px; align-items: center; justify-content: center; - justify-self: center; width: 30px; - min-width: 30px; height: 18px; .AvatarStack.AvatarStack--small { @@ -366,9 +365,7 @@ .commitGraph-date { @include ellipsis; color: var(--text-secondary-color); - justify-self: start; min-width: 0; - max-width: 100%; line-height: 18px; } @@ -376,7 +373,6 @@ display: inline-flex; align-items: center; justify-content: center; - justify-self: center; height: 16px; min-width: 18px; border-radius: 8px; From e48d797405b4963d6658626f8ee7dc4bfec6b5c1 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 16 May 2026 18:41:51 +0200 Subject: [PATCH 04/10] Fix unnecessary line indentation when merge is immediately followed by branch-out --- app/src/ui/history/commit-graph-model.ts | 76 +++++++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/app/src/ui/history/commit-graph-model.ts b/app/src/ui/history/commit-graph-model.ts index f0682a63e05..736cd7ef4f1 100644 --- a/app/src/ui/history/commit-graph-model.ts +++ b/app/src/ui/history/commit-graph-model.ts @@ -33,9 +33,22 @@ const commitGraph_BackgroundColor = '#9ea4aa' // lanes indefinitely, which can make busy histories much wider than needed. const commitGraph_MaxMergeConnectorRows = 100 +// When a branch-out (lane collapse via deduplication) is immediately followed +// by a merge that would re-add a lane, the net lane count stays the same but +// lanes shift unnecessarily. This constant controls how many commits ahead to +// look: if an upcoming merge (within this distance) would add back a lane, +// pre-fill the freed slot instead of collapsing. Set to 0 to disable. +const commitGraph_MinCollapseDistance = 1 + interface ICommitGraphActiveLane { readonly sha: string readonly color: string + /** + * True when this lane was pre-seeded by a lookahead and hasn't yet been + * officially introduced by its merge commit. Pre-filled lanes are excluded + * from background lane drawing until the merge row claims them. + */ + readonly preFilled?: boolean } export interface ICommitGraphRefColor { @@ -159,7 +172,7 @@ export function commitGraph_buildRows( const currentLane = lanes[column] const lanesToContinue = new Array() for (let laneColumn = 0; laneColumn < lanes.length; laneColumn++) { - if (laneColumn !== column) { + if (laneColumn !== column && !lanes[laneColumn].preFilled) { const lane = lanes[laneColumn] lanesToContinue.push({ column: laneColumn, color: lane.color }) } @@ -196,16 +209,73 @@ export function commitGraph_buildRows( for (let i = 1; i < parents.length; i++) { const parent = parents[i] - if (!nextLanes.some(lane => lane.sha === parent)) { + const existingIdx = nextLanes.findIndex(l => l.sha === parent) + if (existingIdx < 0) { nextLanes.splice(Math.min(column + 1, nextLanes.length), 0, { sha: parent, color: colorForSha(parent), }) + } else if (nextLanes[existingIdx].preFilled) { + // Lane was pre-seeded by lookahead; officially claim it now. + nextLanes[existingIdx] = { ...nextLanes[existingIdx], preFilled: false } } } nextLanes = commitGraph_dedupeLanes(nextLanes) + // If deduplication freed slot(s) and an upcoming merge (within + // commitGraph_MinCollapseDistance rows) would insert new parents anyway, + // pre-fill those freed slots now so the total lane count stays stable and + // adjacent lanes don't shift unnecessarily. + if (nextLanes.length < lanes.length) { + for ( + let ahead = 1; + ahead <= commitGraph_MinCollapseDistance && + nextLanes.length < lanes.length; + ahead++ + ) { + const futureCommit = commits[rowIndex + ahead] + if (futureCommit === undefined) { + break + } + const futureColumn = nextLanes.findIndex( + l => l.sha === futureCommit.sha + ) + if (futureColumn < 0) { + continue + } + + for ( + let pi = 1; + pi < futureCommit.parentSHAs.length && + nextLanes.length < lanes.length; + pi++ + ) { + const parentSha = futureCommit.parentSHAs[pi] + if (!rowIndexBySha.has(parentSha)) { + continue + } + if (nextLanes.some(l => l.sha === parentSha)) { + continue + } + + const parentRowIndex = rowIndexBySha.get(parentSha)! + const isLongConnector = + parentRowIndex - (rowIndex + ahead) > + commitGraph_MaxMergeConnectorRows + if (isLongConnector) { + continue + } + + nextLanes.splice(Math.min(futureColumn + 1, nextLanes.length), 0, { + sha: parentSha, + color: colorForSha(parentSha), + preFilled: true, + }) + } + } + } + const columnsByParentSha = new Map() for (let laneColumn = 0; laneColumn < nextLanes.length; laneColumn++) { columnsByParentSha.set(nextLanes[laneColumn].sha, laneColumn) @@ -213,7 +283,7 @@ export function commitGraph_buildRows( const shifts = new Array() for (let laneColumn = 0; laneColumn < lanes.length; laneColumn++) { - if (laneColumn === column) { + if (laneColumn === column || lanes[laneColumn].preFilled) { continue } From 16d4c04cce93b0fd94f134f735838d2f7ef20064 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 16 May 2026 18:44:36 +0200 Subject: [PATCH 05/10] Improve visibility of commit graph dots --- app/src/ui/history/commit-graph-commit-list-item.tsx | 2 +- app/styles/ui/history/_commit-graph.scss | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/ui/history/commit-graph-commit-list-item.tsx b/app/src/ui/history/commit-graph-commit-list-item.tsx index 6904143fbf4..6ea90402f8a 100644 --- a/app/src/ui/history/commit-graph-commit-list-item.tsx +++ b/app/src/ui/history/commit-graph-commit-list-item.tsx @@ -37,7 +37,7 @@ interface ICommitGraphCommitListItemProps { const commitGraph_LaneGap = 18 const commitGraph_LeadingPadding = 8 const commitGraph_MessageGap = 16 -const commitGraph_DotRadius = 4 +const commitGraph_DotRadius = 5 const commitGraph_RecentCommitWeekdayThreshold = 6 const commitGraph_ShortRefLabelLength = 12 diff --git a/app/styles/ui/history/_commit-graph.scss b/app/styles/ui/history/_commit-graph.scss index 15120e58cc4..ef608d7ac17 100644 --- a/app/styles/ui/history/_commit-graph.scss +++ b/app/styles/ui/history/_commit-graph.scss @@ -394,6 +394,7 @@ .focus-within:not(.in-keyboard-insertion-mode) .list-item.selected { .commitGraph-dot { stroke: var(--box-selected-active-background-color); + fill: white; } .commitGraph-unpushed-indicator { From 2bb8a46bcb9c9345ed55ebc5209554b762e936f1 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 16 May 2026 19:02:04 +0200 Subject: [PATCH 06/10] Use gradients for commit graph connection colors --- .../history/commit-graph-commit-list-item.tsx | 64 ++++++++++++++++++- app/src/ui/history/commit-graph-model.ts | 16 +++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/app/src/ui/history/commit-graph-commit-list-item.tsx b/app/src/ui/history/commit-graph-commit-list-item.tsx index 6ea90402f8a..d9a2146300d 100644 --- a/app/src/ui/history/commit-graph-commit-list-item.tsx +++ b/app/src/ui/history/commit-graph-commit-list-item.tsx @@ -108,7 +108,7 @@ export class CommitGraphCommitListItem extends React.PureComponent shift.fromColumn) ) + // Build gradient definitions for connections and shifts whose start/end colors + // differ. SVG gradient IDs are document-scoped, so embed the commit SHA for + // uniqueness. Prefix "c" = connection, "s" = shift. + const gradientDefs: JSX.Element[] = [] + for (let i = 0; i < commitGraphRow.connections.length; i++) { + const conn = commitGraphRow.connections[i] + if (conn.fromColor === conn.toColor) { + continue + } + const fromX = xForColumn(conn.fromColumn) + const toX = xForColumn(conn.toColumn) + gradientDefs.push( + + + + + ) + } + for (let i = 0; i < commitGraphRow.shifts.length; i++) { + const shift = commitGraphRow.shifts[i] + if (shift.fromColor === shift.toColor) { + continue + } + const fromX = xForColumn(shift.fromColumn) + const toX = xForColumn(shift.toColumn) + gradientDefs.push( + + + + + ) + } + return ( + {gradientDefs.length > 0 && {gradientDefs}} {commitGraphRow.lanes.map(lane => ( ) })} @@ -178,13 +232,17 @@ export class CommitGraphCommitListItem extends React.PureComponent ) })} diff --git a/app/src/ui/history/commit-graph-model.ts b/app/src/ui/history/commit-graph-model.ts index 736cd7ef4f1..0ae119fd571 100644 --- a/app/src/ui/history/commit-graph-model.ts +++ b/app/src/ui/history/commit-graph-model.ts @@ -64,13 +64,19 @@ export interface ICommitGraphLane { export interface ICommitGraphConnection { readonly fromColumn: number readonly toColumn: number - readonly color: string + /** Color at the commit dot (top of the connection). */ + readonly fromColor: string + /** Color at the parent row (bottom of the connection). */ + readonly toColor: string } export interface ICommitGraphLaneShift { readonly fromColumn: number readonly toColumn: number - readonly color: string + /** Color at the top of the shift (the lane's current color). */ + readonly fromColor: string + /** Color at the bottom of the shift (the lane's next color). */ + readonly toColor: string } export interface ICommitGraphRow { @@ -299,7 +305,8 @@ export function commitGraph_buildRows( shifts.push({ fromColumn: laneColumn, toColumn: nextColumn, - color: nextLane?.color ?? lane.color, + fromColor: lane.color, + toColor: nextLane?.color ?? lane.color, }) } @@ -311,7 +318,8 @@ export function commitGraph_buildRows( connections.push({ fromColumn: column, toColumn, - color: parentLane?.color ?? currentLane.color, + fromColor: currentLane.color, + toColor: parentLane?.color ?? currentLane.color, }) } From 0b9e0df3df6e5c189fe0d4159267a974dcee4727 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 16 May 2026 20:08:04 +0200 Subject: [PATCH 07/10] Allow amending and reverting the last commit in the graph view --- app/src/lib/stores/app-store.ts | 12 ++++++++++++ app/src/ui/history/commit-graph-sidebar.tsx | 5 +++-- app/src/ui/history/commit-list.tsx | 17 +++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index a34d47434ea..1e20d988db1 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -1837,6 +1837,18 @@ export class AppStore extends TypedBaseStore { return } + // When the tip changed and the commit graph is active, its cached SHAs are + // stale (e.g. after amend or undo commit). Reload the graph so it reflects + // the new HEAD commit. + if ( + currentSha !== null && + previousTip !== null && + currentSha !== previousTip && + compareState.commitGraphRefs.length > 0 + ) { + void this._commitGraph_load(repository, compareState.commitGraphRefs) + } + // load initial group of commits for current branch const commits = await gitStore.loadCommitBatch('HEAD', 0, false) diff --git a/app/src/ui/history/commit-graph-sidebar.tsx b/app/src/ui/history/commit-graph-sidebar.tsx index 17c3c9c5edf..d3b6c9c80f7 100644 --- a/app/src/ui/history/commit-graph-sidebar.tsx +++ b/app/src/ui/history/commit-graph-sidebar.tsx @@ -753,8 +753,9 @@ export class CommitGraphSidebar extends React.Component< shasToHighlight={this.props.shasToHighlight} localCommitSHAs={this.props.localCommitSHAs} canResetToCommits={!commitGraphIsTreeMode} - canUndoCommits={!commitGraphIsTreeMode} - canAmendCommits={!commitGraphIsTreeMode} + canUndoCommits={true} + canAmendCommits={true} + headCommitSha={this.props.currentTipSha ?? undefined} emoji={this.props.emoji} reorderingEnabled={!commitGraphIsTreeMode} onViewCommitOnGitHub={this.props.onViewCommitOnGitHub} diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx index 9db56a2db10..ca397dcaf59 100644 --- a/app/src/ui/history/commit-list.tsx +++ b/app/src/ui/history/commit-list.tsx @@ -66,6 +66,14 @@ interface ICommitListProps { */ readonly allHistoryCommitSHAs?: ReadonlyArray + /** + * The SHA of the HEAD commit (tip of the checked-out branch). When provided, + * this is used to identify the amendable/undoable commit instead of relying + * on position in allHistoryCommitSHAs (which may not have HEAD at index 0 in + * the commit graph view where multiple branches are shown). + */ + readonly headCommitSha?: string + /** Whether or not commits in this list can be undone. */ readonly canUndoCommits?: boolean @@ -840,8 +848,13 @@ export class CommitList extends React.Component< const actualRow = this.props.allHistoryCommitSHAs?.indexOf(commit.sha) ?? row - const canBeUndone = this.props.canUndoCommits === true && actualRow === 0 - const canBeAmended = this.props.canAmendCommits === true && actualRow === 0 + const isHeadCommit = + this.props.headCommitSha !== undefined + ? commit.sha === this.props.headCommitSha + : actualRow === 0 + + const canBeUndone = this.props.canUndoCommits === true && isHeadCommit + const canBeAmended = this.props.canAmendCommits === true && isHeadCommit // The user can reset to any commit up to the first non-local one (included). // They cannot reset to the most recent commit... because they're already // in it. From 73043fa9b666ff8c423d110c44662ce9aaff442e Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sun, 17 May 2026 10:18:13 +0200 Subject: [PATCH 08/10] Increment size of unpushed indicator in commit tree view --- app/styles/ui/history/_commit-graph.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/styles/ui/history/_commit-graph.scss b/app/styles/ui/history/_commit-graph.scss index ef608d7ac17..88d1b10fbe2 100644 --- a/app/styles/ui/history/_commit-graph.scss +++ b/app/styles/ui/history/_commit-graph.scss @@ -378,6 +378,7 @@ border-radius: 8px; color: var(--list-item-badge-color); background: var(--list-item-badge-background-color); + padding: 0 var(--spacing-half); } .list-item.selected:not(.in-keyboard-insertion-mode) { From baac0c3af6e446d8c97eda66ed6ce323b47e3c7d Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sun, 17 May 2026 11:46:33 +0200 Subject: [PATCH 09/10] Fix possible cause for UI block in commit graph --- app/src/ui/history/commit-graph-model.ts | 7 +++- app/test/unit/commit-graph-model-test.ts | 50 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 app/test/unit/commit-graph-model-test.ts diff --git a/app/src/ui/history/commit-graph-model.ts b/app/src/ui/history/commit-graph-model.ts index 0ae119fd571..2bbf8168687 100644 --- a/app/src/ui/history/commit-graph-model.ts +++ b/app/src/ui/history/commit-graph-model.ts @@ -140,10 +140,15 @@ export function commitGraph_buildRows( if (useBackgroundForUnseededLanes) { color = commitGraph_BackgroundColor } else { + let attempts = 0 do { color = commitGraph_getColor(nextColor) nextColor++ - } while (usedColors.has(color)) + attempts++ + } while ( + usedColors.has(color) && + attempts < commitGraph_Colors.length + 361 + ) usedColors.add(color) } diff --git a/app/test/unit/commit-graph-model-test.ts b/app/test/unit/commit-graph-model-test.ts new file mode 100644 index 00000000000..584a2327780 --- /dev/null +++ b/app/test/unit/commit-graph-model-test.ts @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { commitGraph_buildRows } from '../../src/ui/history/commit-graph-model' +import { Commit } from '../../src/models/commit' +import { CommitIdentity } from '../../src/models/commit-identity' + +function commitGraph_makeTestCommit( + sha: string, + parentSHAs: ReadonlyArray +): Commit { + const identity = new CommitIdentity( + 'Test', + 'test@example.com', + new Date(0), + 0 + ) + return new Commit( + sha, + sha.slice(0, 7), + 'summary', + '', + identity, + identity, + parentSHAs, + [], + [] + ) +} + +describe('commitGraph_buildRows', () => { + it( + 'terminates when more lanes are allocated than the producible color palette', + { timeout: 5000 }, + () => { + // commitGraph_getColor produces a finite set of distinct strings (the + // named palette + at most 361 distinct HSL hues). With no seeded + // refColors, each disconnected root commit consumes one entry in the + // dedup `usedColors` set. Before the loop cap, the allocation that + // saturated the set caused an infinite loop on dedup. 400 disconnected + // single-commit branches reliably exceed the producible-color count. + const commits = Array.from({ length: 400 }, (_, i) => + commitGraph_makeTestCommit(`sha${i.toString().padStart(4, '0')}`, []) + ) + + const rows = commitGraph_buildRows(commits, [], undefined) + + assert.equal(rows.length, 400) + } + ) +}) From a3ff81c28b2cd2e3b69868fe8ea41d88ce2d14a9 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sun, 17 May 2026 12:53:04 +0200 Subject: [PATCH 10/10] Use icons instead of text for history view selector --- app/src/ui/history/commit-graph-sidebar.tsx | 8 ++++++-- app/styles/ui/history/_commit-graph.scss | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/ui/history/commit-graph-sidebar.tsx b/app/src/ui/history/commit-graph-sidebar.tsx index d3b6c9c80f7..ae65996842f 100644 --- a/app/src/ui/history/commit-graph-sidebar.tsx +++ b/app/src/ui/history/commit-graph-sidebar.tsx @@ -587,9 +587,11 @@ export class CommitGraphSidebar extends React.Component< ariaPressed={ this.state.commitGraphViewMode === CommitGraphViewMode.List } + ariaLabel="List view" + tooltip="List view" onClick={this.commitGraph_onListModeClicked} > - List + ) diff --git a/app/styles/ui/history/_commit-graph.scss b/app/styles/ui/history/_commit-graph.scss index 88d1b10fbe2..4b25bd80bb9 100644 --- a/app/styles/ui/history/_commit-graph.scss +++ b/app/styles/ui/history/_commit-graph.scss @@ -20,6 +20,11 @@ padding: var(--spacing-half); padding-left: 0; + .button-group-item .octicon { + height: 14px; + width: 14px; + } + .button-group-item.selected, .button-group-item[aria-pressed='true'] { color: var(--box-selected-active-text-color);