Skip to content

Commit 079e15b

Browse files
BoscoCHWiflindafcollonval
authored
Git commit graph visualization on history sidebar (#1156)
* 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]>
1 parent fe57441 commit 079e15b

15 files changed

+2012
-1512
lines changed

jupyterlab_git/git.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ async def log(self, path, history_count=10, follow_path=None):
493493
cmd = [
494494
"git",
495495
"log",
496-
"--pretty=format:%H%n%an%n%ar%n%s",
496+
"--pretty=format:%H%n%an%n%ar%n%s%n%P",
497497
("-%d" % history_count),
498498
]
499499
if is_single_file:
@@ -523,29 +523,28 @@ async def log(self, path, history_count=10, follow_path=None):
523523
)
524524
line_array = parsed_lines
525525

526-
PREVIOUS_COMMIT_OFFSET = 5 if is_single_file else 4
526+
PREVIOUS_COMMIT_OFFSET = 6 if is_single_file else 5
527527
for i in range(0, len(line_array), PREVIOUS_COMMIT_OFFSET):
528528
commit = {
529529
"commit": line_array[i],
530530
"author": line_array[i + 1],
531531
"date": line_array[i + 2],
532532
"commit_msg": line_array[i + 3],
533-
"pre_commit": "",
533+
"pre_commits": line_array[i + 4].split(" ")
534+
if i + 4 < len(line_array) and line_array[i + 4]
535+
else [],
534536
}
535537

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

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

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

546-
if i + PREVIOUS_COMMIT_OFFSET < len(line_array):
547-
commit["pre_commit"] = line_array[i + PREVIOUS_COMMIT_OFFSET]
548-
549548
result.append(commit)
550549

551550
return {"code": code, "commits": result}

jupyterlab_git/tests/test_single_file_log.py

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,22 @@ async def test_single_file_log():
1313
with patch("jupyterlab_git.git.execute") as mock_execute:
1414
# Given
1515
process_output = [
16-
"8852729159bef63d7197f8aa26355b387283cb58",
16+
"74baf6e1d18dfa004d9b9105ff86746ab78084eb",
1717
"Lazy Senior Developer",
18-
"2 hours ago",
18+
"1 hours ago",
1919
"Something",
20-
"0 1 folder/test.txt\x00\x00e6d4eed300811e886cadffb16eeed19588eb5eec",
21-
"Lazy Senior Developer",
22-
"18 hours ago",
23-
"move test.txt to folder/test.txt",
24-
"0 0 \x00test.txt\x00folder/test.txt\x00\x00263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
20+
"",
21+
"0 0 test.txt\x00\x008852729159bef63d7197f8aa26355b387283cb58",
2522
"Lazy Senior Developer",
26-
"18 hours ago",
27-
"append more to test.txt",
28-
"1 0 test.txt\x00\x00d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
29-
"Lazy Senior Developer",
30-
"18 hours ago",
31-
"add test.txt to root",
32-
"1 0 test.txt\x00",
23+
"2 hours ago",
24+
"Something Else",
25+
"e6d4eed300811e886cadffb16eeed19588eb5eec",
26+
"0 1 test.txt\x00\x00d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
27+
"Lazy Junior Developer",
28+
"5 hours ago",
29+
"Something More",
30+
"263f762e0aad329c3c01bbd9a28f66403e6cfa5f e6d4eed300811e886cadffb16eeed19588eb5eec",
31+
"1 1 test.txt",
3332
]
3433

3534
mock_execute.return_value = maybe_future((0, "\n".join(process_output), ""))
@@ -38,39 +37,32 @@ async def test_single_file_log():
3837
"code": 0,
3938
"commits": [
4039
{
41-
"commit": "8852729159bef63d7197f8aa26355b387283cb58",
40+
"commit": "74baf6e1d18dfa004d9b9105ff86746ab78084eb",
4241
"author": "Lazy Senior Developer",
43-
"date": "2 hours ago",
42+
"date": "1 hours ago",
4443
"commit_msg": "Something",
45-
"pre_commit": "e6d4eed300811e886cadffb16eeed19588eb5eec",
46-
"is_binary": False,
47-
"file_path": "folder/test.txt",
48-
},
49-
{
50-
"commit": "e6d4eed300811e886cadffb16eeed19588eb5eec",
51-
"author": "Lazy Senior Developer",
52-
"date": "18 hours ago",
53-
"commit_msg": "move test.txt to folder/test.txt",
54-
"pre_commit": "263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
44+
"pre_commits": [],
5545
"is_binary": False,
56-
"file_path": "folder/test.txt",
57-
"previous_file_path": "test.txt",
46+
"file_path": "test.txt",
5847
},
5948
{
60-
"commit": "263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
49+
"commit": "8852729159bef63d7197f8aa26355b387283cb58",
6150
"author": "Lazy Senior Developer",
62-
"date": "18 hours ago",
63-
"commit_msg": "append more to test.txt",
64-
"pre_commit": "d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
51+
"date": "2 hours ago",
52+
"commit_msg": "Something Else",
53+
"pre_commits": ["e6d4eed300811e886cadffb16eeed19588eb5eec"],
6554
"is_binary": False,
6655
"file_path": "test.txt",
6756
},
6857
{
6958
"commit": "d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0",
70-
"author": "Lazy Senior Developer",
71-
"date": "18 hours ago",
72-
"commit_msg": "add test.txt to root",
73-
"pre_commit": "",
59+
"author": "Lazy Junior Developer",
60+
"date": "5 hours ago",
61+
"commit_msg": "Something More",
62+
"pre_commits": [
63+
"263f762e0aad329c3c01bbd9a28f66403e6cfa5f",
64+
"e6d4eed300811e886cadffb16eeed19588eb5eec",
65+
],
7466
"is_binary": False,
7567
"file_path": "test.txt",
7668
},
@@ -89,7 +81,7 @@ async def test_single_file_log():
8981
[
9082
"git",
9183
"log",
92-
"--pretty=format:%H%n%an%n%ar%n%s",
84+
"--pretty=format:%H%n%an%n%ar%n%s%n%P",
9385
"-25",
9486
"-z",
9587
"--numstat",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"@types/react-dom": "^17.0.0",
9797
"@types/react-virtualized-auto-sizer": "^1.0.0",
9898
"@types/react-window": "^1.8.2",
99+
"@types/resize-observer-browser": "^0.1.7",
99100
"@typescript-eslint/eslint-plugin": "^4.13.0",
100101
"@typescript-eslint/parser": "^4.13.0",
101102
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",

src/components/GitCommitGraph.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import * as React from 'react';
2+
import {
3+
generateGraphData,
4+
ICommit,
5+
INode,
6+
IRoute
7+
} from '../generateGraphData';
8+
import { SVGPathData } from '../svgPathData';
9+
10+
const COLOURS = [
11+
'#e11d21',
12+
'#fbca04',
13+
'#009800',
14+
'#006b75',
15+
'#207de5',
16+
'#0052cc',
17+
'#5319e7',
18+
'#f7c6c7',
19+
'#fad8c7',
20+
'#fef2c0',
21+
'#bfe5bf',
22+
'#c7def8',
23+
'#bfdadc',
24+
'#bfd4f2',
25+
'#d4c5f9',
26+
'#cccccc',
27+
'#84b6eb',
28+
'#e6e6e6',
29+
'#cc317c'
30+
];
31+
32+
const DEFAULT_BRANCH_GAP = 10;
33+
const DEFAULT_RADIUS = 3;
34+
const DEFAULT_LINE_WIDTH = 2;
35+
36+
const getColour = function (branch: number) {
37+
const n = COLOURS.length;
38+
return COLOURS[branch % n];
39+
};
40+
41+
const branchCount = (commitNodes: INode[]): number => {
42+
let maxBranch = -1;
43+
44+
commitNodes.forEach(node => {
45+
maxBranch = node.routes.reduce((max, route) => {
46+
return Math.max(max, route.from, route.to);
47+
}, maxBranch);
48+
});
49+
50+
return maxBranch + 1;
51+
};
52+
53+
export interface IGitCommitGraphProps {
54+
/**
55+
* A list of commits with its own hash and its parents' hashes.
56+
*/
57+
commits: ICommit[];
58+
/**
59+
* Callback to inquire the height of a specific SinglePastCommitInfo component.
60+
*/
61+
getNodeHeight: (sha: string) => number;
62+
/**
63+
* Radius of the commit dot.
64+
*/
65+
dotRadius?: number;
66+
/**
67+
* Width of the lines connecting the commit dots.
68+
*/
69+
lineWidth?: number;
70+
}
71+
72+
export class GitCommitGraph extends React.Component<IGitCommitGraphProps> {
73+
constructor(props: IGitCommitGraphProps) {
74+
super(props);
75+
this._graphData = [];
76+
this._x_step = DEFAULT_BRANCH_GAP;
77+
this._dotRadius = this.props.dotRadius || DEFAULT_RADIUS;
78+
this._lineWidth = this.props.lineWidth || DEFAULT_LINE_WIDTH;
79+
}
80+
81+
getGraphData(): INode[] {
82+
this._graphData = generateGraphData(
83+
this.props.commits,
84+
this.props.getNodeHeight
85+
);
86+
return this._graphData;
87+
}
88+
89+
getBranchCount(): number {
90+
return branchCount(this.getGraphData());
91+
}
92+
93+
getWidth(): number {
94+
return (this.getBranchCount() + 0.5) * this._x_step;
95+
}
96+
97+
getHeight(): number {
98+
return (
99+
this._graphData[this._graphData.length - 1].yOffset +
100+
this.props.getNodeHeight(
101+
this.props.commits[this.props.commits.length - 1].sha
102+
)
103+
);
104+
}
105+
106+
renderRouteNode(svgPathDataAttribute: string, branch: number): JSX.Element {
107+
const colour = getColour(branch);
108+
const style = {
109+
stroke: colour,
110+
'stroke-width': this._lineWidth,
111+
fill: 'none'
112+
};
113+
114+
const classes = `commits-graph-branch-${branch}`;
115+
116+
return (
117+
<path d={svgPathDataAttribute} style={style} className={classes}></path>
118+
);
119+
}
120+
121+
renderRoute(yOffset: number, route: IRoute, height: number): JSX.Element {
122+
const { from, to, branch } = route;
123+
const x_step = this._x_step;
124+
125+
const svgPath = new SVGPathData();
126+
127+
const from_x = (from + 1) * x_step;
128+
const from_y = yOffset;
129+
const to_x = (to + 1) * x_step;
130+
const to_y = yOffset + height;
131+
132+
svgPath.moveTo(from_x, from_y);
133+
if (from_x === to_x) {
134+
svgPath.lineTo(to_x, to_y);
135+
} else {
136+
svgPath.bezierCurveTo(
137+
from_x - x_step / 4,
138+
from_y + height / 2,
139+
to_x + x_step / 4,
140+
to_y - height / 2,
141+
to_x,
142+
to_y
143+
);
144+
}
145+
146+
return this.renderRouteNode(svgPath.toString(), branch);
147+
}
148+
149+
renderCommitNode(
150+
x: number,
151+
y: number,
152+
sha: string,
153+
dot_branch: number
154+
): JSX.Element {
155+
const radius = this._dotRadius;
156+
157+
const colour = getColour(dot_branch);
158+
const strokeWidth = 1;
159+
const style = {
160+
stroke: colour,
161+
'stroke-width': strokeWidth,
162+
fill: colour
163+
};
164+
165+
const classes = `commits-graph-branch-${dot_branch}`;
166+
167+
return (
168+
<circle
169+
cx={x}
170+
cy={y}
171+
r={radius}
172+
style={style}
173+
data-sha={sha}
174+
className={classes}
175+
>
176+
<title>{sha.slice(0, 7)}</title>
177+
</circle>
178+
);
179+
}
180+
181+
renderCommit(commit: INode): [JSX.Element, JSX.Element[]] {
182+
const { sha, dot, routes, yOffset } = commit;
183+
const { lateralOffset, branch } = dot;
184+
185+
// draw dot
186+
const x = (lateralOffset + 1) * this._x_step;
187+
const y = yOffset;
188+
189+
const commitNode = this.renderCommitNode(x, y, sha, branch);
190+
191+
const routeNodes = routes.map(route =>
192+
this.renderRoute(
193+
commit.yOffset,
194+
route,
195+
this.props.getNodeHeight(commit.sha)
196+
)
197+
);
198+
return [commitNode, routeNodes];
199+
}
200+
201+
render(): JSX.Element {
202+
// reset lookup table of commit node locations
203+
const allCommitNodes: JSX.Element[] = [];
204+
let allRouteNodes: JSX.Element[] = [];
205+
const commitNodes = this.getGraphData();
206+
commitNodes.forEach(node => {
207+
const commit = node;
208+
const [commitNode, routeNodes] = this.renderCommit(commit);
209+
allCommitNodes.push(commitNode);
210+
allRouteNodes = allRouteNodes.concat(routeNodes);
211+
});
212+
213+
const children = [].concat(allRouteNodes, allCommitNodes);
214+
215+
const height = this.getHeight();
216+
const width = this.getWidth();
217+
218+
const style = { height, width, 'flex-shrink': 0 };
219+
220+
return (
221+
<svg height={height} width={width} style={style}>
222+
{...children}
223+
</svg>
224+
);
225+
}
226+
227+
private _graphData: INode[];
228+
private _x_step: number;
229+
private _dotRadius: number;
230+
private _lineWidth: number;
231+
}

0 commit comments

Comments
 (0)