diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 3d75f4b3080..e3b05f1e089 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -1006,6 +1006,9 @@ export interface ICompareState { /** The SHA associated with the most recent history state */ readonly tip: string | null + /** The SHAs of commits to render in the compare list */ + readonly prevFilteredHistoryCommitSHAs: ReadonlyArray + /** The SHAs of commits to render in the compare list */ readonly filteredHistoryCommitSHAs: ReadonlyArray diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index aeefcde6c22..cfe17308e86 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -450,6 +450,7 @@ import { gatherCommitContext, } from '../copilot-conflict-context' import { resolveWithin } from '../path' +import { TFilters } from '../../ui/history/commit-graph-filter-button' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -2019,7 +2020,8 @@ export class AppStore extends TypedBaseStore { public async _loadNextCommitBatch( repository: Repository, alreadyFiltered: number, - queryTextLowercase?: string + queryTextLowercase?: string, + authorFiltersLowercase?: string[] ): Promise { const gitStore = this.gitStoreCache.get(repository) @@ -2055,14 +2057,28 @@ export class AppStore extends TypedBaseStore { return } - const newFilteredCommits = newCommits.filter(sha => + const baseFilteredCommits = newCommits.filter(sha => this.commitIsIncluded(gitStore.commitLookup.get(sha), queryTextLowercase) ) + const newFilteredCommits = + authorFiltersLowercase && authorFiltersLowercase.length > 0 + ? baseFilteredCommits.filter(sha => { + const commit = gitStore.commitLookup.get(sha) + return authorFiltersLowercase.some(filter => + this.commitIsIncludedByAuthorFilter(commit, filter) + ) + }) + : baseFilteredCommits + this.repositoryStateCache.updateCompareState(repository, () => ({ allHistoryCommitSHAs: commits.concat(newCommits), filteredHistoryCommitSHAs: state.compareState.filteredHistoryCommitSHAs.concat(newFilteredCommits), + prevFilteredHistoryCommitSHAs: + state.compareState.filteredHistoryCommitSHAs.concat( + baseFilteredCommits + ), })) const numFilteredCommits = alreadyFiltered + newFilteredCommits.length @@ -2073,7 +2089,8 @@ export class AppStore extends TypedBaseStore { return this._loadNextCommitBatch( repository, numFilteredCommits, - queryTextLowercase + queryTextLowercase, + authorFiltersLowercase ) } return @@ -2301,14 +2318,33 @@ export class AppStore extends TypedBaseStore { ) } + private commitIsIncludedByAuthorFilter( + commit: Commit | undefined, + filterTextLowerCase: string + ): boolean { + if (!commit) { + return false + } + return ( + !filterTextLowerCase || + commit.author.email.toLowerCase() === filterTextLowerCase + ) + } + public async _updateCommitSearchQuery( repository: Repository, - query: string + query: string, + filters?: TFilters ): Promise { const state = this.repositoryStateCache.get(repository) const compareState = state.compareState - const isIncrementalSearch = query.startsWith(compareState.commitSearchQuery) - + const authorFiltersSet = filters && filters.get('author') + const authorFiltersLowercase = authorFiltersSet + ? Array.from(authorFiltersSet).map(item => item.toLowerCase()) + : [] + const isIncrementalSearch = query + ? query.startsWith(compareState.commitSearchQuery) + : false this.repositoryStateCache.updateCompareState(repository, () => ({ commitSearchQuery: query, })) @@ -2318,23 +2354,37 @@ export class AppStore extends TypedBaseStore { } const candidateCommitSHAs = isIncrementalSearch - ? compareState.filteredHistoryCommitSHAs + ? compareState.prevFilteredHistoryCommitSHAs : compareState.allHistoryCommitSHAs + const queryTextLowercase = query.toLowerCase() - const filteredCommitSHAs = queryTextLowercase + const baseFilteredCommitSHAs = queryTextLowercase ? candidateCommitSHAs.filter(sha => this.commitIsIncluded(state.commitLookup.get(sha), queryTextLowercase) ) : candidateCommitSHAs + + const newFilteredCommitSHAs = + authorFiltersLowercase.length > 0 + ? baseFilteredCommitSHAs.filter(sha => { + const commit = state.commitLookup.get(sha) + return authorFiltersLowercase.some(filter => + this.commitIsIncludedByAuthorFilter(commit, filter) + ) + }) + : baseFilteredCommitSHAs + this.repositoryStateCache.updateCompareState(repository, () => ({ - filteredHistoryCommitSHAs: filteredCommitSHAs, + filteredHistoryCommitSHAs: newFilteredCommitSHAs, + prevFilteredHistoryCommitSHAs: baseFilteredCommitSHAs, })) this.emitUpdate() - if (filteredCommitSHAs.length < MinimumFilteredCommitsToLoad) { + if (newFilteredCommitSHAs.length < MinimumFilteredCommitsToLoad) { this.currentCommitFilterPromise = this._loadNextCommitBatch( repository, - filteredCommitSHAs.length, - queryTextLowercase + newFilteredCommitSHAs.length, + queryTextLowercase, + authorFiltersLowercase ) await this.currentCommitFilterPromise this.currentCommitFilterPromise = null diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index ff61f8de302..2d5e6be2b53 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -366,6 +366,7 @@ function getInitialRepositoryState(): IRepositoryState { filterText: '', commitSearchQuery: '', allHistoryCommitSHAs: [], + prevFilteredHistoryCommitSHAs: [], filteredHistoryCommitSHAs: [], commitGraphRefs: [], commitGraphHiddenBranchRefs: null, diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 8a4e53bfee9..4e1a555434a 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -146,6 +146,7 @@ import { ICopilotConflictResolutionResponse, IConflictResolutionProgress, } from '../../lib/copilot-conflict-resolution' +import { TFilters } from '../history/commit-graph-filter-button' /** * An error handler function. @@ -296,9 +297,10 @@ export class Dispatcher { /** Update the commit search filter text. */ public setCommitSearchQuery( repository: Repository, - text: string + text: string, + filters?: TFilters ): Promise { - return this.appStore._updateCommitSearchQuery(repository, text) + return this.appStore._updateCommitSearchQuery(repository, text, filters) } /** Load the changed files for the current history selection. */ diff --git a/app/src/ui/history/commit-graph-filter-button.tsx b/app/src/ui/history/commit-graph-filter-button.tsx new file mode 100644 index 00000000000..40bae15152c --- /dev/null +++ b/app/src/ui/history/commit-graph-filter-button.tsx @@ -0,0 +1,249 @@ +import * as React from 'react' +import { Button } from '../lib/button' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { + Popover, + PopoverAnchorPosition, + PopoverDecoration, +} from '../lib/popover' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import classNames from 'classnames' + +export const AUTHOR_FILTER_KEY = 'author' +export type TFilters = Map> +export type TFilterFillData = { name: string; email: string } +interface ICommitGraphFilterButtonProps { + readonly filters: TFilters + readonly filtersFillData: Record> + readonly onFilterUpdate: (filters: TFilters) => void +} +interface ICommitGraphFilterButtonState { + readonly isFilterOptionsOpen: boolean + readonly isAuthorFilterChecked: boolean + readonly hasFilterOptionsMounted: boolean + readonly activeFilterData: ReadonlyArray +} +export class CommitGraphFilterButton extends React.Component< + ICommitGraphFilterButtonProps, + ICommitGraphFilterButtonState +> { + private filterOptionsButtonRef: HTMLButtonElement | null = null + private filterContainerRef: HTMLDivElement | null = null + + public constructor(props: ICommitGraphFilterButtonProps) { + super(props) + + this.state = { + isFilterOptionsOpen: false, + isAuthorFilterChecked: false, + hasFilterOptionsMounted: false, + activeFilterData: [], + } + } + + private onFilterOptionsButtonRef = (buttonRef: HTMLButtonElement | null) => { + this.filterOptionsButtonRef = buttonRef + } + + private toggleFilterOptionsOpen = () => { + this.setState(prevState => ({ + isFilterOptionsOpen: !prevState.isFilterOptionsOpen, + })) + } + private closeFilterOptions = () => { + this.setState({ isFilterOptionsOpen: false }) + } + private onAuthorFilterToIncludedInCommit = ( + evt: React.FormEvent + ) => { + const newFilters = new Map(this.props.filters) + if (evt.currentTarget.checked) { + newFilters.set(AUTHOR_FILTER_KEY, new Set()) + this.setState({ + isAuthorFilterChecked: true, + activeFilterData: this.props.filtersFillData[AUTHOR_FILTER_KEY], + }) + } else { + newFilters.delete(AUTHOR_FILTER_KEY) + this.setState({ + isAuthorFilterChecked: false, + }) + } + this.props.onFilterUpdate(newFilters) + // this.closeFilterOptions() + } + + private onAuthorSubFilterSelect = ( + event: React.FormEvent, + key: string + ) => { + if (!key) { + return + } + const checked = event.currentTarget.checked + + const newFilters = new Map(this.props.filters) + const newSet = new Set(newFilters.get(AUTHOR_FILTER_KEY)) + newFilters.set(AUTHOR_FILTER_KEY, newSet) + if (checked) { + newSet.add(key) + } else { + newSet.delete(key) + } + this.props.onFilterUpdate(newFilters) + } + + private onFilterContainerRef = (divRef: HTMLDivElement | null) => { + this.filterContainerRef = divRef + } + + private onMountFilterOptions = () => { + this.setState({ + hasFilterOptionsMounted: true, + }) + } + + private onUnmountFilterOptions = () => { + this.setState({ + hasFilterOptionsMounted: false, + }) + } + + private onAuthorSubFilterChange = (email: string) => { + return (event: React.FormEvent) => { + this.onAuthorSubFilterSelect(event, email) + } + } + + private renderFilterOptions() { + return ( + <> + +
+

Filter Options

+ +
+
+ +
+ {/* {filtersActive && ( +
+ +
+ )} */} +
+ {this.state.hasFilterOptionsMounted && + this.props.filters.size > 0 && + this.renderSubFilterOptions()} + + ) + } + + private renderSubFilterOptions() { + return ( + +
+

Authors

+ {/* */} +
+
+ {this.state.activeFilterData.map(({ name, email }) => { + return ( + + ) + })} +
+ {/* {filtersActive && ( +
+ +
+ )} */} +
+ ) + } + + public render() { + const authorFilterSet = this.props.filters.get(AUTHOR_FILTER_KEY) + const activeFiltersCount = authorFilterSet ? authorFilterSet.size : 0 + const hasActiveFilters = authorFilterSet ? activeFiltersCount > 0 : false + const buttonTextLabel = `Filter Options ${ + hasActiveFilters ? `(${activeFiltersCount} applied)` : '' + }` + return ( + <> + + {this.state.isFilterOptionsOpen && this.renderFilterOptions()} + + ) + } +} diff --git a/app/src/ui/history/commit-graph-sidebar.tsx b/app/src/ui/history/commit-graph-sidebar.tsx index 2a916343f0a..64db17ea010 100644 --- a/app/src/ui/history/commit-graph-sidebar.tsx +++ b/app/src/ui/history/commit-graph-sidebar.tsx @@ -1,42 +1,48 @@ import * as React from 'react' -import { Commit, CommitOneLine, ICommitContext } from '../../models/commit' +import classNames from 'classnames' +import memoizeOne from 'memoize-one' import { ICompareState, IConstrainedValue } from '../../lib/app-state' +import { Emoji } from '../../lib/emoji' +import { doMergeCommitsExistAfterCommit } from '../../lib/git' +import { getSquashedCommitDescription } from '../../lib/squash/squashed-commit-description' import { commitGraph_getStoredViewMode, commitGraph_setStoredViewMode, CommitHistoryViewMode, } from '../../lib/stores/commit-graph-state' -import { Repository } from '../../models/repository' +import { getUniqueCoauthorsAsAuthors } from '../../lib/unique-coauthors-as-authors' +import { Account } from '../../models/account' 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 { Commit, CommitOneLine, ICommitContext } from '../../models/commit' +import { DragType } from '../../models/drag-drop' +import { PopupType } from '../../models/popup' +import { Repository } from '../../models/repository' +import { defaultErrorHandler, Dispatcher } from '../dispatcher' 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 { FancyTextBox } from '../lib/fancy-text-box' 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 { Octicon, syncClockwise } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { Resizable } from '../resizable' +import { CommitGraphCommitListItem } from './commit-graph-commit-list-item' +import { + AUTHOR_FILTER_KEY, + CommitGraphFilterButton, + TFilterFillData, + TFilters, +} from './commit-graph-filter-button' import { commitGraph_buildRows, commitGraph_getColor, commitGraph_RowHeight, ICommitGraphRow, } from './commit-graph-model' -import { CommitGraphCommitListItem } from './commit-graph-commit-list-item' +import type { ICommitListItemRenderProps } from './commit-list' +import { CommitList } from './commit-list' type CommitGraphBranchGroup = | 'local' @@ -82,6 +88,7 @@ interface ICommitGraphSidebarState { readonly isSearching: boolean readonly commitGraphViewMode: CommitHistoryViewMode readonly commitGraphSelectedBranchRef: string | null + readonly filters: TFilters } interface ICommitGraphBranches { @@ -519,6 +526,7 @@ export class CommitGraphSidebar extends React.Component< isSearching: false, commitGraphViewMode: commitGraph_getStoredViewMode(), commitGraphSelectedBranchRef: null, + filters: new Map(), } } @@ -538,6 +546,31 @@ export class CommitGraphSidebar extends React.Component< this.commitListRef.current?.focus() } + private onFilterUpdate = (filters: TFilters) => { + this.setState({ filters }) + this.onCommitSearchFiltersChanged(filters) + } + + private getAuthorFilterData = () => { + const seenEmails = new Set() + const uniqueAuthors: TFilterFillData[] = [] + + for (const sha of this.props.compareState.allHistoryCommitSHAs) { + const commit = this.props.commitLookup.get(sha) + + if (commit?.author?.email && !seenEmails.has(commit.author.email)) { + seenEmails.add(commit.author.email) + + uniqueAuthors.push({ + name: commit.author.name, + email: commit.author.email, + }) + } + } + + return uniqueAuthors + } + public render() { const { commitSearchQuery } = this.props.compareState @@ -545,15 +578,28 @@ export class CommitGraphSidebar extends React.Component<
- +
+ + + + +
{this.commitGraph_renderViewModeSwitch()}
@@ -1313,7 +1359,7 @@ export class CommitGraphSidebar extends React.Component< }) } - private onCommitSearchQueryChanged = async (text: string) => { + private onCommitQuery = async (text: string, filters: TFilters) => { if (this.state.commitGraphViewMode === CommitHistoryViewMode.Graph) { this.props.dispatcher.updateCompareForm(this.props.repository, { commitSearchQuery: text, @@ -1331,10 +1377,17 @@ export class CommitGraphSidebar extends React.Component< this.setState({ isSearching: true }) await this.props.dispatcher.setCommitSearchQuery( this.props.repository, - text + text, + filters ) this.setState({ isSearching: false }) } + private onCommitSearchQueryChanged = async (text: string) => { + await this.onCommitQuery(text, this.state.filters) + } + private onCommitSearchFiltersChanged = async (filters: TFilters) => { + await this.onCommitQuery(this.props.compareState.commitSearchQuery, filters) + } private onCreateTag = (targetCommitSha: string) => { this.props.dispatcher.showCreateTagDialog( diff --git a/app/src/ui/lib/popover.tsx b/app/src/ui/lib/popover.tsx index 8268acb0ef1..415d4a7c4b6 100644 --- a/app/src/ui/lib/popover.tsx +++ b/app/src/ui/lib/popover.tsx @@ -18,6 +18,7 @@ import { } from '@floating-ui/core' import { assertNever } from '../../lib/fatal-error' import { isMacOSSequoia, isMacOSSonoma, isMacOSVentura } from '../../lib/get-os' +import { createObservableRef } from './observable-ref' /** * Position of the popover relative to its anchor element. It's composed by 2 @@ -84,6 +85,18 @@ interface IPopoverProps { readonly maxHeight?: number /** Minimum height decided by clients of Popover */ readonly minHeight?: number + + /** + * The `ref` for the underlying container
element. + * + * Ideally this would be named `ref`, but TypeScript seems to special-case its + * handling of the `ref` type into some ungodly monstrosity. Hopefully someday + * this will be unnecessary. + */ + readonly onContainerRef?: (instance: HTMLDivElement | null) => void + + readonly onComponentDidMount?: () => void + readonly onComponentWillUnmount?: () => void } interface IPopoverState { @@ -92,13 +105,14 @@ interface IPopoverState { export class Popover extends React.Component { private focusTrapOptions: FocusTrapOptions - private containerDivRef = React.createRef() + private containerDivRef = createObservableRef() private contentDivRef = React.createRef() private tipDivRef = React.createRef() private floatingCleanUp: (() => void) | null = null public constructor(props: IPopoverProps) { super(props) + this.containerDivRef.subscribe(div => this.props.onContainerRef?.(div)) this.focusTrapOptions = { allowOutsideClick: true, @@ -195,6 +209,7 @@ export class Popover extends React.Component { document.addEventListener('click', this.onDocumentClick) document.addEventListener('mousedown', this.onDocumentMouseDown) this.setupPosition() + this.props.onComponentDidMount?.() } public componentDidUpdate(prevProps: IPopoverProps) { @@ -206,6 +221,7 @@ export class Popover extends React.Component { public componentWillUnmount() { document.removeEventListener('click', this.onDocumentClick) document.removeEventListener('mousedown', this.onDocumentMouseDown) + this.props.onComponentWillUnmount?.() } private onDocumentClick = (event: MouseEvent) => { diff --git a/app/styles/ui/history/_commit-graph.scss b/app/styles/ui/history/_commit-graph.scss index 4b25bd80bb9..3cbb064602a 100644 --- a/app/styles/ui/history/_commit-graph.scss +++ b/app/styles/ui/history/_commit-graph.scss @@ -1,11 +1,119 @@ @import '../../mixins'; +.filter-box-container { + display: flex; + align-items: center; + transition: all 0.3s ease-out; + + .filter-popover { + text-align: right; + min-width: 200px; + + .filter-popover-header { + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + margin: 0; + } + } + + .filter-options { + margin: var(--spacing) 0; + } + + .filter-options-footer { + padding: var(--spacing-half) 0 var(--spacing) 0; + margin-top: var(--spacing-quarter); + text-align: left; + } + .popover-content { + padding: var(--spacing) var(--spacing) 0 var(--spacing); + } + + @include close-button; + + .close { + margin: 0; + } + } + + .filter-button { + border-radius: var(--border-radius) 0 0 var(--border-radius); + border-right: none; + font-weight: var(--font-weight-semibold); + color: var(--text-color); + justify-content: space-between; + display: inline-flex; + align-items: center; + padding-right: var(--spacing-half); + position: relative; + + &.active { + span:first-child { + color: var(--box-selected-active-background-color); + } + } + + .active-badge { + position: absolute; + right: 18px; + top: 4px; + + .badge-bg { + padding: 1px; + border-radius: 50%; + background-color: var(--secondary-button-background); + + .badge { + width: 5px; + height: 5px; + background-color: var(--box-selected-active-background-color); + border-radius: 50%; + } + } + } + + &:hover { + color: var(--text-secondary-color); + background-color: inherit; + + .badge-bg { + background-color: var(--secondary-button-background); + } + } + } + + .fancy-text-box-component { + border: var(--contrast-border); + border-radius: 0 var(--border-radius) var(--border-radius) 0; + flex-grow: 1; + height: var(--text-field-height); + + &.focused { + border-color: var(--focus-color); + } + + & .fancy-octicon, + & .text-box-component input { + height: 100%; + } + } + + .fancy-text-box-component, + .text-box-component { + min-width: 0; + } +} + #compare-view { .commitGraph-view-toolbar { display: flex; align-items: stretch; background: var(--box-alt-background-color); border-bottom: var(--base-border); + padding-inline: var(--spacing-half); .commit-search-form { flex: 1 1 auto;