From 2091f480dbf74fdf7699dde987d739a70de6f01e Mon Sep 17 00:00:00 2001 From: Bosco Chan <45115214+BoscoCHW@users.noreply.github.com> Date: Thu, 28 Jul 2022 06:59:56 -0700 Subject: [PATCH] Support multi-selection in file lists (#1136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enable multiple selection with control-click without functionality * Implemented multiple file selection with control-click * Remove handling this.state.selectedFiles is null in FileList selectFile method * Enable toggle select/deselect files with control-click * Implement Shift-Click file selection Shift-click behaviour is not ideal. Failed to simulate shift-click behaviour on Google Drive. Issues: 1. Can't deselect when shift click direction changes Other issues: 1. IStatusFile object equality checking leads to a lot of duplicate codes 2. File status changes is not immediately reflected in FileList state, leading to invalid commands in context menu * extract file comparison logic and remove duplicate selected files due to shift click * limit multiple file selection by control-click to within the same category (git status) * rename replaceSelectedFile to selectOnlyOneFile for better comprehension * shift-click on a file with a different category (git status) becomes a simple click, handle partially staged files for shift clicks * Make FileItem action commands (open, diff and stage file) apply to selected files * Make FileItem action commands (open, diff and unstage file) apply to selected files * Make FileItem action commands (discarding changes and staging file) apply to selected files * fix bug - open correct files on clicking action button * fix bug - diff correct files on clicking action button * fix bug - unstage correct files on clicking action button * fix bug - stage correct files on clicking action button * fix bug - discard changes on correct files on clicking action button * fix FileItem test * Rename function filesAreEqual to areFilesEqual Use _isSelectedFile where appropriate * Remove selectedFileStatus from state * change _openDiffViews function to async and await the execute command * Remove unnecessary code in openContextMenu * Ensure selectFiles arrives at correct state * implement single selection callback 'setSelection' * rework _selectUntilFile to index in sub array * fix FileItem test * implement advanced shift-click behaviours * Added label to wrap around div object to allow click on whole div element to control checkbox * Fixed label placement issue * Styled filename div * Fixed styling issue by importing and applying fileLabelStyle to the appropriate label container * tried implementing shiftclick but unsuccessful: * Fixed issue with shift click when clicking in reverse order * Implement markedFile getter in gitModel * implement shift click problem: the clicked file is not selected * Fix bug for shift-click for simple staging * Clean up * remove console logs * insert in FileItemStyle.ts * added simple ui tests for ctlr and shift click on file selection for normal staging * Add skeleton code for implementing de/select all button for simple staging * Fix original ui tests * Changed file name in ui-tests README and have the select all in simple staging selecting all files * Changed name of variables and function from markAllFile to toggleAllFiles for clarity * Added the ability to deselect items using select all * Simplified code in toggleAllFiles to use state and removed redundant code. * Improve mark-all button behaviour - sync checked state of the box to reflect if all files are marked * Fix ui tests * Add ui tests for simple staging * Clear all signal connections when unmounting FileList Co-authored-by: Frédéric Collonval * Delete commented code in GitPanel Co-authored-by: Frédéric Collonval * Delete unused prop from interface (GitStage) Co-authored-by: Frédéric Collonval * Improve consistency and fix bug * fix ui test readme * Change control-click ui test to expect exactly 2 selected items * Stop propagation when users click on action buttons on FileItem * Add doc strings for functions * Document helper functions Co-authored-by: iflinda Co-authored-by: Frédéric Collonval --- src/components/FileItem.tsx | 114 ++-- src/components/FileList.tsx | 538 +++++++++++++++--- src/components/GitPanel.tsx | 1 - src/components/GitStage.tsx | 5 + src/components/SelectAllButton.tsx | 41 ++ src/model.ts | 28 + src/style/FileItemStyle.ts | 15 + tests/test-components/FileItem.spec.tsx | 72 +-- .../tests/data/test-repository-dirty.tar.gz | Bin 0 -> 2166993 bytes ui-tests/tests/file-selection.spec.ts | 100 ++++ 10 files changed, 746 insertions(+), 168 deletions(-) create mode 100644 src/components/SelectAllButton.tsx create mode 100644 ui-tests/tests/data/test-repository-dirty.tar.gz create mode 100644 ui-tests/tests/file-selection.spec.ts diff --git a/src/components/FileItem.tsx b/src/components/FileItem.tsx index 3f49ec7d3..b7beb82f8 100644 --- a/src/components/FileItem.tsx +++ b/src/components/FileItem.tsx @@ -3,6 +3,9 @@ import * as React from 'react'; import { classes } from 'typestyle'; import { GitExtension } from '../model'; import { + checkboxLabelContainerStyle, + checkboxLabelLastContainerStyle, + checkboxLabelStyle, fileChangedLabelBrandStyle, fileChangedLabelInfoStyle, fileChangedLabelStyle, @@ -12,6 +15,7 @@ import { selectedFileChangedLabelStyle, selectedFileStyle } from '../style/FileItemStyle'; +import { fileLabelStyle } from '../style/FilePathStyle'; import { Git } from '../tokens'; import { FilePath } from './FilePath'; @@ -44,20 +48,16 @@ interface IGitMarkBoxProps { * File status */ stage: Git.Status; + /** + * Whether the checkbox is checked + */ + checked: boolean; } /** * Render the selection box in simple mode */ class GitMarkBox extends React.PureComponent { - protected _onClick = (): void => { - // toggle will emit a markChanged signal - this.props.model.toggleMark(this.props.fname); - - // needed if markChanged doesn't force an update of a parent - this.forceUpdate(); - }; - protected _onDoubleClick = ( event: React.MouseEvent ): void => { @@ -76,8 +76,7 @@ class GitMarkBox extends React.PureComponent { name="gitMark" className={gitMarkBoxStyle} type="checkbox" - checked={this.props.model.getMark(this.props.fname)} - onChange={this._onClick} + checked={this.props.checked} onDoubleClick={this._onDoubleClick} /> ); @@ -117,9 +116,12 @@ export interface IFileItemProps { */ selected?: boolean; /** - * Callback to select the file + * Callback to select file(s) */ - selectFile?: (file: Git.IStatusFile | null) => void; + setSelection?: ( + file: Git.IStatusFile, + options?: { singleton?: boolean; group?: boolean } + ) => void; /** * Optional style class */ @@ -132,12 +134,40 @@ export interface IFileItemProps { * The application language translator. */ trans: TranslationBundle; + /** + * Callback to implement shift-click for simple staging + */ + markUntilFile?: (file: Git.IStatusFile) => void; + /** + * whether the GitMarkBox is checked + */ + checked?: boolean; } export class FileItem extends React.PureComponent { constructor(props: IFileItemProps) { super(props); } + + protected _onClick = (event: React.MouseEvent): void => { + if (this.props.markBox) { + if (event.shiftKey) { + this.props.markUntilFile(this.props.file); + } else { + this.props.model.toggleMark(this.props.file.to); + this.props.setSelection(this.props.file, { singleton: true }); + } + } else { + if (event.ctrlKey || event.metaKey) { + this.props.setSelection(this.props.file); + } else if (event.shiftKey) { + this.props.setSelection(this.props.file, { group: true }); + } else { + this.props.setSelection(this.props.file, { singleton: true }); + } + } + }; + protected _getFileChangedLabel(change: keyof typeof STATUS_CODES): string { return STATUS_CODES[change] || 'Unmodified'; } @@ -190,11 +220,10 @@ export class FileItem extends React.PureComponent { return (
this.props.selectFile(this.props.file)) - } + onClick={this._onClick} onContextMenu={ this.props.contextMenu && (event => { @@ -205,29 +234,36 @@ export class FileItem extends React.PureComponent { style={this.props.style} title={this.props.trans.__(`%1 • ${status}`, this.props.file.to)} > - {this.props.markBox && ( - - )} - - {this.props.actions} - - {this.props.file.status === 'unmerged' - ? '!' - : this.props.file.y === '?' - ? 'U' - : status_code} - +
+
+ {this.props.markBox && ( + + )} + +
+
+ {this.props.actions} + + {this.props.file.status === 'unmerged' + ? '!' + : this.props.file.y === '?' + ? 'U' + : status_code} + +
+
); } diff --git a/src/components/FileList.tsx b/src/components/FileList.tsx index 2888d7c9a..9de5f0708 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -2,6 +2,7 @@ import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { CommandRegistry } from '@lumino/commands'; import { Menu } from '@lumino/widgets'; +import { Signal } from '@lumino/signaling'; import { TranslationBundle } from '@jupyterlab/translation'; import * as React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -22,9 +23,12 @@ import { ActionButton } from './ActionButton'; import { FileItem } from './FileItem'; import { GitStage } from './GitStage'; import { discardAllChanges } from '../widgets/discardAllChanges'; +import { SelectAllButton } from './SelectAllButton'; export interface IFileListState { - selectedFile: Git.IStatusFile | null; + selectedFiles: Git.IStatusFile[]; + lastClickedFile: Git.IStatusFile | null; + markedFiles: Git.IStatusFile[]; } export interface IFileListProps { @@ -115,15 +119,60 @@ const SIMPLE_CONTEXT_COMMANDS: ContextCommands = { unmerged: [ContextCommandIDs.gitFileDiff] }; +/** + * Compare fileA and fileB. + * @param fileA + * @param fileB + * @returns true if fileA and fileB are equal, otherwise, false. + */ +const areFilesEqual = (fileA: Git.IStatusFile, fileB: Git.IStatusFile) => { + return ( + fileA.x === fileB.x && + fileA.y === fileB.y && + fileA.from === fileB.from && + fileA.to === fileB.to && + fileA.status === fileB.status + ); +}; + +/** + * Wrap mouse event handler to stop event propagation + * @param fn Mouse event handler + * @returns Mouse event handler that stops event from propagating + */ +const stopPropagationWrapper = + ( + fn: React.EventHandler + ): React.EventHandler => + (event: React.MouseEvent) => { + event.stopPropagation(); + fn(event); + }; + export class FileList extends React.Component { constructor(props: IFileListProps) { super(props); this.state = { - selectedFile: null + selectedFiles: [], + lastClickedFile: null, + markedFiles: props.model.markedFiles }; } + componentDidMount(): void { + const { model } = this.props; + model.markChanged.connect(() => { + this.setState({ markedFiles: model.markedFiles }); + }, this); + model.repositoryChanged.connect(() => { + this.setState({ markedFiles: model.markedFiles }); + }, this); + } + + componentWillUnmount(): void { + Signal.clearData(this); + } /** * Open the context menu on the advanced view * @@ -135,14 +184,17 @@ export class FileList extends React.Component { event: React.MouseEvent ): void => { event.preventDefault(); - - this.setState({ - selectedFile - }); + let selectedFiles: Git.IStatusFile[]; + if (!this._isSelectedFile(selectedFile)) { + this._selectOnlyOneFile(selectedFile); + selectedFiles = [selectedFile]; + } else { + selectedFiles = this.state.selectedFiles; + } const contextMenu = new Menu({ commands: this.props.commands }); - const commands = CONTEXT_COMMANDS[selectedFile.status]; - addMenuItems(commands, contextMenu, [selectedFile]); + const commands = CONTEXT_COMMANDS[selectedFiles[0].status]; + addMenuItems(commands, contextMenu, selectedFiles); contextMenu.open(event.clientX, event.clientY); }; @@ -171,9 +223,28 @@ export class FileList extends React.Component { await this.props.model.reset(); }; - /** Reset a specific staged file */ - resetStagedFile = async (file: string): Promise => { - await this.props.model.reset(file); + /** Reset staged selected files */ + resetSelectedFiles = (file: Git.IStatusFile): void => { + if (this._isSelectedFile(file)) { + this.state.selectedFiles.forEach(file => this.props.model.reset(file.to)); + } else { + this.props.model.reset(file.to); + } + }; + + /** If the clicked file is selected, open all selected files. + * If the clicked file is not selected, open the clicked file only. + */ + openSelectedFiles = (clickedFile: Git.IStatusFile): void => { + if (this._isSelectedFile(clickedFile)) { + this.props.commands.execute(ContextCommandIDs.gitFileOpen, { + files: this.state.selectedFiles + } as CommandArguments.IGitContextAction as any); + } else { + this.props.commands.execute(ContextCommandIDs.gitFileOpen, { + files: [clickedFile] + } as CommandArguments.IGitContextAction as any); + } }; /** Add all unstaged files */ @@ -221,10 +292,16 @@ export class FileList extends React.Component { }; /** Discard changes in a specific unstaged or staged file */ - discardChanges = async (file: Git.IStatusFile): Promise => { - await this.props.commands.execute(ContextCommandIDs.gitFileDiscard, { - files: [file] - } as CommandArguments.IGitContextAction as any); + discardChanges = (file: Git.IStatusFile): void => { + if (this._isSelectedFile(file)) { + this.props.commands.execute(ContextCommandIDs.gitFileDiscard, { + files: this.state.selectedFiles + } as CommandArguments.IGitContextAction as any); + } else { + this.props.commands.execute(ContextCommandIDs.gitFileDiscard, { + files: [file] + } as CommandArguments.IGitContextAction as any); + } }; /** Add all untracked files */ @@ -237,8 +314,248 @@ export class FileList extends React.Component { await this.addFile(...this.markedFiles.map(file => file.to)); }; - updateSelectedFile = (file: Git.IStatusFile | null): void => { - this.setState({ selectedFile: file }); + /** + * Select files into state.selectedFiles + * @param file The current cliced-on file + * @param options Selection options + */ + setSelection = ( + file: Git.IStatusFile, + options?: { singleton?: boolean; group?: boolean } + ): void => { + if (options && options.singleton) { + this._selectOnlyOneFile(file); + } + if (options && options.group) { + this._selectUntilFile(file); + } + if (!options) { + this._toggleFile(file); + } + }; + + /** + * Mark files from the latest selected to this one + * + * @param file The current clicked-on file + */ + markUntilFile = (file: Git.IStatusFile): void => { + if (!this.state.lastClickedFile) { + this.props.model.setMark(file.to, true); + return; + } + const filesWithMarkBox = this.props.files.filter( + fileStatus => !['unmerged', 'remote-changed'].includes(fileStatus.status) + ); + + const lastClickedFileIndex = filesWithMarkBox.findIndex(fileStatus => + areFilesEqual(fileStatus, this.state.lastClickedFile) + ); + const currentFileIndex = filesWithMarkBox.findIndex(fileStatus => + areFilesEqual(fileStatus, file) + ); + + if (currentFileIndex > lastClickedFileIndex) { + const filesToAdd = filesWithMarkBox.slice( + lastClickedFileIndex, + currentFileIndex + 1 + ); + filesToAdd.forEach(f => this.props.model.setMark(f.to, true)); + } else { + const filesToAdd = filesWithMarkBox.slice( + currentFileIndex, + lastClickedFileIndex + 1 + ); + filesToAdd.forEach(f => this.props.model.setMark(f.to, true)); + } + }; + + /** + * Set mark status from select-all button + * + * @param files Files to toggle + */ + toggleAllFiles = (files: Git.IStatusFile[]): void => { + const areFilesAllMarked = this._areFilesAllMarked(); + files.forEach(f => this.props.model.setMark(f.to, !areFilesAllMarked)); + }; + + private _selectOnlyOneFile = (file: Git.IStatusFile): void => { + this.setState({ + selectedFiles: [file], + lastClickedFile: file + }); + }; + + /** + * Toggle selection status of a file + * @param file The clicked file + */ + private _toggleFile = (file: Git.IStatusFile): void => { + if (file.status !== this.state.lastClickedFile.status) { + this._selectOnlyOneFile(file); + return; + } + + const fileStatus = this.state.selectedFiles.find(fileStatus => + areFilesEqual(fileStatus, file) + ); + if (!fileStatus) { + this.setState({ + selectedFiles: [...this.state.selectedFiles, file], + lastClickedFile: file + }); + } else { + this.setState({ + selectedFiles: this.state.selectedFiles.filter( + fileStatus => !areFilesEqual(fileStatus, file) + ), + lastClickedFile: file + }); + } + }; + + /** + * Select a list of files + * @param files List of files to select + */ + private _selectFiles = (files: Git.IStatusFile[]): void => { + this.setState(prevState => { + return { + selectedFiles: [ + ...prevState.selectedFiles, + ...files.filter( + file => !prevState.selectedFiles.some(f => areFilesEqual(f, file)) + ) + ] + }; + }); + }; + + /** + * Deselect a list of file + * @param files List of file to deselect + */ + private _deselectFiles = (files: Git.IStatusFile[]): void => { + this.setState(prevState => { + return { + selectedFiles: prevState.selectedFiles.filter( + selectedFile => !files.some(file => areFilesEqual(selectedFile, file)) + ) + }; + }); + }; + + /** + * Handle shift-click behaviour for file selection + * @param file The shift-clicked file + */ + private _selectUntilFile = (file: Git.IStatusFile): void => { + if ( + !this.state.lastClickedFile || + file.status !== this.state.lastClickedFile.status + ) { + this._selectOnlyOneFile(file); + return; + } + + const selectedFileStatus = this.state.lastClickedFile.status; + const allFilesWithSelectedStatus = this.props.files.filter( + fileStatus => fileStatus.status === selectedFileStatus + ); + + const partiallyStagedFiles = this.props.files.filter( + fileStatus => fileStatus.status === 'partially-staged' + ); + + switch (selectedFileStatus) { + case 'staged': + allFilesWithSelectedStatus.push( + ...partiallyStagedFiles.map( + fileStatus => + ({ + ...fileStatus, + status: 'staged' + } as Git.IStatusFile) + ) + ); + break; + case 'unstaged': + allFilesWithSelectedStatus.push( + ...partiallyStagedFiles.map( + fileStatus => + ({ + ...fileStatus, + status: 'unstaged' + } as Git.IStatusFile) + ) + ); + break; + } + + allFilesWithSelectedStatus.sort((a, b) => a.to.localeCompare(b.to)); + + const lastClickedFileIndex = allFilesWithSelectedStatus.findIndex( + fileStatus => areFilesEqual(fileStatus, this.state.lastClickedFile) + ); + const currentFileIndex = allFilesWithSelectedStatus.findIndex(fileStatus => + areFilesEqual(fileStatus, file) + ); + + if (currentFileIndex > lastClickedFileIndex) { + const highestSelectedIndex = allFilesWithSelectedStatus.findIndex( + (file, index) => + index > lastClickedFileIndex && !this._isSelectedFile(file) + ); + if (highestSelectedIndex === -1) { + this._deselectFiles( + allFilesWithSelectedStatus.slice(currentFileIndex + 1) + ); + } else if (currentFileIndex < highestSelectedIndex) { + this._deselectFiles( + allFilesWithSelectedStatus.slice( + currentFileIndex + 1, + highestSelectedIndex + ) + ); + } else { + this._selectFiles( + allFilesWithSelectedStatus.slice( + highestSelectedIndex, + currentFileIndex + 1 + ) + ); + } + } else if (currentFileIndex < lastClickedFileIndex) { + const lowestSelectedIndex = allFilesWithSelectedStatus.findIndex( + (file, index) => + index < lastClickedFileIndex && this._isSelectedFile(file) + ); + if (lowestSelectedIndex === -1) { + this._selectFiles( + allFilesWithSelectedStatus.slice( + currentFileIndex, + lastClickedFileIndex + ) + ); + } else if (currentFileIndex < lowestSelectedIndex) { + this._selectFiles( + allFilesWithSelectedStatus.slice( + currentFileIndex, + lowestSelectedIndex + ) + ); + } else { + this._deselectFiles( + allFilesWithSelectedStatus.slice( + lowestSelectedIndex, + currentFileIndex + ) + ); + } + } else { + this._selectOnlyOneFile(file); + } }; pullFromRemote = async (event: React.MouseEvent): Promise => { @@ -246,7 +563,7 @@ export class FileList extends React.Component { }; get markedFiles(): Git.IStatusFile[] { - return this.props.files.filter(file => this.props.model.getMark(file.to)); + return this.props.model.markedFiles; } /** @@ -349,16 +666,8 @@ export class FileList extends React.Component { * @param candidate file to test */ private _isSelectedFile(candidate: Git.IStatusFile): boolean { - if (this.state.selectedFile === null) { - return false; - } - - return ( - this.state.selectedFile.x === candidate.x && - this.state.selectedFile.y === candidate.y && - this.state.selectedFile.from === candidate.from && - this.state.selectedFile.to === candidate.to && - this.state.selectedFile.status === candidate.status + return this.state.selectedFiles.some(file => + areFilesEqual(file, candidate) ); } @@ -384,8 +693,8 @@ export class FileList extends React.Component { file={file} model={this.props.model} selected={this._isSelectedFile(file)} - selectFile={this.updateSelectedFile} - onDoubleClick={() => this._openDiffView(file)} + setSelection={this.setSelection} + onDoubleClick={() => this._openDiffViews([file])} style={{ ...style }} /> ); @@ -423,11 +732,6 @@ export class FileList extends React.Component { .composite as boolean; const { data, index, style } = rowProps; const file = data[index] as Git.IStatusFile; - const openFile = () => { - this.props.commands.execute(ContextCommandIDs.gitFileOpen, { - files: [file] - } as CommandArguments.IGitContextAction as any); - }; const diffButton = this._createDiffButton(file); return ( { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={openFile} + onClick={stopPropagationWrapper(() => + this.openSelectedFiles(file) + )} /> {diffButton} { - this.resetStagedFile(file.to); - }} + onClick={stopPropagationWrapper(() => { + this.resetSelectedFiles(file); + })} /> } @@ -455,13 +761,13 @@ export class FileList extends React.Component { contextMenu={this.openContextMenu} model={this.props.model} selected={this._isSelectedFile(file)} - selectFile={this.updateSelectedFile} + setSelection={this.setSelection} onDoubleClick={ doubleClickDiff ? diffButton - ? () => this._openDiffView(file) + ? () => this._openDiffViews([file]) : () => undefined - : openFile + : () => this.openSelectedFiles(file) } style={style} /> @@ -510,11 +816,6 @@ export class FileList extends React.Component { .composite as boolean; const { data, index, style } = rowProps; const file = data[index] as Git.IStatusFile; - const openFile = () => { - this.props.commands.execute(ContextCommandIDs.gitFileOpen, { - files: [file] - } as CommandArguments.IGitContextAction as any); - }; const diffButton = this._createDiffButton(file); return ( { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={openFile} + onClick={stopPropagationWrapper(() => + this.openSelectedFiles(file) + )} /> {diffButton} { + onClick={stopPropagationWrapper(() => { this.discardChanges(file); - }} + })} /> { - this.addFile(file.to); - }} + onClick={stopPropagationWrapper(() => { + if (this._isSelectedFile(file)) { + this.addFile( + ...this.state.selectedFiles.map( + selectedFile => selectedFile.to + ) + ); + } else { + this.addFile(file.to); + } + })} /> } @@ -550,13 +861,13 @@ export class FileList extends React.Component { contextMenu={this.openContextMenu} model={this.props.model} selected={this._isSelectedFile(file)} - selectFile={this.updateSelectedFile} + setSelection={this.setSelection} onDoubleClick={ doubleClickDiff ? diffButton - ? () => this._openDiffView(file) + ? () => this._openDiffViews([file]) : () => undefined - : openFile + : () => this.openSelectedFiles(file) } style={style} /> @@ -624,19 +935,25 @@ export class FileList extends React.Component { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={() => { - this.props.commands.execute(ContextCommandIDs.gitFileOpen, { - files: [file] - } as CommandArguments.IGitContextAction as any); - }} + onClick={stopPropagationWrapper(() => + this.openSelectedFiles(file) + )} /> { - this.addFile(file.to); - }} + onClick={stopPropagationWrapper(() => { + if (this._isSelectedFile(file)) { + this.addFile( + ...this.state.selectedFiles.map( + selectedFile => selectedFile.to + ) + ); + } else { + this.addFile(file.to); + } + })} /> } @@ -651,7 +968,7 @@ export class FileList extends React.Component { } }} selected={this._isSelectedFile(file)} - selectFile={this.updateSelectedFile} + setSelection={this.setSelection} style={style} /> ); @@ -708,11 +1025,9 @@ export class FileList extends React.Component { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={() => { - this.props.commands.execute(ContextCommandIDs.gitFileOpen, { - files: [file] - } as CommandArguments.IGitContextAction as any); - }} + onClick={stopPropagationWrapper(() => + this.openSelectedFiles(file) + )} /> } @@ -727,7 +1042,7 @@ export class FileList extends React.Component { } }} selected={this._isSelectedFile(file)} - selectFile={this.updateSelectedFile} + setSelection={this.setSelection} style={style} /> ); @@ -776,7 +1091,7 @@ export class FileList extends React.Component { const doubleClickDiff = this.props.settings.get('doubleClickDiff') .composite as boolean; - const openFile = () => { + const openFile = (): void => { this.props.commands.execute(ContextCommandIDs.gitFileOpen, { files: [file] } as CommandArguments.IGitContextAction as any); @@ -788,7 +1103,7 @@ export class FileList extends React.Component { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={openFile} + onClick={stopPropagationWrapper(openFile)} /> ); let onDoubleClick = doubleClickDiff ? (): void => undefined : openFile; @@ -801,22 +1116,22 @@ export class FileList extends React.Component { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={openFile} + onClick={stopPropagationWrapper(openFile)} /> {diffButton} { + onClick={stopPropagationWrapper(() => { this.discardChanges(file); - }} + })} /> ); onDoubleClick = doubleClickDiff ? diffButton - ? () => this._openDiffView(file) + ? () => this._openDiffViews([file]) : () => undefined : openFile; } else if (file.status === 'staged') { @@ -827,26 +1142,29 @@ export class FileList extends React.Component { className={hiddenButtonStyle} icon={openIcon} title={this.props.trans.__('Open this file')} - onClick={openFile} + onClick={stopPropagationWrapper(openFile)} /> {diffButton} { + onClick={stopPropagationWrapper(() => { this.discardChanges(file); - }} + })} /> ); onDoubleClick = doubleClickDiff ? diffButton - ? () => this._openDiffView(file) + ? () => this._openDiffViews([file]) : () => undefined : openFile; } + const checked = this.markedFiles.some(fileStatus => + areFilesEqual(fileStatus, file) + ); return ( { model={this.props.model} onDoubleClick={onDoubleClick} contextMenu={this.openSimpleContextMenu} - selectFile={this.updateSelectedFile} + setSelection={this.setSelection} style={style} + markUntilFile={this.markUntilFile} + checked={checked} /> ); }; @@ -871,6 +1191,14 @@ export class FileList extends React.Component { private _renderSimpleStage(files: Git.IStatusFile[], height: number) { return ( { + this.toggleAllFiles(files); + }} + checked={this._areFilesAllMarked()} + /> + } actions={ { * @param currentRef the ref to diff against the git 'HEAD' ref */ private _createDiffButton(file: Git.IStatusFile): JSX.Element { + let handleClick: () => void; + if (this.props.settings.composite['simpleStaging']) { + handleClick = () => this._openDiffViews([file]); + } else { + handleClick = () => { + if (this._isSelectedFile(file)) { + this._openDiffViews(this.state.selectedFiles); + } else { + this._openDiffViews([file]); + } + }; + } return ( (getDiffProvider(file.to) || !file.is_binary) && ( this._openDiffView(file)} + onClick={stopPropagationWrapper(handleClick)} /> ) ); @@ -914,19 +1254,33 @@ export class FileList extends React.Component { * @param file File to open diff for * @param currentRef the ref to diff against the git 'HEAD' ref */ - private async _openDiffView(file: Git.IStatusFile): Promise { + private async _openDiffViews(files: Git.IStatusFile[]): Promise { try { await this.props.commands.execute(ContextCommandIDs.gitFileDiff, { - files: [ - { - filePath: file.to, - isText: !file.is_binary, - status: file.status - } - ] + files: files.map(file => ({ + filePath: file.to, + isText: !file.is_binary, + status: file.status + })) } as CommandArguments.IGitFileDiff as any); } catch (reason) { - console.error(`Failed to open diff view for ${file.to}.\n${reason}`); + console.error(`Failed to open diff views.\n${reason}`); } } + + /** + * Determine if files in simple staging are all marked + * @returns True if files are all marked + */ + private _areFilesAllMarked(): boolean { + const filesForSimpleStaging = this.props.files.filter( + file => !['unmerged', 'remote-changed'].includes(file.status) + ); + return ( + filesForSimpleStaging.length !== 0 && + filesForSimpleStaging.every(file => + this.state.markedFiles.some(mf => areFilesEqual(file, mf)) + ) + ); + } } diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 954add4cb..05372266f 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -221,7 +221,6 @@ export class GitPanel extends React.Component { this.setState({ tab: 1 }); this.refreshHistory(); }, this); - model.markChanged.connect(() => this.forceUpdate(), this); model.notifyRemoteChanges.connect((_, args) => { this.warningDialog(args); }, this); diff --git a/src/components/GitStage.tsx b/src/components/GitStage.tsx index b0f9924af..3f7fe3edf 100644 --- a/src/components/GitStage.tsx +++ b/src/components/GitStage.tsx @@ -41,6 +41,10 @@ export interface IGitStageProps { * Row renderer */ rowRenderer: (props: ListChildComponentProps) => JSX.Element; + /** + * Optional select all element + */ + selectAllButton?: React.ReactElement; } export const GitStage: React.FunctionComponent = ( @@ -59,6 +63,7 @@ export const GitStage: React.FunctionComponent = ( } }} > + {props.selectAllButton && props.selectAllButton} {props.collapsible && (