Strip down explorer app to a barebones React app (#88)
Summary: We’re not deleting it because it works with the build system and has the service worker stuff from create-react-app, but we’ll soon repurpose it. Paired with @dandelionmane. Test Plan: The following still work: - `yarn test` - `yarn start` - `yarn build; (cd build; python -m SimpleHTTPServer)` wchargin-branch: dismantle-explorer
This commit is contained in:
parent
ca85fdf234
commit
8f8d9c4564
|
@ -1,51 +0,0 @@
|
||||||
.App {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-areas: "header header"
|
|
||||||
"fileexplorer userexplorer";
|
|
||||||
|
|
||||||
grid-template-rows: 50px 1fr;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
grid-gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #root, .App {
|
|
||||||
height: 100%;
|
|
||||||
font-family: Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anchor-button:visited {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-path {
|
|
||||||
background-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-intro {
|
|
||||||
font-size: large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-title {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-intro {
|
|
||||||
font-size: large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-pane {
|
|
||||||
overflow: scroll;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-explorer {
|
|
||||||
grid-area: fileexplorer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-explorer {
|
|
||||||
grid-area: userexplorer;
|
|
||||||
}
|
|
|
@ -1,31 +1,16 @@
|
||||||
// @flow
|
// @flow
|
||||||
import React, {Component} from "react";
|
|
||||||
import "./App.css";
|
import React from "react";
|
||||||
import {GraphExplorer} from "./GraphExplorer";
|
|
||||||
|
|
||||||
type Props = {};
|
type Props = {};
|
||||||
type State = {};
|
type State = {};
|
||||||
|
|
||||||
class App extends Component<Props, State> {
|
export default class App extends React.Component<Props, State> {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="App" style={{backgroundColor: "#eeeeee"}}>
|
<div>
|
||||||
<header
|
<h1>Hello, world!</h1>
|
||||||
style={{
|
|
||||||
backgroundColor: "#01579B",
|
|
||||||
color: "white",
|
|
||||||
gridArea: "header",
|
|
||||||
textAlign: "center",
|
|
||||||
boxShadow: "0px 2px 2px #aeaeae",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{fontSize: "1.5em"}}>SourceCred Explorer</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<GraphExplorer />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
|
@ -1,95 +0,0 @@
|
||||||
// @flow
|
|
||||||
import React, {Component} from "react";
|
|
||||||
import {buildTree} from "./commitUtils";
|
|
||||||
import type {CommitData, FileTree} from "./commitUtils";
|
|
||||||
|
|
||||||
export class FileExplorer extends Component<{
|
|
||||||
selectedPath: string,
|
|
||||||
onSelectPath: (newPath: string) => void,
|
|
||||||
data: CommitData,
|
|
||||||
}> {
|
|
||||||
render() {
|
|
||||||
// within the FileExplorer, paths start with "./", outside they don't
|
|
||||||
// which is hacky and should be cleaned up
|
|
||||||
const fileNames = Object.keys(this.props.data.fileToCommits).sort();
|
|
||||||
const tree = buildTree(fileNames);
|
|
||||||
const selectPath = (path) => {
|
|
||||||
if (path.startsWith("./")) {
|
|
||||||
path = path.slice(2);
|
|
||||||
}
|
|
||||||
this.props.onSelectPath(path);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="file-explorer plugin-pane">
|
|
||||||
<h3 style={{textAlign: "center"}}>File Explorer</h3>
|
|
||||||
<div style={{fontFamily: "monospace"}}>
|
|
||||||
<FileEntry
|
|
||||||
alwaysExpand={true}
|
|
||||||
name=""
|
|
||||||
path="."
|
|
||||||
tree={tree}
|
|
||||||
onSelectPath={selectPath}
|
|
||||||
selectedPath={`./${this.props.selectedPath}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileEntry extends Component<
|
|
||||||
{
|
|
||||||
name: string,
|
|
||||||
path: string,
|
|
||||||
alwaysExpand: boolean,
|
|
||||||
tree: FileTree,
|
|
||||||
selectedPath: string,
|
|
||||||
onSelectPath: (newPath: string) => void,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expanded: boolean,
|
|
||||||
}
|
|
||||||
> {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.state = {expanded: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const topLevels = Object.keys(this.props.tree);
|
|
||||||
const subEntries = topLevels.map((x) => (
|
|
||||||
<FileEntry
|
|
||||||
key={x}
|
|
||||||
name={x}
|
|
||||||
path={`${this.props.path}/${x}`}
|
|
||||||
alwaysExpand={false}
|
|
||||||
tree={this.props.tree[x]}
|
|
||||||
selectedPath={this.props.selectedPath}
|
|
||||||
onSelectPath={this.props.onSelectPath}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
const isFolder = topLevels.length > 0 && !this.props.alwaysExpand;
|
|
||||||
const toggleExpand = () => this.setState({expanded: !this.state.expanded});
|
|
||||||
const isSelected = this.props.path === this.props.selectedPath;
|
|
||||||
const selectTarget = isSelected ? "." : this.props.path;
|
|
||||||
const onClick = () => this.props.onSelectPath(selectTarget);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={isSelected ? "selected-path" : ""}
|
|
||||||
style={{marginLeft: this.props.path === "." ? 0 : 25}}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{isFolder && (
|
|
||||||
<button style={{marginRight: 3}} onClick={toggleExpand}>
|
|
||||||
»
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<a href="javascript: void 0" onClick={onClick}>
|
|
||||||
{this.props.name}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{(this.state.expanded || this.props.alwaysExpand) && subEntries}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
// @flow
|
|
||||||
// A frontend for visualizing Contribution Graphs
|
|
||||||
|
|
||||||
import React, {Component} from "react";
|
|
||||||
|
|
||||||
type Props = {};
|
|
||||||
type State = {};
|
|
||||||
|
|
||||||
export class GraphExplorer extends Component<Props, State> {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Graph Explorer</h1>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
// @flow
|
|
||||||
import React, {Component} from "react";
|
|
||||||
import {commitWeight, userWeightForPath} from "./commitUtils";
|
|
||||||
import type {CommitData, FileTree} from "./commitUtils";
|
|
||||||
|
|
||||||
export class UserExplorer extends Component<{
|
|
||||||
selectedPath: string,
|
|
||||||
selectedUser: ?string,
|
|
||||||
onSelectUser: (newUser: string) => void,
|
|
||||||
data: CommitData,
|
|
||||||
}> {
|
|
||||||
render() {
|
|
||||||
const weights = userWeightForPath(
|
|
||||||
this.props.selectedPath,
|
|
||||||
this.props.data,
|
|
||||||
commitWeight
|
|
||||||
);
|
|
||||||
const sortedUserWeightTuples = Object.keys(weights)
|
|
||||||
.map((k) => [k, weights[k]])
|
|
||||||
.sort((a, b) => b[1] - a[1]);
|
|
||||||
const entries = sortedUserWeightTuples.map((authorWeight) => {
|
|
||||||
const [author, weight] = authorWeight;
|
|
||||||
return <UserEntry userId={author} weight={weight} key={author} />;
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div className="user-explorer plugin-pane">
|
|
||||||
<h3 style={{textAlign: "center"}}> User Explorer </h3>
|
|
||||||
<div style={{marginLeft: 8, marginRight: 8}}>{entries}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record the cred earned by the user in a given scope.
|
|
||||||
*/
|
|
||||||
class UserEntry extends Component<{
|
|
||||||
userId: string,
|
|
||||||
weight: number,
|
|
||||||
}> {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="user-entry">
|
|
||||||
<span> {this.props.userId} </span>
|
|
||||||
<span> {this.props.weight.toFixed(1)} </span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
// @flow
|
|
||||||
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
export type CommitData = {
|
|
||||||
// TODO improve variable names
|
|
||||||
fileToCommits: {[filename: string]: string[]},
|
|
||||||
commits: {[commithash: string]: Commit},
|
|
||||||
};
|
|
||||||
|
|
||||||
type Commit = {
|
|
||||||
author: string,
|
|
||||||
stats: {[filename: string]: FileStats},
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileStats = {
|
|
||||||
lines: number,
|
|
||||||
added: number,
|
|
||||||
deleted: number,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function commitWeight(commit: Commit, filepath: string): number {
|
|
||||||
// hack - GitPython encodes renames in the filepath. ignore for now.
|
|
||||||
if (filepath.indexOf("=>") !== -1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.sqrt(commit.stats[filepath].lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
function allSelectedFiles(filepath: string, data: CommitData): string[] {
|
|
||||||
const fnames = Object.keys(data.fileToCommits);
|
|
||||||
return fnames.filter((x) => x.startsWith(filepath));
|
|
||||||
}
|
|
||||||
|
|
||||||
function* userWeights(
|
|
||||||
files: string[],
|
|
||||||
data: CommitData,
|
|
||||||
weightFn: WeightFn
|
|
||||||
): Iterable<[string, number]> {
|
|
||||||
for (const file of files) {
|
|
||||||
for (const commitHash of data.fileToCommits[file]) {
|
|
||||||
const commit = data.commits[commitHash];
|
|
||||||
let w;
|
|
||||||
if (commit.stats[file] == null) {
|
|
||||||
// hack - likely due to the GitPython file rename issue
|
|
||||||
w = 0;
|
|
||||||
} else {
|
|
||||||
w = weightFn(commit, file);
|
|
||||||
}
|
|
||||||
yield [commit.author, w];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A weight function determines how much commit contributes to the importance
|
|
||||||
* of a particular file. For instance, a weight function might be defined as,
|
|
||||||
* “the number of lines changed in filepath in commit commit”, or “1 if
|
|
||||||
* filepath was changed in commit, else 0”.
|
|
||||||
*/
|
|
||||||
type WeightFn = (commit: Commit, filepath: string) => number;
|
|
||||||
|
|
||||||
export function userWeightForPath(
|
|
||||||
path: string,
|
|
||||||
data: CommitData,
|
|
||||||
weightFn: WeightFn
|
|
||||||
): {[string]: number} {
|
|
||||||
const userWeightMap = {};
|
|
||||||
const files = allSelectedFiles(path, data);
|
|
||||||
for (const [user, weight] of userWeights(files, data, weightFn)) {
|
|
||||||
if (userWeightMap[user] == null) {
|
|
||||||
userWeightMap[user] = 0;
|
|
||||||
}
|
|
||||||
userWeightMap[user] += weight;
|
|
||||||
}
|
|
||||||
return userWeightMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileTree = {[string]: FileTree};
|
|
||||||
|
|
||||||
export function buildTree(fileNames: string[]): FileTree {
|
|
||||||
const sortedFileNames = fileNames.slice().sort();
|
|
||||||
return _buildTree(sortedFileNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _buildTree(sortedFileNames: string[]): FileTree {
|
|
||||||
const topLevelBuckets: {[root: string]: string[]} = {};
|
|
||||||
for (const fileName of sortedFileNames) {
|
|
||||||
const topLevel = fileName.split("/")[0];
|
|
||||||
const remainder = fileName
|
|
||||||
.split("/")
|
|
||||||
.slice(1)
|
|
||||||
.join("/");
|
|
||||||
if (topLevelBuckets[topLevel] == null) {
|
|
||||||
topLevelBuckets[topLevel] = [];
|
|
||||||
}
|
|
||||||
if (remainder !== "") {
|
|
||||||
topLevelBuckets[topLevel].push(remainder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = {};
|
|
||||||
for (const topLevel of Object.keys(topLevelBuckets)) {
|
|
||||||
const remainders = topLevelBuckets[topLevel];
|
|
||||||
result[topLevel] = _buildTree(remainders);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
|
@ -1,74 +0,0 @@
|
||||||
// @flow
|
|
||||||
import {userWeightForPath, buildTree} from "./commitUtils";
|
|
||||||
|
|
||||||
const exampleData = {
|
|
||||||
fileToCommits: {
|
|
||||||
"foo.txt": ["1"],
|
|
||||||
"bar.txt": ["1", "2"],
|
|
||||||
},
|
|
||||||
commits: {
|
|
||||||
"1": {
|
|
||||||
author: "dandelionmane",
|
|
||||||
stats: {
|
|
||||||
"foo.txt": {lines: 5, added: 3, deleted: 2},
|
|
||||||
"bar.txt": {lines: 100, added: 100, deleted: 0},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
author: "wchargin",
|
|
||||||
stats: {
|
|
||||||
"bar.txt": {lines: 100, added: 50, deleted: 50},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authors: ["dandelionmane", "wchargin"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyData = {fileToCommits: {}, commits: {}, authors: []};
|
|
||||||
|
|
||||||
function weightByNumFilesTouched(commit, filepath) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("userWeightForPath", () => {
|
|
||||||
it("works on empty data", () => {
|
|
||||||
const actual = userWeightForPath("", emptyData, weightByNumFilesTouched);
|
|
||||||
const expected = {};
|
|
||||||
expect(actual).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("works in simple case", () => {
|
|
||||||
const actual = userWeightForPath("", exampleData, weightByNumFilesTouched);
|
|
||||||
const expected = {dandelionmane: 2, wchargin: 1};
|
|
||||||
expect(actual).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects file paths", () => {
|
|
||||||
const actual = userWeightForPath(
|
|
||||||
"bar.txt",
|
|
||||||
exampleData,
|
|
||||||
weightByNumFilesTouched
|
|
||||||
);
|
|
||||||
const expected = {dandelionmane: 1, wchargin: 1};
|
|
||||||
expect(actual).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses custom weight function", () => {
|
|
||||||
const myWeight = (commit, filepath) => commit.stats[filepath].lines;
|
|
||||||
const actual = userWeightForPath("", exampleData, myWeight);
|
|
||||||
const expected = {dandelionmane: 105, wchargin: 100};
|
|
||||||
expect(actual).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildTree", () => {
|
|
||||||
it("handles empty tree", () => {
|
|
||||||
expect(buildTree([])).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles trees", () => {
|
|
||||||
const names = ["foo", "bar/zod", "bar/zoink"];
|
|
||||||
const expected = {foo: {}, bar: {zod: {}, zoink: {}}};
|
|
||||||
expect(buildTree(names)).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue