From 560d453dcf43306d2b57f458af04bda6189d9895 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 10 Dec 2024 12:13:25 -0500 Subject: [PATCH] Feature flag and substitute modified impactful components --- app/src/lib/feature-flag.ts | 2 + app/src/ui/changes/filter-changes-list.tsx | 1061 ++++++++++++++++++++ app/src/ui/changes/sidebar.tsx | 8 +- app/src/ui/lib/augmented-filter-list.tsx | 669 ++++++++++++ 4 files changed, 1739 insertions(+), 1 deletion(-) create mode 100644 app/src/ui/changes/filter-changes-list.tsx create mode 100644 app/src/ui/lib/augmented-filter-list.tsx diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index ae9ab63eae6..5bc16eda80b 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -101,3 +101,5 @@ export const enableCustomIntegration = () => true export const enableResizingToolbarButtons = () => true export const enableGitConfigParameters = enableBetaFeatures + +export const enableFilteredChangesList = enableDevelopmentFeatures diff --git a/app/src/ui/changes/filter-changes-list.tsx b/app/src/ui/changes/filter-changes-list.tsx new file mode 100644 index 00000000000..8dfcab28462 --- /dev/null +++ b/app/src/ui/changes/filter-changes-list.tsx @@ -0,0 +1,1061 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Dispatcher } from '../dispatcher' +import { IMenuItem } from '../../lib/menu-item' +import { revealInFileManager } from '../../lib/app-shell' +import { + WorkingDirectoryStatus, + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../models/status' +import { DiffSelectionType } from '../../models/diff' +import { CommitIdentity } from '../../models/commit-identity' +import { ICommitMessage } from '../../models/commit-message' +import { + isRepositoryWithGitHubRepository, + Repository, +} from '../../models/repository' +import { Account } from '../../models/account' +import { Author, UnknownAuthor } from '../../models/author' +import { List, ClickSource } from '../lib/list' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { + isSafeFileExtension, + DefaultEditorLabel, + CopyFilePathLabel, + RevealInFileManagerLabel, + OpenWithDefaultProgramLabel, + CopyRelativeFilePathLabel, + CopySelectedPathsLabel, + CopySelectedRelativePathsLabel, +} from '../lib/context-menu' +import { CommitMessage } from './commit-message' +import { ChangedFile } from './changed-file' +import { IAutocompletionProvider } from '../autocompletion' +import { showContextualMenu } from '../../lib/menu-item' +import { arrayEquals } from '../../lib/equality' +import { clipboard } from 'electron' +import { basename } from 'path' +import { Commit, ICommitContext } from '../../models/commit' +import { + RebaseConflictState, + ConflictState, + Foldout, +} from '../../lib/app-state' +import { ContinueRebase } from './continue-rebase' +import { Octicon, OcticonSymbolVariant } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { IStashEntry } from '../../models/stash-entry' +import classNames from 'classnames' +import { hasWritePermission } from '../../models/github-repository' +import { hasConflictedFiles } from '../../lib/status' +import { createObservableRef } from '../lib/observable-ref' +import { TooltipDirection } from '../lib/tooltip' +import { Popup } from '../../models/popup' +import { EOL } from 'os' +import { TooltippedContent } from '../lib/tooltipped-content' +import { RepoRulesInfo } from '../../models/repo-rules' +import { IAheadBehind } from '../../models/branch' +import { StashDiffViewerId } from '../stashing' + +const RowHeight = 29 +const StashIcon: OcticonSymbolVariant = { + w: 16, + h: 16, + p: [ + 'M10.5 1.286h-9a.214.214 0 0 0-.214.214v9a.214.214 0 0 0 .214.214h9a.214.214 0 0 0 ' + + '.214-.214v-9a.214.214 0 0 0-.214-.214zM1.5 0h9A1.5 1.5 0 0 1 12 1.5v9a1.5 1.5 0 0 1-1.5 ' + + '1.5h-9A1.5 1.5 0 0 1 0 10.5v-9A1.5 1.5 0 0 1 1.5 0zm5.712 7.212a1.714 1.714 0 1 ' + + '1-2.424-2.424 1.714 1.714 0 0 1 2.424 2.424zM2.015 12.71c.102.729.728 1.29 1.485 ' + + '1.29h9a1.5 1.5 0 0 0 1.5-1.5v-9a1.5 1.5 0 0 0-1.29-1.485v1.442a.216.216 0 0 1 ' + + '.004.043v9a.214.214 0 0 1-.214.214h-9a.216.216 0 0 1-.043-.004H2.015zm2 2c.102.729.728 ' + + '1.29 1.485 1.29h9a1.5 1.5 0 0 0 1.5-1.5v-9a1.5 1.5 0 0 0-1.29-1.485v1.442a.216.216 0 0 1 ' + + '.004.043v9a.214.214 0 0 1-.214.214h-9a.216.216 0 0 1-.043-.004H4.015z', + ], +} + +const GitIgnoreFileName = '.gitignore' + +/** Compute the 'Include All' checkbox value from the repository state */ +function getIncludeAllValue( + workingDirectory: WorkingDirectoryStatus, + rebaseConflictState: RebaseConflictState | null +) { + if (rebaseConflictState !== null) { + if (workingDirectory.files.length === 0) { + // the current commit will be skipped in the rebase + return CheckboxValue.Off + } + + // untracked files will be skipped by the rebase, so we need to ensure that + // the "Include All" checkbox matches this state + const onlyUntrackedFilesFound = workingDirectory.files.every( + f => f.status.kind === AppFileStatusKind.Untracked + ) + + if (onlyUntrackedFilesFound) { + return CheckboxValue.Off + } + + const onlyTrackedFilesFound = workingDirectory.files.every( + f => f.status.kind !== AppFileStatusKind.Untracked + ) + + // show "Mixed" if we have a mixture of tracked and untracked changes + return onlyTrackedFilesFound ? CheckboxValue.On : CheckboxValue.Mixed + } + + const { includeAll } = workingDirectory + if (includeAll === true) { + return CheckboxValue.On + } else if (includeAll === false) { + return CheckboxValue.Off + } else { + return CheckboxValue.Mixed + } +} + +interface IFilterChangesListProps { + readonly repository: Repository + readonly repositoryAccount: Account | null + readonly workingDirectory: WorkingDirectoryStatus + readonly mostRecentLocalCommit: Commit | null + /** + * An object containing the conflicts in the working directory. + * When null it means that there are no conflicts. + */ + readonly conflictState: ConflictState | null + readonly rebaseConflictState: RebaseConflictState | null + readonly selectedFileIDs: ReadonlyArray + readonly onFileSelectionChanged: (rows: ReadonlyArray) => void + readonly onIncludeChanged: (path: string, include: boolean) => void + readonly onSelectAll: (selectAll: boolean) => void + readonly onCreateCommit: (context: ICommitContext) => Promise + readonly onDiscardChanges: (file: WorkingDirectoryFileChange) => void + readonly askForConfirmationOnDiscardChanges: boolean + readonly focusCommitMessage: boolean + readonly isShowingModal: boolean + readonly isShowingFoldout: boolean + readonly onDiscardChangesFromFiles: ( + files: ReadonlyArray, + isDiscardingAllChanges: boolean + ) => void + + /** Callback that fires on page scroll to pass the new scrollTop location */ + readonly onChangesListScrolled: (scrollTop: number) => void + + /* The scrollTop of the compareList. It is stored to allow for scroll position persistence */ + readonly changesListScrollTop?: number + + /** + * Called to open a file in its default application + * + * @param path The path of the file relative to the root of the repository + */ + readonly onOpenItem: (path: string) => void + + /** + * Called to open a file in the default external editor + * + * @param path The path of the file relative to the root of the repository + */ + readonly onOpenItemInExternalEditor: (path: string) => void + + /** + * The currently checked out branch (null if no branch is checked out). + */ + readonly branch: string | null + readonly commitAuthor: CommitIdentity | null + readonly dispatcher: Dispatcher + readonly availableWidth: number + readonly isCommitting: boolean + readonly commitToAmend: Commit | null + readonly currentBranchProtected: boolean + readonly currentRepoRulesInfo: RepoRulesInfo + readonly aheadBehind: IAheadBehind | null + + /** + * Click event handler passed directly to the onRowClick prop of List, see + * List Props for documentation. + */ + readonly onRowClick?: (row: number, source: ClickSource) => void + readonly commitMessage: ICommitMessage + + /** The autocompletion providers available to the repository. */ + readonly autocompletionProviders: ReadonlyArray> + + /** Called when the given file should be ignored. */ + readonly onIgnoreFile: (pattern: string | string[]) => void + + /** Called when the given pattern should be ignored. */ + readonly onIgnorePattern: (pattern: string | string[]) => void + + /** + * Whether or not to show a field for adding co-authors to + * a commit (currently only supported for GH/GHE repositories) + */ + readonly showCoAuthoredBy: boolean + + /** + * A list of authors (name, email pairs) which have been + * entered into the co-authors input box in the commit form + * and which _may_ be used in the subsequent commit to add + * Co-Authored-By commit message trailers depending on whether + * the user has chosen to do so. + */ + readonly coAuthors: ReadonlyArray + + /** The name of the currently selected external editor */ + readonly externalEditorLabel?: string + + readonly stashEntry: IStashEntry | null + + readonly isShowingStashEntry: boolean + + /** + * Whether we should show the onboarding tutorial nudge + * arrow pointing at the commit summary box + */ + readonly shouldNudgeToCommit: boolean + + readonly commitSpellcheckEnabled: boolean + + readonly showCommitLengthWarning: boolean + + readonly accounts: ReadonlyArray +} + +interface IFilterChangesListState { + readonly selectedRows: ReadonlyArray + readonly focusedRow: number | null +} + +function getSelectedRowsFromProps( + props: IFilterChangesListProps +): ReadonlyArray { + const selectedFileIDs = props.selectedFileIDs + const selectedRows = [] + + for (const id of selectedFileIDs) { + const ix = props.workingDirectory.findFileIndexByID(id) + if (ix !== -1) { + selectedRows.push(ix) + } + } + + return selectedRows +} + +export class FilterChangesList extends React.Component< + IFilterChangesListProps, + IFilterChangesListState +> { + private headerRef = createObservableRef() + private includeAllCheckBoxRef = React.createRef() + + public constructor(props: IFilterChangesListProps) { + super(props) + this.state = { + selectedRows: getSelectedRowsFromProps(props), + focusedRow: null, + } + } + + public componentWillReceiveProps(nextProps: IFilterChangesListProps) { + // No need to update state unless we haven't done it yet or the + // selected file id list has changed. + if ( + !arrayEquals(nextProps.selectedFileIDs, this.props.selectedFileIDs) || + !arrayEquals( + nextProps.workingDirectory.files, + this.props.workingDirectory.files + ) + ) { + this.setState({ selectedRows: getSelectedRowsFromProps(nextProps) }) + } + } + + private onIncludeAllChanged = (event: React.FormEvent) => { + const include = event.currentTarget.checked + this.props.onSelectAll(include) + } + + private renderRow = (row: number): JSX.Element => { + const { + workingDirectory, + rebaseConflictState, + isCommitting, + onIncludeChanged, + availableWidth, + } = this.props + + const file = workingDirectory.files[row] + const selection = file.selection.getSelectionType() + const { submoduleStatus } = file.status + + const isUncommittableSubmodule = + submoduleStatus !== undefined && + file.status.kind === AppFileStatusKind.Modified && + !submoduleStatus.commitChanged + + const isPartiallyCommittableSubmodule = + submoduleStatus !== undefined && + (submoduleStatus.commitChanged || + file.status.kind === AppFileStatusKind.New) && + (submoduleStatus.modifiedChanges || submoduleStatus.untrackedChanges) + + const includeAll = + selection === DiffSelectionType.All + ? true + : selection === DiffSelectionType.None + ? false + : null + + const include = isUncommittableSubmodule + ? false + : rebaseConflictState !== null + ? file.status.kind !== AppFileStatusKind.Untracked + : includeAll + + const disableSelection = + isCommitting || rebaseConflictState !== null || isUncommittableSubmodule + + const checkboxTooltip = isUncommittableSubmodule + ? 'This submodule change cannot be added to a commit in this repository because it contains changes that have not been committed.' + : isPartiallyCommittableSubmodule + ? 'Only changes that have been committed within the submodule will be added to this repository. You need to commit any other modified or untracked changes in the submodule before including them in this repository.' + : undefined + + return ( + + ) + } + + private onDiscardAllChanges = () => { + this.props.onDiscardChangesFromFiles( + this.props.workingDirectory.files, + true + ) + } + + private onStashChanges = () => { + this.props.dispatcher.createStashForCurrentBranch(this.props.repository) + } + + private onDiscardChanges = (files: ReadonlyArray) => { + const workingDirectory = this.props.workingDirectory + + if (files.length === 1) { + const modifiedFile = workingDirectory.files.find(f => f.path === files[0]) + + if (modifiedFile != null) { + this.props.onDiscardChanges(modifiedFile) + } + } else { + const modifiedFiles = new Array() + + files.forEach(file => { + const modifiedFile = workingDirectory.files.find(f => f.path === file) + + if (modifiedFile != null) { + modifiedFiles.push(modifiedFile) + } + }) + + if (modifiedFiles.length > 0) { + // DiscardAllChanges can also be used for discarding several selected changes. + // Therefore, we update the pop up to reflect whether or not it is "all" changes. + const discardingAllChanges = + modifiedFiles.length === workingDirectory.files.length + + this.props.onDiscardChangesFromFiles( + modifiedFiles, + discardingAllChanges + ) + } + } + } + + private getDiscardChangesMenuItemLabel = (files: ReadonlyArray) => { + const label = + files.length === 1 + ? __DARWIN__ + ? `Discard Changes` + : `Discard changes` + : __DARWIN__ + ? `Discard ${files.length} Selected Changes` + : `Discard ${files.length} selected changes` + + return this.props.askForConfirmationOnDiscardChanges ? `${label}…` : label + } + + private onContextMenu = (event: React.MouseEvent) => { + event.preventDefault() + + // need to preserve the working directory state while dealing with conflicts + if (this.props.rebaseConflictState !== null || this.props.isCommitting) { + return + } + + const hasLocalChanges = this.props.workingDirectory.files.length > 0 + const hasStash = this.props.stashEntry !== null + const hasConflicts = + this.props.conflictState !== null || + hasConflictedFiles(this.props.workingDirectory) + + const stashAllChangesLabel = __DARWIN__ + ? 'Stash All Changes' + : 'Stash all changes' + const confirmStashAllChangesLabel = __DARWIN__ + ? 'Stash All Changes…' + : 'Stash all changes…' + + const items: IMenuItem[] = [ + { + label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…', + action: this.onDiscardAllChanges, + enabled: hasLocalChanges, + }, + { + label: hasStash ? confirmStashAllChangesLabel : stashAllChangesLabel, + action: this.onStashChanges, + enabled: hasLocalChanges && this.props.branch !== null && !hasConflicts, + }, + ] + + showContextualMenu(items) + } + + private getDiscardChangesMenuItem = ( + paths: ReadonlyArray + ): IMenuItem => { + return { + label: this.getDiscardChangesMenuItemLabel(paths), + action: () => this.onDiscardChanges(paths), + } + } + + private getCopyPathMenuItem = ( + file: WorkingDirectoryFileChange + ): IMenuItem => { + return { + label: CopyFilePathLabel, + action: () => { + const fullPath = Path.join(this.props.repository.path, file.path) + clipboard.writeText(fullPath) + }, + } + } + + private getCopyRelativePathMenuItem = ( + file: WorkingDirectoryFileChange + ): IMenuItem => { + return { + label: CopyRelativeFilePathLabel, + action: () => clipboard.writeText(Path.normalize(file.path)), + } + } + + private getCopySelectedPathsMenuItem = ( + files: WorkingDirectoryFileChange[] + ): IMenuItem => { + return { + label: CopySelectedPathsLabel, + action: () => { + const fullPaths = files.map(file => + Path.join(this.props.repository.path, file.path) + ) + clipboard.writeText(fullPaths.join(EOL)) + }, + } + } + + private getCopySelectedRelativePathsMenuItem = ( + files: WorkingDirectoryFileChange[] + ): IMenuItem => { + return { + label: CopySelectedRelativePathsLabel, + action: () => { + const paths = files.map(file => Path.normalize(file.path)) + clipboard.writeText(paths.join(EOL)) + }, + } + } + + private getRevealInFileManagerMenuItem = ( + file: WorkingDirectoryFileChange + ): IMenuItem => { + return { + label: RevealInFileManagerLabel, + action: () => revealInFileManager(this.props.repository, file.path), + enabled: file.status.kind !== AppFileStatusKind.Deleted, + } + } + + private getOpenInExternalEditorMenuItem = ( + file: WorkingDirectoryFileChange, + enabled: boolean + ): IMenuItem => { + const { externalEditorLabel } = this.props + + const openInExternalEditor = externalEditorLabel + ? `Open in ${externalEditorLabel}` + : DefaultEditorLabel + + return { + label: openInExternalEditor, + action: () => { + this.props.onOpenItemInExternalEditor(file.path) + }, + enabled, + } + } + + private getDefaultContextMenu( + file: WorkingDirectoryFileChange + ): ReadonlyArray { + const { id, path, status } = file + + const extension = Path.extname(path) + const isSafeExtension = isSafeFileExtension(extension) + + const { workingDirectory, selectedFileIDs } = this.props + + const selectedFiles = new Array() + const paths = new Array() + const extensions = new Set() + + const addItemToArray = (fileID: string) => { + const newFile = workingDirectory.findFileWithID(fileID) + if (newFile) { + selectedFiles.push(newFile) + paths.push(newFile.path) + + const extension = Path.extname(newFile.path) + if (extension.length) { + extensions.add(extension) + } + } + } + + if (selectedFileIDs.includes(id)) { + // user has selected a file inside an existing selection + // -> context menu entries should be applied to all selected files + selectedFileIDs.forEach(addItemToArray) + } else { + // this is outside their previous selection + // -> context menu entries should be applied to just this file + addItemToArray(id) + } + + const items: IMenuItem[] = [ + this.getDiscardChangesMenuItem(paths), + { type: 'separator' }, + ] + if (paths.length === 1) { + const enabled = Path.basename(path) !== GitIgnoreFileName + items.push({ + label: __DARWIN__ + ? 'Ignore File (Add to .gitignore)' + : 'Ignore file (add to .gitignore)', + action: () => this.props.onIgnoreFile(path), + enabled, + }) + + // Even on Windows, the path separator is '/' for git operations so cannot + // use Path.sep + const pathComponents = path.split('/').slice(0, -1) + if (pathComponents.length > 0) { + const submenu = pathComponents.map((_, index) => { + const label = `/${pathComponents + .slice(0, pathComponents.length - index) + .join('/')}` + return { + label, + action: () => this.props.onIgnoreFile(label), + } + }) + + items.push({ + label: __DARWIN__ + ? 'Ignore Folder (Add to .gitignore)' + : 'Ignore folder (add to .gitignore)', + submenu, + enabled, + }) + } + } else if (paths.length > 1) { + items.push({ + label: __DARWIN__ + ? `Ignore ${paths.length} Selected Files (Add to .gitignore)` + : `Ignore ${paths.length} selected files (add to .gitignore)`, + action: () => { + // Filter out any .gitignores that happens to be selected, ignoring + // those doesn't make sense. + this.props.onIgnoreFile( + paths.filter(path => Path.basename(path) !== GitIgnoreFileName) + ) + }, + // Enable this action as long as there's something selected which isn't + // a .gitignore file. + enabled: paths.some(path => Path.basename(path) !== GitIgnoreFileName), + }) + } + // Five menu items should be enough for everyone + Array.from(extensions) + .slice(0, 5) + .forEach(extension => { + items.push({ + label: __DARWIN__ + ? `Ignore All ${extension} Files (Add to .gitignore)` + : `Ignore all ${extension} files (add to .gitignore)`, + action: () => this.props.onIgnorePattern(`*${extension}`), + }) + }) + + if (paths.length > 1) { + items.push( + { type: 'separator' }, + { + label: __DARWIN__ + ? 'Include Selected Files' + : 'Include selected files', + action: () => { + selectedFiles.map(file => + this.props.onIncludeChanged(file.path, true) + ) + }, + }, + { + label: __DARWIN__ + ? 'Exclude Selected Files' + : 'Exclude selected files', + action: () => { + selectedFiles.map(file => + this.props.onIncludeChanged(file.path, false) + ) + }, + }, + { type: 'separator' }, + this.getCopySelectedPathsMenuItem(selectedFiles), + this.getCopySelectedRelativePathsMenuItem(selectedFiles) + ) + } else { + items.push( + { type: 'separator' }, + this.getCopyPathMenuItem(file), + this.getCopyRelativePathMenuItem(file) + ) + } + + const enabled = status.kind !== AppFileStatusKind.Deleted + items.push( + { type: 'separator' }, + this.getRevealInFileManagerMenuItem(file), + this.getOpenInExternalEditorMenuItem(file, enabled), + { + label: OpenWithDefaultProgramLabel, + action: () => this.props.onOpenItem(path), + enabled: enabled && isSafeExtension, + } + ) + + return items + } + + private getRebaseContextMenu( + file: WorkingDirectoryFileChange + ): ReadonlyArray { + const { path, status } = file + + const extension = Path.extname(path) + const isSafeExtension = isSafeFileExtension(extension) + + const items = new Array() + + if (file.status.kind === AppFileStatusKind.Untracked) { + items.push(this.getDiscardChangesMenuItem([file.path]), { + type: 'separator', + }) + } + + const enabled = status.kind !== AppFileStatusKind.Deleted + + items.push( + this.getCopyPathMenuItem(file), + this.getCopyRelativePathMenuItem(file), + { type: 'separator' }, + this.getRevealInFileManagerMenuItem(file), + this.getOpenInExternalEditorMenuItem(file, enabled), + { + label: OpenWithDefaultProgramLabel, + action: () => this.props.onOpenItem(path), + enabled: enabled && isSafeExtension, + } + ) + + return items + } + + private onItemContextMenu = ( + row: number, + event: React.MouseEvent + ) => { + const { workingDirectory } = this.props + const file = workingDirectory.files[row] + + if (this.props.isCommitting) { + return + } + + event.preventDefault() + + const items = + this.props.rebaseConflictState === null + ? this.getDefaultContextMenu(file) + : this.getRebaseContextMenu(file) + + showContextualMenu(items) + } + + private getPlaceholderMessage( + files: ReadonlyArray, + prepopulateCommitSummary: boolean + ) { + if (!prepopulateCommitSummary) { + return 'Summary (required)' + } + + const firstFile = files[0] + const fileName = basename(firstFile.path) + + switch (firstFile.status.kind) { + case AppFileStatusKind.New: + case AppFileStatusKind.Untracked: + return `Create ${fileName}` + case AppFileStatusKind.Deleted: + return `Delete ${fileName}` + default: + // TODO: + // this doesn't feel like a great message for AppFileStatus.Copied or + // AppFileStatus.Renamed but without more insight (and whether this + // affects other parts of the flow) we can just default to this for now + return `Update ${fileName}` + } + } + + private onScroll = (scrollTop: number, clientHeight: number) => { + this.props.onChangesListScrolled(scrollTop) + } + + private renderCommitMessageForm = (): JSX.Element => { + const { + rebaseConflictState, + workingDirectory, + repository, + repositoryAccount, + dispatcher, + isCommitting, + commitToAmend, + currentBranchProtected, + currentRepoRulesInfo: currentRepoRulesInfo, + } = this.props + + if (rebaseConflictState !== null) { + const hasUntrackedChanges = workingDirectory.files.some( + f => f.status.kind === AppFileStatusKind.Untracked + ) + + return ( + + ) + } + + const fileCount = workingDirectory.files.length + + const includeAllValue = getIncludeAllValue( + workingDirectory, + rebaseConflictState + ) + + const anyFilesSelected = + fileCount > 0 && includeAllValue !== CheckboxValue.Off + + const filesSelected = workingDirectory.files.filter( + f => f.selection.getSelectionType() !== DiffSelectionType.None + ) + + // When a single file is selected, we use a default commit summary + // based on the file name and change status. + // However, for onboarding tutorial repositories, we don't want to do this. + // See https://github.com/desktop/desktop/issues/8354 + const prepopulateCommitSummary = + filesSelected.length === 1 && !repository.isTutorialRepository + + // if this is not a github repo, we don't want to + // restrict what the user can do at all + const hasWritePermissionForRepository = + this.props.repository.gitHubRepository === null || + hasWritePermission(this.props.repository.gitHubRepository) + + return ( + 0} + repository={repository} + repositoryAccount={repositoryAccount} + commitMessage={this.props.commitMessage} + focusCommitMessage={this.props.focusCommitMessage} + autocompletionProviders={this.props.autocompletionProviders} + isCommitting={isCommitting} + commitToAmend={commitToAmend} + showCoAuthoredBy={this.props.showCoAuthoredBy} + coAuthors={this.props.coAuthors} + placeholder={this.getPlaceholderMessage( + filesSelected, + prepopulateCommitSummary + )} + prepopulateCommitSummary={prepopulateCommitSummary} + key={repository.id} + showBranchProtected={fileCount > 0 && currentBranchProtected} + repoRulesInfo={currentRepoRulesInfo} + aheadBehind={this.props.aheadBehind} + showNoWriteAccess={fileCount > 0 && !hasWritePermissionForRepository} + shouldNudge={this.props.shouldNudgeToCommit} + commitSpellcheckEnabled={this.props.commitSpellcheckEnabled} + showCommitLengthWarning={this.props.showCommitLengthWarning} + onCoAuthorsUpdated={this.onCoAuthorsUpdated} + onShowCoAuthoredByChanged={this.onShowCoAuthoredByChanged} + onConfirmCommitWithUnknownCoAuthors={ + this.onConfirmCommitWithUnknownCoAuthors + } + onPersistCommitMessage={this.onPersistCommitMessage} + onCommitMessageFocusSet={this.onCommitMessageFocusSet} + onRefreshAuthor={this.onRefreshAuthor} + onShowPopup={this.onShowPopup} + onShowFoldout={this.onShowFoldout} + onCommitSpellcheckEnabledChanged={this.onCommitSpellcheckEnabledChanged} + onStopAmending={this.onStopAmending} + onShowCreateForkDialog={this.onShowCreateForkDialog} + accounts={this.props.accounts} + /> + ) + } + + private onCoAuthorsUpdated = (coAuthors: ReadonlyArray) => + this.props.dispatcher.setCoAuthors(this.props.repository, coAuthors) + + private onShowCoAuthoredByChanged = (showCoAuthors: boolean) => { + const { dispatcher, repository } = this.props + dispatcher.setShowCoAuthoredBy(repository, showCoAuthors) + } + + private onConfirmCommitWithUnknownCoAuthors = ( + coAuthors: ReadonlyArray, + onCommitAnyway: () => void + ) => { + const { dispatcher } = this.props + dispatcher.showUnknownAuthorsCommitWarning(coAuthors, onCommitAnyway) + } + + private onRefreshAuthor = () => + this.props.dispatcher.refreshAuthor(this.props.repository) + + private onCommitMessageFocusSet = () => + this.props.dispatcher.setCommitMessageFocus(false) + + private onPersistCommitMessage = (message: ICommitMessage) => + this.props.dispatcher.setCommitMessage(this.props.repository, message) + + private onShowPopup = (p: Popup) => this.props.dispatcher.showPopup(p) + private onShowFoldout = (f: Foldout) => this.props.dispatcher.showFoldout(f) + + private onCommitSpellcheckEnabledChanged = (enabled: boolean) => + this.props.dispatcher.setCommitSpellcheckEnabled(enabled) + + private onStopAmending = () => + this.props.dispatcher.stopAmendingRepository(this.props.repository) + + private onShowCreateForkDialog = () => { + if (isRepositoryWithGitHubRepository(this.props.repository)) { + this.props.dispatcher.showCreateForkDialog(this.props.repository) + } + } + + private onStashEntryClicked = () => { + const { isShowingStashEntry, dispatcher, repository } = this.props + + if (isShowingStashEntry) { + dispatcher.selectWorkingDirectoryFiles(repository) + + // If the button is clicked, that implies the stash was not restored or discarded + dispatcher.incrementMetric('noActionTakenOnStashCount') + } else { + dispatcher.selectStashedFile(repository) + dispatcher.incrementMetric('stashViewCount') + } + } + + private renderStashedChanges() { + if (this.props.stashEntry === null) { + return null + } + + const className = classNames( + 'stashed-changes-button', + this.props.isShowingStashEntry ? 'selected' : null + ) + + return ( + + ) + } + + private onRowDoubleClick = (row: number) => { + const file = this.props.workingDirectory.files[row] + + this.props.onOpenItemInExternalEditor(file.path) + } + + private onRowKeyDown = ( + _row: number, + event: React.KeyboardEvent + ) => { + // The commit is already in-flight but this check prevents the + // user from changing selection. + if ( + this.props.isCommitting && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault() + } + + return + } + + public focus() { + this.includeAllCheckBoxRef.current?.focus() + } + + public render() { + const { workingDirectory, rebaseConflictState, isCommitting } = this.props + const { files } = workingDirectory + + const filesPlural = files.length === 1 ? 'file' : 'files' + const filesDescription = `${files.length} changed ${filesPlural}` + + const selectedChangeCount = files.filter( + file => file.selection.getSelectionType() !== DiffSelectionType.None + ).length + const totalFilesPlural = files.length === 1 ? 'file' : 'files' + const selectedChangesDescription = `${selectedChangeCount}/${files.length} changed ${totalFilesPlural} included` + + const includeAllValue = getIncludeAllValue( + workingDirectory, + rebaseConflictState + ) + + const disableAllCheckbox = + files.length === 0 || isCommitting || rebaseConflictState !== null + + return ( + <> +
+
+ + + +
+ {selectedChangesDescription} +
+
+ +
+ {this.renderStashedChanges()} + {this.renderCommitMessageForm()} + + ) + } + + private onRowFocus = (row: number) => { + this.setState({ focusedRow: row }) + } + + private onRowBlur = (row: number) => { + if (this.state.focusedRow === row) { + this.setState({ focusedRow: null }) + } + } +} diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx index 50d1f86440f..f665a7ad8fe 100644 --- a/app/src/ui/changes/sidebar.tsx +++ b/app/src/ui/changes/sidebar.tsx @@ -31,6 +31,8 @@ import { isConflictedFile, hasUnresolvedConflicts } from '../../lib/status' import { getAccountForRepository } from '../../lib/get-account-for-repository' import { IAheadBehind } from '../../models/branch' import { Emoji } from '../../lib/emoji' +import { enableFilteredChangesList } from '../../lib/feature-flag' +import { FilterChangesList } from './filter-changes-list' /** * The timeout for the animation of the enter/leave animation for Undo. @@ -395,9 +397,13 @@ export class ChangesSidebar extends React.Component { this.props.repository ) + const ChangesListComponent = enableFilteredChangesList() + ? FilterChangesList + : ChangesList + return (
- + + /** A unique identifier for the item. */ + readonly id: string +} + +/** A group of items in the list. */ +export interface IFilterListGroup { + /** The identifier for this group. */ + readonly identifier: string + + /** The items in the group. */ + readonly items: ReadonlyArray +} + +interface IFlattenedGroup { + readonly kind: 'group' + readonly identifier: string +} + +interface IFlattenedItem { + readonly kind: 'item' + readonly item: T + /** Array of indexes in `item.text` that should be highlighted */ + readonly matches: IMatches +} + +/** + * A row in the list. This is used internally after the user-provided groups are + * flattened. + */ +type IFilterListRow = + | IFlattenedGroup + | IFlattenedItem + +interface IAugmentedFilterListProps { + /** A class name for the wrapping element. */ + readonly className?: string + + /** The height of the rows. */ + readonly rowHeight: number + + /** The ordered groups to display in the list. */ + // eslint-disable-next-line react/no-unused-prop-types + readonly groups: ReadonlyArray> + + /** The selected item. */ + readonly selectedItem: T | null + + /** Called to render each visible item. */ + readonly renderItem: (item: T, matches: IMatches) => JSX.Element | null + + /** Called to render header for the group with the given identifier. */ + readonly renderGroupHeader?: (identifier: string) => JSX.Element | null + + /** Called to render content before/above the filter and list. */ + readonly renderPreList?: () => JSX.Element | null + + /** + * This function will be called when a pointer device is pressed and then + * released on a selectable row. Note that this follows the conventions + * of button elements such that pressing Enter or Space on a keyboard + * while focused on a particular row will also trigger this event. Consumers + * can differentiate between the two using the source parameter. + * + * Note that this event handler will not be called for keyboard events + * if `event.preventDefault()` was called in the onRowKeyDown event handler. + * + * Consumers of this event do _not_ have to call event.preventDefault, + * when this event is subscribed to the list will automatically call it. + */ + readonly onItemClick?: (item: T, source: ClickSource) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * + * @param selectedItem - The item that was just selected + * @param source - The kind of user action that provoked the change, + * either a pointer device press, or a keyboard event + * (arrow up/down) + */ + readonly onSelectionChanged?: ( + selectedItem: T | null, + source: SelectionSource + ) => void + + /** + * Called when a key down happens in the filter text input. Users have a + * chance to respond or cancel the default behavior by calling + * `preventDefault()`. + */ + readonly onFilterKeyDown?: ( + event: React.KeyboardEvent + ) => void + + /** Called when the Enter key is pressed in field of type search */ + readonly onEnterPressedWithoutFilteredItems?: (text: string) => void + + /** The current filter text to use in the form */ + readonly filterText?: string + + /** Called when the filter text is changed by the user */ + readonly onFilterTextChanged?: (text: string) => void + + /** + * Whether or not the filter list should allow selection + * and filtering. Defaults to false. + */ + readonly disabled?: boolean + + /** Any props which should cause a re-render if they change. */ + readonly invalidationProps: any + + /** Called to render content after the filter. */ + readonly renderPostFilter?: () => JSX.Element | null + + /** Called when there are no items to render. */ + readonly renderNoItems?: () => JSX.Element | null + + /** + * A reference to a TextBox that will be used to control this component. + * + * See https://github.com/desktop/desktop/issues/4317 for refactoring work to + * make this more composable which should make this unnecessary. + */ + readonly filterTextBox?: TextBox + + /** + * Callback to fire when the items in the filter list are updated + */ + readonly onFilterListResultsChanged?: (resultCount: number) => void + + /** Placeholder text for text box. Default is "Filter". */ + readonly placeholderText?: string + + /** If true, we do not render the filter. */ + readonly hideFilterRow?: boolean + + /** + * A handler called whenever a context menu event is received on the + * row container element. + * + * The context menu is invoked when a user right clicks the row or + * uses keyboard shortcut.s + */ + readonly onItemContextMenu?: ( + item: T, + event: React.MouseEvent + ) => void +} + +interface IAugmentedFilterListState { + readonly rows: ReadonlyArray> + readonly selectedRow: number + readonly filterValue: string + readonly filterValueChanged: boolean +} + +/** + * Interface describing a user initiated selection change event + * originating from changing the filter text. + */ +export interface IFilterSelectionSource { + kind: 'filter' + + /** The filter text at the time the selection event was raised. */ + filterText: string +} + +export type SelectionSource = ListSelectionSource | IFilterSelectionSource + +/** A List which includes the ability to filter based on its contents. */ +export class AugmentedFilterList< + T extends IFilterListItem +> extends React.Component< + IAugmentedFilterListProps, + IAugmentedFilterListState +> { + private list: List | null = null + private filterTextBox: TextBox | null = null + + public constructor(props: IAugmentedFilterListProps) { + super(props) + + if (props.filterTextBox !== undefined) { + this.filterTextBox = props.filterTextBox + } + + this.state = createStateUpdate(props, null) + } + + public componentWillReceiveProps(nextProps: IAugmentedFilterListProps) { + this.setState(createStateUpdate(nextProps, this.state)) + } + + public componentDidUpdate( + prevProps: IAugmentedFilterListProps, + prevState: IAugmentedFilterListState + ) { + if (this.props.onSelectionChanged) { + const oldSelectedItemId = getItemIdFromRowIndex( + prevState.rows, + prevState.selectedRow + ) + const newSelectedItemId = getItemIdFromRowIndex( + this.state.rows, + this.state.selectedRow + ) + + if (oldSelectedItemId !== newSelectedItemId) { + const propSelectionId = this.props.selectedItem + ? this.props.selectedItem.id + : null + + if (propSelectionId !== newSelectedItemId) { + const newSelectedItem = getItemFromRowIndex( + this.state.rows, + this.state.selectedRow + ) + this.props.onSelectionChanged(newSelectedItem, { + kind: 'filter', + filterText: this.props.filterText || '', + }) + } + } + } + + if (this.props.onFilterListResultsChanged !== undefined) { + const itemCount = this.state.rows.filter( + row => row.kind === 'item' + ).length + + this.props.onFilterListResultsChanged(itemCount) + } + } + + public componentDidMount() { + if (this.filterTextBox !== null) { + this.filterTextBox.selectAll() + } + } + + public renderTextBox() { + return ( + + ) + } + + public renderLiveContainer() { + if (!this.state.filterValueChanged) { + return null + } + + const itemRows = this.state.rows.filter(row => row.kind === 'item') + const resultsPluralized = itemRows.length === 1 ? 'result' : 'results' + const screenReaderMessage = `${itemRows.length} ${resultsPluralized}` + + return ( + + ) + } + + public renderFilterRow() { + if (this.props.hideFilterRow === true) { + return null + } + + return ( + + {this.props.filterTextBox === undefined ? this.renderTextBox() : null} + {this.props.renderPostFilter ? this.props.renderPostFilter() : null} + + ) + } + + public render() { + return ( +
+ {this.renderLiveContainer()} + + {this.props.renderPreList ? this.props.renderPreList() : null} + + {this.renderFilterRow()} + +
{this.renderContent()}
+
+ ) + } + + public selectNextItem( + focus: boolean = false, + inDirection: SelectionDirection = 'down' + ) { + if (this.list === null) { + return + } + let next: number | null = null + + if ( + this.state.selectedRow === -1 || + this.state.selectedRow === this.state.rows.length + ) { + next = findNextSelectableRow( + this.state.rows.length, + { + direction: inDirection, + row: -1, + }, + this.canSelectRow + ) + } else { + next = findNextSelectableRow( + this.state.rows.length, + { + direction: inDirection, + row: this.state.selectedRow, + }, + this.canSelectRow + ) + } + + if (next !== null) { + this.setState({ selectedRow: next }, () => { + if (focus && this.list !== null) { + this.list.focus() + } + }) + } + } + + private renderContent() { + if (this.state.rows.length === 0 && this.props.renderNoItems) { + return this.props.renderNoItems() + } else { + return ( + + ) + } + } + + private renderRow = (index: number) => { + const row = this.state.rows[index] + if (row.kind === 'item') { + return this.props.renderItem(row.item, row.matches) + } else if (this.props.renderGroupHeader) { + return this.props.renderGroupHeader(row.identifier) + } else { + return null + } + } + + private onTextBoxRef = (component: TextBox | null) => { + this.filterTextBox = component + } + + private onListRef = (instance: List | null) => { + this.list = instance + } + + private onFilterValueChanged = (text: string) => { + if (this.props.onFilterTextChanged) { + this.props.onFilterTextChanged(text) + } + } + + private onEnterPressed = (text: string) => { + const rows = this.state.rows.length + if ( + rows === 0 && + text.trim().length > 0 && + this.props.onEnterPressedWithoutFilteredItems !== undefined + ) { + this.props.onEnterPressedWithoutFilteredItems(text) + } + } + + private onSelectedRowChanged = (index: number, source: SelectionSource) => { + this.setState({ selectedRow: index }) + + if (this.props.onSelectionChanged) { + const row = this.state.rows[index] + if (row.kind === 'item') { + this.props.onSelectionChanged(row.item, source) + } + } + } + + private canSelectRow = (index: number) => { + if (this.props.disabled) { + return false + } + + const row = this.state.rows[index] + return row.kind === 'item' + } + + private onRowClick = (index: number, source: ClickSource) => { + if (this.props.onItemClick) { + const row = this.state.rows[index] + + if (row.kind === 'item') { + this.props.onItemClick(row.item, source) + } + } + } + + private onRowContextMenu = ( + index: number, + source: React.MouseEvent + ) => { + if (!this.props.onItemContextMenu) { + return + } + + const row = this.state.rows[index] + + if (row.kind !== 'item') { + return + } + + this.props.onItemContextMenu(row.item, source) + } + + private onRowKeyDown = (row: number, event: React.KeyboardEvent) => { + const list = this.list + if (!list) { + return + } + + const rowCount = this.state.rows.length + + const firstSelectableRow = findNextSelectableRow( + rowCount, + { direction: 'down', row: -1 }, + this.canSelectRow + ) + const lastSelectableRow = findNextSelectableRow( + rowCount, + { direction: 'up', row: 0 }, + this.canSelectRow + ) + + let shouldFocus = false + + if (event.key === 'ArrowUp' && row === firstSelectableRow) { + shouldFocus = true + } else if (event.key === 'ArrowDown' && row === lastSelectableRow) { + shouldFocus = true + } + + if (shouldFocus) { + const textBox = this.filterTextBox + + if (textBox) { + event.preventDefault() + textBox.focus() + } + } + } + + private onKeyDown = (event: React.KeyboardEvent) => { + const list = this.list + const key = event.key + + if (!list) { + return + } + + if (this.props.onFilterKeyDown) { + this.props.onFilterKeyDown(event) + } + + if (event.defaultPrevented) { + return + } + + const rowCount = this.state.rows.length + + if (key === 'ArrowDown') { + if (rowCount > 0) { + const selectedRow = findNextSelectableRow( + rowCount, + { direction: 'down', row: -1 }, + this.canSelectRow + ) + if (selectedRow != null) { + this.setState({ selectedRow }, () => { + list.focus() + }) + } + } + + event.preventDefault() + } else if (key === 'ArrowUp') { + if (rowCount > 0) { + const selectedRow = findNextSelectableRow( + rowCount, + { direction: 'up', row: 0 }, + this.canSelectRow + ) + if (selectedRow != null) { + this.setState({ selectedRow }, () => { + list.focus() + }) + } + } + + event.preventDefault() + } else if (key === 'Enter') { + // no repositories currently displayed, bail out + if (rowCount === 0) { + return event.preventDefault() + } + + const filterText = this.props.filterText + + if (filterText !== undefined && !/\S/.test(filterText)) { + return event.preventDefault() + } + + const row = findNextSelectableRow( + rowCount, + { direction: 'down', row: -1 }, + this.canSelectRow + ) + + if (row != null) { + this.onRowClick(row, { kind: 'keyboard', event }) + } + } + } +} + +export function getText( + item: T +): ReadonlyArray { + return item['text'] +} + +function createStateUpdate( + props: IAugmentedFilterListProps, + state: IAugmentedFilterListState | null +) { + const flattenedRows = new Array>() + const filter = (props.filterText || '').toLowerCase() + + for (const group of props.groups) { + const items: ReadonlyArray> = filter + ? match(filter, group.items, getText) + : group.items.map(item => ({ + score: 1, + matches: { title: [], subtitle: [] }, + item, + })) + + if (!items.length) { + continue + } + + if (props.renderGroupHeader) { + flattenedRows.push({ kind: 'group', identifier: group.identifier }) + } + + for (const { item, matches } of items) { + flattenedRows.push({ kind: 'item', item, matches }) + } + } + + let selectedRow = -1 + const selectedItem = props.selectedItem + if (selectedItem) { + selectedRow = flattenedRows.findIndex( + i => i.kind === 'item' && i.item.id === selectedItem.id + ) + } + + if (selectedRow < 0 && filter.length) { + // If the selected item isn't in the list (e.g., filtered out), then + // select the first visible item. + selectedRow = flattenedRows.findIndex(i => i.kind === 'item') + } + + // Stay true if already set, otherwise become true if the filter has content + const filterValueChanged = state?.filterValueChanged + ? true + : filter.length > 0 + + return { + rows: flattenedRows, + selectedRow, + filterValue: filter, + filterValueChanged, + } +} + +function getItemFromRowIndex( + items: ReadonlyArray>, + index: number +): T | null { + if (index >= 0 && index < items.length) { + const row = items[index] + + if (row.kind === 'item') { + return row.item + } + } + + return null +} + +function getItemIdFromRowIndex( + items: ReadonlyArray>, + index: number +): string | null { + const item = getItemFromRowIndex(items, index) + return item ? item.id : null +}