提交 b4801513 编写于 作者: J Jason Park

Show contributors

上级 99a6a60d
...@@ -11,7 +11,7 @@ const { ...@@ -11,7 +11,7 @@ const {
GITHUB_BOT_USERNAME, GITHUB_BOT_USERNAME,
GITHUB_BOT_PASSWORD, GITHUB_BOT_PASSWORD,
GITHUB_ORG = 'algorithm-visualizer', GITHUB_ORG = 'algorithm-visualizer',
GITHUB_REPO_ALGORITHMS = 'algorithms', GITHUB_REPO = 'algorithms',
} = process.env; } = process.env;
const __PROD__ = NODE_ENV === 'production'; const __PROD__ = NODE_ENV === 'production';
...@@ -27,9 +27,7 @@ const githubBotAuth = { ...@@ -27,9 +27,7 @@ const githubBotAuth = {
password: GITHUB_BOT_PASSWORD, password: GITHUB_BOT_PASSWORD,
}; };
const githubOrg = GITHUB_ORG; const githubOrg = GITHUB_ORG;
const githubRepos = { const githubRepo = GITHUB_REPO;
algorithms: GITHUB_REPO_ALGORITHMS
};
const builtPath = path.resolve(__dirname, 'built'); const builtPath = path.resolve(__dirname, 'built');
const frontendBuiltPath = path.resolve(builtPath, 'frontend'); const frontendBuiltPath = path.resolve(builtPath, 'frontend');
...@@ -50,7 +48,7 @@ module.exports = { ...@@ -50,7 +48,7 @@ module.exports = {
githubClientSecret, githubClientSecret,
githubBotAuth, githubBotAuth,
githubOrg, githubOrg,
githubRepos, githubRepo,
frontendBuiltPath, frontendBuiltPath,
backendBuiltPath, backendBuiltPath,
frontendSrcPath, frontendSrcPath,
......
import GitHub from 'github-api';
import { githubBotAuth, githubOrg, githubRepo } from '/environment';
const gh = new GitHub(githubBotAuth);
const repo = gh.getRepo(githubOrg, githubRepo);
export {
repo,
};
\ No newline at end of file
...@@ -2,6 +2,8 @@ import express from 'express'; ...@@ -2,6 +2,8 @@ import express from 'express';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { NotFoundError } from '/common/error'; import { NotFoundError } from '/common/error';
import { exec } from 'child_process';
import { repo } from '/common/github';
const router = express.Router(); const router = express.Router();
...@@ -10,46 +12,98 @@ const createKey = name => name.toLowerCase().replace(/ /g, '-'); ...@@ -10,46 +12,98 @@ const createKey = name => name.toLowerCase().replace(/ /g, '-');
const list = dirPath => fs.readdirSync(dirPath).filter(filename => !filename.startsWith('.')); const list = dirPath => fs.readdirSync(dirPath).filter(filename => !filename.startsWith('.'));
const cacheHierarchy = () => { const cacheHierarchy = () => {
const getCategory = categoryName => { const allFiles = [];
const cacheCategory = categoryName => {
const categoryKey = createKey(categoryName); const categoryKey = createKey(categoryName);
const categoryPath = getPath(categoryName); const categoryPath = getPath(categoryName);
const algorithms = list(categoryPath).map(algorithmName => getAlgorithm(categoryName, algorithmName)); const algorithms = list(categoryPath).map(algorithmName => cacheAlgorithm(categoryName, algorithmName));
return { return {
key: categoryKey, key: categoryKey,
name: categoryName, name: categoryName,
algorithms, algorithms,
}; };
}; };
const getAlgorithm = (categoryName, algorithmName) => { const cacheAlgorithm = (categoryName, algorithmName) => {
const algorithmKey = createKey(algorithmName); const algorithmKey = createKey(algorithmName);
const algorithmPath = getPath(categoryName, algorithmName); const algorithmPath = getPath(categoryName, algorithmName);
const files = list(algorithmPath); const files = list(algorithmPath).map(fileName => cacheFile(categoryName, algorithmName, fileName));
allFiles.push(...files);
return { return {
key: algorithmKey, key: algorithmKey,
name: algorithmName, name: algorithmName,
files, files,
}; };
}; };
return list(getPath()).map(getCategory); const cacheFile = (categoryName, algorithmName, fileName) => {
}; const filePath = getPath(categoryName, algorithmName, fileName);
const content = fs.readFileSync(filePath, 'utf-8');
return {
name: fileName,
path: filePath,
content,
contributors: [],
toJSON: () => fileName,
};
};
const hierarchy = list(getPath()).map(cacheCategory);
const hierarchy = cacheHierarchy(); const commitAuthors = {};
const cacheCommitAuthors = (page, done) => {
repo.listCommits({ per_page: 100, page }).then(({ data }) => {
if (data.length) {
data.forEach(({ sha, commit, author }) => {
if (!author) return;
const { login, avatar_url } = author;
commitAuthors[sha] = { login, avatar_url };
});
cacheCommitAuthors(page + 1, done);
} else done && done();
}).catch(console.error);
};
const cacheContributors = (fileIndex, done) => {
const file = allFiles[fileIndex];
if (file) {
const cwd = getPath();
exec(`git --no-pager log --follow --format="%H" "${file.path}"`, { cwd }, (error, stdout, stderr) => {
if (!error && !stderr) {
const output = stdout.toString().replace(/\n$/, '');
const shas = output.split('\n').reverse();
const contributors = [];
for (const sha of shas) {
const author = commitAuthors[sha];
if (author && !contributors.find(contributor => contributor.login === author.login)) {
contributors.push(author);
}
}
file.contributors = contributors;
}
cacheContributors(fileIndex + 1, done);
});
} else done && done();
};
cacheCommitAuthors(1, () => cacheContributors(0));
return hierarchy;
};
const cachedHierarchy = cacheHierarchy(); // TODO: cache again when webhooked
const getHierarchy = (req, res, next) => { const getHierarchy = (req, res, next) => {
res.json({ hierarchy }); res.json({ hierarchy: cachedHierarchy });
}; };
const getFile = (req, res, next) => { const getFile = (req, res, next) => {
const { categoryKey, algorithmKey, fileName } = req.params; const { categoryKey, algorithmKey, fileName } = req.params;
const category = hierarchy.find(category => category.key === categoryKey); const category = cachedHierarchy.find(category => category.key === categoryKey);
if (!category) return next(new NotFoundError()); if (!category) return next(new NotFoundError());
const algorithm = category.algorithms.find(algorithm => algorithm.key === algorithmKey); const algorithm = category.algorithms.find(algorithm => algorithm.key === algorithmKey);
if (!algorithm) return next(new NotFoundError()); if (!algorithm) return next(new NotFoundError());
if (!algorithm.files.includes(fileName)) return next(new NotFoundError()); const file = algorithm.files.find(file => file.name === fileName);
if (!file) return next(new NotFoundError());
const filePath = getPath(category.name, algorithm.name, fileName); const { content, contributors } = file;
res.sendFile(filePath); res.json({ file: { content, contributors } });
}; };
router.route('/') router.route('/')
......
Subproject commit 417e8c9ffe9bf8411f0099ea01098c0919468a07 Subproject commit f17c57048f65f287d12e13e6f35b606034aeeb31
...@@ -6,7 +6,7 @@ import { Workspace, WSSectionContainer, WSTabContainer } from '/workspace/compon ...@@ -6,7 +6,7 @@ import { Workspace, WSSectionContainer, WSTabContainer } from '/workspace/compon
import { Section } from '/workspace/core'; import { Section } from '/workspace/core';
import { actions as toastActions } from '/reducers/toast'; import { actions as toastActions } from '/reducers/toast';
import { actions as envActions } from '/reducers/env'; import { actions as envActions } from '/reducers/env';
import { HierarchyApi, GitHubApi } from '/apis'; import { GitHubApi, HierarchyApi } from '/apis';
import { tracerManager } from '/core'; import { tracerManager } from '/core';
import styles from './stylesheet.scss'; import styles from './stylesheet.scss';
import 'axios-progress-bar/dist/nprogress.css' import 'axios-progress-bar/dist/nprogress.css'
...@@ -63,7 +63,6 @@ class App extends React.Component { ...@@ -63,7 +63,6 @@ class App extends React.Component {
updateDirectory({ categoryKey = null, algorithmKey = null }) { updateDirectory({ categoryKey = null, algorithmKey = null }) {
if (categoryKey && algorithmKey) { if (categoryKey && algorithmKey) {
this.props.setDirectory(categoryKey, algorithmKey); this.props.setDirectory(categoryKey, algorithmKey);
HierarchyApi.getFile(categoryKey, algorithmKey, 'code.js').then(code => tracerManager.setCode(code));
} }
} }
......
...@@ -28,7 +28,11 @@ class Button extends React.Component { ...@@ -28,7 +28,11 @@ class Button extends React.Component {
return to ? ( return to ? (
<Link to={to} {...props} /> <Link to={to} {...props} />
) : href ? ( ) : href ? (
<a href={href} {...props} /> /^https?:\/\//i.test(href) ? (
<a href={href} rel="noopener" target="_blank" {...props} />
) : (
<a href={href} {...props} />
)
) : ( ) : (
<div {...props} /> <div {...props} />
); );
......
import React from 'react'; import React from 'react';
import AceEditor from 'react-ace'; import AceEditor from 'react-ace';
import { connect } from 'react-redux';
import 'brace/mode/javascript'; import 'brace/mode/javascript';
import 'brace/theme/tomorrow_night_eighties'; import 'brace/theme/tomorrow_night_eighties';
import { tracerManager } from '/core'; import { tracerManager } from '/core';
import { classes } from '/common/util'; import { classes } from '/common/util';
import styles from './stylesheet.scss'; import styles from './stylesheet.scss';
import { HierarchyApi } from '/apis';
import { ContributorsViewer } from '/components';
import { actions as envActions } from '/reducers/env';
// TODO: code should not be reloaded when reopening tab
@connect(
({ env }) => ({
env
}), {
...envActions
}
)
class CodeEditor extends React.Component { class CodeEditor extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { lineIndicator, code } = tracerManager; const { lineIndicator } = tracerManager;
this.state = { this.state = {
lineMarker: this.createLineMarker(lineIndicator), lineMarker: this.createLineMarker(lineIndicator),
code, file: null,
}; };
} }
componentDidMount() { componentDidMount() {
tracerManager.setOnUpdateCode(code => this.setState({ code })); const { categoryKey, algorithmKey } = this.props.env;
this.loadFile(categoryKey, algorithmKey);
tracerManager.setOnUpdateLineIndicator(lineIndicator => this.setState({ lineMarker: this.createLineMarker(lineIndicator) })); tracerManager.setOnUpdateLineIndicator(lineIndicator => this.setState({ lineMarker: this.createLineMarker(lineIndicator) }));
} }
componentWillReceiveProps(nextProps) {
const { categoryKey, algorithmKey } = nextProps.env;
if (categoryKey !== this.props.env.categoryKey ||
algorithmKey !== this.props.env.algorithmKey) {
this.loadFile(categoryKey, algorithmKey);
}
}
componentWillUnmount() { componentWillUnmount() {
tracerManager.setOnUpdateCode(null);
tracerManager.setOnUpdateLineIndicator(null); tracerManager.setOnUpdateLineIndicator(null);
} }
loadFile(categoryKey, algorithmKey) {
HierarchyApi.getFile(categoryKey, algorithmKey, 'code.js')
.then(({ file }) => {
this.setState({ file });
tracerManager.setCode(file.content);
})
.catch(() => this.setState({ file: null }));
}
createLineMarker(lineIndicator) { createLineMarker(lineIndicator) {
if (lineIndicator === null) return null; if (lineIndicator === null) return null;
const { lineNumber, cursor } = lineIndicator; const { lineNumber, cursor } = lineIndicator;
...@@ -42,21 +72,30 @@ class CodeEditor extends React.Component { ...@@ -42,21 +72,30 @@ class CodeEditor extends React.Component {
}; };
} }
handleChangeCode(code) {
const file = { ...this.state.file, content: code };
this.setState({ file });
tracerManager.setCode(code);
}
render() { render() {
const { lineMarker, code } = this.state; const { lineMarker, file } = this.state;
const { className, relativeWeight } = this.props; const { className, relativeWeight } = this.props;
return ( return file && (
<AceEditor <div className={classes(styles.code_editor, className)}>
className={classes(styles.code_editor, className)} <AceEditor
mode="javascript" className={styles.ace_editor}
theme="tomorrow_night_eighties" mode="javascript"
name="code_editor" theme="tomorrow_night_eighties"
editorProps={{ $blockScrolling: true }} name="code_editor"
onChange={code => tracerManager.setCode(code)} editorProps={{ $blockScrolling: true }}
markers={lineMarker ? [lineMarker] : []} onChange={code => this.handleChangeCode(code)}
value={code} markers={lineMarker ? [lineMarker] : []}
width={`${relativeWeight}`} /> // trick to update on resize value={file.content}
width={`${relativeWeight}`} />
<ContributorsViewer contributors={file.contributors} />
</div> // TODO: trick to update on resize
); );
} }
} }
......
@import "~/common/stylesheet/index"; @import "~/common/stylesheet/index";
.code_editor { .code_editor {
width: 100% !important; flex: 1;
height: 100% !important; display: flex;
min-width: 0 !important; flex-direction: column;
min-height: 0 !important; align-items: stretch;
.current_line_marker { .ace_editor {
background: rgba($color-highlight, 0.4); flex: 1;
border: 1px solid $color-highlight;
position: absolute;
width: 100% !important; width: 100% !important;
height: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
animation: line_highlight .1s; .current_line_marker {
} background: rgba($color-highlight, 0.4);
border: 1px solid $color-highlight;
position: absolute;
width: 100% !important;
@keyframes line_highlight { animation: line_highlight .1s;
from {
background: rgba($color-highlight, 0.1);
} }
to {
background: rgba($color-highlight, 0.4); @keyframes line_highlight {
from {
background: rgba($color-highlight, 0.1);
}
to {
background: rgba($color-highlight, 0.4);
}
} }
} }
} }
\ No newline at end of file
import React from 'react';
import { classes } from '/common/util';
import styles from './stylesheet.scss';
import { Button } from '/components';
class ContributorsViewer extends React.Component {
render() {
const { className, contributors } = this.props;
return (
<div className={classes(styles.contributors_viewer, className)}>
<span className={classes(styles.contributor, styles.label)}>Contributed by</span>
{
contributors.map(contributor => (
<Button className={styles.contributor} icon={contributor.avatar_url} key={contributor.login}
href={`https://github.com/${contributor.login}`}>
{contributor.login}
</Button>
))
}
</div>
);
}
}
export default ContributorsViewer;
@import "~/common/stylesheet/index";
.contributors_viewer {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 4px;
background-color: $theme-normal;
.contributor {
height: 28px;
padding: 0 6px;
font-weight: bold;
&.label {
display: flex;
align-items: center;
white-space: nowrap;
}
}
}
\ No newline at end of file
...@@ -2,7 +2,9 @@ import React from 'react'; ...@@ -2,7 +2,9 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { actions as envActions } from '/reducers/env'; import { actions as envActions } from '/reducers/env';
import { HierarchyApi } from '/apis/index'; import { HierarchyApi } from '/apis/index';
import { MarkdownViewer } from '/components'; import { ContributorsViewer, MarkdownViewer } from '/components';
import styles from './stylesheet.scss';
import { classes } from '/common/util';
@connect( @connect(
({ env }) => ({ ({ env }) => ({
...@@ -16,14 +18,14 @@ class DescriptionViewer extends React.Component { ...@@ -16,14 +18,14 @@ class DescriptionViewer extends React.Component {
super(props); super(props);
this.state = { this.state = {
source: null, file: null,
}; };
} }
componentDidMount() { componentDidMount() {
const { categoryKey, algorithmKey } = this.props.env; const { categoryKey, algorithmKey } = this.props.env;
const href = `/algorithm/${categoryKey}/${algorithmKey}`; const href = `/algorithm/${categoryKey}/${algorithmKey}`;
this.loadMarkdown(href); this.loadFile(href);
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
...@@ -31,21 +33,25 @@ class DescriptionViewer extends React.Component { ...@@ -31,21 +33,25 @@ class DescriptionViewer extends React.Component {
if (categoryKey !== this.props.env.categoryKey || if (categoryKey !== this.props.env.categoryKey ||
algorithmKey !== this.props.env.algorithmKey) { algorithmKey !== this.props.env.algorithmKey) {
const href = `/algorithm/${categoryKey}/${algorithmKey}`; const href = `/algorithm/${categoryKey}/${algorithmKey}`;
this.loadMarkdown(href); this.loadFile(href);
} }
} }
loadMarkdown(href) { loadFile(href) {
const [, , categoryKey, algorithmKey] = href.split('/'); const [, , categoryKey, algorithmKey] = href.split('/');
HierarchyApi.getFile(categoryKey, algorithmKey, 'desc.md') HierarchyApi.getFile(categoryKey, algorithmKey, 'desc.md')
.then(source => this.setState({ source })) .then(({ file }) => this.setState({ file }))
.catch(() => this.setState({ source: null })); .catch(() => this.setState({ file: null }));
} }
render() { render() {
const { source } = this.state; const { className } = this.props;
return ( const { file } = this.state;
<MarkdownViewer source={source} onClickLink={href => this.loadMarkdown(href)} /> return file && (
<div className={classes(styles.description_viewer, className)}>
<MarkdownViewer className={styles.markdown_viewer} source={file.content} onClickLink={href => this.loadFile(href)} />
<ContributorsViewer contributors={file.contributors} />
</div>
); );
} }
} }
......
@import "~/common/stylesheet/index"; @import "~/common/stylesheet/index";
.description_viewer { .description_viewer {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
padding: 16px;
font-size: $font-size-large;
overflow-y: auto;
li { .markdown_viewer {
margin: 10px 0px; flex: 1;
}
.complexity_type {
font-weight: bold;
}
a {
text-decoration: underline;
cursor: pointer;
}
h3 {
border-bottom: 1px solid rgb(81, 81, 81);
padding: 5px;
margin: 2px;
} }
} }
\ No newline at end of file
...@@ -8,7 +8,7 @@ class MarkdownViewer extends React.Component { ...@@ -8,7 +8,7 @@ class MarkdownViewer extends React.Component {
const { className, source, onClickLink } = this.props; const { className, source, onClickLink } = this.props;
const link = ({ href, ...rest }) => { const link = ({ href, ...rest }) => {
return /^https?:\/\//.test(href) ? ( return /^https?:\/\//i.test(href) ? (
<a href={href} rel="noopener" target="_blank" {...rest} /> <a href={href} rel="noopener" target="_blank" {...rest} />
) : ( ) : (
<a onClick={() => onClickLink(href)} {...rest} /> <a onClick={() => onClickLink(href)} {...rest} />
......
export { default as App } from './App'; export { default as App } from './App';
export { default as Button } from './Button'; export { default as Button } from './Button';
export { default as CodeEditor } from './CodeEditor'; export { default as CodeEditor } from './CodeEditor';
export { default as ContributorsViewer } from './ContributorsViewer';
export { default as DescriptionViewer } from './DescriptionViewer'; export { default as DescriptionViewer } from './DescriptionViewer';
export { default as Ellipsis } from './Ellipsis'; export { default as Ellipsis } from './Ellipsis';
export { default as ExpandableListItem } from './ExpandableListItem'; export { default as ExpandableListItem } from './ExpandableListItem';
......
...@@ -46,11 +46,6 @@ class TracerManager { ...@@ -46,11 +46,6 @@ class TracerManager {
this.onError = onError; this.onError = onError;
} }
setOnUpdateCode(onUpdateCode) {
this.onUpdateCode = onUpdateCode;
if (this.onUpdateCode) this.onUpdateCode(this.code);
}
render() { render() {
Object.values(this.datas).forEach(data => data.render()); Object.values(this.datas).forEach(data => data.render());
if (this.onRender) this.onRender(this.renderers); if (this.onRender) this.onRender(this.renderers);
...@@ -79,7 +74,6 @@ class TracerManager { ...@@ -79,7 +74,6 @@ class TracerManager {
setCode(code) { setCode(code) {
this.code = code; this.code = code;
this.runInitial(); this.runInitial();
if (this.onUpdateCode) this.onUpdateCode(code);
} }
reset(seed = new Seed()) { reset(seed = new Seed()) {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册