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
|
||||
import React, {Component} from "react";
|
||||
import "./App.css";
|
||||
import {GraphExplorer} from "./GraphExplorer";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type Props = {};
|
||||
type State = {};
|
||||
|
||||
class App extends Component<Props, State> {
|
||||
export default class App extends React.Component<Props, State> {
|
||||
render() {
|
||||
return (
|
||||
<div className="App" style={{backgroundColor: "#eeeeee"}}>
|
||||
<header
|
||||
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>
|
||||
<h1>Hello, world!</h1>
|
||||
</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