diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 9975a009e..9e27650a8 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -616,11 +616,17 @@ async def detailed_log(self, selected_hash, path): "modified_files": result, } - async def diff(self, path): + async def diff(self, path, previous=None, current=None): """ Execute git diff command & return the result. """ cmd = ["git", "diff", "--numstat", "-z"] + + if previous: + cmd.append(previous) + if current: + cmd.append(current) + code, my_output, my_error = await execute(cmd, cwd=path) if code != 0: diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 7b16ef009..769f93516 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -255,10 +255,19 @@ class GitDiffHandler(GitHandler): @tornado.web.authenticated async def post(self, path: str = ""): """ - POST request handler, fetches differences between commits & current working + POST request handler, fetches differences between two states tree. """ - my_output = await self.git.diff(self.url2localpath(path)) + data = self.get_json_body() + + if data: + my_output = await self.git.diff( + self.url2localpath(path), + data.get("previous"), + data.get("current"), + ) + else: + my_output = await self.git.diff(self.url2localpath(path)) if my_output["code"] != 0: self.set_status(500) diff --git a/src/components/CommitComparisonBox.tsx b/src/components/CommitComparisonBox.tsx new file mode 100644 index 000000000..2bbec46b2 --- /dev/null +++ b/src/components/CommitComparisonBox.tsx @@ -0,0 +1,308 @@ +import { TranslationBundle } from '@jupyterlab/translation'; +import { + caretDownIcon, + caretRightIcon, + fileIcon +} from '@jupyterlab/ui-components'; +import { CommandRegistry } from '@lumino/commands'; +import * as React from 'react'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; +import { classes } from 'typestyle'; +import { getDiffProvider } from '../model'; +import { + clickableSpanStyle, + commitComparisonBoxChangedFileListStyle, + commitComparisonBoxDetailStyle +} from '../style/CommitComparisonBox'; +import { + changeStageButtonStyle, + sectionAreaStyle, + sectionHeaderLabelStyle +} from '../style/GitStageStyle'; +import { + deletionsMadeIcon, + diffIcon, + insertionsMadeIcon +} from '../style/icons'; +import { + commitClass, + commitDetailFileClass, + commitDetailHeaderClass, + commitOverviewNumbersClass, + deletionsIconClass, + fileListClass, + iconClass, + insertionsIconClass +} from '../style/SinglePastCommitInfo'; +import { Git, IGitExtension } from '../tokens'; +import { ActionButton } from './ActionButton'; +import { FilePath } from './FilePath'; + +const ITEM_HEIGHT = 24; // File list item height + +interface ICommitComparisonBoxHeaderProps { + collapsible: boolean; + collapsed?: boolean; + label?: string; + trans: TranslationBundle; + onCollapseExpand?: (event: React.MouseEvent) => void; + onClickCancel?: (event: React.MouseEvent) => void; +} + +const CommitComparisonBoxHeader: React.VFC = ( + props: ICommitComparisonBoxHeaderProps +) => { + return ( +
+ {props.collapsible && ( + + )} + {props.label} + {props.onClickCancel && ( + + {props.trans.__('Cancel')} + + )} +
+ ); +}; + +interface ICommitComparisonBoxOverviewProps { + totalFiles: number; + totalInsertions: number; + totalDeletions: number; + trans: TranslationBundle; +} + +const CommitComparisonBoxOverview: React.VFC = + (props: ICommitComparisonBoxOverviewProps) => { + return ( +
+
+ + + {props.totalFiles} + + + + {props.totalInsertions} + + + + {props.totalDeletions} + +
+
+ ); + }; + +interface ICommitComparisonBoxChangedFileListProps { + files: Git.ICommitModifiedFile[]; + trans: TranslationBundle; + onOpenDiff?: ( + filePath: string, + isText: boolean, + previousFilePath?: string + ) => (event: React.MouseEvent) => void; +} + +class CommitComparisonBoxChangedFileList extends React.Component { + render() { + return ( +
+
+ {this.props.trans.__('Changed')} +
+ {this.props.files.length > 0 && ( + data[index].modified_file_path} + itemSize={ITEM_HEIGHT} + style={{ overflowX: 'hidden' }} + width={'auto'} + > + {this._renderFile} + + )} +
+ ); + } + /** + * Renders a modified file. + * + * @param props Row properties + * @returns React element + */ + private _renderFile = ( + props: ListChildComponentProps + ): JSX.Element => { + const { data, index, style } = props; + const file = data[index]; + const path = file.modified_file_path; + const previous = file.previous_file_path; + const flg = !!getDiffProvider(path) || !file.is_binary; + return ( +
  • + + {flg ? ( + + ) : null} +
  • + ); + }; +} + +interface ICommitComparisonBoxBodyProps { + files: Git.ICommitModifiedFile[]; + show: boolean; + trans: TranslationBundle; + onOpenDiff?: ( + filePath: string, + isText: boolean, + previousFilePath?: string + ) => (event: React.MouseEvent) => void; +} + +const CommitComparisonBoxBody: React.VFC = ( + props: ICommitComparisonBoxBodyProps +) => { + const totalInsertions = props.files.reduce((acc, file) => { + const insertions = Number.parseInt(file.insertion, 10); + return acc + (Number.isNaN(insertions) ? 0 : insertions); + }, 0); + const totalDeletions = props.files.reduce((acc, file) => { + const deletions = Number.parseInt(file.deletion, 10); + return acc + (Number.isNaN(deletions) ? 0 : deletions); + }, 0); + return ( + + + + + ); +}; + +/** + * Interface describing ComparisonBox component properties. + */ +export interface ICommitComparisonBoxProps { + /** + * Is this collapsible? + */ + collapsible: boolean; + + /** + * Jupyter App commands registry. + */ + commands: CommandRegistry; + + /** + * The commit to compare against. + */ + referenceCommit: Git.ISingleCommitInfo | null; + + /** + * The commit to compare. + */ + challengerCommit: Git.ISingleCommitInfo | null; + + /** + * The commit comparison result. + */ + changedFiles: Git.ICommitModifiedFile[] | null; + + /** + * Header text. + */ + header: string; + + /** + * Extension data model. + */ + model: IGitExtension; + + /** + * The application language translator. + */ + trans: TranslationBundle; + + /** + * Returns a callback to be invoked on clicking cancel. + */ + onCancel?: (event: React.MouseEvent) => void; + + /** + * Returns a callback to be invoked on click to display a file diff. + * + * @param filePath file path + * @param isText indicates whether the file supports displaying a diff + * @param previousFilePath when file has been relocated + * @returns callback + */ + onOpenDiff?: ( + filePath: string, + isText: boolean, + previousFilePath?: string + ) => (event: React.MouseEvent) => void; +} + +/** + * A component which displays a comparison between two commits. + */ +export const CommitComparisonBox: React.VFC = ( + props: ICommitComparisonBoxProps +) => { + const [collapsed, setCollapsed] = React.useState(false); + return ( + + setCollapsed(!collapsed)} + onClickCancel={props.onCancel} + trans={props.trans} + /> + {!collapsed && props.changedFiles && ( + + )} + + ); +}; diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 6b6818e4e..c79440b0e 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -21,8 +21,10 @@ import { warningTextClass } from '../style/GitPanel'; import { CommandIDs, Git, ILogMessage, Level } from '../tokens'; +import { openFileDiff } from '../utils'; import { GitAuthorForm } from '../widgets/AuthorBox'; import { CommitBox } from './CommitBox'; +import { CommitComparisonBox } from './CommitComparisonBox'; import { FileList } from './FileList'; import { HistorySideBar } from './HistorySideBar'; import { Toolbar } from './Toolbar'; @@ -130,6 +132,21 @@ export interface IGitPanelState { * Whether there are dirty (e.g., unsaved) staged files. */ hasDirtyStagedFiles: boolean; + + /** + * The commit to compare against + */ + referenceCommit: Git.ISingleCommitInfo | null; + + /** + * The commit to compare + */ + challengerCommit: Git.ISingleCommitInfo | null; + + /** + * The commit comparison result + */ + comparedFiles: Git.ICommitModifiedFile[] | null; } /** @@ -160,7 +177,10 @@ export class GitPanel extends React.Component { commitSummary: '', commitDescription: '', commitAmend: false, - hasDirtyStagedFiles: hasDirtyStagedFiles + hasDirtyStagedFiles: hasDirtyStagedFiles, + referenceCommit: null, + challengerCommit: null, + comparedFiles: null }; } @@ -213,6 +233,29 @@ export class GitPanel extends React.Component { }); } + async componentDidUpdate( + _: Readonly, + { + referenceCommit: prevReferenceCommit, + challengerCommit: prevChallengerCommit, + comparedFiles: prevComparedFiles + }: Readonly + ): Promise { + const { + referenceCommit: currReferenceCommit, + challengerCommit: currChallengerCommit + } = this.state; + + const commitsReady = currReferenceCommit && currChallengerCommit; + const commitChanged = + prevReferenceCommit !== currReferenceCommit || + prevChallengerCommit !== currChallengerCommit; + + if (commitsReady && (!!prevComparedFiles || commitChanged)) { + await this._doCommitComparsion(); + } + } + componentWillUnmount(): void { // Clear all signal connections Signal.clearData(this); @@ -228,7 +271,10 @@ export class GitPanel extends React.Component { const { currentBranch } = this.props.model; this.setState({ - currentBranch: currentBranch ? currentBranch.name : 'master' + currentBranch: currentBranch ? currentBranch.name : 'master', + referenceCommit: null, + challengerCommit: null, + comparedFiles: null }); }; @@ -448,13 +494,66 @@ export class GitPanel extends React.Component { */ private _renderHistory(): React.ReactElement { return ( - + + async event => { + event.stopPropagation(); + this._setCommitComparisonState({ reference: commit }); + }} + onCompareWithSelected={commit => async event => { + event.stopPropagation(); + this._setCommitComparisonState({ challenger: commit }); + }} + /> + {(this.state.referenceCommit || this.state.challengerCommit) && ( + { + event.stopPropagation(); + this._setCommitComparisonState({ + reference: null, + challenger: null, + comparedFiles: null + }); + }} + onOpenDiff={ + this.state.referenceCommit && this.state.challengerCommit + ? openFileDiff(this.props.commands)( + this.state.challengerCommit, + this.state.referenceCommit + ) + : undefined + } + /> + )} + ); } @@ -760,4 +859,73 @@ export class GitPanel extends React.Component { } return
    {elem}
    ; } + + private _setCommitComparisonState(state: { + reference?: Git.ISingleCommitInfo; + challenger?: Git.ISingleCommitInfo; + comparedFiles?: Git.ICommitModifiedFile[]; + }): void { + this.setState(currentState => ({ + referenceCommit: + typeof state.reference !== 'undefined' + ? state.reference + : currentState.referenceCommit, + challengerCommit: + typeof state.challenger !== 'undefined' + ? state.challenger + : currentState.challengerCommit, + comparedFiles: + typeof state.comparedFiles !== 'undefined' + ? state.comparedFiles + : currentState.comparedFiles + })); + } + + private async _doCommitComparsion(): Promise { + let diffResult: Git.IDiffResult = null; + try { + diffResult = await this.props.model.diff( + this.state.referenceCommit.commit, + this.state.challengerCommit.commit + ); + if (diffResult.code !== 0) { + throw new Error(diffResult.message); + } + } catch (err) { + console.error( + `Error while getting the diff for commit ${this.state.referenceCommit} and commit ${this.state.challengerCommit}!`, + err + ); + this.props.logger.log({ + level: Level.ERROR, + message: `Error while getting the diff for commit ${this.state.referenceCommit} and commit ${this.state.challengerCommit}!`, + error: err + }); + return; + } + if (diffResult) { + this.setState({ + comparedFiles: diffResult.result.map(changedFile => { + const pathParts = changedFile.filename.split('/'); + const fileName = pathParts[pathParts.length - 1]; + const filePath = changedFile.filename; + return { + deletion: changedFile.deletions, + insertion: changedFile.insertions, + is_binary: + changedFile.deletions === '-' || changedFile.insertions === '-', + modified_file_name: fileName, + modified_file_path: filePath, + type: changedFile.filetype + } as Git.ICommitModifiedFile; + }) + }); + } else { + this.setState({ + referenceCommit: null, + challengerCommit: null, + comparedFiles: null + }); + } + } } diff --git a/src/components/HistorySideBar.tsx b/src/components/HistorySideBar.tsx index b13913e56..78aa01446 100644 --- a/src/components/HistorySideBar.tsx +++ b/src/components/HistorySideBar.tsx @@ -2,7 +2,6 @@ import { TranslationBundle } from '@jupyterlab/translation'; import { closeIcon } from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; -import { CommandArguments } from '../commandsAndMenu'; import { GitExtension } from '../model'; import { hiddenButtonStyle } from '../style/ActionButtonStyle'; import { @@ -10,7 +9,8 @@ import { noHistoryFoundStyle, selectedHistoryFileStyle } from '../style/HistorySideBarStyle'; -import { ContextCommandIDs, Git } from '../tokens'; +import { Git } from '../tokens'; +import { openFileDiff } from '../utils'; import { ActionButton } from './ActionButton'; import { FileItem } from './FileItem'; import { PastCommitNode } from './PastCommitNode'; @@ -44,6 +44,32 @@ export interface IHistorySideBarProps { * The application language translator. */ trans: TranslationBundle; + + /** + * The commit to compare against. + */ + referenceCommit?: Git.ISingleCommitInfo; + + /** + * The commit to compare. + */ + challengerCommit?: Git.ISingleCommitInfo; + + /** + * Callback invoked upon clicking to select a commit for comparison. + * @param event - event object + */ + onSelectForCompare?: ( + commit: Git.ISingleCommitInfo + ) => (event: React.MouseEvent) => Promise; + + /** + * Callback invoked upon clicking to compare a commit against the selected. + * @param event - event object + */ + onCompareWithSelected?: ( + commit: Git.ISingleCommitInfo + ) => (event: React.MouseEvent) => Promise; } /** @@ -62,52 +88,6 @@ export const HistorySideBar: React.FunctionComponent = ( props.model.selectedHistoryFile = null; }; - /** - * Curried callback function to display a file diff. - * - * @param commit Commit data. - */ - const openDiff = - (commit: Git.ISingleCommitInfo) => - /** - * Returns a callback to be invoked on click to display a file diff. - * - * @param filePath file path - * @param isText indicates whether the file supports displaying a diff - * @param previousFilePath when file has been relocated - * @returns callback - */ - (filePath: string, isText: boolean, previousFilePath?: string) => - /** - * Callback invoked upon clicking to display a file diff. - * - * @param event - event object - */ - async (event: React.MouseEvent) => { - // Prevent the commit component from being collapsed: - event.stopPropagation(); - - if (isText) { - try { - props.commands.execute(ContextCommandIDs.gitFileDiff, { - files: [ - { - filePath, - previousFilePath, - isText, - context: { - previousRef: commit.pre_commit, - currentRef: commit.commit - } - } - ] - } as CommandArguments.IGitFileDiff as any); - } catch (err) { - console.error(`Failed to open diff view for ${filePath}.\n${err}`); - } - } - }; - /** * Commit info for 'Uncommitted Changes' history. */ @@ -165,7 +145,7 @@ export const HistorySideBar: React.FunctionComponent = ( // and its diff is viewable const onOpenDiff = props.model.selectedHistoryFile && !commit.is_binary - ? openDiff(commit)( + ? openFileDiff(props.commands)(commit)( commit.file_path ?? props.model.selectedHistoryFile.to, !commit.is_binary, commit.previous_file_path @@ -176,12 +156,20 @@ export const HistorySideBar: React.FunctionComponent = ( {!props.model.selectedHistoryFile && ( )} diff --git a/src/components/PastCommitNode.tsx b/src/components/PastCommitNode.tsx index f9cb2eaa2..6b3cd455e 100644 --- a/src/components/PastCommitNode.tsx +++ b/src/components/PastCommitNode.tsx @@ -4,11 +4,17 @@ import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; import { classes } from 'typestyle'; import { GitExtension } from '../model'; -import { diffIcon } from '../style/icons'; +import { + compareWithSelectedIcon, + diffIcon, + selectForCompareIcon +} from '../style/icons'; import { branchClass, branchWrapperClass, commitBodyClass, + referenceCommitNodeClass, + challengerCommitNodeClass, commitExpandedClass, commitHeaderClass, commitHeaderItemClass, @@ -20,6 +26,7 @@ import { workingBranchClass } from '../style/PastCommitNode'; import { Git } from '../tokens'; +import { ActionButton } from './ActionButton'; /** * Interface describing component properties. @@ -50,6 +57,16 @@ export interface IPastCommitNodeProps { */ trans: TranslationBundle; + /** + * The commit to compare against. + */ + isReferenceCommit?: boolean; + + /** + * The commit to compare. + */ + isChallengerCommit?: boolean; + /** * Callback invoked upon clicking to display a file diff. * @@ -58,6 +75,22 @@ export interface IPastCommitNodeProps { onOpenDiff?: ( event: React.MouseEvent ) => Promise; + + /** + * Callback invoked upon clicking to select a commit for comparison. + * @param event - event object + */ + onSelectForCompare?: ( + event: React.MouseEvent + ) => Promise; + + /** + * Callback invoked upon clicking to compare a commit against the selected. + * @param event - event object + */ + onCompareWithSelected?: ( + event: React.MouseEvent + ) => Promise; } /** @@ -104,7 +137,9 @@ export class PastCommitNode extends React.Component< ? singleFileCommitClass : this.state.expanded ? commitExpandedClass - : null + : null, + this.props.isReferenceCommit && referenceCommitNodeClass, + this.props.isChallengerCommit && challengerCommitNodeClass )} title={ this.props.children @@ -125,6 +160,22 @@ export class PastCommitNode extends React.Component< {this.props.commit.date} + {!this.props.commit.is_binary && ( + + + + + )} {this.props.children ? ( this.state.expanded ? ( diff --git a/src/model.ts b/src/model.ts index f7f100c94..b1f7fe5ae 100644 --- a/src/model.ts +++ b/src/model.ts @@ -728,6 +728,42 @@ export class GitExtension implements IGitExtension { return data; } + /** + * Get the diff of two commits. + * If no commit is provided, the diff of HEAD and INDEX is returned. + * If the current commit (the commit to compare) is not provided, + * the diff of the previous commit and INDEX is returned. + * + * @param previous - the commit to compare against + * @param current - the commit to compare + * @returns promise which resolves upon retrieving the diff + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async diff(previous?: string, current?: string): Promise { + const path = await this._getPathRepository(); + const data = await this._taskHandler.execute( + 'git:diff', + async () => { + return await requestAPI( + URLExt.join(path, 'diff'), + 'POST', + { + previous, + current + } + ); + } + ); + data.result = data.result.map(f => { + f.filetype = this._resolveFileType(f.filename); + return f; + }); + return data; + } + /** * Dispose of model resources. */ diff --git a/src/style/CommitComparisonBox.ts b/src/style/CommitComparisonBox.ts new file mode 100644 index 000000000..18a032827 --- /dev/null +++ b/src/style/CommitComparisonBox.ts @@ -0,0 +1,33 @@ +import { style } from 'typestyle'; + +export const clickableSpanStyle = style({ + cursor: 'pointer' +}); + +export const commitComparisonBoxStyle = style({ + display: 'flex', + flexDirection: 'column', + + minHeight: '200px', + + marginBlockStart: 0, + marginBlockEnd: 0, + paddingLeft: 0, + + overflowY: 'auto', + + $nest: { + '& button:disabled': { + opacity: 0.5 + } + } +}); + +export const commitComparisonBoxDetailStyle = style({ + maxHeight: '25%', + overflowY: 'hidden' +}); + +export const commitComparisonBoxChangedFileListStyle = style({ + maxHeight: '100%' +}); diff --git a/src/style/PastCommitNode.ts b/src/style/PastCommitNode.ts index 33cea04a1..2b81b82c7 100644 --- a/src/style/PastCommitNode.ts +++ b/src/style/PastCommitNode.ts @@ -83,3 +83,11 @@ export const singleFileCommitClass = style({ } } }); + +export const referenceCommitNodeClass = style({ + borderLeft: '6px solid var(--jp-git-diff-deleted-color)' +}); + +export const challengerCommitNodeClass = style({ + borderLeft: '6px solid var(--jp-git-diff-added-color)' +}); diff --git a/src/style/icons.ts b/src/style/icons.ts index 690b5d795..0af7070a3 100644 --- a/src/style/icons.ts +++ b/src/style/icons.ts @@ -5,6 +5,7 @@ import addSvg from '../../style/icons/add.svg'; import branchSvg from '../../style/icons/branch.svg'; import clockSvg from '../../style/icons/clock.svg'; import cloneSvg from '../../style/icons/clone.svg'; +import compareWithSelectedSvg from '../../style/icons/compare-with-selected.svg'; import deletionsMadeSvg from '../../style/icons/deletions.svg'; import desktopSvg from '../../style/icons/desktop.svg'; import diffSvg from '../../style/icons/diff.svg'; @@ -17,6 +18,7 @@ import pullSvg from '../../style/icons/pull.svg'; import pushSvg from '../../style/icons/push.svg'; import removeSvg from '../../style/icons/remove.svg'; import rewindSvg from '../../style/icons/rewind.svg'; +import selectForCompareSvg from '../../style/icons/select-for-compare.svg'; import tagSvg from '../../style/icons/tag.svg'; import trashSvg from '../../style/icons/trash.svg'; import verticalMoreSvg from '../../style/icons/vertical-more.svg'; @@ -34,6 +36,10 @@ export const cloneIcon = new LabIcon({ name: 'git:clone', svgstr: cloneSvg }); +export const compareWithSelectedIcon = new LabIcon({ + name: 'git:compare-with-selected', + svgstr: compareWithSelectedSvg +}); export const deletionsMadeIcon = new LabIcon({ name: 'git:deletions', svgstr: deletionsMadeSvg @@ -82,6 +88,10 @@ export const rewindIcon = new LabIcon({ name: 'git:rewind', svgstr: rewindSvg }); +export const selectForCompareIcon = new LabIcon({ + name: 'git:select-for-compare', + svgstr: selectForCompareSvg +}); export const tagIcon = new LabIcon({ name: 'git:tag', svgstr: tagSvg diff --git a/src/tokens.ts b/src/tokens.ts index f82d3ed3d..740f34c27 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -291,6 +291,22 @@ export interface IGitExtension extends IDisposable { */ detailedLog(hash: string): Promise; + /** + * Get the diff of two commits. + * If no commit is provided, the diff of HEAD and INDEX is returned. + * If the current commit (the commit to compare) is not provided, + * the diff of the previous commit and INDEX is returned. + * + * @param previous - the commit to compare against + * @param current - the commit to compare + * @returns promise which resolves upon retrieving the diff + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + diff(previous?: string, current?: string): Promise; + /** * Ensure a .gitignore file exists * @@ -813,6 +829,21 @@ export namespace Git { content: string; } + /** + * Interface for GitDiff request result + */ + export interface IDiffResult { + code: number; + command?: string; + message?: string; + result?: { + insertions: string; + deletions: string; + filename: string; + filetype?: DocumentRegistry.IFileType; + }[]; + } + /** * Git repository status */ diff --git a/src/utils.ts b/src/utils.ts index 0d1818842..f4c0a9f17 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,7 @@ import { PathExt } from '@jupyterlab/coreutils'; -import { Git } from './tokens'; +import { CommandRegistry } from '@lumino/commands'; +import { CommandArguments } from './commandsAndMenu'; +import { ContextCommandIDs, Git } from './tokens'; /** Get the filename from a path */ export function extractFilename(path: string): string { @@ -52,3 +54,58 @@ export function decodeStage(x: string, y: string): Git.Status { export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } + +/** + * A callback function to display a file diff between two commits. + * @param commands the command registry. + * @returns a callback function to display a file diff. + */ +export const openFileDiff = + (commands: CommandRegistry) => + /** + * A callback function to display a file diff between two commits. + * + * @param commit Commit data. + * @param previousCommit Previous commit data to display the diff against. If not specified, the diff will be against the preceding commit. + * + * @returns A callback function. + */ + (commit: Git.ISingleCommitInfo, previousCommit?: Git.ISingleCommitInfo) => + /** + * Returns a callback to be invoked on click to display a file diff. + * + * @param filePath file path. + * @param isText indicates whether the file supports displaying a diff. + * @param previousFilePath when file has been relocated. + * @returns callback. + */ + (filePath: string, isText: boolean, previousFilePath?: string) => + /** + * Callback invoked upon clicking to display a file diff. + * + * @param event - event object + */ + async (event: React.MouseEvent): Promise => { + // Prevent the commit component from being collapsed: + event.stopPropagation(); + + if (isText) { + try { + commands.execute(ContextCommandIDs.gitFileDiff, { + files: [ + { + filePath, + previousFilePath, + isText, + context: { + previousRef: previousCommit?.commit ?? commit.pre_commit, + currentRef: commit.commit + } + } + ] + } as CommandArguments.IGitFileDiff as any); + } catch (err) { + console.error(`Failed to open diff view for ${filePath}.\n${err}`); + } + } + }; diff --git a/style/icons/compare-with-selected.svg b/style/icons/compare-with-selected.svg new file mode 100644 index 000000000..8ace65ea2 --- /dev/null +++ b/style/icons/compare-with-selected.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/style/icons/select-for-compare.svg b/style/icons/select-for-compare.svg new file mode 100644 index 000000000..a46860636 --- /dev/null +++ b/style/icons/select-for-compare.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/tests/test-components/HistorySideBar.spec.tsx b/tests/test-components/HistorySideBar.spec.tsx index c7e78e6eb..1654edd19 100644 --- a/tests/test-components/HistorySideBar.spec.tsx +++ b/tests/test-components/HistorySideBar.spec.tsx @@ -23,7 +23,7 @@ describe('HistorySideBar', () => { author: null, date: null, commit_msg: null, - pre_commit: null + pre_commit: null, } ], branches: [], @@ -31,7 +31,11 @@ describe('HistorySideBar', () => { selectedHistoryFile: null } as GitExtension, commands: null, - trans + trans, + referenceCommit: null, + challengerCommit: null, + onSelectForCompare: _ => async _ => null, + onCompareWithSelected: _ => async _ => null }; it('renders the commit nodes', () => {