Skip to content

Commit

Permalink
Git commit graph visualization on history sidebar (#1156)
Browse files Browse the repository at this point in the history
* Reformatted git.py file

* Merged with main

* Crude implementation of commit graph in history panel

* Fix bug in git log function

* Get heights of each commit node and feed them to GitCommitGraph

* delete unnecessary codes in GitCommitGraph

* Fix issue with switching repo

* Implement git commit graph that is responsive to widget expansion

* Implement git commit graph responsive to commit history expansion

* Implement node height look up with useState

* Fix commit graph responsiveness after repository change

* Delete commented code and document code

* Clean up code

* Fixed test_single_log.py for a single assert

* Added an extra assertion for test_single_file_log.py

* Remove unnecessary backend codes

* Remove setting command for hiding git commit graph

* remove package-lock.json

* Document codes in generateGraphData and GitCommitGraph

* Fix jest test on HistorySideBar and PastCommitNode

* Testing changes to pytset test_single_file_log

* Modified test case for test_single_file.

* Define getBranch with function declaration

* Change the variable commands to _SVGPath

* Changed condition for returning empty array inside log and updated test_single_file_log to test for empty pre-commits

* Removed graph_log function

* Apply suggestions from code review

* Prettify

Co-authored-by: iflinda <[email protected]>
Co-authored-by: Frédéric Collonval <[email protected]>
Co-authored-by: Frédéric Collonval <[email protected]>
  • Loading branch information
4 people authored Aug 19, 2022
1 parent fe57441 commit 079e15b
Show file tree
Hide file tree
Showing 15 changed files with 2,012 additions and 1,512 deletions.
15 changes: 7 additions & 8 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ async def log(self, path, history_count=10, follow_path=None):
cmd = [
"git",
"log",
"--pretty=format:%H%n%an%n%ar%n%s",
"--pretty=format:%H%n%an%n%ar%n%s%n%P",
("-%d" % history_count),
]
if is_single_file:
Expand Down Expand Up @@ -523,29 +523,28 @@ async def log(self, path, history_count=10, follow_path=None):
)
line_array = parsed_lines

PREVIOUS_COMMIT_OFFSET = 5 if is_single_file else 4
PREVIOUS_COMMIT_OFFSET = 6 if is_single_file else 5
for i in range(0, len(line_array), PREVIOUS_COMMIT_OFFSET):
commit = {
"commit": line_array[i],
"author": line_array[i + 1],
"date": line_array[i + 2],
"commit_msg": line_array[i + 3],
"pre_commit": "",
"pre_commits": line_array[i + 4].split(" ")
if i + 4 < len(line_array) and line_array[i + 4]
else [],
}

if is_single_file:
commit["is_binary"] = line_array[i + 4].startswith("-\t-\t")
commit["is_binary"] = line_array[i + 5].startswith("-\t-\t")

# [insertions, deletions, previous_file_path?, current_file_path]
file_info = line_array[i + 4].split()
file_info = line_array[i + 5].split()

if len(file_info) == 4:
commit["previous_file_path"] = file_info[2]
commit["file_path"] = file_info[-1]

if i + PREVIOUS_COMMIT_OFFSET < len(line_array):
commit["pre_commit"] = line_array[i + PREVIOUS_COMMIT_OFFSET]

result.append(commit)

return {"code": code, "commits": result}
Expand Down
66 changes: 29 additions & 37 deletions jupyterlab_git/tests/test_single_file_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,22 @@ async def test_single_file_log():
with patch("jupyterlab_git.git.execute") as mock_execute:
# Given
process_output = [
"8852729159bef63d7197f8aa26355b387283cb58",
"74baf6e1d18dfa004d9b9105ff86746ab78084eb",
"Lazy Senior Developer",
"2 hours ago",
"1 hours ago",
"Something",
"0 1 folder/test.txt\x00\x00e6d4eed300811e886cadffb16eeed19588eb5eec",
"Lazy Senior Developer",
"18 hours ago",
"move test.txt to folder/test.txt",
"0 0 \x00test.txt\x00folder/test.txt\x00\x00263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
"",
"0 0 test.txt\x00\x008852729159bef63d7197f8aa26355b387283cb58",
"Lazy Senior Developer",
"18 hours ago",
"append more to test.txt",
"1 0 test.txt\x00\x00d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
"Lazy Senior Developer",
"18 hours ago",
"add test.txt to root",
"1 0 test.txt\x00",
"2 hours ago",
"Something Else",
"e6d4eed300811e886cadffb16eeed19588eb5eec",
"0 1 test.txt\x00\x00d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
"Lazy Junior Developer",
"5 hours ago",
"Something More",
"263f762e0aad329c3c01bbd9a28f66403e6cfa5f e6d4eed300811e886cadffb16eeed19588eb5eec",
"1 1 test.txt",
]

mock_execute.return_value = maybe_future((0, "\n".join(process_output), ""))
Expand All @@ -38,39 +37,32 @@ async def test_single_file_log():
"code": 0,
"commits": [
{
"commit": "8852729159bef63d7197f8aa26355b387283cb58",
"commit": "74baf6e1d18dfa004d9b9105ff86746ab78084eb",
"author": "Lazy Senior Developer",
"date": "2 hours ago",
"date": "1 hours ago",
"commit_msg": "Something",
"pre_commit": "e6d4eed300811e886cadffb16eeed19588eb5eec",
"is_binary": False,
"file_path": "folder/test.txt",
},
{
"commit": "e6d4eed300811e886cadffb16eeed19588eb5eec",
"author": "Lazy Senior Developer",
"date": "18 hours ago",
"commit_msg": "move test.txt to folder/test.txt",
"pre_commit": "263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
"pre_commits": [],
"is_binary": False,
"file_path": "folder/test.txt",
"previous_file_path": "test.txt",
"file_path": "test.txt",
},
{
"commit": "263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
"commit": "8852729159bef63d7197f8aa26355b387283cb58",
"author": "Lazy Senior Developer",
"date": "18 hours ago",
"commit_msg": "append more to test.txt",
"pre_commit": "d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
"date": "2 hours ago",
"commit_msg": "Something Else",
"pre_commits": ["e6d4eed300811e886cadffb16eeed19588eb5eec"],
"is_binary": False,
"file_path": "test.txt",
},
{
"commit": "d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
"author": "Lazy Senior Developer",
"date": "18 hours ago",
"commit_msg": "add test.txt to root",
"pre_commit": "",
"author": "Lazy Junior Developer",
"date": "5 hours ago",
"commit_msg": "Something More",
"pre_commits": [
"263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
"e6d4eed300811e886cadffb16eeed19588eb5eec",
],
"is_binary": False,
"file_path": "test.txt",
},
Expand All @@ -89,7 +81,7 @@ async def test_single_file_log():
[
"git",
"log",
"--pretty=format:%H%n%an%n%ar%n%s",
"--pretty=format:%H%n%an%n%ar%n%s%n%P",
"-25",
"-z",
"--numstat",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"@types/react-dom": "^17.0.0",
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2",
"@types/resize-observer-browser": "^0.1.7",
"@typescript-eslint/eslint-plugin": "^4.13.0",
"@typescript-eslint/parser": "^4.13.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
Expand Down
231 changes: 231 additions & 0 deletions src/components/GitCommitGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import * as React from 'react';
import {
generateGraphData,
ICommit,
INode,
IRoute
} from '../generateGraphData';
import { SVGPathData } from '../svgPathData';

const COLOURS = [
'#e11d21',
'#fbca04',
'#009800',
'#006b75',
'#207de5',
'#0052cc',
'#5319e7',
'#f7c6c7',
'#fad8c7',
'#fef2c0',
'#bfe5bf',
'#c7def8',
'#bfdadc',
'#bfd4f2',
'#d4c5f9',
'#cccccc',
'#84b6eb',
'#e6e6e6',
'#cc317c'
];

const DEFAULT_BRANCH_GAP = 10;
const DEFAULT_RADIUS = 3;
const DEFAULT_LINE_WIDTH = 2;

const getColour = function (branch: number) {
const n = COLOURS.length;
return COLOURS[branch % n];
};

const branchCount = (commitNodes: INode[]): number => {
let maxBranch = -1;

commitNodes.forEach(node => {
maxBranch = node.routes.reduce((max, route) => {
return Math.max(max, route.from, route.to);
}, maxBranch);
});

return maxBranch + 1;
};

export interface IGitCommitGraphProps {
/**
* A list of commits with its own hash and its parents' hashes.
*/
commits: ICommit[];
/**
* Callback to inquire the height of a specific SinglePastCommitInfo component.
*/
getNodeHeight: (sha: string) => number;
/**
* Radius of the commit dot.
*/
dotRadius?: number;
/**
* Width of the lines connecting the commit dots.
*/
lineWidth?: number;
}

export class GitCommitGraph extends React.Component<IGitCommitGraphProps> {
constructor(props: IGitCommitGraphProps) {
super(props);
this._graphData = [];
this._x_step = DEFAULT_BRANCH_GAP;
this._dotRadius = this.props.dotRadius || DEFAULT_RADIUS;
this._lineWidth = this.props.lineWidth || DEFAULT_LINE_WIDTH;
}

getGraphData(): INode[] {
this._graphData = generateGraphData(
this.props.commits,
this.props.getNodeHeight
);
return this._graphData;
}

getBranchCount(): number {
return branchCount(this.getGraphData());
}

getWidth(): number {
return (this.getBranchCount() + 0.5) * this._x_step;
}

getHeight(): number {
return (
this._graphData[this._graphData.length - 1].yOffset +
this.props.getNodeHeight(
this.props.commits[this.props.commits.length - 1].sha
)
);
}

renderRouteNode(svgPathDataAttribute: string, branch: number): JSX.Element {
const colour = getColour(branch);
const style = {
stroke: colour,
'stroke-width': this._lineWidth,
fill: 'none'
};

const classes = `commits-graph-branch-${branch}`;

return (
<path d={svgPathDataAttribute} style={style} className={classes}></path>
);
}

renderRoute(yOffset: number, route: IRoute, height: number): JSX.Element {
const { from, to, branch } = route;
const x_step = this._x_step;

const svgPath = new SVGPathData();

const from_x = (from + 1) * x_step;
const from_y = yOffset;
const to_x = (to + 1) * x_step;
const to_y = yOffset + height;

svgPath.moveTo(from_x, from_y);
if (from_x === to_x) {
svgPath.lineTo(to_x, to_y);
} else {
svgPath.bezierCurveTo(
from_x - x_step / 4,
from_y + height / 2,
to_x + x_step / 4,
to_y - height / 2,
to_x,
to_y
);
}

return this.renderRouteNode(svgPath.toString(), branch);
}

renderCommitNode(
x: number,
y: number,
sha: string,
dot_branch: number
): JSX.Element {
const radius = this._dotRadius;

const colour = getColour(dot_branch);
const strokeWidth = 1;
const style = {
stroke: colour,
'stroke-width': strokeWidth,
fill: colour
};

const classes = `commits-graph-branch-${dot_branch}`;

return (
<circle
cx={x}
cy={y}
r={radius}
style={style}
data-sha={sha}
className={classes}
>
<title>{sha.slice(0, 7)}</title>
</circle>
);
}

renderCommit(commit: INode): [JSX.Element, JSX.Element[]] {
const { sha, dot, routes, yOffset } = commit;
const { lateralOffset, branch } = dot;

// draw dot
const x = (lateralOffset + 1) * this._x_step;
const y = yOffset;

const commitNode = this.renderCommitNode(x, y, sha, branch);

const routeNodes = routes.map(route =>
this.renderRoute(
commit.yOffset,
route,
this.props.getNodeHeight(commit.sha)
)
);
return [commitNode, routeNodes];
}

render(): JSX.Element {
// reset lookup table of commit node locations
const allCommitNodes: JSX.Element[] = [];
let allRouteNodes: JSX.Element[] = [];
const commitNodes = this.getGraphData();
commitNodes.forEach(node => {
const commit = node;
const [commitNode, routeNodes] = this.renderCommit(commit);
allCommitNodes.push(commitNode);
allRouteNodes = allRouteNodes.concat(routeNodes);
});

const children = [].concat(allRouteNodes, allCommitNodes);

const height = this.getHeight();
const width = this.getWidth();

const style = { height, width, 'flex-shrink': 0 };

return (
<svg height={height} width={width} style={style}>
{...children}
</svg>
);
}

private _graphData: INode[];
private _x_step: number;
private _dotRadius: number;
private _lineWidth: number;
}
Loading

0 comments on commit 079e15b

Please sign in to comment.