From b4801513dcdf45088451b50559d6940110781414 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 1 May 2018 04:20:27 -0500 Subject: [PATCH] Show contributors --- environment.js | 8 +- src/backend/common/github.js | 9 +++ src/backend/controllers/hierarchy.js | 78 ++++++++++++++++--- src/backend/public/algorithms | 2 +- src/frontend/components/App/index.jsx | 3 +- src/frontend/components/Button/index.jsx | 6 +- src/frontend/components/CodeEditor/index.jsx | 71 +++++++++++++---- .../components/CodeEditor/stylesheet.scss | 38 +++++---- .../components/ContributorsViewer/index.jsx | 27 +++++++ .../ContributorsViewer/stylesheet.scss | 21 +++++ .../components/DescriptionViewer/index.jsx | 26 ++++--- .../DescriptionViewer/stylesheet.scss | 23 +----- .../components/MarkdownViewer/index.jsx | 2 +- src/frontend/components/index.js | 1 + src/frontend/core/tracerManager.jsx | 6 -- 15 files changed, 232 insertions(+), 89 deletions(-) create mode 100644 src/backend/common/github.js create mode 100644 src/frontend/components/ContributorsViewer/index.jsx create mode 100644 src/frontend/components/ContributorsViewer/stylesheet.scss diff --git a/environment.js b/environment.js index fc365b1..0a0a28f 100644 --- a/environment.js +++ b/environment.js @@ -11,7 +11,7 @@ const { GITHUB_BOT_USERNAME, GITHUB_BOT_PASSWORD, GITHUB_ORG = 'algorithm-visualizer', - GITHUB_REPO_ALGORITHMS = 'algorithms', + GITHUB_REPO = 'algorithms', } = process.env; const __PROD__ = NODE_ENV === 'production'; @@ -27,9 +27,7 @@ const githubBotAuth = { password: GITHUB_BOT_PASSWORD, }; const githubOrg = GITHUB_ORG; -const githubRepos = { - algorithms: GITHUB_REPO_ALGORITHMS -}; +const githubRepo = GITHUB_REPO; const builtPath = path.resolve(__dirname, 'built'); const frontendBuiltPath = path.resolve(builtPath, 'frontend'); @@ -50,7 +48,7 @@ module.exports = { githubClientSecret, githubBotAuth, githubOrg, - githubRepos, + githubRepo, frontendBuiltPath, backendBuiltPath, frontendSrcPath, diff --git a/src/backend/common/github.js b/src/backend/common/github.js new file mode 100644 index 0000000..f28cc5f --- /dev/null +++ b/src/backend/common/github.js @@ -0,0 +1,9 @@ +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 diff --git a/src/backend/controllers/hierarchy.js b/src/backend/controllers/hierarchy.js index f68883e..dc9d36f 100644 --- a/src/backend/controllers/hierarchy.js +++ b/src/backend/controllers/hierarchy.js @@ -2,6 +2,8 @@ import express from 'express'; import fs from 'fs'; import path from 'path'; import { NotFoundError } from '/common/error'; +import { exec } from 'child_process'; +import { repo } from '/common/github'; const router = express.Router(); @@ -10,46 +12,98 @@ const createKey = name => name.toLowerCase().replace(/ /g, '-'); const list = dirPath => fs.readdirSync(dirPath).filter(filename => !filename.startsWith('.')); const cacheHierarchy = () => { - const getCategory = categoryName => { + const allFiles = []; + const cacheCategory = categoryName => { const categoryKey = createKey(categoryName); const categoryPath = getPath(categoryName); - const algorithms = list(categoryPath).map(algorithmName => getAlgorithm(categoryName, algorithmName)); + const algorithms = list(categoryPath).map(algorithmName => cacheAlgorithm(categoryName, algorithmName)); return { key: categoryKey, name: categoryName, algorithms, }; }; - const getAlgorithm = (categoryName, algorithmName) => { + const cacheAlgorithm = (categoryName, algorithmName) => { const algorithmKey = createKey(algorithmName); const algorithmPath = getPath(categoryName, algorithmName); - const files = list(algorithmPath); + const files = list(algorithmPath).map(fileName => cacheFile(categoryName, algorithmName, fileName)); + allFiles.push(...files); return { key: algorithmKey, name: algorithmName, 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) => { - res.json({ hierarchy }); + res.json({ hierarchy: cachedHierarchy }); }; const getFile = (req, res, next) => { 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()); const algorithm = category.algorithms.find(algorithm => algorithm.key === algorithmKey); 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); - res.sendFile(filePath); + const { content, contributors } = file; + res.json({ file: { content, contributors } }); }; router.route('/') diff --git a/src/backend/public/algorithms b/src/backend/public/algorithms index 417e8c9..f17c570 160000 --- a/src/backend/public/algorithms +++ b/src/backend/public/algorithms @@ -1 +1 @@ -Subproject commit 417e8c9ffe9bf8411f0099ea01098c0919468a07 +Subproject commit f17c57048f65f287d12e13e6f35b606034aeeb31 diff --git a/src/frontend/components/App/index.jsx b/src/frontend/components/App/index.jsx index 493f6d8..bc56c5f 100644 --- a/src/frontend/components/App/index.jsx +++ b/src/frontend/components/App/index.jsx @@ -6,7 +6,7 @@ import { Workspace, WSSectionContainer, WSTabContainer } from '/workspace/compon import { Section } from '/workspace/core'; import { actions as toastActions } from '/reducers/toast'; import { actions as envActions } from '/reducers/env'; -import { HierarchyApi, GitHubApi } from '/apis'; +import { GitHubApi, HierarchyApi } from '/apis'; import { tracerManager } from '/core'; import styles from './stylesheet.scss'; import 'axios-progress-bar/dist/nprogress.css' @@ -63,7 +63,6 @@ class App extends React.Component { updateDirectory({ categoryKey = null, algorithmKey = null }) { if (categoryKey && algorithmKey) { this.props.setDirectory(categoryKey, algorithmKey); - HierarchyApi.getFile(categoryKey, algorithmKey, 'code.js').then(code => tracerManager.setCode(code)); } } diff --git a/src/frontend/components/Button/index.jsx b/src/frontend/components/Button/index.jsx index 296a586..5deabdf 100644 --- a/src/frontend/components/Button/index.jsx +++ b/src/frontend/components/Button/index.jsx @@ -28,7 +28,11 @@ class Button extends React.Component { return to ? ( ) : href ? ( - + /^https?:\/\//i.test(href) ? ( + + ) : ( + + ) ) : (
); diff --git a/src/frontend/components/CodeEditor/index.jsx b/src/frontend/components/CodeEditor/index.jsx index 751652b..0170e74 100644 --- a/src/frontend/components/CodeEditor/index.jsx +++ b/src/frontend/components/CodeEditor/index.jsx @@ -1,32 +1,62 @@ import React from 'react'; import AceEditor from 'react-ace'; +import { connect } from 'react-redux'; import 'brace/mode/javascript'; import 'brace/theme/tomorrow_night_eighties'; import { tracerManager } from '/core'; import { classes } from '/common/util'; 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 { constructor(props) { super(props); - const { lineIndicator, code } = tracerManager; + const { lineIndicator } = tracerManager; this.state = { lineMarker: this.createLineMarker(lineIndicator), - code, + file: null, }; } 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) })); } + componentWillReceiveProps(nextProps) { + const { categoryKey, algorithmKey } = nextProps.env; + if (categoryKey !== this.props.env.categoryKey || + algorithmKey !== this.props.env.algorithmKey) { + this.loadFile(categoryKey, algorithmKey); + } + } + componentWillUnmount() { - tracerManager.setOnUpdateCode(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) { if (lineIndicator === null) return null; const { lineNumber, cursor } = lineIndicator; @@ -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() { - const { lineMarker, code } = this.state; + const { lineMarker, file } = this.state; const { className, relativeWeight } = this.props; - return ( - tracerManager.setCode(code)} - markers={lineMarker ? [lineMarker] : []} - value={code} - width={`${relativeWeight}`} /> // trick to update on resize + return file && ( +
+ this.handleChangeCode(code)} + markers={lineMarker ? [lineMarker] : []} + value={file.content} + width={`${relativeWeight}`} /> + +
// TODO: trick to update on resize ); } } diff --git a/src/frontend/components/CodeEditor/stylesheet.scss b/src/frontend/components/CodeEditor/stylesheet.scss index cbd2664..e95ab84 100644 --- a/src/frontend/components/CodeEditor/stylesheet.scss +++ b/src/frontend/components/CodeEditor/stylesheet.scss @@ -1,26 +1,34 @@ @import "~/common/stylesheet/index"; .code_editor { - width: 100% !important; - height: 100% !important; - min-width: 0 !important; - min-height: 0 !important; + flex: 1; + display: flex; + flex-direction: column; + align-items: stretch; - .current_line_marker { - background: rgba($color-highlight, 0.4); - border: 1px solid $color-highlight; - position: absolute; + .ace_editor { + flex: 1; 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 { - from { - background: rgba($color-highlight, 0.1); + animation: line_highlight .1s; } - 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 diff --git a/src/frontend/components/ContributorsViewer/index.jsx b/src/frontend/components/ContributorsViewer/index.jsx new file mode 100644 index 0000000..257b505 --- /dev/null +++ b/src/frontend/components/ContributorsViewer/index.jsx @@ -0,0 +1,27 @@ +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 ( +
+ Contributed by + { + contributors.map(contributor => ( + + )) + } +
+ ); + } +} + +export default ContributorsViewer; + diff --git a/src/frontend/components/ContributorsViewer/stylesheet.scss b/src/frontend/components/ContributorsViewer/stylesheet.scss new file mode 100644 index 0000000..3aa0ae5 --- /dev/null +++ b/src/frontend/components/ContributorsViewer/stylesheet.scss @@ -0,0 +1,21 @@ +@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 diff --git a/src/frontend/components/DescriptionViewer/index.jsx b/src/frontend/components/DescriptionViewer/index.jsx index 1b97c46..9124baf 100644 --- a/src/frontend/components/DescriptionViewer/index.jsx +++ b/src/frontend/components/DescriptionViewer/index.jsx @@ -2,7 +2,9 @@ import React from 'react'; import { connect } from 'react-redux'; import { actions as envActions } from '/reducers/env'; 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( ({ env }) => ({ @@ -16,14 +18,14 @@ class DescriptionViewer extends React.Component { super(props); this.state = { - source: null, + file: null, }; } componentDidMount() { const { categoryKey, algorithmKey } = this.props.env; const href = `/algorithm/${categoryKey}/${algorithmKey}`; - this.loadMarkdown(href); + this.loadFile(href); } componentWillReceiveProps(nextProps) { @@ -31,21 +33,25 @@ class DescriptionViewer extends React.Component { if (categoryKey !== this.props.env.categoryKey || algorithmKey !== this.props.env.algorithmKey) { const href = `/algorithm/${categoryKey}/${algorithmKey}`; - this.loadMarkdown(href); + this.loadFile(href); } } - loadMarkdown(href) { + loadFile(href) { const [, , categoryKey, algorithmKey] = href.split('/'); HierarchyApi.getFile(categoryKey, algorithmKey, 'desc.md') - .then(source => this.setState({ source })) - .catch(() => this.setState({ source: null })); + .then(({ file }) => this.setState({ file })) + .catch(() => this.setState({ file: null })); } render() { - const { source } = this.state; - return ( - this.loadMarkdown(href)} /> + const { className } = this.props; + const { file } = this.state; + return file && ( +
+ this.loadFile(href)} /> + +
); } } diff --git a/src/frontend/components/DescriptionViewer/stylesheet.scss b/src/frontend/components/DescriptionViewer/stylesheet.scss index b6ea300..ff23e8a 100644 --- a/src/frontend/components/DescriptionViewer/stylesheet.scss +++ b/src/frontend/components/DescriptionViewer/stylesheet.scss @@ -1,29 +1,12 @@ @import "~/common/stylesheet/index"; .description_viewer { + flex: 1; display: flex; flex-direction: column; align-items: stretch; - padding: 16px; - font-size: $font-size-large; - overflow-y: auto; - li { - margin: 10px 0px; - } - - .complexity_type { - font-weight: bold; - } - - a { - text-decoration: underline; - cursor: pointer; - } - - h3 { - border-bottom: 1px solid rgb(81, 81, 81); - padding: 5px; - margin: 2px; + .markdown_viewer { + flex: 1; } } \ No newline at end of file diff --git a/src/frontend/components/MarkdownViewer/index.jsx b/src/frontend/components/MarkdownViewer/index.jsx index cf44eda..f85b2c4 100644 --- a/src/frontend/components/MarkdownViewer/index.jsx +++ b/src/frontend/components/MarkdownViewer/index.jsx @@ -8,7 +8,7 @@ class MarkdownViewer extends React.Component { const { className, source, onClickLink } = this.props; const link = ({ href, ...rest }) => { - return /^https?:\/\//.test(href) ? ( + return /^https?:\/\//i.test(href) ? (
) : ( onClickLink(href)} {...rest} /> diff --git a/src/frontend/components/index.js b/src/frontend/components/index.js index 7cc220f..7da29de 100644 --- a/src/frontend/components/index.js +++ b/src/frontend/components/index.js @@ -1,6 +1,7 @@ export { default as App } from './App'; export { default as Button } from './Button'; export { default as CodeEditor } from './CodeEditor'; +export { default as ContributorsViewer } from './ContributorsViewer'; export { default as DescriptionViewer } from './DescriptionViewer'; export { default as Ellipsis } from './Ellipsis'; export { default as ExpandableListItem } from './ExpandableListItem'; diff --git a/src/frontend/core/tracerManager.jsx b/src/frontend/core/tracerManager.jsx index afa5c24..156f761 100644 --- a/src/frontend/core/tracerManager.jsx +++ b/src/frontend/core/tracerManager.jsx @@ -46,11 +46,6 @@ class TracerManager { this.onError = onError; } - setOnUpdateCode(onUpdateCode) { - this.onUpdateCode = onUpdateCode; - if (this.onUpdateCode) this.onUpdateCode(this.code); - } - render() { Object.values(this.datas).forEach(data => data.render()); if (this.onRender) this.onRender(this.renderers); @@ -79,7 +74,6 @@ class TracerManager { setCode(code) { this.code = code; this.runInitial(); - if (this.onUpdateCode) this.onUpdateCode(code); } reset(seed = new Seed()) { -- GitLab