diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 8a002b549..2cd15b041 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -432,16 +432,24 @@ async def status(self, path): return data - async def log(self, path, history_count=10): + async def log(self, path, history_count=10, follow_path=None): """ Execute git log command & return the result. """ + is_single_file = follow_path != None cmd = [ "git", "log", "--pretty=format:%H%n%an%n%ar%n%s", ("-%d" % history_count), ] + if is_single_file: + cmd = cmd + [ + "--numstat", + "--follow", + "--", + follow_path, + ] code, my_output, my_error = await execute( cmd, cwd=path, @@ -450,30 +458,28 @@ async def log(self, path, history_count=10): return {"code": code, "command": " ".join(cmd), "message": my_error} result = [] + if is_single_file: + # an extra newline get outputted when --numstat is used + my_output = my_output.replace("\n\n", "\n") line_array = my_output.splitlines() i = 0 - PREVIOUS_COMMIT_OFFSET = 4 + PREVIOUS_COMMIT_OFFSET = 5 if is_single_file else 4 while i < len(line_array): + commit = { + "commit": line_array[i], + "author": line_array[i + 1], + "date": line_array[i + 2], + "commit_msg": line_array[i + 3], + "pre_commit": "", + } + + if is_single_file: + commit["is_binary"] = line_array[i + 4].startswith("-\t-\t") + if i + PREVIOUS_COMMIT_OFFSET < len(line_array): - result.append( - { - "commit": line_array[i], - "author": line_array[i + 1], - "date": line_array[i + 2], - "commit_msg": line_array[i + 3], - "pre_commit": line_array[i + PREVIOUS_COMMIT_OFFSET], - } - ) - else: - result.append( - { - "commit": line_array[i], - "author": line_array[i + 1], - "date": line_array[i + 2], - "commit_msg": line_array[i + 3], - "pre_commit": "", - } - ) + commit["pre_commit"] = line_array[i + PREVIOUS_COMMIT_OFFSET] + + result.append(commit) i += PREVIOUS_COMMIT_OFFSET return {"code": code, "commits": result} diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 076f89da1..6cb61a179 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -196,7 +196,7 @@ async def post(self, path: str = ""): class GitLogHandler(GitHandler): """ - Handler for 'git log --pretty=format:%H-%an-%ar-%s'. + Handler for 'git log'. Fetches Commit SHA, Author Name, Commit Date & Commit Message. """ @@ -208,7 +208,10 @@ async def post(self, path: str = ""): """ body = self.get_json_body() history_count = body.get("history_count", 25) - result = await self.git.log(self.url2localpath(path), history_count) + follow_path = body.get("follow_path") + result = await self.git.log( + self.url2localpath(path), history_count, follow_path + ) if result["code"] != 0: self.set_status(500) diff --git a/jupyterlab_git/tests/test_handlers.py b/jupyterlab_git/tests/test_handlers.py index 44bd9b4cb..ad90f4164 100644 --- a/jupyterlab_git/tests/test_handlers.py +++ b/jupyterlab_git/tests/test_handlers.py @@ -281,7 +281,7 @@ async def test_log_handler(mock_git, jp_fetch, jp_root_dir): ) # Then - mock_git.log.assert_called_with(str(local_path), 20) + mock_git.log.assert_called_with(str(local_path), 20, None) assert response.code == 200 payload = json.loads(response.body) @@ -301,7 +301,7 @@ async def test_log_handler_no_history_count(mock_git, jp_fetch, jp_root_dir): ) # Then - mock_git.log.assert_called_with(str(local_path), 25) + mock_git.log.assert_called_with(str(local_path), 25, None) assert response.code == 200 payload = json.loads(response.body) diff --git a/jupyterlab_git/tests/test_single_file_log.py b/jupyterlab_git/tests/test_single_file_log.py new file mode 100644 index 000000000..db2e1d7ba --- /dev/null +++ b/jupyterlab_git/tests/test_single_file_log.py @@ -0,0 +1,398 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from jupyterlab_git.git import Git + +from .testutils import maybe_future + + +@pytest.mark.asyncio +async def test_single_file_log(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + process_output = [ + "8b080cb6cdd526e91199fc003b10e1ba8010f1c4", + "Awesome Developer", + "4 months ago", + "Set _commitAndPush_ to false by default", + "1\t\t1\tREADME.md", + "", + "4fe9688ba74b2cf023af3d76fd737c9fe31548fe", + "Awesome Developer", + "4 months ago", + "Fixes #762 (#914)", + "1\t\t1\tREADME.md", + "", + "4444de0ee37e26d4a7849812f845370b6c7da994", + "Awesome Developer", + "4 months ago", + "Add new setting to README", + "1\t\t0\tREADME.md", + "", + "a216011e407d58bd7ef5c6c1005903a72758016d", + "Awesome Developer", + "5 months ago", + "restore preview gif", + "2\t\t0\tREADME.md", + "", + "2777209f76c70b91ff0ffdd4d878b779758ab355", + "Awesome Developer", + "5 months ago", + "fill out details in troubleshooting", + "1\t\t1\tREADME.md", + "", + "ab35dafe2afb9de3da69590f94028f9582b52862", + "Awesome Developer", + "5 months ago", + "Update README.md:Git panel is not visible (#891)", + "4\t\t1\tREADME.md", + "", + "966671c3e0d4ec62bb4ab03c3a363fbba2ef2666", + "Awesome Developer", + "6 months ago", + "Add some notes for JupyterLab <3 (#865)", + "13\t\t1\tREADME.md", + "", + "c03c1aae4d638cf7ff533ac4065148fa4fce88e0", + "Awesome Developer", + "6 months ago", + "Fix #859", + "2\t\t2\tREADME.md", + "", + "a72d4b761e0701c226b39a822ac42b80a91ad9df", + "Awesome Developer", + "7 months ago", + "Update to Jupyterlab 3.0 (#818)", + "47\t4\t3\tREADME.md", + "", + "a72b9852ed5af0f9368acb6737557960515a6fdb", + "Awesome Developer", + "8 months ago", + "Update README.md", + "3\t\t1\tREADME.md", + "", + "120b04e1a3641eb5ae0a042e3934bd48eb6469df", + "Awesome Developer", + "9 months ago", + "Reduce polling (#812)", + "1\t\t0\tREADME.md", + "", + "7b7f0e53a7a25c0726bbf6f27912918185193b2d", + "Awesome Developer", + "10 months ago", + "Hide overflow on focus (#792)", + "1\t\t1\tREADME.md", + "", + "4efb0a776cd22756b80cae7ec2c1cda5f701ab93", + "Awesome Developer", + "10 months ago", + "Add conda installation instructions", + "12\t\t5\tREADME.md", + "", + "706fb7a8d5cc8f98bcaf61374a8f4ca77f92f056", + "Awesome Developer", + "10 months ago", + "Clarify post_init directions (#781)", + "6\t\t0\tREADME.md", + "", + "70f26818d434e086ca5d5aead8548bdeb0fd6564", + "Awesome Developer", + "11 months ago", + "add black to pre-commit", + "1\t\t0\tREADME.md", + "", + "a046b66415c6afabcbdf6e624e2a367523ee2e80", + "Awesome Developer", + "12 months ago", + "Switch to GitHub action for testing (#738)", + "1\t\t1\tREADME.md", + "", + "6eb994e7fee4a6bc482f641f1265a6fa031112d4", + "Awesome Developer", + "12 months ago", + "Restore server_root endpoint (#733)", + "19\t\t1\tREADME.md", + "", + "5056ca14dd90472b4a3f621d1bc2edd0c30a9a12", + "Awesome Developer", + "1 year ago", + "Check git version and compatibility front/backend version (#658)", + "1\t\t1\tREADME.md", + "", + "ab00273e351d04d1f4be0c89d3943c083bee0b73", + "Awesome Developer", + "1 year ago", + "docs: update README.md [skip ci]", + "1\t\t1\tREADME.md", + "", + "6ffe9fec322ac4f6c126b4d46f145b003c52195d", + "Awesome Developer", + "1 year ago", + "docs: update README.md [skip ci]", + "12\t1\t3\tREADME.md", + "", + "dafc4006d5d3df5b07d4ac0ef045a73ea41577da", + "Awesome Developer", + "1 year ago", + 'Merge PR #630 "Provide UI feedback during Git command execution"', + "4\t\t1\tREADME.md", + "", + "2f9ad78074bd8219200587020236ac77daa761be", + "Awesome Developer", + "1 year, 1 month ago", + "link -> install", + "2\t\t2\tREADME.md", + "", + "bbcc5d8e6b7f5a8abc71b0d473845a55fbbaad42", + "Awesome Developer", + "1 year, 2 months ago", + "add doubleClickDiff setting to readme", + "1\t\t0\tREADME.md", + "", + "8e79eae1277f8a9b8a07123717dbd5cc8ba5f83d", + "Awesome Developer", + "1 year, 2 months ago", + "Switch sponsorship icon (#579)", + "11\t\t7\tREADME.md", + "", + "ec66c24fb7391116ea0d91d5b7a275cf57ff0fe7", + "Awesome Developer", + "1 year, 2 months ago", + "Fix #551 (#649)", + "2\t\t2\tREADME.md", + "", + ] + + mock_execute.return_value = maybe_future((0, "\n".join(process_output), "")) + + expected_response = { + "code": 0, + "commits": [ + { + "commit": "8b080cb6cdd526e91199fc003b10e1ba8010f1c4", + "author": "Awesome Developer", + "date": "4 months ago", + "commit_msg": "Set _commitAndPush_ to false by default", + "pre_commit": "4fe9688ba74b2cf023af3d76fd737c9fe31548fe", + "is_binary": False, + }, + { + "commit": "4fe9688ba74b2cf023af3d76fd737c9fe31548fe", + "author": "Awesome Developer", + "date": "4 months ago", + "commit_msg": "Fixes #762 (#914)", + "pre_commit": "4444de0ee37e26d4a7849812f845370b6c7da994", + "is_binary": False, + }, + { + "commit": "4444de0ee37e26d4a7849812f845370b6c7da994", + "author": "Awesome Developer", + "date": "4 months ago", + "commit_msg": "Add new setting to README", + "pre_commit": "a216011e407d58bd7ef5c6c1005903a72758016d", + "is_binary": False, + }, + { + "commit": "a216011e407d58bd7ef5c6c1005903a72758016d", + "author": "Awesome Developer", + "date": "5 months ago", + "commit_msg": "restore preview gif", + "pre_commit": "2777209f76c70b91ff0ffdd4d878b779758ab355", + "is_binary": False, + }, + { + "commit": "2777209f76c70b91ff0ffdd4d878b779758ab355", + "author": "Awesome Developer", + "date": "5 months ago", + "commit_msg": "fill out details in troubleshooting", + "pre_commit": "ab35dafe2afb9de3da69590f94028f9582b52862", + "is_binary": False, + }, + { + "commit": "ab35dafe2afb9de3da69590f94028f9582b52862", + "author": "Awesome Developer", + "date": "5 months ago", + "commit_msg": "Update README.md:Git panel is not visible (#891)", + "pre_commit": "966671c3e0d4ec62bb4ab03c3a363fbba2ef2666", + "is_binary": False, + }, + { + "commit": "966671c3e0d4ec62bb4ab03c3a363fbba2ef2666", + "author": "Awesome Developer", + "date": "6 months ago", + "commit_msg": "Add some notes for JupyterLab <3 (#865)", + "pre_commit": "c03c1aae4d638cf7ff533ac4065148fa4fce88e0", + "is_binary": False, + }, + { + "commit": "c03c1aae4d638cf7ff533ac4065148fa4fce88e0", + "author": "Awesome Developer", + "date": "6 months ago", + "commit_msg": "Fix #859", + "pre_commit": "a72d4b761e0701c226b39a822ac42b80a91ad9df", + "is_binary": False, + }, + { + "commit": "a72d4b761e0701c226b39a822ac42b80a91ad9df", + "author": "Awesome Developer", + "date": "7 months ago", + "commit_msg": "Update to Jupyterlab 3.0 (#818)", + "pre_commit": "a72b9852ed5af0f9368acb6737557960515a6fdb", + "is_binary": False, + }, + { + "commit": "a72b9852ed5af0f9368acb6737557960515a6fdb", + "author": "Awesome Developer", + "date": "8 months ago", + "commit_msg": "Update README.md", + "pre_commit": "120b04e1a3641eb5ae0a042e3934bd48eb6469df", + "is_binary": False, + }, + { + "commit": "120b04e1a3641eb5ae0a042e3934bd48eb6469df", + "author": "Awesome Developer", + "date": "9 months ago", + "commit_msg": "Reduce polling (#812)", + "pre_commit": "7b7f0e53a7a25c0726bbf6f27912918185193b2d", + "is_binary": False, + }, + { + "commit": "7b7f0e53a7a25c0726bbf6f27912918185193b2d", + "author": "Awesome Developer", + "date": "10 months ago", + "commit_msg": "Hide overflow on focus (#792)", + "pre_commit": "4efb0a776cd22756b80cae7ec2c1cda5f701ab93", + "is_binary": False, + }, + { + "commit": "4efb0a776cd22756b80cae7ec2c1cda5f701ab93", + "author": "Awesome Developer", + "date": "10 months ago", + "commit_msg": "Add conda installation instructions", + "pre_commit": "706fb7a8d5cc8f98bcaf61374a8f4ca77f92f056", + "is_binary": False, + }, + { + "commit": "706fb7a8d5cc8f98bcaf61374a8f4ca77f92f056", + "author": "Awesome Developer", + "date": "10 months ago", + "commit_msg": "Clarify post_init directions (#781)", + "pre_commit": "70f26818d434e086ca5d5aead8548bdeb0fd6564", + "is_binary": False, + }, + { + "commit": "70f26818d434e086ca5d5aead8548bdeb0fd6564", + "author": "Awesome Developer", + "date": "11 months ago", + "commit_msg": "add black to pre-commit", + "pre_commit": "a046b66415c6afabcbdf6e624e2a367523ee2e80", + "is_binary": False, + }, + { + "commit": "a046b66415c6afabcbdf6e624e2a367523ee2e80", + "author": "Awesome Developer", + "date": "12 months ago", + "commit_msg": "Switch to GitHub action for testing (#738)", + "pre_commit": "6eb994e7fee4a6bc482f641f1265a6fa031112d4", + "is_binary": False, + }, + { + "commit": "6eb994e7fee4a6bc482f641f1265a6fa031112d4", + "author": "Awesome Developer", + "date": "12 months ago", + "commit_msg": "Restore server_root endpoint (#733)", + "pre_commit": "5056ca14dd90472b4a3f621d1bc2edd0c30a9a12", + "is_binary": False, + }, + { + "commit": "5056ca14dd90472b4a3f621d1bc2edd0c30a9a12", + "author": "Awesome Developer", + "date": "1 year ago", + "commit_msg": "Check git version and compatibility front/backend version (#658)", + "pre_commit": "ab00273e351d04d1f4be0c89d3943c083bee0b73", + "is_binary": False, + }, + { + "commit": "ab00273e351d04d1f4be0c89d3943c083bee0b73", + "author": "Awesome Developer", + "date": "1 year ago", + "commit_msg": "docs: update README.md [skip ci]", + "pre_commit": "6ffe9fec322ac4f6c126b4d46f145b003c52195d", + "is_binary": False, + }, + { + "commit": "6ffe9fec322ac4f6c126b4d46f145b003c52195d", + "author": "Awesome Developer", + "date": "1 year ago", + "commit_msg": "docs: update README.md [skip ci]", + "pre_commit": "dafc4006d5d3df5b07d4ac0ef045a73ea41577da", + "is_binary": False, + }, + { + "commit": "dafc4006d5d3df5b07d4ac0ef045a73ea41577da", + "author": "Awesome Developer", + "date": "1 year ago", + "commit_msg": 'Merge PR #630 "Provide UI feedback during Git command execution"', + "pre_commit": "2f9ad78074bd8219200587020236ac77daa761be", + "is_binary": False, + }, + { + "commit": "2f9ad78074bd8219200587020236ac77daa761be", + "author": "Awesome Developer", + "date": "1 year, 1 month ago", + "commit_msg": "link -> install", + "pre_commit": "bbcc5d8e6b7f5a8abc71b0d473845a55fbbaad42", + "is_binary": False, + }, + { + "commit": "bbcc5d8e6b7f5a8abc71b0d473845a55fbbaad42", + "author": "Awesome Developer", + "date": "1 year, 2 months ago", + "commit_msg": "add doubleClickDiff setting to readme", + "pre_commit": "8e79eae1277f8a9b8a07123717dbd5cc8ba5f83d", + "is_binary": False, + }, + { + "commit": "8e79eae1277f8a9b8a07123717dbd5cc8ba5f83d", + "author": "Awesome Developer", + "date": "1 year, 2 months ago", + "commit_msg": "Switch sponsorship icon (#579)", + "pre_commit": "ec66c24fb7391116ea0d91d5b7a275cf57ff0fe7", + "is_binary": False, + }, + { + "commit": "ec66c24fb7391116ea0d91d5b7a275cf57ff0fe7", + "author": "Awesome Developer", + "date": "1 year, 2 months ago", + "commit_msg": "Fix #551 (#649)", + "pre_commit": "", + "is_binary": False, + }, + ], + } + + # When + actual_response = await Git().log( + path=str(Path("/bin/test_curr_path")), + history_count=25, + follow_path="README.md", + ) + + # Then + mock_execute.assert_called_once_with( + [ + "git", + "log", + "--pretty=format:%H%n%an%n%ar%n%s", + "-25", + "--numstat", + "--follow", + "--", + "README.md", + ], + cwd=str(Path("/bin") / "test_curr_path"), + ) + + assert expected_response == actual_response diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 72ac411aa..4b5dfba8e 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -34,7 +34,8 @@ import { discardIcon, gitIcon, openIcon, - removeIcon + removeIcon, + historyIcon } from './style/icons'; import { CommandIDs, @@ -897,6 +898,25 @@ export function addCommands( } }); + commands.addCommand(ContextCommandIDs.gitFileHistory, { + label: trans.__('History'), + caption: trans.__('View the history of this file'), + execute: args => { + const { files } = args as any as CommandArguments.IGitContextAction; + const file = files[0]; + if (!file) { + return; + } + gitModel.selectedHistoryFile = file; + shell.activateById('jp-git-sessions'); + }, + isEnabled: args => { + const { files } = args as any as CommandArguments.IGitContextAction; + return files.length === 1; + }, + icon: historyIcon.bindprops({ stylesheet: 'menuItem' }) + }); + commands.addCommand(ContextCommandIDs.gitNoAction, { label: trans.__('No actions available'), isEnabled: () => false, @@ -1051,9 +1071,7 @@ export function addFileBrowserContextMenu( const items = getSelectedBrowserItems(); const statuses = new Set( - items - .map(item => model.getFile(item.path)?.status) - .filter(status => typeof status !== 'undefined') + items.map(item => model.getFile(item.path).status) ); // get commands and de-duplicate them @@ -1078,10 +1096,9 @@ export function addFileBrowserContextMenu( ) ); - // if looking at a tracked file with no changes, - // it has no status, nor any actions available + // if looking at a tracked file without any actions available // (although `git rm` would be a valid action) - if (allCommands.size === 0 && statuses.size === 0) { + if (allCommands.size === 0) { allCommands.add(ContextCommandIDs.gitNoAction); } @@ -1101,14 +1118,10 @@ export function addFileBrowserContextMenu( addMenuItems( commandsList, this, - paths - .map(path => model.getFile(path)) - // if file cannot be resolved (has no action available), - // omit the undefined result - .filter(file => typeof file !== 'undefined') + paths.map(path => model.getFile(path)) ); if (wasShown) { - // show he menu again after downtime for refresh + // show the menu again after downtime for refresh parent.triggerActiveItem(); } this._commands = commandsList; diff --git a/src/components/FileItem.tsx b/src/components/FileItem.tsx index eb0cd74f2..09827a471 100644 --- a/src/components/FileItem.tsx +++ b/src/components/FileItem.tsx @@ -118,10 +118,14 @@ export interface IFileItemProps { * Callback to select the file */ selectFile?: (file: Git.IStatusFile | null) => void; + /** + * Optional style class + */ + className?: string; /** * Inline styling for the windowing */ - style: React.CSSProperties; + style?: React.CSSProperties; /** * The application language translator. */ @@ -133,7 +137,7 @@ export class FileItem extends React.PureComponent { super(props); } protected _getFileChangedLabel(change: keyof typeof STATUS_CODES): string { - return STATUS_CODES[change]; + return STATUS_CODES[change] || 'Unmodified'; } protected _getFileChangedLabelClass(change: string): string { @@ -157,9 +161,13 @@ export class FileItem extends React.PureComponent { } protected _getFileClass(): string { - return this.props.selected + const baseClass = this.props.selected ? classes(fileStyle, selectedFileStyle) : fileStyle; + + return this.props.className + ? `${baseClass} ${this.props.className}` + : baseClass; } render(): JSX.Element { @@ -182,7 +190,7 @@ export class FileItem extends React.PureComponent { } onDoubleClick={this.props.onDoubleClick} style={this.props.style} - title={this.props.trans.__(`%1 ● ${status}`, this.props.file.to)} + title={this.props.trans.__(`%1 • ${status}`, this.props.file.to)} > {this.props.markBox && ( { diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index a7ffd855e..fe7b4ffa5 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -176,6 +176,10 @@ export class GitPanel extends React.Component { this.refreshHistory(); } }, this); + model.selectedHistoryFileChanged.connect(() => { + this.setState({ tab: 1 }); + this.refreshHistory(); + }, this); model.markChanged.connect(() => this.forceUpdate(), this); settings.changed.connect(this.refreshView, this); diff --git a/src/components/HistorySideBar.tsx b/src/components/HistorySideBar.tsx index fefad6efa..071a78d53 100644 --- a/src/components/HistorySideBar.tsx +++ b/src/components/HistorySideBar.tsx @@ -1,10 +1,20 @@ 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 { historySideBarStyle } from '../style/HistorySideBarStyle'; -import { Git } from '../tokens'; +import { hiddenButtonStyle } from '../style/ActionButtonStyle'; +import { + historySideBarStyle, + noHistoryFoundStyle, + selectedHistoryFileStyle +} from '../style/HistorySideBarStyle'; +import { ContextCommandIDs, Git } from '../tokens'; +import { ActionButton } from './ActionButton'; +import { FileItem } from './FileItem'; import { PastCommitNode } from './PastCommitNode'; +import { SinglePastCommitInfo } from './SinglePastCommitInfo'; /** * Interface describing component properties. @@ -44,17 +54,141 @@ export interface IHistorySideBarProps { */ export const HistorySideBar: React.FunctionComponent = ( props: IHistorySideBarProps -): React.ReactElement => ( -
    - {props.commits.map((commit: Git.ISingleCommitInfo) => ( - - ))} -
-); +): React.ReactElement => { + /** + * Discards the selected file and shows the full history. + */ + const removeSelectedFile = () => { + 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 + * @returns callback + */ + (filePath: string, isText: boolean) => + /** + * 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, + 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. + */ + const uncommitted = React.useMemo(() => { + return { + author: props.trans.__('You'), + commit: `${ + props.model.selectedHistoryFile?.status === 'staged' + ? Git.Diff.SpecialRef.INDEX + : Git.Diff.SpecialRef.WORKING + }`, + pre_commit: 'HEAD', + is_binary: props.commits[0]?.is_binary ?? false, + commit_msg: props.trans.__('Uncommitted Changes'), + date: props.trans.__('now') + }; + }, [props.model.selectedHistoryFile]); + + const commits = + props.model.selectedHistoryFile && + props.model.selectedHistoryFile?.status !== 'unmodified' + ? [uncommitted, ...props.commits] + : props.commits; + + return ( +
    + {!!props.model.selectedHistoryFile && ( + + } + file={props.model.selectedHistoryFile} + onDoubleClick={removeSelectedFile} + /> + )} + {commits.length ? ( + commits.map((commit: Git.ISingleCommitInfo) => { + const commonProps = { + commit, + branches: props.branches, + model: props.model, + commands: props.commands, + trans: props.trans + }; + + // Only pass down callback when single file history is open + // and its diff is viewable + const onOpenDiff = + props.model.selectedHistoryFile && !commit.is_binary + ? openDiff(commit)( + props.model.selectedHistoryFile.to, + !commit.is_binary + ) + : undefined; + + return ( + + {!props.model.selectedHistoryFile && ( + + )} + + ); + }) + ) : ( +
  1. + {props.trans.__('No history found.')} +
  2. + )} +
+ ); +}; diff --git a/src/components/PastCommitNode.tsx b/src/components/PastCommitNode.tsx index 1ca7ea298..f9cb2eaa2 100644 --- a/src/components/PastCommitNode.tsx +++ b/src/components/PastCommitNode.tsx @@ -4,6 +4,7 @@ import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; import { classes } from 'typestyle'; import { GitExtension } from '../model'; +import { diffIcon } from '../style/icons'; import { branchClass, branchWrapperClass, @@ -15,10 +16,10 @@ import { iconButtonClass, localBranchClass, remoteBranchClass, + singleFileCommitClass, workingBranchClass } from '../style/PastCommitNode'; import { Git } from '../tokens'; -import { SinglePastCommitInfo } from './SinglePastCommitInfo'; /** * Interface describing component properties. @@ -48,6 +49,15 @@ export interface IPastCommitNodeProps { * The application language translator. */ trans: TranslationBundle; + + /** + * Callback invoked upon clicking to display a file diff. + * + * @param event - event object + */ + onOpenDiff?: ( + event: React.MouseEvent + ) => Promise; } /** @@ -64,7 +74,7 @@ export interface IPastCommitNodeState { * React component for rendering an individual commit. */ export class PastCommitNode extends React.Component< - IPastCommitNodeProps, + React.PropsWithChildren, IPastCommitNodeState > { /** @@ -73,7 +83,7 @@ export class PastCommitNode extends React.Component< * @param props - component properties * @returns React component */ - constructor(props: IPastCommitNodeProps) { + constructor(props: React.PropsWithChildren) { super(props); this.state = { expanded: false @@ -90,8 +100,17 @@ export class PastCommitNode extends React.Component<
  • @@ -99,28 +118,27 @@ export class PastCommitNode extends React.Component< {this.props.commit.author} - {this.props.commit.commit.slice(0, 7)} + {+this.props.commit.commit in Git.Diff.SpecialRef + ? Git.Diff.SpecialRef[+this.props.commit.commit] + : this.props.commit.commit.slice(0, 7)} {this.props.commit.date} - {this.state.expanded ? ( - + {this.props.children ? ( + this.state.expanded ? ( + + ) : ( + + ) ) : ( - + )}
    {this._renderBranches()}
    {this.props.commit.commit_msg} - {this.state.expanded && ( - - )} + {this.state.expanded && this.props.children}
  • ); @@ -174,9 +192,15 @@ export class PastCommitNode extends React.Component< * * @param event - event object */ - private _onCommitClick = (): void => { - this.setState({ - expanded: !this.state.expanded - }); + private _onCommitClick = ( + event: React.MouseEvent + ): void => { + if (this.props.children) { + this.setState({ + expanded: !this.state.expanded + }); + } else { + this.props.onOpenDiff?.call(this, event); + } }; } diff --git a/src/components/SinglePastCommitInfo.tsx b/src/components/SinglePastCommitInfo.tsx index 8096ff62a..3cf189c04 100644 --- a/src/components/SinglePastCommitInfo.tsx +++ b/src/components/SinglePastCommitInfo.tsx @@ -4,7 +4,6 @@ import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { classes } from 'typestyle'; -import { CommandArguments } from '../commandsAndMenu'; import { LoggerContext } from '../logger'; import { getDiffProvider, GitExtension } from '../model'; import { @@ -26,7 +25,7 @@ import { iconClass, insertionsIconClass } from '../style/SinglePastCommitInfo'; -import { ContextCommandIDs, Git } from '../tokens'; +import { Git } from '../tokens'; import { ActionButton } from './ActionButton'; import { FilePath } from './FilePath'; import { ResetRevertDialog } from './ResetRevertDialog'; @@ -57,6 +56,18 @@ export interface ISinglePastCommitInfoProps { * The application language translator. */ trans: TranslationBundle; + + /** + * 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 + * @returns callback + */ + onOpenDiff: ( + filePath: string, + isText: boolean + ) => (event: React.MouseEvent) => void; } /** @@ -262,7 +273,7 @@ export class SinglePastCommitInfo extends React.Component< return (
  • @@ -311,60 +322,4 @@ export class SinglePastCommitInfo extends React.Component< resetRevertDialog: false }); }; - - /** - * Returns a callback to be invoked clicking a button to display a file diff. - * - * @param fpath - modified file path - * @param bool - boolean indicating whether a displaying a diff is supported for this file path - * @returns callback - */ - private _onDiffClickFactory(fpath: string, bool: boolean) { - const self = this; - if (bool) { - return onShowDiff; - } - return onClick; - - /** - * Callback invoked upon clicking a button to display a file diff. - * - * @private - * @param event - event object - */ - function onClick(event: React.MouseEvent) { - // Prevent the commit component from being collapsed: - event.stopPropagation(); - } - - /** - * Callback invoked upon clicking a button to display a file diff. - * - * @private - * @param event - event object - */ - async function onShowDiff( - event: React.MouseEvent - ) { - // Prevent the commit component from being collapsed: - event.stopPropagation(); - - try { - self.props.commands.execute(ContextCommandIDs.gitFileDiff, { - files: [ - { - filePath: fpath, - isText: bool, - context: { - previousRef: self.props.commit.pre_commit, - currentRef: self.props.commit.commit - } - } - ] - } as CommandArguments.IGitFileDiff as any); - } catch (err) { - console.error(`Failed to open diff view for ${fpath}.\n${err}`); - } - } - } } diff --git a/src/model.ts b/src/model.ts index 6a8b52dc0..50ba00e81 100644 --- a/src/model.ts +++ b/src/model.ts @@ -186,6 +186,19 @@ export class GitExtension implements IGitExtension { this._standbyCondition = v; } + /** + * Selected file for single file history + */ + get selectedHistoryFile(): Git.IStatusFile | null { + return this._selectedHistoryFile; + } + set selectedHistoryFile(file: Git.IStatusFile | null) { + if (this._selectedHistoryFile !== file) { + this._selectedHistoryFile = file; + this._selectedHistoryFileChanged.emit(file); + } + } + /** * Git repository status */ @@ -207,6 +220,16 @@ export class GitExtension implements IGitExtension { return this._markChanged; } + /** + * A signal emitted when the current file selected for history of the Git repository changes. + */ + get selectedHistoryFileChanged(): ISignal< + IGitExtension, + Git.IStatusFile | null + > { + return this._selectedHistoryFileChanged; + } + /** * A signal emitted when the current Git repository changes. */ @@ -271,14 +294,24 @@ export class GitExtension implements IGitExtension { /** * Match files status information based on a provided file path. * - * If the file is tracked and has no changes, undefined will be returned + * If the file is tracked and has no changes, a StatusFile of unmodified will be returned * * @param path the file path relative to the server root */ getFile(path: string): Git.IStatusFile { - return this._status.files.find(status => { - return this.getRelativeFilePath(status.to) === path; - }); + return ( + this._status.files.find(status => { + return this.getRelativeFilePath(status.to) === path; + }) ?? { + x: '', + y: '', + to: path, + from: '', + is_binary: null, + status: 'unmodified', + type: this._resolveFileType(path) + } + ); } /** @@ -692,7 +725,8 @@ export class GitExtension implements IGitExtension { URLExt.join(path, 'log'), 'POST', { - history_count: count + history_count: count, + follow_path: this.selectedHistoryFile?.to } ); } @@ -1375,9 +1409,14 @@ export class GitExtension implements IGitExtension { private _standbyCondition: () => boolean = () => false; private _statusPoll: Poll; private _taskHandler: TaskHandler; + private _selectedHistoryFile: Git.IStatusFile | null = null; private _headChanged = new Signal(this); private _markChanged = new Signal(this); + private _selectedHistoryFileChanged = new Signal< + IGitExtension, + Git.IStatusFile | null + >(this); private _repositoryChanged = new Signal< IGitExtension, IChangedArgs diff --git a/src/style/HistorySideBarStyle.ts b/src/style/HistorySideBarStyle.ts index 7702fa78b..54b497fc8 100644 --- a/src/style/HistorySideBarStyle.ts +++ b/src/style/HistorySideBarStyle.ts @@ -1,5 +1,28 @@ import { style } from 'typestyle'; +export const selectedHistoryFileStyle = style({ + minHeight: '48px', + + top: 0, + position: 'sticky', + + flexGrow: 0, + flexShrink: 0, + + overflowX: 'hidden', + + backgroundColor: 'var(--jp-toolbar-active-background)' +}); + +export const noHistoryFoundStyle = style({ + display: 'flex', + justifyContent: 'center', + + padding: '10px 0', + + color: 'var(--jp-ui-font-color2)' +}); + export const historySideBarStyle = style({ display: 'flex', flexDirection: 'column', diff --git a/src/style/PastCommitNode.ts b/src/style/PastCommitNode.ts index 9bd625b7c..33cea04a1 100644 --- a/src/style/PastCommitNode.ts +++ b/src/style/PastCommitNode.ts @@ -75,3 +75,11 @@ export const iconButtonClass = style({ /* top | right | bottom | left */ margin: 'auto 8px auto auto' }); + +export const singleFileCommitClass = style({ + $nest: { + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + } + } +}); diff --git a/src/style/icons.ts b/src/style/icons.ts index fd5e8191c..b93b74e77 100644 --- a/src/style/icons.ts +++ b/src/style/icons.ts @@ -17,6 +17,7 @@ import removeSvg from '../../style/icons/remove.svg'; import rewindSvg from '../../style/icons/rewind.svg'; import tagSvg from '../../style/icons/tag.svg'; import trashSvg from '../../style/icons/trash.svg'; +import clockSvg from '../../style/icons/clock.svg'; export const gitIcon = new LabIcon({ name: 'git', svgstr: gitSvg }); export const addIcon = new LabIcon({ @@ -79,3 +80,7 @@ export const trashIcon = new LabIcon({ name: 'git:trash', svgstr: trashSvg }); +export const historyIcon = new LabIcon({ + name: 'git:history', + svgstr: clockSvg +}); diff --git a/src/tokens.ts b/src/tokens.ts index 52d808a15..1752d3fe7 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -55,6 +55,11 @@ export interface IGitExtension extends IDisposable { */ refreshStandbyCondition: () => boolean; + /** + * Selected file for single file history + */ + selectedHistoryFile: Git.IStatusFile | null; + /** * Git repository status. */ @@ -70,6 +75,14 @@ export interface IGitExtension extends IDisposable { */ readonly taskChanged: ISignal; + /** + * A signal emitted when the current file selected for history of the Git repository changes. + */ + readonly selectedHistoryFileChanged: ISignal< + IGitExtension, + Git.IStatusFile | null + >; + /** * Add one or more files to the repository staging area. * @@ -253,7 +266,7 @@ export interface IGitExtension extends IDisposable { /** * Match files status information based on a provided file path. * - * If the file is tracked and has no changes, undefined will be returned + * If the file is tracked and has no changes, a StatusFile of unmodified will be returned * * @param path the file path relative to the server root */ @@ -765,6 +778,7 @@ export namespace Git { date: string; commit_msg: string; pre_commit: string; + is_binary?: boolean; // for single file history } /** Interface for GitCommit request result, @@ -863,6 +877,7 @@ export namespace Git { | 'staged' | 'unstaged' | 'partially-staged' + | 'unmodified' | null; export interface ITagResult { @@ -959,6 +974,7 @@ export enum ContextCommandIDs { gitFileUnstage = 'git:context-unstage', gitFileStage = 'git:context-stage', gitFileTrack = 'git:context-track', + gitFileHistory = 'git:context-history', gitIgnore = 'git:context-ignore', gitIgnoreExtension = 'git:context-ignoreExtension', gitNoAction = 'git:no-action' diff --git a/style/icons/clock.svg b/style/icons/clock.svg new file mode 100644 index 000000000..8b3c96162 --- /dev/null +++ b/style/icons/clock.svg @@ -0,0 +1,13 @@ + diff --git a/tests/test-components/FileItem.spec.tsx b/tests/test-components/FileItem.spec.tsx index 9329bde78..254105875 100644 --- a/tests/test-components/FileItem.spec.tsx +++ b/tests/test-components/FileItem.spec.tsx @@ -29,7 +29,7 @@ describe('FileItem', () => { const component = shallow(); it('should display the full path on hover', () => { expect( - component.find('[title="some/file/path/file-name ● Modified"]') + component.find('[title="some/file/path/file-name • Modified"]') ).toHaveLength(1); }); }); diff --git a/tests/test-components/GitPanel.spec.tsx b/tests/test-components/GitPanel.spec.tsx index ddc97e472..8a29613c2 100644 --- a/tests/test-components/GitPanel.spec.tsx +++ b/tests/test-components/GitPanel.spec.tsx @@ -244,6 +244,9 @@ describe('GitPanel', () => { }, statusChanged: { connect: jest.fn() + }, + selectedHistoryFileChanged: { + connect: jest.fn() } } as any; diff --git a/tests/test-components/HistorySideBar.spec.tsx b/tests/test-components/HistorySideBar.spec.tsx index 2f46ae34f..c7e78e6eb 100644 --- a/tests/test-components/HistorySideBar.spec.tsx +++ b/tests/test-components/HistorySideBar.spec.tsx @@ -7,8 +7,15 @@ import { import 'jest'; import { PastCommitNode } from '../../src/components/PastCommitNode'; +import { GitExtension } from '../../src/model'; +import { nullTranslator } from '@jupyterlab/translation'; +import { FileItem } from '../../src/components/FileItem'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { SinglePastCommitInfo } from '../../src/components/SinglePastCommitInfo'; describe('HistorySideBar', () => { + const trans = nullTranslator.load('jupyterlab-git'); + const props: IHistorySideBarProps = { commits: [ { @@ -20,12 +27,56 @@ describe('HistorySideBar', () => { } ], branches: [], - model: null, + model: { + selectedHistoryFile: null + } as GitExtension, commands: null, - trans: null + trans }; - test('renders commit nodes', () => { + + it('renders the commit nodes', () => { const historySideBar = shallow(); expect(historySideBar.find(PastCommitNode)).toHaveLength(1); + expect(historySideBar.find(SinglePastCommitInfo)).toHaveLength(1); + // Selected history file element + expect(historySideBar.find(FileItem)).toHaveLength(0); + }); + + it('shows a message if no commits are found', () => { + const propsWithoutCommits: IHistorySideBarProps = { ...props, commits: [] }; + const historySideBar = shallow(); + expect(historySideBar.find(PastCommitNode)).toHaveLength(0); + + const noHistoryFound = historySideBar.find('li'); + expect(noHistoryFound).toHaveLength(1); + expect(noHistoryFound.text()).toEqual('No history found.'); + }); + + it('correctly shows the selected history file', () => { + const propsWithSelectedFile: IHistorySideBarProps = { + ...props, + model: { + selectedHistoryFile: { + x: '', + y: '', + to: '/path/to/file', + from: '', + is_binary: null, + status: 'unmodified', + type: {} as DocumentRegistry.IFileType + } + } as GitExtension + }; + + const historySideBar = shallow( + + ); + const selectedHistoryFile = historySideBar.find(FileItem); + expect(selectedHistoryFile).toHaveLength(1); + expect(selectedHistoryFile.prop('file')).toEqual( + propsWithSelectedFile.model.selectedHistoryFile + ); + // Only renders with repository history + expect(historySideBar.find(SinglePastCommitInfo)).toHaveLength(0); }); }); diff --git a/tests/test-components/PastCommitNode.spec.tsx b/tests/test-components/PastCommitNode.spec.tsx index 5c56c8d9d..b883f1f2d 100644 --- a/tests/test-components/PastCommitNode.spec.tsx +++ b/tests/test-components/PastCommitNode.spec.tsx @@ -1,14 +1,16 @@ -import * as React from 'react'; +import { nullTranslator } from '@jupyterlab/translation'; import { shallow } from 'enzyme'; -import { SinglePastCommitInfo } from '../../src/components/SinglePastCommitInfo'; +import 'jest'; +import * as React from 'react'; import { - PastCommitNode, - IPastCommitNodeProps + IPastCommitNodeProps, + PastCommitNode } from '../../src/components/PastCommitNode'; import { Git } from '../../src/tokens'; -import 'jest'; describe('PastCommitNode', () => { + const trans = nullTranslator.load('jupyterlab-git'); + const notMatchingBranches: Git.IBranch[] = [ { is_current_branch: false, @@ -57,7 +59,7 @@ describe('PastCommitNode', () => { }, branches: branches, commands: null, - trans: null + trans }; test('Includes commit info', () => { @@ -76,22 +78,16 @@ describe('PastCommitNode', () => { expect(node.text()).not.toMatch('name2'); }); - test('Doesnt include details at first', () => { - const node = shallow(); - expect(node.find(SinglePastCommitInfo)).toHaveLength(0); - }); - - test('includes details after click', () => { - const node = shallow(); - node.simulate('click'); - expect(node.find(SinglePastCommitInfo)).toHaveLength(1); - }); - - test('hides details after collapse', () => { - const node = shallow(); + test('Toggle show details', () => { + // simulates SinglePastCommitInfo child + const node = shallow( + +
    +
    + ); node.simulate('click'); - expect(node.find(SinglePastCommitInfo)).toHaveLength(1); + expect(node.find('div#singlePastCommitInfo')).toHaveLength(1); node.simulate('click'); - expect(node.find(SinglePastCommitInfo)).toHaveLength(0); + expect(node.find('div#singlePastCommitInfo')).toHaveLength(0); }); });