Merge branch 'develop'

This commit is contained in:
EnixCoda 2018-03-01 17:49:44 +08:00
commit b24e4430ab
27 changed files with 6100 additions and 1 deletions

17
.babelrc Normal file
View file

@ -0,0 +1,17 @@
{
"presets": [
[
"babel-preset-env",
{
"targets": {
"chrome": "60"
}
}
],
"react"
],
"plugins": [
"transform-class-properties",
["transform-object-rest-spread", { "useBuiltIns": true }]
]
}

26
.eslintrc.json Normal file
View file

@ -0,0 +1,26 @@
{
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"sourceType": "module"
},
"rules": {
"no-const-assign": "warn",
"no-this-before-super": "warn",
"no-undef": "warn",
"no-unreachable": "warn",
"no-unused-vars": "warn",
"constructor-super": "warn",
"valid-typeof": "warn"
},
"plugins": [
"class-property"
]
}

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.vscode
node_modules
tmp
dist
src/assets/icons/octicons

View file

@ -15,7 +15,7 @@ Yet another Chrome extension for GitHub. Inspired by [Octotree](https://github.c
- [x] support access token (for private repo and frequent usage)
- [x] fancy loading animation ;)
- [x] use [octicons](https://octicons.github.com)
- [x] light size (40kb) & low memory usage
- [x] light size (26kb) & low memory usage
- [more WIP](https://github.com/EnixCoda/Gitako/projects/1)
### Download

33
generate-octicon.js Normal file
View file

@ -0,0 +1,33 @@
const octicons = require('octicons')
const fs = require('fs')
const pathToIconsFolder = `${__dirname}/src/assets/icons/octicons`
function generateIconSVGFiles() {
Object.values(octicons).forEach(icon => {
fs.writeFile(
`${pathToIconsFolder}/${icon.symbol}.svg`,
icon.toSVG({ xmlns: 'http://www.w3.org/2000/svg' }),
err => {
if (err) throw err
}
)
})
}
fs.exists(pathToIconsFolder, exists => {
if (exists) {
fs.lstat(pathToIconsFolder, (err, stats) => {
if (err) throw err
if (!stats.isDirectory()) {
throw new Error(`${pathToIconsFolder} is not a folder!`)
}
generateIconSVGFiles()
})
} else {
fs.mkdir(pathToIconsFolder, err => {
if (err) throw err
generateIconSVGFiles()
})
}
})

44
package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "gitako",
"version": "0.1.4",
"description": "yet another extension for GitHub",
"repository": "https://github.com/EnixCoda/Gitako",
"author": "EnixCoda",
"license": "MIT",
"private": true,
"scripts": {
"start": "webpack --watch",
"dev": "webpack",
"prod": "NODE_ENV=production webpack && cd dist && rm -f ./gitako.zip && zip gitako.zip -r *"
},
"dependencies": {
"octicons": "^7.1.0",
"pjax": "^0.2.4",
"preact": "^8.2.6"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-react-require": "^3.0.0",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"copy-webpack-plugin": "^4.2.0",
"css-loader": "^0.28.7",
"file-loader": "^1.1.5",
"less": "^2.7.3",
"less-loader": "^4.0.5",
"style-loader": "^0.19.0",
"svgr": "^1.6.0",
"uglifyjs-webpack-plugin": "^1.1.1",
"url-loader": "^0.6.2",
"webpack": "^3.8.1"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"semi": false,
"trailingComma": "es5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -0,0 +1,201 @@
import preact from 'preact'
/** @jsx preact.h */
import SearchBar from './SearchBar'
import Node from './Node'
import DOMHelper from '../utils/DOMHelper'
import treeParser from '../utils/treeParser'
import URLHelper from '../utils/URLHelper'
import VisibleNodesGenerator from '../utils/VisibleNodesGenerator'
export default class List extends preact.Component {
state = {
// generated by this.visibleNodesGenerator
visibleNodes: null,
}
props = {
treeData: null,
metaData: null,
}
tasksAfterRender = []
visibleNodesGenerator = new VisibleNodesGenerator()
componentWillMount() {
const { treeData, metaData } = this.props
const { root, nodes } = treeParser.parse(treeData, metaData)
this.visibleNodesGenerator.plantTree(root, nodes)
const currentPath = URLHelper.getCurrentPath(true)
this.tasksAfterRender.push(DOMHelper.focusSearchInput)
if (currentPath.length) {
const nodeExpandedTo = this.visibleNodesGenerator.expandTo(currentPath)
this.visibleNodesGenerator.focusNode(nodeExpandedTo)
const { nodes } = this.visibleNodesGenerator.visibleNodes
this.tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(nodes.indexOf(nodeExpandedTo)))
}
this.updateVisibleNodes()
}
componentDidMount() {
this.execAfterRender()
}
componentDidUpdate(prevProps, prevState) {
this.execAfterRender()
}
execAfterRender() {
for (const task of this.tasksAfterRender) {
task()
}
this.tasksAfterRender.length = 0
}
updateVisibleNodes() {
const { visibleNodes } = this.visibleNodesGenerator
this.setState({ visibleNodes })
this.tasksAfterRender.push(() => DOMHelper.attachPJAX('gitako'))
}
handleKeyDown = event => {
const { key } = event
const { visibleNodes: { nodes, focusedNode, expandedNodes, depths } } = this.state
let shouldStopPropagation = true // prevent body scrolling
if (focusedNode) {
const focusedNodeIndex = nodes.indexOf(focusedNode)
switch (key) {
case 'ArrowUp':
// focus on previous node
if (focusedNodeIndex === 0) {
this.focusNode(null)
this.tasksAfterRender.push(DOMHelper.focusSearchInput)
} else {
this.focusNode(nodes[focusedNodeIndex - 1])
}
break
case 'ArrowDown':
// focus on next node
if (focusedNodeIndex + 1 < nodes.length) {
this.focusNode(nodes[focusedNodeIndex + 1])
} else {
this.focusNode(null)
this.tasksAfterRender.push(DOMHelper.focusSearchInput)
}
break
case 'ArrowLeft':
// collapse node or go to parent node
if (expandedNodes.has(focusedNode)) {
this.setExpand(focusedNode, false)
} else {
// go forward to the start of the list, find the closest node with lower depth
let indexOfParentNode = focusedNodeIndex
const focusedNodeDepth = depths.get(nodes[focusedNodeIndex])
while (
indexOfParentNode !== -1 &&
depths.get(nodes[indexOfParentNode]) >= focusedNodeDepth
) {
--indexOfParentNode
}
if (indexOfParentNode !== -1) {
this.focusNode(nodes[indexOfParentNode])
}
}
break
// consider the two keys as 'confirm' key
case 'ArrowRight':
case 'Enter':
// expand node or redirect to file page
if (focusedNode.type === 'tree') {
this.setExpand(focusedNode, true)
} else {
// simulate click to trigger pjax
DOMHelper.clickOnNodeElement(focusedNodeIndex)
}
break
default:
shouldStopPropagation = false
}
} else {
// now search input is focused
if (nodes.length) {
switch (key) {
case 'ArrowDown':
this.focusNode(nodes[0])
break
case 'ArrowUp':
this.focusNode(nodes[nodes.length - 1])
break
default:
shouldStopPropagation = false
}
} else {
shouldStopPropagation = false
}
}
if (shouldStopPropagation) {
event.stopPropagation()
event.preventDefault()
}
}
handleSearchKeyChange = async event => {
const searchKey = event.target.value
await this.visibleNodesGenerator.search(searchKey)
this.updateVisibleNodes()
}
setExpand = (node, expand) => {
this.visibleNodesGenerator.setExpand(node, expand)
this.focusNode(node)
this.tasksAfterRender.push(DOMHelper.focusSearchInput)
}
toggleNodeExpand = node => {
this.visibleNodesGenerator.toggleExpand(node)
this.focusNode(node)
this.tasksAfterRender.push(DOMHelper.focusFileExplorer)
}
focusNode = node => {
this.visibleNodesGenerator.focusNode(node)
if (node) {
// when focus a node not in viewport(by keyboard), scroll to it
const { visibleNodes: { nodes } } = this.state
const indexOfToBeFocusedNode = nodes.indexOf(node)
this.tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(indexOfToBeFocusedNode))
this.tasksAfterRender.push(DOMHelper.focusSearchInput)
}
this.updateVisibleNodes()
}
render() {
const { visibleNodes: { nodes, depths, focusedNode, expandedNodes } } = this.state
return (
<div className={`file-explorer`} tabIndex={-1} onKeyDown={this.handleKeyDown}>
<SearchBar onSearchKeyChange={this.handleSearchKeyChange} />
{nodes.length === 0 ? (
<label className={'no-results'}>No results found.</label>
) : (
<div className={'files'}>
{nodes.map(node => (
<Node
key={node.path}
node={node}
depth={depths.get(node)}
focused={focusedNode === node}
expanded={expandedNodes.has(node)}
toggleExpand={this.toggleNodeExpand.bind(null, node)}
/>
))}
</div>
)}
</div>
)
}
}

View file

@ -0,0 +1,142 @@
import preact from 'preact'
/** @jsx preact.h */
import FileExplorer from './FileExplorer'
import Logo from './Logo'
import MetaBar from './MetaBar'
import SettingsBar from './SettingsBar'
import cx from '../utils/cx'
import DOMHelper, { REPO_TYPE_PRIVATE } from '../utils/DOMHelper'
import GitHubHelper, { NOT_FOUND } from '../utils/GitHubHelper'
import storageHelper from '../utils/storageHelper'
import URLHelper from '../utils/URLHelper'
export default class GitakoSideBar extends preact.Component {
state = {
// whether Gitako side bar should be shown
shouldShow: false,
// whether show settings pane
showSettings: false,
// whether pending for network request
loading: true,
// whether failed loading the repo due to it is private
errorDueToPrivateRepo: false,
// got access token for GitHub
hasAccessToken: null,
// meta data for the repository
metaData: null,
// file tree data
treeData: null,
}
async componentWillMount() {
try {
const metaDataFromUrl = URLHelper.parse()
this.setState({ metaData: metaDataFromUrl })
this.decorateGitHubPageContent()
const accessToken = await storageHelper.getAccessToken()
this.setState({ hasAccessToken: !!accessToken })
const metaDataFromAPI = await GitHubHelper.getRepoMeta({ ...metaDataFromUrl, accessToken })
const branchName = metaDataFromUrl.branchName || metaDataFromAPI['default_branch']
const metaData = { ...metaDataFromUrl, branchName, api: metaDataFromAPI }
this.setState({ metaData })
this.setShouldShow(URLHelper.isInCodePage(metaData))
const treeData = await GitHubHelper.getTreeData({ ...metaData, accessToken })
this.setState({ treeData, loading: false })
window.addEventListener('pjax:send', this.onPJAXStart)
window.addEventListener('pjax:complete', this.onPJAXEnd)
} catch (err) {
// TODO: detect request time exceeds limit
if (err.message === NOT_FOUND) {
const repoPageType = await DOMHelper.getRepoPageType()
const errorDueToPrivateRepo = repoPageType === REPO_TYPE_PRIVATE
this.setState({
showSettings: repoPageType !== null,
errorDueToPrivateRepo,
})
this.setShouldShow(errorDueToPrivateRepo)
} else {
console.error(err)
this.setShouldShow(false)
}
}
}
onPJAXStart = () => {
this.setState({ loading: true })
}
onPJAXEnd = (() => {
let lastLocation
return () => {
if (location.href !== lastLocation) {
lastLocation = location.href
const { metaData } = this.state
this.setState({ loading: false })
this.setShouldShow(URLHelper.isInCodePage(metaData))
this.decorateGitHubPageContent()
DOMHelper.scrollToRepoContent()
DOMHelper.focusSearchInput()
}
}
})()
decorateGitHubPageContent() {
DOMHelper.attachCopyFileBtn()
DOMHelper.attachCopySnippet()
DOMHelper.attachPJAX('github')
}
setShouldShow = shouldShow => {
this.setState({ shouldShow })
DOMHelper.setBodyIndent(shouldShow)
}
toggleShowSettings = () => {
const { showSettings } = this.state
this.setState({ showSettings: !showSettings })
}
renderPrivateRepoError() {
return (
<div className={'description'}>
<h5>Access Denied</h5>
<p>
Gitako need access token with proper scopes (recommended: repo) to read this repository's
data.
</p>
</div>
)
}
render() {
const {
shouldShow,
loading,
metaData,
treeData,
showSettings,
hasAccessToken,
errorDueToPrivateRepo,
} = this.state
return (
<div className={cx('gitako', { hidden: !shouldShow })}>
<div className={'gitako-side-bar'}>
<Logo loading={loading} />
<div className={'gitako-side-bar-content'}>
{metaData && <MetaBar metaData={metaData} />}
{errorDueToPrivateRepo && this.renderPrivateRepoError()}
{metaData && treeData && <FileExplorer metaData={metaData} treeData={treeData} />}
</div>
<SettingsBar
toggleShowSettings={this.toggleShowSettings}
activated={showSettings}
hasAccessToken={hasAccessToken}
/>
</div>
</div>
)
}
}

66
src/components/Icon.js Normal file
View file

@ -0,0 +1,66 @@
import preact from 'preact'
/** @jsx preact.h */
import TriangleRight from '../assets/icons/octicons/triangle-right.svg?svgr'
import FilePdf from '../assets/icons/octicons/file-pdf.svg?svgr'
import File from '../assets/icons/octicons/file.svg?svgr'
import FileZip from '../assets/icons/octicons/file-zip.svg?svgr'
import Markdown from '../assets/icons/octicons/markdown.svg?svgr'
import FileMedia from '../assets/icons/octicons/file-media.svg?svgr'
import FileCode from '../assets/icons/octicons/file-code.svg?svgr'
// import FileBinary from '../assets/icons/octicons/file-binary.svg?svgr'
// import FileSubmodule from '../assets/icons/octicons/file-submodule.svg?svgr'
// import FileSymlinkDirectory from '../assets/icons/octicons/file-symlink-directory.svg?svgr'
// import FileSymlinkFile from '../assets/icons/octicons/file-symlink-file.svg?svgr'
import X from '../assets/icons/octicons/x.svg?svgr'
import Gear from '../assets/icons/octicons/gear.svg?svgr'
function getSVGIconComponent(type) {
switch (type) {
case 'x':
return X
case 'gear':
return Gear
case 'folder':
return TriangleRight
case '.pdf':
return FilePdf
case '.zip':
case '.rar':
case '.7z':
return FileZip
case '.md':
return Markdown
case '.png':
case '.jpg':
case '.gif':
case '.bmp':
return FileMedia
case '.js':
case '.jsx':
case '.ts':
case '.tsx':
case '.es6':
case '.coffee':
case '.css':
case '.less':
case '.scss':
case '.sass':
return FileCode
// TODO: adapt to more file types
// case '': return FileBinary
// case '': return FileSubmodule
// case '': return FileSymlinkDirectory
// case '': return FileSymlinkFile
default:
return File
}
}
export default function Icon({ type, ...otherProps }) {
return (
<div className={'octicon-wrapper'} {...otherProps}>
{preact.h(getSVGIconComponent(type), { width: '100%', height: '100%' })}
</div>
)
}

14
src/components/Logo.js Normal file
View file

@ -0,0 +1,14 @@
import preact from 'preact'
/** @jsx preact.h */
import cx from '../utils/cx'
export default function Logo({ loading }) {
return (
<div className={'gitako-logo-wrapper Header'}>
<h3>&nbsp;</h3>
<h3 className={cx('gitako-logo', { invisible: loading })}>Gitako</h3>
<h3 className={cx('gitako-logo breath', { invisible: !loading })}>Gitako</h3>
</div>
)
}

18
src/components/MetaBar.js Normal file
View file

@ -0,0 +1,18 @@
import preact from 'preact'
/** @jsx preact.h */
export default function MetaBar({ metaData }) {
const userUrl = metaData ? metaData.api && metaData.api.owner.html_url : undefined
const repoUrl = metaData ? metaData.api && metaData.api.html_url : undefined
return (
<div className={'meta-bar'}>
<a className={'username'} href={userUrl}>
{metaData.userName}
</a>
&nbsp;/&nbsp;
<a className={'repo-name pjax-link'} href={repoUrl}>
{metaData.repoName}
</a>
</div>
)
}

42
src/components/Node.js Normal file
View file

@ -0,0 +1,42 @@
import preact from 'preact'
/** @jsx preact.h */
import Icon from './Icon'
import cx from '../utils/cx'
function getIconType(node) {
switch (node.type) {
case 'tree':
return 'folder'
default:
return node.name.replace(/.*\./, '.')
}
}
export default function Node({ node, depth, expanded, focused, toggleExpand }) {
const { name, url, type } = node
const item = (
<p
className={cx('node-item', { expanded })}
style={{ paddingLeft: `${10 + 20 * depth}px` }}
onClick={node.type === 'tree' ? toggleExpand : undefined}
>
<Icon type={getIconType(node)} />
<span className={'node-item-name'}>{name}</span>
</p>
)
return (
<div className={cx(`node-item-row`, { focused })}>
{
type !== 'tree'
? (
<a className={'pjax-link'} href={url} tabIndex={-1}>
{ item }
</a>
)
: item
}
</div>
)
}

View file

@ -0,0 +1,17 @@
import preact from 'preact'
/** @jsx preact.h */
export default function SearchBar({ onSearchKeyChange }) {
return (
<div className={'search-input-wrapper'}>
<input
tabIndex={0}
className="form-control search-input"
aria-label="search files"
placeholder="Search files (regexp)"
type="text"
onInput={onSearchKeyChange}
/>
</div>
)
}

View file

@ -0,0 +1,100 @@
import preact from 'preact'
/** @jsx preact.h */
import Icon from './Icon'
import storageHelper from '../utils/storageHelper'
const ACCESS_TOKEN_REGEXP = /^[0-9a-f]{40}$/
export default class SettingsBar extends preact.Component {
state = {
hint: null,
tokenCleared: false,
}
handleAccessTokenChange = event => {
const value = event.target.value
const { hasAccessToken } = this.props
if (value === '') {
if (hasAccessToken) {
storageHelper.setAccessToken('')
this.setState({ tokenCleared: true })
} else {
this.setState({ hint: '' })
}
} else if (ACCESS_TOKEN_REGEXP.test(value)) {
storageHelper.setAccessToken(value)
this.setState({ hint: 'Your token is saved, refresh the page to make it work!' })
} else {
this.setState({ hint: 'Invalid token' })
}
}
render() {
const { hint, tokenCleared } = this.state
const { toggleShowSettings, activated, hasAccessToken } = this.props
return (
<div className={'gitako-settings-bar'}>
<div className={'placeholder-row'}>
<h3>{activated ? 'Settings' : ''}</h3>
<Icon type={activated ? 'x' : 'gear'} className={'settings-icon'} onClick={toggleShowSettings} />
</div>
{activated && (
<div className={'gitako-settings-bar-content'}>
<div className={'gitako-settings-bar-content-section access-token'}>
<h4>Access Token</h4>
<span>
With access token, Gitako will be able to browse your private repositories with no
API request time limit.
</span>
<br />
<a href="https://github.com/blog/1509-personal-api-tokens" target="_blank">
How to create access token?
</a>
<br />
<span>
Gitako stores the token in{' '}
<a href="https://developer.chrome.com/apps/storage" target="_blank">
chrome local storage
</a>{' '}
locally and safely.
</span>
<br />
<input
className={'access-token-input form-control'}
placeholder={
hasAccessToken
? tokenCleared ? 'Your token is cleared' : 'Your token is saved'
: 'Input your token here'
}
onInput={this.handleAccessTokenChange}
/>
{hint && <span className={'hint'}>{hint}</span>}
</div>
<div className={'gitako-settings-bar-content-section position'}>
<h5>Position of Gitako (WIP)</h5>
<select value={'next to'} disabled>
<option value="next to">next to main content</option>
</select>
</div>
<div className={'gitako-settings-bar-content-section TOC'}>
<h5>Table of Markdown Content (WIP)</h5>
<label htmlFor={'toc'}>
<input name={'toc'} type={'checkbox'} disabled />
&nbsp;enable
</label>
</div>
<div className={'gitako-settings-bar-content-section issue'}>
<h4>Issue</h4>
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
Draft a issue on Github.
</a>
<br />
<span>Report BUG or request feature.</span>
</div>
</div>
)}
</div>
)
}
}

10
src/content.js Normal file
View file

@ -0,0 +1,10 @@
import preact from 'preact'
/** @jsx preact.h */
import './content.less'
import GitakoSideBar from './components/GitakoSideBar'
const GitakoSideBarElement = document.createElement('div')
document.body.appendChild(GitakoSideBarElement)
preact.render(<GitakoSideBar />, GitakoSideBarElement)

283
src/content.less Normal file
View file

@ -0,0 +1,283 @@
@side-bar-width: 260px;
@github-content-width: 1280px;
@content-width: (@side-bar-width + @github-content-width);
.with-gitako-spacing {
margin-left: @side-bar-width;
@media (min-width: @github-content-width) and (max-width: @content-width) {
margin-left: ~'calc(' @content-width ~' - 100vw)';
}
@media (min-width: @content-width) {
margin-left: 0;
}
}
.markdown-body {
.clippy-wrapper {
position: relative;
width: 0;
height: 0;
top: 8px;
left: ~'calc(100% - 40px)';
z-index: 1;
.clippy {
width: 32px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9f9f9;
&:hover {
background: #fff;
}
&:active {
background: #f0f0f0;
}
.icon {
width: 100%;
height: 100%;
display: block;
background-image: url("./assets/icons/octicons/clippy.svg?inline");
background-position: center;
background-repeat: no-repeat;
}
&.success {
.icon {
background-image: url("./assets/icons/octicons/check.svg?inline");
}
}
&.fail {
.icon {
background-image: url("./assets/icons/octicons/x.svg?inline");
}
}
}
}
}
.hidden {
display: none;
}
.invisible {
visibility: hidden;
}
.gitako {
.gitako-side-bar {
position: fixed;
top: 0;
left: 0;
display: flex;
flex-direction: column;
width: @side-bar-width;
height: 100vh;
z-index: 2;
background: #fafbfc;
border-right: 1px solid #e1e4e8;
@media screen and (min-width: @content-width) {
border-left: 1px solid #e1e4e8;
left: ~'calc((100vw - ' @content-width ~') / 2)';
}
&.hidden {
.hidden;
}
.gitako-logo-wrapper.Header {
position: relative;
.gitako-logo {
display: flex;
justify-content: space-around;
align-items: center;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
cursor: default;
user-select: none;
-webkit-font-smoothing: antialiased;
&.breath {
background: linear-gradient(
135deg,
#ff2400,
#e81d1d,
#e8b71d,
#e3e81d,
#1de840,
#1ddde8,
#2b1de8,
#dd00f3,
#dd00f3
);
background-size: 450% 450%;
animation: rainbow 9s ease infinite;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
background-clip: text;
// https://stackoverflow.com/questions/10814178/css-performance-relative-to-translatez0
-webkit-transform: rotateZ(360deg);
transform: rotateZ(360deg);
}
}
}
.octicon {
transition: 0.3s ease;
color: rgba(3, 47, 98, 0.55);
vertical-align: initial;
}
.octicon-wrapper {
display: inline-block;
width: 16px;
height: 16px;
text-align: center;
vertical-align: middle;
}
.octicon-color {
color: rgba(3, 47, 98, 0.55);
}
.gitako-side-bar-content {
display: flex;
flex: 1;
flex-direction: column;
.meta-bar {
position: relative;
padding: 10px;
font-size: 13px;
line-height: 20px;
color: #586069;
background-color: #f1f8ff;
border-bottom: 1px solid #c8e1ff;
a {
// fix a weird bug:
// when gitako failed loading repo, cursor hovering <a> in meta bar will be 'text'
cursor: pointer;
}
.repo-name {
font-weight: bolder;
}
}
.description {
padding: 4px 10px;
}
.file-explorer {
width: 100%;
height: 100%;
outline: none;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
/* search input */
.search-input-wrapper {
input[type='text'].form-control {
box-shadow: none;
width: 100%;
}
}
.no-results {
padding: 0px 10px;
color: #666;
}
.files {
flex: 1;
overflow: auto;
}
.node-item-row {
background: #fff;
&:hover {
background: #f6f8fa;
}
&.focused {
background: #f0f0f6;
}
.node-item {
word-wrap: normal;
word-break: break-all;
margin: 0;
color: #0366d6;
line-height: 20px;
padding: 6px 0;
cursor: pointer;
border-top: 1px solid #eaecef;
transition: all 0.5s ease;
// folder icon rotate when expand
&.expanded .octicon.octicon-triangle-right {
transform: rotate(90deg);
}
.node-item-name {
padding-left: 6px;
vertical-align: middle;
}
.node-item:hover .node-item-name {
text-decoration: underline;
}
}
}
}
}
.gitako-settings-bar {
border-top: 1px solid #eaecef;
&-content {
padding: 0 10px;
&-section {
padding-bottom: 10px;
}
}
.access-token {
border-bottom: none; // prevent overwrite by github style
}
.access-token-input {
width: 100%;
margin-top: 4px;
box-shadow: none;
}
.placeholder-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
.settings-icon {
width: 20px;
height: 20px;
cursor: pointer;
}
}
}
}
}
@keyframes rainbow {
0% {
background-position: 9% 91%;
}
50% {
background-position: 91% 9%;
}
100% {
background-position: 9% 91%;
}
}

18
src/manifest.json Normal file
View file

@ -0,0 +1,18 @@
{
"manifest_version": 2,
"name": "Gitako",
"version": "0.1.4",
"author": "EnixCoda",
"icons": {
"128": "icons/Gitako-128x128.png"
},
"description": "yet another GitHub extension, inspired by Octotree and GayHub.",
"homepage_url": "https://github.com/EnixCoda/Gitako",
"permissions": ["tabs", "storage", "*://github.com/*"],
"content_scripts": [
{
"matches": ["https://github.com/*"],
"js": ["content.js"]
}
]
}

303
src/utils/DOMHelper.js Normal file
View file

@ -0,0 +1,303 @@
/**
* this helper helps manipulating DOM
*/
import pjax from 'pjax'
/**
* if should show gitako, then move body right to make space for showing gitako
* otherwise, hide the space
*/
function setBodyIndent(shouldShowGitako) {
const spacingClassName = 'with-gitako-spacing'
if (shouldShowGitako) {
document.body.classList.add(spacingClassName)
} else {
document.body.classList.remove(spacingClassName)
}
}
/**
* content above the file navigation bar is same for all pages of the repo
* use this function to scroll down a bit to hide them
*/
function scrollToRepoContent() {
const fileNavigationSelector = '.file-navigation.js-zeroclipboard-container'
const fileNavigationElement = document.querySelector(fileNavigationSelector)
// cannot to use behavior: smooth here as it will scroll horizontally
if (fileNavigationElement) {
fileNavigationElement.scrollIntoView()
} else {
document.body.scrollIntoView()
}
}
/**
* scroll to index-th element in the list
* @param {number} index index of node item in the list
*/
function scrollToNodeElement(index) {
const nodeElementSelector = '.node-item'
const nodeElements = document.querySelectorAll(nodeElementSelector)
nodeElements[index].scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
/**
* add pjax listeners
* call this when pjax redirected or page loaded
*/
function attachPJAX(fields) {
// TODO: switch for fields
const elements = [
'.gitako a.pjax-link', // links in Gitako file tree & list
'.js-path-segment a', // links in the file navigation bar
].join()
new pjax({
elements,
selectors: ['.repository-content'],
scrollTo: false,
analytics: () => {},
})
}
/**
* there are few types of pages on GitHub, mainly
* 1. raw text: code
* 2. rendered content: like Markdown
* 3. preview: like image
*/
const PAGE_TYPES = {
RAW_TEXT: 'raw_text',
RENDERED: 'rendered',
// PREVIEW: 'preview',
OTHERS: 'others',
}
/**
* this function tries to tell which type current page is of
*
* note: not determining through file extension here
* becasuse there might be files using wrong extension name
*
* TODO: distinguish type 'preview'
*/
function getCurrentPageType() {
const blobWrapperSelector = '.repository-content .file .blob-wrapper'
const blobWrapperElement = document.querySelector(blobWrapperSelector)
if (blobWrapperElement) {
if (blobWrapperElement.querySelector('table')) {
return PAGE_TYPES.RAW_TEXT
}
} else {
const readmeSelector = '.repository-content .readme'
const readmeElement = document.querySelector(readmeSelector)
if (readmeElement) {
return PAGE_TYPES.RENDERED
}
}
return PAGE_TYPES.OTHERS
}
export const REPO_TYPE_PRIVATE = 'private'
export const REPO_TYPE_PUBLIC = 'public'
function getRepoPageType() {
const headerSelector = `#js-repo-pjax-container .pagehead.repohead h1`
const header = document.querySelector(headerSelector)
if (header) {
const repoPageTypes = [REPO_TYPE_PRIVATE, REPO_TYPE_PUBLIC]
for (const repoPageType of repoPageTypes) {
if (header.classList.contains(repoPageType)) {
return repoPageType
}
}
}
return null
}
/**
* add copy file content buttons to button groups
* click these buttons will copy file content to clipboard
*/
function attachCopyFileBtn() {
/**
* get text content of raw text content
*/
function getCodeElement() {
if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) {
const codeContentSelector = '.repository-content .file .data table'
return document.querySelector(codeContentSelector)
}
}
/**
* change inner text of copy file button to give feedback
* @param {element} copyFileBtn
* @param {string} text
*/
function setTempCopyFileBtnText(copyFileBtn, text) {
copyFileBtn.innerText = text
window.setTimeout(() => (copyFileBtn.innerText = 'Copy file'), 1000)
}
if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) {
const btnGroupSelector = [
// the button group next to navigation bar
'.repository-content .file-navigation.js-zeroclipboard-container .BtnGroup',
// the button group in file content header
'.repository-content .file .file-header .file-actions .BtnGroup',
].join(', ')
const btnGroups = document.querySelectorAll(btnGroupSelector)
btnGroups.forEach(btnGroup => {
const copyFileBtn = document.createElement('button')
copyFileBtn.classList.add('btn', 'btn-sm', 'BtnGroup-item', 'copy-file-btn')
copyFileBtn.innerText = 'Copy file'
copyFileBtn.addEventListener('click', () => {
const codeElement = getCodeElement()
if (copyElementContent(codeElement)) {
setTempCopyFileBtnText(copyFileBtn, 'Success!')
} else {
setTempCopyFileBtnText(copyFileBtn, 'Copy failed!')
}
})
btnGroup.insertBefore(copyFileBtn, btnGroups.lastChild)
})
}
}
/**
* copy content of a DOM element to clipboard
* @param {element} element
* @returns {boolean} whether copy is successful
*/
function copyElementContent(element) {
window.getSelection().removeAllRanges()
const range = document.createRange()
range.selectNode(element)
window.getSelection().addRange(range)
const isCopySuccessful = document.execCommand('copy')
window.getSelection().removeAllRanges()
return isCopySuccessful
}
/**
* create a copy file content button `clippy`
* once mouse enters a code snippet of markdown, move clippy into it
* user can copy the snippet's content by click it
*
* TODO: 'reactify' it
*/
function createClippy() {
function setTempClippyIconFeedback(clippy, type) {
const tempIconClassName = type === 'success' ? 'success' : 'fail'
clippy.classList.add(tempIconClassName)
window.setTimeout(() => {
clippy.classList.remove(tempIconClassName)
}, 1000)
}
/**
* <div class="clippy-wrapper">
* <button class="clippy">
* <i class="octicon octicon-clippy" />
* </button>
* </div>
*/
const clippyWrapper = document.createElement('div')
clippyWrapper.classList.add('clippy-wrapper')
const clippy = document.createElement('button')
clippy.classList.add('clippy')
const clippyIcon = document.createElement('i')
clippyIcon.classList.add('icon')
clippyWrapper.appendChild(clippy)
clippy.appendChild(clippyIcon)
// set clipboard with current code snippet element's content
clippy.addEventListener('click', function onClippyClick() {
if (copyElementContent(currentCodeSnippetElement)) {
setTempClippyIconFeedback(clippy, 'success')
} else {
setTempClippyIconFeedback(clippy, 'fail')
}
})
return clippyWrapper
}
const clippy = createClippy()
let currentCodeSnippetElement
function attachCopySnippet() {
const readmeSelector = '.repository-content .readme article'
const readmeElement = document.querySelector(readmeSelector)
if (readmeElement) {
readmeElement.addEventListener('mouseover', ({ target }) => {
// only move clippy when mouse is over a new snippet(<pre>)
if (target.nodeName === 'PRE') {
if (
currentCodeSnippetElement !== target
) {
currentCodeSnippetElement = target
/**
* <article>
* <pre></pre> <!-- case A -->
* <div class="highlight">
* <pre></pre> <!-- case B -->
* </div>
* </article>
*/
target.parentNode.insertBefore(clippy, target)
}
}
})
}
}
/**
* focus to side bar, user will be able to manipulate it with keyboard
*/
function focusFileExplorer() {
const sideBarContentSelector = '.gitako .file-explorer'
const sideBarElement = document.querySelector(sideBarContentSelector)
if (sideBarElement) {
sideBarElement.focus()
}
}
function focusSearchInput() {
const searchInputSelector = '.search-input'
const searchInputElement = document.querySelector(searchInputSelector)
if (searchInputElement) {
if (document.activeElement !== searchInputElement) {
searchInputElement.focus()
}
}
}
/**
* simulate click on node item, for triggering pjax
* @param {number} index
*/
function clickOnNodeElement(index = 0) {
const nodeElementSelector = '.node-item'
const nodeElements = document.querySelectorAll(nodeElementSelector)
nodeElements[index].click()
}
export default {
attachPJAX,
attachCopyFileBtn,
attachCopySnippet,
clickOnNodeElement,
focusSearchInput,
focusFileExplorer,
getCurrentPageType,
getRepoPageType,
setBodyIndent,
scrollToNodeElement,
scrollToRepoContent,
}

34
src/utils/GitHubHelper.js Normal file
View file

@ -0,0 +1,34 @@
export const NOT_FOUND = 'Repo Not Found'
async function request(url, { accessToken } = {}) {
const headers = {}
if (accessToken) {
headers.Authorization = `token ${accessToken}`
}
const res = await fetch(url, { headers })
if (res.status === 200) return res.json()
// for private repo, GitHub api also responses with 404 when unauthorized
if (res.status === 404) throw new Error(NOT_FOUND)
const content = await res.json()
throw new Error(content.message)
}
async function getRepoMeta({ userName, repoName, accessToken }) {
const url = `https://api.github.com/repos/${userName}/${repoName}`
return await request(url, { accessToken })
}
async function getTreeData({ userName, repoName, branchName, accessToken }) {
const url = `https://api.github.com/repos/${userName}/${repoName}/git/trees/${branchName}?recursive=1`
return await request(url, { accessToken })
}
function getUrlForRedirect({ userName, repoName, branchName }, path) {
return `https://github.com/${userName}/${repoName}/tree/${branchName}/${path}`
}
export default {
getRepoMeta,
getTreeData,
getUrlForRedirect,
}

49
src/utils/URLHelper.js Normal file
View file

@ -0,0 +1,49 @@
function parseRaw() {
const { pathname } = window.location
let [
/* ignore content before the first '/' */,
userName,
repoName,
type,
branchName,
...path
] = pathname.split('/')
return {
userName,
repoName,
type,
branchName,
path,
}
}
function parse() {
const parsedData = parseRaw()
if (!isInCodePage()) {
delete parsedData.type
delete parsedData.branchName
}
return parsedData
}
const RESERVED_NAME = ['blog']
function isInCodePage(metaData = {}) {
const { userName, repoName, type, branchName } = {...parseRaw(), ...metaData}
return (
userName &&
!RESERVED_NAME.find(_ => _ === userName) &&
repoName &&
(!type || type === 'tree' || type === 'blob') &&
(branchName || !type && !branchName)
)
}
function getCurrentPath(decode = false) {
const { path } = parseRaw()
return decode ? path.map(decodeURIComponent) : path
}
export default {
getCurrentPath,
isInCodePage,
parse,
}

12
src/utils/cx.js Normal file
View file

@ -0,0 +1,12 @@
/**
* cx('class1', { class2: true, class3: false }) --> 'class1 class2'
* @param {string} baseClassNames
* @param {object} optionalClassNames
*/
export default function cx(baseClassNames = '', optionalClassNames = {}) {
return Object.entries(optionalClassNames)
.map(([key, value]) => value ? key : '')
.filter(_ => _)
.concat([baseClassNames])
.join(' ')
}

View file

@ -0,0 +1,25 @@
const localStorage = chrome.storage.local
const ACCESS_TOKEN_KEY = 'access_token'
function get(key) {
return new Promise(resolve => localStorage.get(key, items => resolve(items[key])))
}
function set(key, value) {
return new Promise(resolve => localStorage.set({ [key]: value }, resolve))
}
function getAccessToken() {
return get(ACCESS_TOKEN_KEY)
}
function setAccessToken(accessToken) {
return set(ACCESS_TOKEN_KEY, accessToken)
}
export default {
get,
set,
getAccessToken,
setAccessToken,
}

72
src/utils/treeParser.js Normal file
View file

@ -0,0 +1,72 @@
import GitHubHelper from './GitHubHelper'
const nodeTemplate = {
name: null,
path: null,
mode: null,
type: null,
sha: null,
url: null,
}
function sortFoldersToFront(root) {
const isFolder = node => node.type === 'tree'
const isNotFolder = (...args) => !isFolder(...args)
function DFS(root) {
const nodes = root.contents
if (nodes) {
nodes.splice(0, Infinity, ...nodes.filter(isFolder), ...nodes.filter(isNotFolder))
nodes.forEach(DFS)
}
return root
}
return DFS(root)
}
function parse(treeData, metaData) {
const { tree } = treeData
// nodes are created from items and put onto tree
const pathToNode = new Map()
const pathToItem = new Map()
const root = { ...nodeTemplate, name: '', path: '', contents: [] }
pathToNode.set('', root)
tree.forEach(item => pathToItem.set(item.path, item))
tree.forEach(item => {
// bottom-up search for the deepest node created
let path = item.path
const itemsToCreateTreeNode = []
while (path !== '' && !pathToNode.has(path)) {
itemsToCreateTreeNode.push(pathToItem.get(path))
// 'a/b' -> 'a'
// 'a' -> ''
path = path.substring(0, path.lastIndexOf('/'))
}
// top-down create nodes
while (itemsToCreateTreeNode.length) {
const item = itemsToCreateTreeNode.pop()
const node = {
...nodeTemplate,
...item,
name: item.path.replace(/^.*\//, ''),
url: GitHubHelper.getUrlForRedirect(metaData, item.path),
contents: item.type === 'tree' ? [] : null,
}
pathToNode.get(path).contents.push(node)
pathToNode.set(node.path, node)
path = node.path
}
})
return {
root: sortFoldersToFront(root),
nodes: Array.from(pathToNode.values()),
}
}
export default {
parse,
}

View file

@ -0,0 +1,135 @@
/**
* This is the stack for generating an array of nodes for rendering
*
* when lower layer changes, higher layers would reset
* when higher layer changes, lower layers would not notice
*
* render stack | when will change | on change callback
*
* ^ changes frequently
* |
* |4 focus | when hover/focus move | onFocusChange
* | | | expandedNodes + focusNode -> visibleNodes
* |3 expansion | when fold/unfold | onExpansionChange
* | | | searchedNodes + toggleNode -> expandedNodes
* |2 search key | when search | onSearch
* | | | treeNodes + searchKey -> searchedNodes
* |1 tree: { root <-> nodes } | when tree init | treeHelper.parse
* | | tree data from api -> { root, nodes }
* v stable
*/
function getFilterFunc(keyRegex) {
return function filterFunc({ name }) {
return keyRegex.test(name)
}
}
function search(treeNodes, searchKey) {
if (!searchKey) return
/**
* if searchKey is 'abcd'
* then keyRegex will be /a.*?b.*?c.*?d/i
*/
const keyRegex = new RegExp(
searchKey
.replace(/\//, '')
.split('')
.join('.*?'),
'i'
)
return treeNodes.filter(getFilterFunc(keyRegex))
}
function debounce(func, delay) {
let timer
return (...args) => {
return new Promise(resolve => {
window.clearTimeout(timer)
timer = window.setTimeout(() => resolve(func(...args)), delay)
})
}
}
export const debouncedSearch = debounce(search, 250)
export default class VisibleNodesGenerator {
// LEVEL 1
root = null
nodes = null
plantTree(root, nodes) {
this.root = root
this.nodes = nodes
// a simplified sync 'search'
this.searchedNodes = this.root.contents
this.generateVisibleNodes()
}
// LEVEL 2
searchedNodes = null
async search(searchKey) {
this.searchedNodes = (await debouncedSearch(this.nodes, searchKey)) || this.root.contents
this.expandedNodes.clear()
this.generateVisibleNodes()
}
// LEVEL 3
expandedNodes = new Set()
depths = new Map()
toggleExpand(node) {
this.setExpand(node, !this.expandedNodes.has(node))
}
setExpand(node, expand) {
if (expand && node.contents) {
// only node with contents is expandable
this.expandedNodes.add(node)
} else {
this.expandedNodes.delete(node)
}
this.generateVisibleNodes()
}
expandTo(path) {
let rootNode = this.root
let targetPath
for (const step of path) {
targetPath = rootNode.path ? `${rootNode.path}/${step}` : step
rootNode = rootNode.contents.find(node => node.path === targetPath)
this.setExpand(rootNode, true)
}
return rootNode
}
visibleNodes = null
generateVisibleNodes() {
this.focusedNode = null
this.depths.clear()
const nodesSet = new Set() // prevent duplication
const get = (nodes, depth = 0) => {
return [].concat(
...nodes.map(node => {
if (nodesSet.has(node)) return []
this.depths.set(node, depth)
nodesSet.add(node)
const children = this.expandedNodes.has(node) ? get(node.contents, depth + 1) : []
return [node, ...children]
})
)
}
this.visibleNodes = {
nodes: get(this.searchedNodes),
depths: this.depths,
expandedNodes: this.expandedNodes,
}
this.focusNode(null)
}
// LEVEL 4
focusedNode = null
focusNode(node) {
this.focusedNode = node
this.visibleNodes.focusedNode = node
}
}

83
webpack.config.js Normal file
View file

@ -0,0 +1,83 @@
const webpack = require('webpack')
const path = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const UglifyJSWebpackPlugin = require('uglifyjs-webpack-plugin')
const srcPath = path.resolve(__dirname, 'src')
const plugins = [
new CopyWebpackPlugin([
{
from: './src/manifest.json',
to: 'manifest.json',
},
{
from: './src/assets/icons/*',
to: 'icons/[name].[ext]',
},
]),
new webpack.SourceMapDevToolPlugin({}),
]
if (process.env.NODE_ENV === 'production') {
plugins.push(
new UglifyJSWebpackPlugin({
cache: true,
uglifyOptions: {
ecma: 6,
},
})
)
plugins.push(
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
},
})
)
}
module.exports = {
entry: {
content: './src/content.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
},
resolve: {
alias: {
react: 'preact', // for svgr
},
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
include: [srcPath],
},
{
test: /\.less$/,
loader: ['style-loader', 'css-loader', 'less-loader'],
include: [srcPath],
},
{
test: /\.svg$/,
resourceQuery: /inline/,
loader: ['url-loader'],
include: [srcPath],
},
{
test: /\.svg$/,
resourceQuery: /svgr/,
loader: ['babel-loader', 'svgr/webpack'],
include: [srcPath],
},
],
},
plugins,
}

4350
yarn.lock Normal file

File diff suppressed because it is too large Load diff