Merge pull request #23 from EnixCoda/develop

Develop
This commit is contained in:
Enix 2018-10-28 21:42:34 +08:00 committed by GitHub
commit d362e8e88a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2730 additions and 896 deletions

View file

@ -11,6 +11,7 @@
"react"
],
"plugins": [
"transform-decorators-legacy",
"transform-class-properties",
["transform-object-rest-spread", { "useBuiltIns": true }]
]

View file

@ -1,4 +1,5 @@
{
"parser": "babel-eslint",
"env": {
"browser": true,
"commonjs": true,
@ -12,15 +13,38 @@
"sourceType": "module"
},
"rules": {
"strict": 0,
"no-const-assign": "warn",
"no-this-before-super": "warn",
"no-undef": "warn",
"no-this-before-super": "error",
"no-undef": "error",
"no-unreachable": "warn",
"no-unused-vars": "warn",
"constructor-super": "warn",
"valid-typeof": "warn"
"react/jsx-uses-vars": 1,
"react/jsx-uses-react": 1,
"valid-typeof": "warn",
"babel/new-cap": 1,
"babel/no-invalid-this": 1,
"babel/object-curly-spacing": 0,
"babel/quotes": 0,
"babel/semi": 0,
"babel/no-unused-expressions": 1,
"import/order": [
1,
{
"groups": ["external", "internal", "builtin", ["parent", "sibling", "index"]],
"newlines-between": "never"
}
],
"babel/no-invalid-this": 0,
"no-unused-vars": 0
},
"plugins": [
"class-property"
]
"babel",
"react",
"import"
],
"globals": {
"chrome": true
}
}

View file

@ -1,33 +1,35 @@
# Gitako
Yet another Chrome extension for GitHub. Inspired by [Octotree](https://github.com/buunguyen/octotree) & [GayHub](https://github.com/jawil/GayHub). Aimed at providing better UX, consistent UI regards to GitHub and more essential features.
Yet another Chrome extension for GitHub. Inspired by [Octotree](https://github.com/buunguyen/octotree) & [GayHub](https://github.com/jawil/GayHub), thanks to them! Gitako is aimed at providing better UX, consistent UI regards to GitHub and more essential features.
### Features
- [x] neat file tree
- [x] instant file search
- [x] keyboard accessibility
- press 'up' and 'down' to nav between search input and files in the list
- press 'enter' or 'right' to redirect to file or expand folder
- press 'left' to jump to parent folder or collapse current folder
- [x] fast even in gigantic projects
- [x] intuitive keyboard accessibility
- file navigation using arrow keys
- set shortcut to toggle sidebar
- [x] redirect smoothly with pjax
- [x] enlargeable when window is wide enough
- [x] copy snippets with one click
- copy code snippets inside markdown preview
- copy all contents of a file
- [x] support access token (for private repo and frequent usage)
- [x] consistent UI with GitHub
- [x] use [octicons](https://octicons.github.com)
- [x] light size (~30kb) & low memory usage
- [x] resize-able when window is wide enough
- [x] copy snippets with single click
- copy code snippets from markdown preview
- copy whole content of a file
- [x] support private repo and frequent usages
- [x] support git submodule
- [x] UI consistent to GitHub's
- [x] light size (~60kb) & low memory usage
- [more WIP](https://github.com/EnixCoda/Gitako/projects/1)
### Download
[Change Log](https://github.com/EnixCoda/Gitako/wiki/ChangeLog)
[install from Chrome web store](https://chrome.google.com/webstore/detail/gitako/giljefjcheohhamkjphiebfjnlphnokk)
### Install
[Please install from Chrome Web Store](https://chrome.google.com/webstore/detail/gitako/giljefjcheohhamkjphiebfjnlphnokk)
### Issues
Any bug report or feature requests are welcomed, feel free to draft a [issue](https://github.com/EnixCoda/Gitako/issues/)!
Any bug report or feature request discussions are welcomed, feel free to draft a [issue](https://github.com/EnixCoda/Gitako/issues/)!
### About

18
jsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"jsx": "react",
"target": "es2017",
"experimentalDecorators": true,
"baseUrl": "./",
"paths": {
"*" : ["src/*", "packages/*"]
}
},
"exclude": [
"node_modules",
],
"include": [
"src/**/*",
"packages/**/*",
]
}

View file

@ -1,6 +1,6 @@
{
"name": "gitako",
"version": "0.2.4",
"version": "0.3.16",
"description": "yet another extension for GitHub",
"repository": "https://github.com/EnixCoda/Gitako",
"author": "EnixCoda",
@ -8,28 +8,41 @@
"private": true,
"scripts": {
"start": "webpack --watch",
"dev": "webpack",
"prod": "NODE_ENV=production webpack && cd dist && rm -f ./gitako.zip && zip gitako.zip -r *"
"stamp": "node scripts/version.js",
"postversion": "npm run stamp && git add src/manifest.json && git commit --amend --no-edit",
"prod": "npm run stamp && NODE_ENV=production webpack && cd dist && rm -f ./gitako.zip && zip gitako.zip -r *"
},
"dependencies": {
"ini": "^1.3.5",
"nprogress": "^0.2.0",
"octicons": "^7.1.0",
"preact": "^8.2.6",
"preact-portal": "^1.1.3"
"prop-types": "^15.6.1",
"react": "^16.4.0",
"react-dom": "^16.4.0"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-eslint": "^8.2.3",
"babel-loader": "^7.1.2",
"babel-plugin-react-require": "^3.0.0",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.5",
"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",
"eslint": "^4.19.1",
"eslint-plugin-babel": "^5.1.0",
"eslint-plugin-class-property": "^1.1.0",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-react": "^7.9.1",
"file-loader": "^1.1.5",
"fsevents": "^1.2.4",
"json-loader": "^0.5.7",
"less": "^2.7.3",
"less-loader": "^4.0.5",
"react-life-hook": "^0.2.0",
"style-loader": "^0.19.0",
"svgr": "^1.6.0",
"uglifyjs-webpack-plugin": "^1.1.1",

10
scripts/version.js Normal file
View file

@ -0,0 +1,10 @@
const fs = require('fs')
const path = require('path')
const rootPath = path.resolve(__dirname, '../')
const packageJSON = require(path.resolve(rootPath, 'package.json'))
const manifestPath = path.resolve(rootPath, 'src/manifest.json')
const manifest = require(manifestPath)
manifest.version = packageJSON.version
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, ' '), 'utf-8')

34
src/analytics.js Normal file
View file

@ -0,0 +1,34 @@
// TODO: set this through ENV or something else
const LOG_ENDPOINT = 'https://enix.one/gitako/log'
export function raiseError(error) {
return reportError(error)
}
export function withErrorLog(method, args) {
return [
function() {
try {
method.apply(this, arguments)
} catch (error) {
raiseError(error)
}
},
args,
]
}
function encodeParams(params) {
return Object.keys(params)
.map(key => `${key}=${encodeURIComponent(JSON.stringify(params[key]))}`)
.join('&')
}
function reportError(error) {
return fetch(
`${LOG_ENDPOINT}?${encodeParams({
error: (error && error.message) || error,
path: window.location.href,
})}`
)
}

View file

@ -1,203 +1,100 @@
import preact from 'preact'
/** @jsx preact.h */
import SearchBar from './SearchBar'
import Node from './Node'
import React from 'react'
import PropTypes from 'prop-types'
import connect from 'driver/connect'
import { FileExplorer as FileExplorerCore } from 'driver/core'
import SearchBar from 'components/SearchBar'
import Node from 'components/Node'
import LoadingIndicator from 'components/LoadingIndicator'
import cx from 'utils/cx'
import cx from '../utils/cx'
import DOMHelper from '../utils/DOMHelper'
import treeParser from '../utils/treeParser'
import URLHelper from '../utils/URLHelper'
import VisibleNodesGenerator from '../utils/VisibleNodesGenerator'
@connect(FileExplorerCore)
export default class FileExplorer extends React.Component {
static propTypes = {
treeData: PropTypes.object,
metaData: PropTypes.object,
freeze: PropTypes.bool,
visibleNodes: PropTypes.object,
export default class List extends preact.Component {
state = {
// generated by this.visibleNodesGenerator
visibleNodes: null,
init: PropTypes.func.isRequired,
execAfterRender: PropTypes.func.isRequired,
handleKeyDown: PropTypes.func.isRequired,
handleSearchKeyChange: PropTypes.func.isRequired,
setExpand: PropTypes.func.isRequired,
toggleNodeExpansion: PropTypes.func.isRequired,
focusNode: PropTypes.func.isRequired,
onNodeClick: PropTypes.func.isRequired,
updateVisibleNodes: PropTypes.func.isRequired,
}
props = {
static defaultProps = {
treeData: null,
metaData: null,
freeze: false,
visibleNodes: 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)
if (nodeExpandedTo) {
this.visibleNodesGenerator.focusNode(nodeExpandedTo)
const { nodes } = this.visibleNodesGenerator.visibleNodes
this.tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(nodes.indexOf(nodeExpandedTo)))
}
}
this.updateVisibleNodes()
const { init, setUpTree, treeData } = this.props
init()
setUpTree(treeData)
}
componentDidMount() {
this.execAfterRender()
const { execAfterRender } = this.props
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 })
}
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()
componentWillReceiveProps(nextProps) {
if (nextProps.treeData !== this.props.treeData) {
const { setUpTree } = nextProps
setUpTree()
}
}
handleSearchKeyChange = async event => {
const searchKey = event.target.value
await this.visibleNodesGenerator.search(searchKey)
this.updateVisibleNodes()
componentDidUpdate() {
const { execAfterRender } = this.props
execAfterRender()
}
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)
renderFiles(visibleNodes, onNodeClick) {
const { nodes, depths, focusedNode, expandedNodes } = visibleNodes
if (nodes.length === 0) {
return (
<label className={'no-results'}>
No results found.
</label>
)
}
this.updateVisibleNodes()
return (
<div className={'files'}>
{nodes.map(node => (
<Node
key={node.path}
node={node}
depth={depths.get(node)}
focused={focusedNode === node}
expanded={expandedNodes.has(node)}
onClick={onNodeClick}
/>
))}
</div>
)
}
render() {
const { visibleNodes: { nodes, depths, focusedNode, expandedNodes } } = this.state
const { freeze } = this.props
const { stateText, visibleNodes, freeze, handleKeyDown, handleSearchKeyChange, onNodeClick, toggleShowSettings } = this.props
return (
<div className={cx(`file-explorer`, { freeze })} 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}
/>
))}
</div>
)}
<div className={cx(`file-explorer`, { freeze })} tabIndex={-1} onKeyDown={handleKeyDown} onClick={ freeze ? toggleShowSettings : null}>
{
stateText
? <LoadingIndicator text={stateText} />
: visibleNodes && (
<React.Fragment>
<SearchBar onSearchKeyChange={handleSearchKeyChange} />
{this.renderFiles(visibleNodes, onNodeClick)}
</React.Fragment>
)
}
</div>
)
}

23
src/components/Gitako.js Normal file
View file

@ -0,0 +1,23 @@
import React from 'react'
import SideBar from 'components/SideBar'
import { Gitako as GitakoCore } from 'driver/core'
import connect from 'driver/connect'
import { raiseError } from 'analytics'
@connect(GitakoCore)
export default class Gitako extends React.PureComponent {
componentDidMount() {
const { init } = this.props
init()
}
componentDidCatch(error) {
raiseError(error)
}
render() {
return (
<SideBar />
)
}
}

View file

@ -1,27 +1,26 @@
import preact from 'preact'
/** @jsx preact.h */
import Grabber from '../assets/icons/octicons/grabber.svg?svgr'
import Octoface from '../assets/icons/octicons/octoface.svg?svgr'
import ChevronDown from '../assets/icons/octicons/chevron-down.svg?svgr'
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'
import cx from '../utils/cx'
import React from 'react'
import Submodule from 'assets/icons/octicons/file-submodule.svg?svgr'
import Grabber from 'assets/icons/octicons/grabber.svg?svgr'
import Octoface from 'assets/icons/octicons/octoface.svg?svgr'
import ChevronDown from 'assets/icons/octicons/chevron-down.svg?svgr'
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 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'
import cx from 'utils/cx'
function getSVGIconComponent(type) {
switch (type) {
case 'submodule':
return Submodule
case 'grabber':
return Grabber
case 'octoface':
@ -68,10 +67,12 @@ function getSVGIconComponent(type) {
}
}
const iconStyle = { width: '100%', height: '100%' }
export default function Icon({ type, className, ...otherProps }) {
return (
<div className={cx('octicon-wrapper', className)} {...otherProps}>
{preact.h(getSVGIconComponent(type), { width: '100%', height: '100%' })}
{React.createElement(getSVGIconComponent(type), iconStyle)}
</div>
)
}

View file

@ -0,0 +1,13 @@
import React from 'react'
import Icon from 'components/Icon';
export default function LoadingIndicator({ text }) {
return (
<div className={'loading-indicator-container'}>
<div className={'loading-indicator'}>
<Icon className={'loading-indicator-icon'} type={'gear'} />
{text}
</div>
</div>
)
}

View file

@ -1,5 +1,4 @@
import preact from 'preact'
/** @jsx preact.h */
import React from 'react'
export default function MetaBar({ metaData }) {
const userUrl = metaData ? metaData.api && metaData.api.owner.html_url : undefined
@ -13,6 +12,8 @@ export default function MetaBar({ metaData }) {
<a className={'repo-name pjax-link'} href={repoUrl}>
{metaData.repoName}
</a>
&nbsp;/&nbsp;
{metaData.branchName}
</div>
)
}

View file

@ -1,49 +1,53 @@
import preact from 'preact'
/** @jsx preact.h */
import PJAX from 'pjax'
import Icon from './Icon'
import cx from '../utils/cx'
import general from '../utils/general'
import DOMHelper from '../utils/DOMHelper'
import React from 'react'
import Icon from 'components/Icon'
import cx from 'utils/cx'
import DOMHelper from 'utils/DOMHelper'
import LoadingIndicator from 'components/LoadingIndicator';
function getIconType(node) {
switch (node.type) {
case 'tree':
return 'folder'
case 'commit':
return 'submodule'
default:
return node.name.replace(/.*\./, '.')
}
}
export default class Node extends preact.Component {
shouldComponentUpdate(nextProps) {
return !general.shallowEqual(this.props, nextProps)
}
onNodeClick = (...args) => {
const { node, toggleExpand } = this.props
if (node.type === 'tree') {
toggleExpand(node, ...args)
} else {
DOMHelper.loadWithPJAX(node.url)
}
export default class Node extends React.PureComponent {
onClick = (event) => {
if (event.metaKey) return
event.preventDefault()
const { node, onClick } = this.props
onClick(node)
}
render() {
const { node, depth, expanded, focused, pjax } = this.props
const { name, path } = node
const { node, depth, expanded, focused } = this.props
const { name, path, virtual } = node
if (virtual) {
// this is not a real node
// for now, all virtual nodes are indicators for pending state
return (
<div className={cx(`node-item-row`, { focused })}>
<div className={'node-item'}>
<LoadingIndicator text={name} />
</div>
</div>
)
}
return (
<div className={cx(`node-item-row`, { focused })} title={path}>
<p
className={cx('node-item', { expanded })}
style={{ paddingLeft: `${10 + 20 * depth}px` }}
onClick={this.onNodeClick}
>
<Icon type={getIconType(node)} />
<span className={'node-item-name'}>{name}</span>
</p>
<a href={node.url} onClick={this.onClick}>
<div
className={cx('node-item', { expanded })}
style={{ paddingLeft: `${10 + 20 * depth}px` }}
>
<Icon type={getIconType(node)} />
<span className={'node-item-name'}>{name}</span>
</div>
</a>
</div>
)
}

View file

@ -1,9 +1,8 @@
import preact from 'preact'
/** @jsx preact.h */
import DOMHelper from '../utils/DOMHelper'
import React from 'react'
import DOMHelper from 'utils/DOMHelper'
export default function PJAXLink({ to, children }) {
return preact.cloneElement(children, {
return React.cloneElement(children, {
onClick: () => DOMHelper.loadWithPJAX(to),
})
}

15
src/components/Portal.js Normal file
View file

@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom'
class Portal extends React.PureComponent {
render() {
const { into, children } = this.props
if (!(into instanceof window.Element)) return null
return ReactDOM.createPortal(
children,
into,
)
}
}
export default Portal

View file

@ -0,0 +1,29 @@
import React from 'react'
import PropTypes from 'prop-types'
import ResizeHandler from 'components/ResizeHandler'
import cx from 'utils/cx';
export default class Resizable extends React.PureComponent {
static propTypes = {
baseSize: PropTypes.number.isRequired,
}
state = {
size: this.props.baseSize,
}
onResize = size => this.setState({ size: Math.max(this.props.baseSize, size) })
render() {
const { className, children } = this.props
const { size } = this.state
return (
<div className={cx('gitako-position-wrapper', className)}>
<ResizeHandler onResize={this.onResize} size={size} />
<div className={'gitako-position-content'} style={{ width: size }}>
{children}
</div>
</div>
)
}
}

View file

@ -1,13 +1,11 @@
import preact from 'preact'
/** @jsx preact.h */
import React from 'react'
import Icon from 'components/Icon'
import Icon from './Icon'
export default class ResizeHandler extends preact.Component {
export default class ResizeHandler extends React.PureComponent {
pointerDown = false
startX = 0
delta = 0
baseSize = this.props.baseSize
baseSize = this.props.size
subscribeEvents = () => {
window.addEventListener('mousemove', this.onPointerMove)
@ -41,7 +39,7 @@ export default class ResizeHandler extends preact.Component {
onPointerUp = () => {
this.pointerDown = false
this.baseSize += this.delta
this.baseSize = Math.max(this.baseSize + this.delta, this.props.size)
this.unsubscribeEvents()
}
@ -57,4 +55,4 @@ export default class ResizeHandler extends preact.Component {
</div>
)
}
}
}

View file

@ -1,5 +1,4 @@
import preact from 'preact'
/** @jsx preact.h */
import React from 'react'
export default function SearchBar({ onSearchKeyChange }) {
return (

View file

@ -1,9 +1,16 @@
import preact from 'preact'
/** @jsx preact.h */
import React from 'react'
import PropTypes from 'prop-types'
import Icon from 'components/Icon'
import configHelper, { config } from 'utils/configHelper'
import keyHelper from 'utils/keyHelper'
import { version } from '../../package'
import Icon from './Icon'
import storageHelper from '../utils/storageHelper'
import keyHelper from '../utils/keyHelper'
const wikiLinks = {
compressSingletonFolder: 'https://github.com/EnixCoda/Gitako/wiki/Compress-Singleton-Folder',
changeLog: 'https://github.com/EnixCoda/Gitako/wiki/Change-Log',
copyFileButton: 'https://github.com/EnixCoda/Gitako/wiki/Copy-file-and-snippet',
copySnippet: 'https://github.com/EnixCoda/Gitako/wiki/Copy-file-and-snippet',
}
const ACCESS_TOKEN_REGEXP = /^[0-9a-f]{40}$/
@ -23,7 +30,7 @@ function detectOS() {
}
function friendlyFormatShortcut(shortcut) {
if (!shortcut) return
if (typeof shortcut !== 'string') return ''
const OS = detectOS()
if (OS === OperatingSystems.Windows) {
return shortcut.replace(/meta/, 'win')
@ -39,54 +46,101 @@ function friendlyFormatShortcut(shortcut) {
}
}
export default class SettingsBar extends preact.Component {
export default class SettingsBar extends React.PureComponent {
static propTypes = {
accessToken: PropTypes.string.isRequired,
activated: PropTypes.bool.isRequired,
onAccessTokenChange: PropTypes.func.isRequired,
onShortcutChange: PropTypes.func.isRequired,
compressSingletonFolder: PropTypes.bool.isRequired,
copyFileButton: PropTypes.bool.isRequired,
copySnippetButton: PropTypes.bool.isRequired,
setCopyFile: PropTypes.func.isRequired,
setCopySnippet: PropTypes.func.isRequired,
setCompressSingleton: PropTypes.func.isRequired,
toggleShowSettings: PropTypes.func.isRequired,
toggleShowSideBarShortcut: PropTypes.string.isRequired,
}
state = {
accessTokenHint: null,
accessToken: '',
shortcutHint: null,
accessTokenHint: '',
shortcutHint: '',
toggleShowSideBarShortcut: '',
reloadHint: '',
moreOptions: [
{
key: 'compress-singleton',
label: 'Compress singleton folder',
onChange: this.createOnChange(config.compressSingletonFolder, this.props.setCompressSingleton),
getValue: () => this.props.compressSingletonFolder,
wikiLink: wikiLinks.compressSingletonFolder,
},
{
key: 'copy-file',
label: 'Copy File',
onChange: this.createOnChange(config.copyFileButton, this.props.setCopyFile),
getValue: () => this.props.copyFileButton,
wikiLink: wikiLinks.copyFileButton,
},
{
key: 'copy-snippet',
label: 'Copy Snippet',
onChange: this.createOnChange(config.copySnippetButton, this.props.setCopySnippet),
getValue: () => this.props.copySnippetButton,
wikiLink: wikiLinks.copySnippet,
},
]
}
componentWillMount() {
const { toggleShowSideBarShortcut } = this.props
this.setState({ toggleShowSideBarShortcut })
const { toggleShowSideBarShortcut, compressSingletonFolder, copyFileButton, copySnippetButton } = this.props
this.setState({ toggleShowSideBarShortcut, compressSingletonFolder, copyFileButton, copySnippetButton })
}
componentWillReceiveProps({ toggleShowSideBarShortcut }) {
this.setState({ toggleShowSideBarShortcut })
componentWillReceiveProps({ toggleShowSideBarShortcut, compressSingletonFolder, copyFileButton, copySnippetButton }) {
this.setState({ toggleShowSideBarShortcut, compressSingletonFolder, copyFileButton, copySnippetButton })
}
onInputAccessToken = event => {
const value = event.target.value
this.setState({ accessToken: value })
this.setState({
accessToken: value,
accessTokenHint: ACCESS_TOKEN_REGEXP.test(value) ? '' : 'This token is in unknown format.',
})
}
saveToken = async () => {
const { onHasAccessTokenChange } = this.props
const { onAccessTokenChange } = this.props
const { accessToken } = this.state
if (accessToken) {
await storageHelper.setAccessToken(accessToken)
onHasAccessTokenChange(true)
await configHelper.setOne(config.accessToken, accessToken)
onAccessTokenChange(accessToken)
this.setState({
accessTokenHint: 'Your token is saved, will work after reloading the page!',
accessToken: '',
accessTokenHint: (
<span>
<a href="#" onClick={() => window.location.reload()}>
Reload
</a>{' '}
to activate!
</span>
),
})
}
}
clearToken = async () => {
const { onHasAccessTokenChange } = this.props
await storageHelper.setAccessToken('')
onHasAccessTokenChange(false)
const { onAccessTokenChange } = this.props
await configHelper.setOne(config.accessToken, '')
onAccessTokenChange('')
this.setState({ accessToken: '' })
}
saveShortcut = async () => {
const { onShortcutChange } = this.props
const { toggleShowSideBarShortcut } = this.state
await storageHelper.setShortcut(toggleShowSideBarShortcut)
await configHelper.setOne(config.shortcut, toggleShowSideBarShortcut)
onShortcutChange(toggleShowSideBarShortcut)
this.setState({
shortcutHint: 'Shortcut is saved!',
@ -102,99 +156,134 @@ export default class SettingsBar extends preact.Component {
this.setState({ toggleShowSideBarShortcut: shortcut })
}
setReloadHint = () => {
this.setState({
reloadHint: (
<span>
Saved,{' '}
<a href="#" onClick={() => window.location.reload()}>
reload
</a>{' '}
to apply.
</span>
),
})
}
// writing this method as arrow function would be more verbose
createOnChange(configKey, set ) {
return async e => {
const enabled = e.target.checked
await configHelper.setOne(configKey, enabled)
set(enabled)
this.setReloadHint()
}
}
render() {
const { accessTokenHint, accessToken, toggleShowSideBarShortcut, shortcutHint } = this.state
const { hasAccessToken } = this.props
const { toggleShowSettings, activated } = this.props
const {
accessTokenHint,
toggleShowSideBarShortcut,
shortcutHint,
accessToken,
reloadHint,
moreOptions,
} = this.state
const { toggleShowSettings, activated, accessToken: hasAccessToken } = this.props
return (
<div className={'gitako-settings-bar'}>
{activated && (
<h3 className={'gitako-settings-bar-title'}>
Settings
</h3>
)}
{activated && (
<div className={'gitako-settings-bar-content'}>
<div className={'shadow-shelter'} />
<div className={'gitako-settings-bar-content-section access-token'}>
<h4>Access Token</h4>
<span>With access token provided, Gitako can access more repositories.</span>
<br />
<a href="https://github.com/blog/1509-personal-api-tokens" target="_blank">
Help: how to create access token?
</a>
<br />
<span>
Gitako stores the token in&nbsp;
<a href="https://developer.chrome.com/apps/storage" target="_blank">
chrome local storage
<React.Fragment>
<h3 className={'gitako-settings-bar-title'}>Settings</h3>
<div className={'gitako-settings-bar-content'}>
<div className={'shadow-shelter'} />
<div className={'gitako-settings-bar-content-section access-token'}>
<h4>Access Token</h4>
<a
href="https://github.com/EnixCoda/Gitako/wiki/How-to-create-access-token-for-Gitako%3F"
target="_blank"
>
Why & how to create it?
</a>
&nbsp;locally and safely.
</span>
<br />
<div className={'access-token-input-control'}>
<input
className={'access-token-input form-control'}
disabled={hasAccessToken}
placeholder={hasAccessToken ? 'Your token is saved' : 'Input your token here'}
value={accessToken}
onInput={this.onInputAccessToken}
/>
{hasAccessToken ? (
<button className={'btn'} onClick={this.clearToken}>
Clear
</button>
) : (
<button className={'btn'} onClick={this.saveToken}>
<br />
<div className={'access-token-input-control'}>
<input
className={'access-token-input form-control'}
disabled={hasAccessToken}
placeholder={hasAccessToken ? 'Your token is saved' : 'Input your token here'}
value={accessToken}
onInput={this.onInputAccessToken}
/>
{hasAccessToken && !accessToken ? (
<button className={'btn'} onClick={this.clearToken}>
Clear
</button>
) : (
<button className={'btn'} onClick={this.saveToken} disabled={!accessToken}>
Save
</button>
)}
</div>
{accessTokenHint && <span className={'hint'}>{accessTokenHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section toggle-shortcut'}>
<h4>Toggle Shortcut</h4>
<span>Set a combination of keys for toggling Gitako sidebar.</span>
<br />
<div className={'toggle-shortcut-input-control'}>
<input
className={'toggle-shortcut-input form-control'}
placeholder={'focus here and press the shortcut keys'}
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
onKeyDown={this.onShortCutInputKeyDown}
readOnly
/>
<button className={'btn'} onClick={this.saveShortcut}>
Save
</button>
)}
</div>
{shortcutHint && <span className={'hint'}>{shortcutHint}</span>}
</div>
{accessTokenHint && <span className={'hint'}>{accessTokenHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section toggle-shortcut'}>
<h4>Toggle shortcut</h4>
<span>Set a combination of keys for toggling Gitako sidebar.</span>
<br />
<div className={'toggle-shortcut-input-control'}>
<input
className={'toggle-shortcut-input form-control'}
placeholder={'focus here and press the shortcut keys'}
default={''}
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
onKeyDown={this.onShortCutInputKeyDown}
/>
<button className={'btn'} onClick={this.saveShortcut}>
Save
</button>
<div className={'gitako-settings-bar-content-section others'}>
<h4>More Options</h4>
{moreOptions.map(option => (
<React.Fragment>
<label key={option.key} htmlFor={option.key}>
<input
id={option.key}
name={option.key}
type={'checkbox'}
onChange={option.onChange}
checked={option.getValue()}
/>
&nbsp;{option.label}&nbsp;
<a href={option.wikiLink} target={'_blank'}>
(?)
</a>
</label>
<br />
</React.Fragment>
))}
{reloadHint && <div className={'hint'}>{reloadHint}</div>}
</div>
{shortcutHint && <span className={'hint'}>{shortcutHint}</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>
<span>
<div className={'gitako-settings-bar-content-section issue'}>
<h4>Contact</h4>
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
Draft a issue on Github
Bug report / feature request.
</a>
&nbsp;for bug report or feature request.
</span>
</div>
</div>
</div>
</React.Fragment>
)}
<div className={'placeholder-row'}>
<a
className={'version'}
href={wikiLinks.changeLog}
target={'_blank'}
title={'Check out new features!'}
>
v{version}
</a>
{activated ? (
<Icon
type={'chevron-down'}

View file

@ -1,141 +1,82 @@
import preact from 'preact'
import Portal from 'preact-portal'
import NProgress from 'nprogress'
/** @jsx preact.h */
import React from 'react'
import PropTypes from 'prop-types'
import { SideBar as SideBarCore } from 'driver/core'
import connect from 'driver/connect'
import FileExplorer from 'components/FileExplorer'
import ToggleShowButton from 'components/ToggleShowButton'
import MetaBar from 'components/MetaBar'
import SettingsBar from 'components/SettingsBar'
import Portal from 'components/Portal'
import Resizable from 'components/Resizable'
import cx from 'utils/cx'
import FileExplorer from './FileExplorer'
import ToggleShowButton from './ToggleShowButton'
import MetaBar from './MetaBar'
import SettingsBar from './SettingsBar'
import ResizeHandler from './ResizeHandler'
import cx from '../utils/cx'
import DOMHelper, { REPO_TYPE_PRIVATE } from '../utils/DOMHelper'
import GitHubHelper, { NOT_FOUND, BAD_CREDENTIALS } from '../utils/GitHubHelper'
import storageHelper from '../utils/storageHelper'
import URLHelper from '../utils/URLHelper'
import keyHelper from '../utils/keyHelper'
// initial width of side bar
const baseSize = 260
export default class SideBar extends preact.Component {
state = {
// current width of side bar
size: 260,
@connect(SideBarCore)
export default class Gitako extends React.PureComponent {
static propTypes = {
// initial width of side bar
baseSize: PropTypes.number,
// error message
error: PropTypes.string,
// whether Gitako side bar should be shown
shouldShow: false,
shouldShow: PropTypes.bool,
// whether show settings pane
showSettings: false,
showSettings: PropTypes.bool,
// whether failed loading the repo due to it is private
errorDueToAuth: false,
// got access token for GitHub
hasAccessToken: false,
errorDueToAuth: PropTypes.bool,
// access token for GitHub
accessToken: PropTypes.string,
// the shortcut string for toggle sidebar
toggleShowSideBarShortcut: '',
toggleShowSideBarShortcut: PropTypes.string,
// meta data for the repository
metaData: null,
metaData: PropTypes.object,
// file tree data
treeData: PropTypes.object,
// few settings
compressSingletonFolder: PropTypes.bool,
copyFileButton: PropTypes.bool,
copySnippetButton: PropTypes.bool,
init: PropTypes.func.isRequired,
onPJAXEnd: PropTypes.func.isRequired,
setShouldShow: PropTypes.func.isRequired,
toggleShowSideBar: PropTypes.func.isRequired,
toggleShowSettings: PropTypes.func.isRequired,
onAccessTokenChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func.isRequired,
onShortcutChange: PropTypes.func.isRequired,
setMetaData: PropTypes.func.isRequired,
setCopyFile: PropTypes.func.isRequired,
setCopySnippet: PropTypes.func.isRequired,
setCompressSingleton: PropTypes.func.isRequired,
}
static defaultProps = {
baseSize: 260,
shouldShow: false,
showSettings: false,
errorDueToAuth: false,
accessToken: '',
toggleShowSideBarShortcut: '',
metaData: null,
treeData: null,
compressSingletonFolder: false,
}
async componentWillMount() {
try {
const metaDataFromUrl = URLHelper.parse()
this.setState({ metaData: metaDataFromUrl })
DOMHelper.decorateGitHubPageContent()
const [accessToken, shortcut] = await Promise.all([
storageHelper.getAccessToken(),
storageHelper.getShortcut(),
])
this.setState({ hasAccessToken: Boolean(accessToken), toggleShowSideBarShortcut: shortcut })
const metaDataFromAPI = await GitHubHelper.getRepoMeta({ ...metaDataFromUrl, accessToken })
const branchName = metaDataFromUrl.branchName || metaDataFromAPI['default_branch']
const metaData = { ...metaDataFromUrl, branchName, api: metaDataFromAPI }
this.setState({ metaData })
const shouldShow = URLHelper.isInCodePage(metaData)
this.setShouldShow(shouldShow)
if (shouldShow) {
NProgress.start()
}
const treeData = await GitHubHelper.getTreeData({ ...metaData, accessToken })
this.setState({ treeData })
this.logoContainerElement = DOMHelper.insertLogo()
if (shouldShow) {
NProgress.done()
}
window.addEventListener('pjax:send', this.onPJAXStart)
window.addEventListener('pjax:complete', this.onPJAXEnd)
window.addEventListener('keydown', this.onKeyDown)
} catch (err) {
// TODO: detect request time exceeds limit
if (err.message === NOT_FOUND || err.message === BAD_CREDENTIALS) {
const repoPageType = await DOMHelper.getRepoPageType()
const errorDueToAuth = repoPageType === REPO_TYPE_PRIVATE || err.message === BAD_CREDENTIALS
this.setState({
showSettings: repoPageType !== null,
errorDueToAuth,
})
this.setShouldShow(errorDueToAuth)
} else {
console.error(err)
this.setShouldShow(false)
}
}
componentWillMount() {
const { init } = this.props
init()
}
onPJAXStart = () => {
componentDidMount() {
const { onPJAXEnd, onKeyDown } = this.props
window.addEventListener('pjax:complete', onPJAXEnd)
window.addEventListener('keydown', onKeyDown)
}
onPJAXEnd = () => {
NProgress.done()
const { metaData } = this.state
const mergedMetaData = { ...metaData, ...URLHelper.parse() }
this.setState({
metaData: mergedMetaData,
})
this.setShouldShow(URLHelper.isInCodePage(mergedMetaData))
DOMHelper.decorateGitHubPageContent()
DOMHelper.focusSearchInput()
}
setShouldShow = shouldShow => {
this.setState({ shouldShow })
DOMHelper.setBodyIndent(shouldShow)
}
toggleShowSideBar = () => {
const { shouldShow } = this.state
this.setShouldShow(!shouldShow)
}
toggleShowSettings = () => {
const { showSettings } = this.state
this.setState({ showSettings: !showSettings })
}
onHasAccessTokenChange = hasAccessToken => {
this.setState({ hasAccessToken })
}
onKeyDown = e => {
const { toggleShowSideBarShortcut } = this.state
if (toggleShowSideBarShortcut) {
const keys = keyHelper.parseEvent(e)
if (keys === toggleShowSideBarShortcut) {
this.toggleShowSideBar()
}
}
}
onShortcutChange = shortcut => {
this.setState({ toggleShowSideBarShortcut: shortcut })
}
onResize = size => {
this.setState({
size,
})
componentWillUnmount() {
const { onPJAXEnd, onKeyDown } = this.props
window.removeEventListener('pjax:complete', onPJAXEnd)
window.removeEventListener('keydown', onKeyDown)
}
renderAccessDeniedError() {
@ -143,55 +84,98 @@ export default class SideBar extends preact.Component {
<div className={'description'}>
<h5>Access Denied</h5>
<p>
Gitako needs access token with proper scopes (recommended: repo) to access this
repository. Please save it in the settings below.
Due to{' '}
<a target="_blank" href="https://developer.github.com/v3/#rate-limiting">
limitation of GitHub
</a>{' '}
or{' '}
<a target="_blank" href="https://developer.github.com/v3/#authentication">
auth needs
</a>
, Gitako needs access token to continue. Please follow the instructions in the settings
panel below.
</p>
</div>
)
}
renderContent() {
const { errorDueToAuth, metaData, treeData, showSettings } = this.state
const {
errorDueToAuth,
metaData,
treeData,
showSettings,
accessToken,
compressSingletonFolder,
toggleShowSettings,
} = this.props
return (
<div className={'gitako-side-bar-content'}>
{metaData && <MetaBar metaData={metaData} />}
{errorDueToAuth && this.renderAccessDeniedError()}
{metaData &&
treeData && (
<FileExplorer metaData={metaData} treeData={treeData} freeze={showSettings} />
)}
{errorDueToAuth
? this.renderAccessDeniedError()
: metaData && (
<FileExplorer
toggleShowSettings={toggleShowSettings}
metaData={metaData}
treeData={treeData}
freeze={showSettings}
accessToken={accessToken}
compressSingletonFolder={compressSingletonFolder}
/>
)}
</div>
)
}
render() {
const {
size,
baseSize,
error,
shouldShow,
showSettings,
hasAccessToken,
accessToken,
compressSingletonFolder,
copyFileButton,
copySnippetButton,
toggleShowSideBarShortcut,
loading,
} = this.state
logoContainerElement,
toggleShowSideBar,
toggleShowSettings,
onShortcutChange,
onAccessTokenChange,
setCompressSingleton,
setCopyFile,
setCopySnippet,
} = this.props
return (
<div className={cx('gitako', { hidden: !shouldShow })}>
<Portal into={this.logoContainerElement}>
<ToggleShowButton shouldShow={shouldShow} toggleShowSideBar={this.toggleShowSideBar} />
<div className={'gitako-side-bar'}>
<Portal into={logoContainerElement}>
<ToggleShowButton
error={error}
shouldShow={shouldShow}
toggleShowSideBar={toggleShowSideBar}
/>
</Portal>
<div className={'gitako-position-wrapper'}>
<ResizeHandler onResize={this.onResize} baseSize={baseSize} style={{ right: size }} />
<div className={'gitako-side-bar'} style={{ width: size }}>
<Resizable className={cx({ hidden: error || !shouldShow })} baseSize={baseSize}>
<div className={'gitako-side-bar-body'}>
{this.renderContent()}
<SettingsBar
toggleShowSettings={this.toggleShowSettings}
onShortcutChange={this.onShortcutChange}
onHasAccessTokenChange={this.onHasAccessTokenChange}
toggleShowSettings={toggleShowSettings}
onShortcutChange={onShortcutChange}
onAccessTokenChange={onAccessTokenChange}
activated={showSettings}
hasAccessToken={hasAccessToken}
accessToken={accessToken}
toggleShowSideBarShortcut={toggleShowSideBarShortcut}
compressSingletonFolder={compressSingletonFolder}
copyFileButton={copyFileButton}
copySnippetButton={copySnippetButton}
setCompressSingleton={setCompressSingleton}
setCopyFile={setCopyFile}
setCopySnippet={setCopySnippet}
/>
</div>
</div>
</Resizable>
</div>
)
}

View file

@ -1,13 +1,18 @@
import preact from 'preact'
/** @jsx preact.h */
import Icon from './Icon'
import React from 'react'
import Icon from 'components/Icon'
import cx from 'utils/cx'
import cx from '../utils/cx'
export default function Logo({ shouldShow, toggleShowSideBar }) {
export default function Logo({ error, shouldShow, toggleShowSideBar }) {
return (
<div className={cx('gitako-toggle-show-button-wrapper', { collapsed: !shouldShow })} onClick={toggleShowSideBar}>
<div
className={cx('gitako-toggle-show-button-wrapper', {
collapsed: !shouldShow || error,
error,
})}
onClick={error ? undefined : toggleShowSideBar}
>
<Icon className={'action-icon'} type={shouldShow ? 'x' : 'octoface'} />
{error && <span className={'error-message'}>{error}</span>}
</div>
)
}

View file

@ -1,10 +1,14 @@
import preact from 'preact'
/** @jsx preact.h */
import React from 'react'
import ReactDOM from 'react-dom'
import Gitako from 'components/Gitako'
import { addMiddleware } from 'driver/connect'
import { withErrorLog } from 'analytics'
import './content.less'
import SideBar from './components/SideBar'
addMiddleware(withErrorLog)
const SideBarElement = document.createElement('div')
document.body.appendChild(SideBarElement)
preact.render(<SideBar />, SideBarElement)
ReactDOM.render(<Gitako />, SideBarElement)

View file

@ -15,6 +15,11 @@
@media (min-width: @width-with-gitako) {
margin-left: 0;
}
.Header > div {
width: 1012px;
margin: 0 auto;
}
}
.markdown-body {
@ -74,24 +79,27 @@
top: 5px;
left: 0;
transform: translate((@side-bar-base-width - 30px));
z-index: 2;
z-index: 32;
display: inline-flex;
justify-content: center;
align-items: center;
cursor: pointer;
width: 30px;
min-width: 30px;
height: 30px;
will-change: transform;
border: 1px solid transparent;
transition: all @animation-duration ease;
@media screen and (max-width: @width-with-gitako) {
transition: all @animation-duration ease;
}
@media screen and (min-width: @width-with-gitako) {
transition: border-color, border-radius, background @animation-duration ease;
transform: translate(~'calc(50vw - (' @github-content-width ~') / 2 - 30px)');
}
&:hover {
.error-message {
display: inline;
}
}
&.collapsed {
border-color: #999999;
border-radius: 3px;
@ -106,262 +114,319 @@
}
}
&.error {
.action-icon {
color: #cb2431;
}
}
.action-icon {
color: #666666;
width: 16px;
height: 20px;
margin: 5px 6px;
transition: all @animation-duration ease;
}
.error-message {
display: none;
margin: 0 4px;
}
}
.@{name} {
.@{name}-side-bar {
.@{name}-position-wrapper {
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 2;
z-index: 32; // be more aggressive, against header in guest mode
min-width: @side-bar-base-width;
display: flex;
justify-content: flex-end;
@media screen and (min-width: @min-screen-width) {
width: ~'calc((100vw - ' @github-content-width ~') / 2)';
}
.@{name}-resize-handler {
@resize-handler-width: 16px;
@side-bar-and-handler-width: (@min-screen-width + @resize-handler-width);
position: absolute;
top: 0;
display: none;
width: @resize-handler-width;
height: 100vh;
background: #fafbfc;
cursor: ew-resize;
user-select: none;
border-left: 1px solid #e1e4e8;
@media screen and (min-width: @min-screen-width) {
display: flex;
align-items: center;
}
.grabber-icon {
transform: rotate(90deg);
width: 12px;
}
&.hidden {
.hidden;
}
.@{name}-side-bar {
position: absolute;
right: 0;
width: @side-bar-base-width;
.gitako-position-content {
max-width: 100%;
height: 100%;
}
}
.@{name}-resize-handler {
@resize-handler-width: 16px;
@side-bar-and-handler-width: (@min-screen-width + @resize-handler-width);
display: none;
width: @resize-handler-width;
background: #fafbfc;
cursor: ew-resize;
user-select: none;
border-left: 1px solid #e1e4e8;
@media screen and (min-width: (@min-screen-width + @side-bar-base-width + @resize-handler-width * 2)) {
display: flex;
align-items: center;
}
.grabber-icon {
transform: rotate(90deg);
width: 12px;
}
}
.@{name}-side-bar-body {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #fafbfc;
border-right: 1px solid #e1e4e8;
border-left: 1px solid #e1e4e8;
overflow: hidden;
&.hidden {
.hidden;
}
.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);
}
.@{name}-side-bar-content {
display: flex;
flex: 1;
flex-direction: column;
background: #fafbfc;
border-right: 1px solid #e1e4e8;
overflow: hidden;
will-change: transform;
border-left: 1px solid #e1e4e8;
&.hidden {
.hidden;
.meta-bar {
position: relative;
padding: 10px;
padding-right: 30px;
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;
}
}
.octicon {
transition: 0.3s ease;
color: rgba(3, 47, 98, 0.55);
vertical-align: initial;
.description {
padding: 4px 10px;
}
.octicon-wrapper {
display: inline-block;
width: 16px;
height: 16px;
text-align: center;
vertical-align: middle;
}
.octicon-color {
color: rgba(3, 47, 98, 0.55);
}
.@{name}-side-bar-content {
.loading-indicator-container {
width: 100%;
height: 100%;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
.meta-bar {
position: relative;
padding: 10px;
padding-right: 30px;
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;
.loading-indicator {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
&.freeze {
filter: blur(1.5px) opacity(0.6) grayscale(0.9);
pointer-events: none;
}
align-items: center;
}
/* 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;
}
}
}
.loading-indicator-icon {
width: 20px;
height: 20px;
animation: rotate 2s infinite linear;
}
}
.@{name}-settings-bar {
z-index: 2;
background: #fafbfc;
.file-explorer {
width: 100%;
height: 100%;
outline: none;
display: flex;
flex-direction: column;
&-title {
border-top: 1px solid #eaecef;
padding: 6px 10px;
box-shadow: 0 1px 8px 0 rgba(0,0,0,.2), 0 3px 4px 0 rgba(0,0,0,.14), 0 3px 3px -2px rgba(0,0,0,.12);
z-index: 1;
flex-wrap: nowrap;
&.freeze {
filter: blur(1.5px) opacity(0.6) grayscale(0.9);
cursor: pointer;
> * {
pointer-events: none;
user-select: none;
}
}
&-content {
padding: 0 10px;
/* 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;
position: relative;
.shadow-shelter {
position: absolute;
width: 100%;
height: 6px;
left: 0;
background: #fafbfc;
z-index: 1;
}
&-section {
padding-bottom: 10px;
}
}
.access-token {
border-bottom: none; // prevent overwrite by github style
.hint {
color: #6a737d;
}
}
.access-token-input-control {
display: flex;
margin-top: 8px;
.access-token-input {
flex: 1;
box-shadow: none;
&:disabled {
cursor: not-allowed;
.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;
}
}
}
.toggle-shortcut {
.hint {
color: #6a737d;
}
}
.toggle-shortcut-input-control {
display: flex;
margin-top: 8px;
.toggle-shortcut-input {
flex: 1;
box-shadow: none;
}
}
.placeholder-row {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 6px 10px;
border-top: 1px solid #eaecef;
}
}
.show-settings-icon {
width: 20px;
height: 20px;
cursor: pointer;
}
.hide-settings-icon {
width: 20px;
height: 20px;
cursor: pointer;
.@{name}-settings-bar {
z-index: 2;
background: #fafbfc;
display: flex;
flex-direction: column;
&-title {
border-top: 1px solid #eaecef;
padding: 6px 10px;
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.2), 0 3px 4px 0 rgba(0, 0, 0, 0.14),
0 3px 3px -2px rgba(0, 0, 0, 0.12);
z-index: 1;
}
&-content {
padding: 0 10px;
flex: 1;
overflow: auto;
position: relative;
.shadow-shelter {
position: absolute;
width: 100%;
height: 6px;
left: 0;
background: #fafbfc;
z-index: 1;
}
&-section {
padding-bottom: 10px;
}
}
.access-token {
border-bottom: none; // prevent overwrite by github style
.hint {
color: #6a737d;
}
}
.access-token-input-control {
display: flex;
margin-top: 8px;
.access-token-input {
flex: 1;
box-shadow: none;
&:disabled {
cursor: not-allowed;
}
}
}
.toggle-shortcut {
.hint {
color: #6a737d;
}
}
.toggle-shortcut-input-control {
display: flex;
margin-top: 8px;
.toggle-shortcut-input {
flex: 1;
box-shadow: none;
}
}
.singleton {
.hint {
color: #6a737d;
}
}
.placeholder-row {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
border-top: 1px solid #eaecef;
.version {
color: #999999;
}
.show-settings-icon {
width: 20px;
height: 20px;
cursor: pointer;
}
.hide-settings-icon {
width: 20px;
height: 20px;
cursor: pointer;
}
}
}
}
}
@keyframes rotate {
from {
transform: rotateZ(0);
}
to {
transform: rotateZ(360deg);
}
}

71
src/driver/connect.js Normal file
View file

@ -0,0 +1,71 @@
import React from 'react'
const middlewares = []
export function addMiddleware(middleware) {
if (typeof middleware !== 'function') return null
const m = middleware.bind(null)
middlewares.push(m)
return function removeMiddleware() {
const index = middlewares.indexOf(m)
if (index === -1) return
middlewares.splice(index, 1)
}
}
function applyMiddlewares(method, args) {
for(const middleware of middlewares) {
[method = method, args = args] = middleware(method, args) || []
}
return [method, args]
}
function run([method, args]) {
method.apply(null, args)
}
function link(instance, sources) {
const wrappedMethods = {/* [keyof sources] -> wrappedMethods.method */}
const map = new Map(/* sources.creator -> wrappedMethods.method */)
Object.entries(sources).forEach(([key, createMethod]) => {
const method = createMethod(dispatch)
wrappedMethods[key] = method
map.set(createMethod, method)
})
const sourcesValues = Object.values(sources)
function dispatch(...args) {
const isFromSource = sourcesValues.includes(args[0])
if (isFromSource) {
const [createMethod, ...otherArgs] = args
const method = map.get(createMethod)
run(applyMiddlewares(method, otherArgs))
} else {
// by doing so, no async updater is available anymore
// luckily I don't need them :)
let [updater, callback] = args
if (typeof updater === 'function') {
updater = updater(instance.state, instance.props)
}
instance.setState(updater, callback)
}
}
return wrappedMethods
}
export default function connect(mapping) {
return function linkComponent(ComponentClass) {
return class AwesomeApp extends React.PureComponent {
static displayName = `Driven${ComponentClass.name}`
state = {}
boundCore = link(this, mapping)
render() {
return <ComponentClass {...this.props} {...this.boundCore} {...this.state} />
}
}
}
}

View file

@ -0,0 +1,269 @@
import ini from 'ini'
import DOMHelper from 'utils/DOMHelper'
import treeParser from 'utils/treeParser'
import URLHelper from 'utils/URLHelper'
import VisibleNodesGenerator from 'utils/VisibleNodesGenerator'
import GitHubHelper from 'utils/GitHubHelper'
function getVisibleParentNode(nodes, focusedNode, depths) {
const focusedNodeIndex = nodes.indexOf(focusedNode)
const focusedNodeDepth = depths.get(focusedNode)
let indexOfParentNode = focusedNodeIndex - 1
while (
indexOfParentNode !== -1 &&
depths.get(nodes[indexOfParentNode]) >= focusedNodeDepth
) {
--indexOfParentNode
}
const parentNode = nodes[indexOfParentNode]
return parentNode
}
const tasksAfterRender = []
const visibleNodesGenerator = new VisibleNodesGenerator()
const init = dispatch => () => dispatch(async () => {
dispatch(setStateText, 'Fetching File List...')
})
function resolveGitModules(root, blobData) {
if (blobData) {
if (blobData.encoding === 'base64') {
const content = atob(blobData.content)
const parsed = ini.parse(content)
Object.values(parsed).map(value => {
const { url, path } = value
// for now, handle modules at root only
const node = root.contents.find(node => node.path === path)
node.url = url
})
}
}
}
const setUpTree = dispatch => () => dispatch(async (state, { treeData, metaData, compressSingletonFolder, accessToken }) => {
if (!treeData) return
dispatch(setStateText, 'Rendering File List...')
const { root, gitModules } = treeParser.parse(treeData, metaData)
if (gitModules) {
const blobData = await GitHubHelper.getBlobData({
userName: metaData.userName,
repoName: metaData.repoName,
fileSHA: gitModules.sha,
accessToken,
})
resolveGitModules(root, blobData)
}
visibleNodesGenerator.setCompress(compressSingletonFolder)
await visibleNodesGenerator.plantTree(root)
tasksAfterRender.push(DOMHelper.focusSearchInput)
dispatch(setStateText, null)
const currentPath = URLHelper.getCurrentPath(true)
if (currentPath.length) {
const nodeExpandedTo = visibleNodesGenerator.expandTo(currentPath.join('/'))
if (nodeExpandedTo) {
visibleNodesGenerator.focusNode(nodeExpandedTo)
const { nodes } = visibleNodesGenerator.visibleNodes
tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(nodes.indexOf(nodeExpandedTo)))
}
}
dispatch(updateVisibleNodes)
})
const execAfterRender = dispatch => () => {
for (const task of tasksAfterRender) {
task()
}
tasksAfterRender.length = 0
}
const setStateText = dispatch => text => dispatch({
stateText: text,
})
const handleKeyDown = dispatch => ({ key }) => dispatch(({ visibleNodes: { nodes, focusedNode, expandedNodes, depths } }) => {
if (focusedNode) {
const focusedNodeIndex = nodes.indexOf(focusedNode)
switch (key) {
case 'ArrowUp':
// focus on previous node
if (focusedNodeIndex === 0) {
dispatch(focusNode, nodes[nodes.length - 1])
} else {
dispatch(focusNode, nodes[focusedNodeIndex - 1])
}
break
case 'ArrowDown':
// focus on next node
if (focusedNodeIndex + 1 < nodes.length) {
dispatch(focusNode, nodes[focusedNodeIndex + 1])
} else {
dispatch(focusNode, nodes[0])
}
break
case 'ArrowLeft':
// collapse node or go to parent node
if (expandedNodes.has(focusedNode)) {
dispatch(setExpand, focusedNode, false)
} else {
// go forward to the start of the list, find the closest node with lower depth
const parentNode = getVisibleParentNode(nodes, focusedNode, depths)
if (parentNode) {
dispatch(focusNode, parentNode)
}
}
break
// consider the two keys as 'confirm' key
case 'ArrowRight':
// expand node or focus on first content node or redirect to file page
if (focusedNode.type === 'tree') {
if (expandedNodes.has(focusedNode)) {
const nextNode = nodes[focusedNodeIndex + 1]
if (depths.get(nextNode) > depths.get(focusedNode)) {
dispatch(focusNode, nextNode)
}
} else {
dispatch(setExpand, focusedNode, true)
}
} else if (focusedNode.type === 'blob') {
DOMHelper.loadWithPJAX(focusedNode.url)
} else if (focusedNode.type === 'commit') {
// redirect to its parent folder
DOMHelper.loadWithPJAX(focusedNode.parent.url)
}
break
case 'Enter':
// expand node or redirect to file page
if (focusedNode.type === 'tree') {
dispatch(setExpand, focusedNode, true)
} else if (focusedNode.type === 'blob') {
DOMHelper.loadWithPJAX(focusedNode.url)
} else if (focusedNode.type === 'commit') {
// redirect to its parent folder
DOMHelper.loadWithPJAX(focusedNode.parent.url)
}
break
}
} else {
// now search input is focused
if (nodes.length) {
switch (key) {
case 'ArrowDown':
dispatch(focusNode, nodes[0])
break
case 'ArrowUp':
dispatch(focusNode, nodes[nodes.length - 1])
break
}
}
}
})
const handleSearchKeyChange = dispatch => {
let i = 0
return async event => {
const searchKey = event.target.value
const j = i += 1
await visibleNodesGenerator.search(searchKey)
if (i === j) dispatch(updateVisibleNodes)
}
}
const delayExpandThreshold = 400
function shouldDelayExpand(node) {
return visibleNodesGenerator.visibleNodes.expandedNodes.has(node)
&& node.contents.length > delayExpandThreshold
}
const setExpand = dispatch => (node, expand) => {
visibleNodesGenerator.setExpand(node, expand)
const applyChanges = () => {
dispatch(focusNode, node)
tasksAfterRender.push(DOMHelper.focusSearchInput)
}
if (shouldDelayExpand(node)) {
dispatch(mountExpandingIndicator, node)
tasksAfterRender.push(() => setTimeout(applyChanges, 0))
} else {
applyChanges()
}
}
const toggleNodeExpansion = dispatch => (node, skipScrollToNode) => {
visibleNodesGenerator.toggleExpand(node)
const applyChanges = () => {
dispatch(focusNode, node, skipScrollToNode)
tasksAfterRender.push(DOMHelper.focusFileExplorer)
}
if (shouldDelayExpand(node)) {
dispatch(mountExpandingIndicator, node)
tasksAfterRender.push(() => setTimeout(applyChanges, 0))
} else {
applyChanges()
}
}
const focusNode = dispatch => (node, skipScroll) => dispatch(({ visibleNodes: { nodes } }) => {
visibleNodesGenerator.focusNode(node)
if (node && !skipScroll) {
// when focus a node not in viewport(by keyboard), scroll to it
const indexOfToBeFocusedNode = nodes.indexOf(node)
tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(indexOfToBeFocusedNode))
tasksAfterRender.push(DOMHelper.focusSearchInput)
}
dispatch(updateVisibleNodes)
})
const onNodeClick = dispatch => (node) => dispatch((state, { metaData, accessToken }) => {
if (node.type === 'tree') {
dispatch(toggleNodeExpansion, node, true)
} else if (node.type === 'blob') {
dispatch(focusNode, node, true)
DOMHelper.loadWithPJAX(node.url)
} else if (node.type === 'commit') {
window.open(node.url, '_blank')
}
})
const mountExpandingIndicator = dispatch => node => dispatch(({ visibleNodes }) => {
const dummyVisibleNodes = {
...visibleNodes,
nodes: visibleNodes.nodes.slice(),
}
dummyVisibleNodes.nodes.splice(
dummyVisibleNodes.nodes.indexOf(node) + 1,
0,
{ virtual: true, name: 'Loading', path: '-' },
)
return {
visibleNodes: dummyVisibleNodes
}
})
const updateVisibleNodes = dispatch => () => {
const { visibleNodes } = visibleNodesGenerator
dispatch({ visibleNodes })
}
export default {
init,
setUpTree,
execAfterRender,
setStateText,
handleKeyDown,
handleSearchKeyChange,
setExpand,
toggleNodeExpansion,
focusNode,
onNodeClick,
updateVisibleNodes,
mountExpandingIndicator,
}

View file

@ -0,0 +1,6 @@
const init = dispatch => () => {
}
export default {
init,
}

137
src/driver/core/SideBar.js Normal file
View file

@ -0,0 +1,137 @@
import DOMHelper, { REPO_TYPE_PRIVATE } from 'utils/DOMHelper'
import GitHubHelper, { NOT_FOUND, BAD_CREDENTIALS, API_RATE_LIMIT } from 'utils/GitHubHelper'
import configHelper from 'utils/configHelper'
import URLHelper from 'utils/URLHelper'
import keyHelper from 'utils/keyHelper'
const init = dispatch => async () => {
try {
if (!URLHelper.isInRepoPage()) return
dispatch({ logoContainerElement: DOMHelper.insertLogoMountPoint() })
let nothingWentWrong = true
const metaData = URLHelper.parse()
dispatch(setMetaData, metaData)
const { access_token: accessToken, shortcut, compressSingletonFolder, copyFileButton, copySnippetButton } = await configHelper.get()
DOMHelper.decorateGitHubPageContent({ copyFileButton, copySnippetButton })
dispatch({ accessToken, toggleShowSideBarShortcut: shortcut, compressSingletonFolder, copyFileButton, copySnippetButton })
const detectedBranchName = DOMHelper.getCurrentBranch() || 'master'
let aggressivelyGotTreeData = GitHubHelper.getTreeData({
...metaData,
branchName: detectedBranchName,
accessToken,
}).catch(() => {
nothingWentWrong = false
})
const metaDataFromAPI = await GitHubHelper.getRepoMeta({ ...metaData, accessToken })
const projectDefaultBranchName = metaDataFromAPI['default_branch']
if (!metaData.branchName) {
// User accessed repo's homepage(no branch name in URL) and we predicted its default branch to be 'master'
if (projectDefaultBranchName !== detectedBranchName) {
// And the repo do not use {defaultBranchName} as default branch,
aggressivelyGotTreeData = GitHubHelper.getTreeData({
...metaData,
branchName: projectDefaultBranchName,
accessToken,
})
}
}
const branchName = metaData.branchName || projectDefaultBranchName
Object.assign(metaData, { branchName, api: metaDataFromAPI })
dispatch(setMetaData, metaData)
const shouldShow = URLHelper.isInCodePage(metaData)
dispatch(setShouldShow, nothingWentWrong && shouldShow)
aggressivelyGotTreeData
.then(treeData => {
dispatch({ treeData })
})
.catch(err => {
dispatch(handleError, err)
})
} catch (err) {
dispatch(handleError, err)
}
}
const handleError = dispatch => async (err) => {
// TODO: detect request time exceeds limit
if (err.message === NOT_FOUND || err.message === BAD_CREDENTIALS || err.message === API_RATE_LIMIT ) {
const repoPageType = await DOMHelper.getRepoPageType()
const errorDueToAuth = repoPageType === REPO_TYPE_PRIVATE || err.message === BAD_CREDENTIALS || err.message === API_RATE_LIMIT
dispatch({ errorDueToAuth })
dispatch(setShowSettings, true)
dispatch(setShouldShow, errorDueToAuth)
if (!errorDueToAuth) {
dispatch(setError, 'Gitako ate a bug, but it should recovery soon!')
}
} else {
dispatch(setShouldShow, false)
dispatch(setError, 'Gitako ate a bug, but it should recovery soon!')
}
}
const onPJAXEnd = dispatch => () => {
dispatch(({ metaData, copyFileButton, copySnippetButton }) => {
DOMHelper.unmountTopProgressBar()
DOMHelper.decorateGitHubPageContent({ copyFileButton, copySnippetButton })
DOMHelper.focusSearchInput()
const mergedMetaData = { ...metaData, ...URLHelper.parse() }
dispatch(setShouldShow, URLHelper.isInCodePage(mergedMetaData))
dispatch(setMetaData, mergedMetaData)
})
}
const onKeyDown = dispatch => e => {
dispatch(({ toggleShowSideBarShortcut }) => {
if (toggleShowSideBarShortcut) {
const keys = keyHelper.parseEvent(e)
if (keys === toggleShowSideBarShortcut) {
dispatch(toggleShowSideBar)
}
}
})
}
const toggleShowSideBar = dispatch => () => dispatch(({ shouldShow }) => dispatch(setShouldShow, !shouldShow))
const setShouldShow = dispatch => shouldShow => {
dispatch({ shouldShow }, shouldShow ? DOMHelper.focusSearchInput : null)
DOMHelper.setBodyIndent(shouldShow)
}
const setError = dispatch => error => {
dispatch({ error })
}
const toggleShowSettings = dispatch => () => dispatch(({ showSettings }) => ({ showSettings: !showSettings }))
const setShowSettings = dispatch => showSettings => dispatch({ showSettings })
const onAccessTokenChange = dispatch => accessToken => dispatch({ accessToken })
const onShortcutChange = dispatch => shortcut => dispatch({ toggleShowSideBarShortcut: shortcut })
const setMetaData = dispatch => metaData => dispatch({ metaData })
const setCompressSingleton = dispatch => compressSingletonFolder => dispatch({ compressSingletonFolder })
const setCopyFile = dispatch => copyFileButton => dispatch({ copyFileButton })
const setCopySnippet = dispatch => copySnippetButton => dispatch({ copySnippetButton })
export default {
init,
onPJAXEnd,
onKeyDown,
setShouldShow,
setShowSettings,
toggleShowSideBar,
toggleShowSettings,
onAccessTokenChange,
onShortcutChange,
setMetaData,
setCompressSingleton,
setCopyFile,
setCopySnippet,
setError,
handleError,
}

3
src/driver/core/index.js Normal file
View file

@ -0,0 +1,3 @@
export { default as Gitako } from './Gitako'
export { default as SideBar } from './SideBar'
export { default as FileExplorer } from './FileExplorer'

View file

@ -1,18 +1,26 @@
{
"manifest_version": 2,
"name": "Gitako",
"version": "0.2.4",
"name": "Gitako - enhanced Github code tree",
"version": "0.3.16",
"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/*"],
"permissions": [
"tabs",
"storage",
"*://github.com/*"
],
"content_scripts": [
{
"matches": ["https://github.com/*"],
"js": ["content.js"]
"matches": [
"https://github.com/*"
],
"js": [
"content.js"
]
}
]
}
}

View file

@ -20,11 +20,29 @@ function setBodyIndent(shouldShowGitako) {
}
}
function isInCodePage() {
const branchListSelector = '.branch-select-menu'
const listElement = document.querySelector(branchListSelector)
return Boolean(listElement)
}
function getBranches() {
const branchSelector = '.branch-select-menu .select-menu-list > div .select-menu-item-text'
const branchElements = Array.from(document.querySelectorAll(branchSelector))
return branchElements.map(element => element.innerHTML.trim())
}
function getCurrentBranch() {
const selectedBranchSelector = '.select-menu.branch-select-menu .select-menu-modal .select-menu-list .select-menu-item.selected svg.select-menu-item-icon + span'
const selectedBranchElement = document.querySelector(selectedBranchSelector)
return selectedBranchElement ? selectedBranchElement.textContent.trim() : null
}
/**
* add the logo element into DOM
*
*/
function insertLogo() {
function insertLogoMountPoint() {
const logoSelector = '.gitako .gitako-logo'
const logoElement = document.querySelector(logoSelector)
if (logoElement) {
@ -58,15 +76,18 @@ function scrollToRepoContent() {
function scrollToNodeElement(index) {
const nodeElementSelector = '.node-item'
const nodeElements = document.querySelectorAll(nodeElementSelector)
nodeElements[index].scrollIntoView({
behavior: 'smooth',
block: 'center',
})
const targetElement = nodeElements[index]
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
const pjax = new PJAX({
elements: '.pjax-link',
selectors: ['.repository-content'],
selectors: ['.repository-content', 'title'],
scrollTo: false,
analytics: false,
cacheBust: false,
@ -247,7 +268,7 @@ const clippy = createClippy()
let currentCodeSnippetElement
function attachCopySnippet() {
const readmeSelector = '.repository-content .readme article'
const readmeSelector = '.repository-content #readme article'
const readmeElement = document.querySelector(readmeSelector)
if (readmeElement) {
readmeElement.addEventListener('mouseover', ({ target }) => {
@ -306,11 +327,18 @@ function clickOnNodeElement(index = 0) {
/**
* a combination of few above functions
*/
function decorateGitHubPageContent() {
attachCopyFileBtn()
attachCopySnippet()
function decorateGitHubPageContent({ copyFileButton, copySnippetButton }) {
if (copyFileButton) attachCopyFileBtn()
if (copySnippetButton) attachCopySnippet()
}
function mountTopProgressBar() {
NProgress.start()
}
function unmountTopProgressBar() {
NProgress.done()
}
export default {
loadWithPJAX,
@ -322,8 +350,13 @@ export default {
focusFileExplorer,
getCurrentPageType,
getRepoPageType,
insertLogo,
insertLogoMountPoint,
setBodyIndent,
scrollToNodeElement,
scrollToRepoContent,
mountTopProgressBar,
unmountTopProgressBar,
isInCodePage,
getBranches,
getCurrentBranch,
}

View file

@ -1,5 +1,11 @@
import { raiseError } from 'analytics'
export const NOT_FOUND = 'Repo Not Found'
export const BAD_CREDENTIALS = 'Bad credentials'
export const API_RATE_LIMIT = `API rate limit`
function apiRateLimitExceeded(content) {
return content && content['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting'
}
async function request(url, { accessToken } = {}) {
const headers = {}
@ -9,9 +15,13 @@ async function request(url, { 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)
else if (res.status === 404) throw new Error(NOT_FOUND)
else {
const content = await res.json()
if (apiRateLimitExceeded(content)) throw new Error(API_RATE_LIMIT)
else if (!res.ok) raiseError(new Error(`Got ${res.statusText} when requesting ${url}`))
throw new Error(content && content.message)
}
}
async function getRepoMeta({ userName, repoName, accessToken }) {
@ -24,12 +34,18 @@ async function getTreeData({ userName, repoName, branchName, accessToken }) {
return await request(url, { accessToken })
}
function getUrlForRedirect({ userName, repoName, branchName }, path) {
return `https://github.com/${userName}/${repoName}/tree/${branchName}/${path}`
async function getBlobData({ userName, repoName, accessToken, fileSHA }) {
const url = `https://api.github.com/repos/${userName}/${repoName}/git/blobs/${fileSHA}`
return await request(url, { accessToken })
}
function getUrlForRedirect({ userName, repoName, branchName }, type = 'blob', path) {
return `https://github.com/${userName}/${repoName}/${type}/${branchName}/${path}`
}
export default {
getRepoMeta,
getTreeData,
getBlobData,
getUrlForRedirect,
}

View file

@ -24,7 +24,11 @@ function parse() {
return parsedData
}
const RESERVED_NAME = ['blog']
function isInRepoPage() {
const repoHeaderSelector = '.repohead'
return Boolean(document.querySelector(repoHeaderSelector))
}
// route types related to determining if sidebar should show
const TYPES = {
TREE: 'tree',
@ -33,12 +37,12 @@ const TYPES = {
// known but not related types: issues, pulls, wiki, insight,
// TODO: record more types
}
function isInCodePage(metaData = {}) {
const { userName, repoName, type, branchName } = { ...parseRaw(), ...metaData }
return (
userName &&
!RESERVED_NAME.find(_ => _ === userName) &&
repoName &&
const mergedRepo = { ...parseRaw(), ...metaData }
const { type, branchName } = mergedRepo
return Boolean(
isInRepoPage(mergedRepo) &&
(!type || type === TYPES.TREE || type === TYPES.BLOB) &&
type !== TYPES.COMMIT &&
(branchName || (!type && !branchName))
@ -52,6 +56,7 @@ function getCurrentPath(decode = false) {
export default {
getCurrentPath,
isInRepoPage,
isInCodePage,
parse,
}

37
src/utils/configHelper.js Normal file
View file

@ -0,0 +1,37 @@
import storageHelper from 'utils/storageHelper'
import { pick } from 'utils/general'
export const config = {
shortcut: 'shortcut',
accessToken: 'access_token',
compressSingletonFolder: 'compressSingletonFolder',
copyFileButton: 'copyFileButton',
copySnippetButton: 'copySnippetButton',
}
const configKeys = Object.values(config)
function get() {
return storageHelper.get(configKeys)
}
function getOne(key) {
return get()[key]
}
function set(partialConfig) {
return storageHelper.set(pick(partialConfig, configKeys))
}
function setOne(key, value) {
return set({
[key]: value,
})
}
export default {
get,
getOne,
set,
setOne,
}

View file

@ -1,18 +1,17 @@
function shallowEqual(a, b) {
if (a === b) return true
if (typeof a === 'object' && typeof a === typeof b) {
if (a === null || b === null) return false
for (const key in a) {
if (a[key] !== b[key]) return false
}
for (const key in b) {
if (!Object.prototype.hasOwnProperty.call(a, key)) return false
}
return true
}
return false
}
export default {
shallowEqual,
/**
* @param {Object} source
* @param {Object|Array} keys
* @returns
*/
export function pick(source, keys) {
if (keys && typeof keys === 'object') {
return (
Array.isArray(keys) ? keys : Object.keys(keys)
).reduce((copy, key) => {
copy[key] = source[key]
return copy
}, {})
}
return {}
}

View file

@ -35,15 +35,12 @@ function parse(keysString) {
function parseKeyCode(code) {
return code
.toLowerCase()
.replace(/(left|right)$/, '')
.replace(/^control$/, 'ctrl')
.replace(/^digit/, '')
.replace(/^key/, '')
}
function parseEvent(e) {
const { altKey: alt, shiftKey: shift, metaKey: meta, ctrlKey: ctrl } = e
const code = parseKeyCode(e.code)
const code = parseKeyCode(e.key)
const keys = { meta, ctrl, shift, alt, [code]: true }
const combination = parse(
Object.entries(keys)

View file

@ -1,36 +1,14 @@
const localStorage = chrome.storage.local
const ACCESS_TOKEN_KEY = 'access_token'
const SHORTCUT_KEY = 'shortcut'
function get(key) {
return new Promise(resolve => localStorage.get(key, items => resolve(items[key])))
function get(mapping) {
return new Promise(resolve => localStorage.get(mapping, resolve))
}
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)
}
function getShortcut() {
return get(SHORTCUT_KEY)
}
function setShortcut(shortcut) {
return set(SHORTCUT_KEY, shortcut)
function set(value) {
return new Promise(resolve => localStorage.set(value, resolve))
}
export default {
get,
set,
getAccessToken,
setAccessToken,
getShortcut,
setShortcut,
}

View file

@ -1,4 +1,4 @@
import GitHubHelper from './GitHubHelper'
import GitHubHelper from 'utils/GitHubHelper'
const nodeTemplate = {
name: null,
@ -11,16 +11,33 @@ const nodeTemplate = {
function sortFoldersToFront(root) {
const isFolder = node => node.type === 'tree'
const isNotFolder = (...args) => !isFolder(...args)
function DFS(root) {
const isNotFolder = node => !isFolder(node)
function depthFirstSearch(root) {
const nodes = root.contents
if (nodes) {
nodes.splice(0, Infinity, ...nodes.filter(isFolder), ...nodes.filter(isNotFolder))
nodes.forEach(DFS)
nodes.forEach(depthFirstSearch)
}
return root
}
return DFS(root)
return depthFirstSearch(root)
}
function setParentNode(root, parent = null) {
root.parent = parent
if (root.contents) {
root.contents.forEach(node => setParentNode(node, root))
}
}
function findGitModules(root) {
if (root.contents) {
const modulesFile = root.contents.find(content => content.name === '.gitmodules')
if (modulesFile) {
return modulesFile
}
}
return null
}
function parse(treeData, metaData) {
@ -52,7 +69,9 @@ function parse(treeData, metaData) {
...nodeTemplate,
...item,
name: item.path.replace(/^.*\//, ''),
url: GitHubHelper.getUrlForRedirect(metaData, item.path),
url: item.url
? GitHubHelper.getUrlForRedirect(metaData, item.type, item.path)
: null,
contents: item.type === 'tree' ? [] : null,
}
pathToNode.get(path).contents.push(node)
@ -61,9 +80,11 @@ function parse(treeData, metaData) {
}
})
setParentNode(root)
return {
gitModules: findGitModules(root),
root: sortFoldersToFront(root),
nodes: Array.from(pathToNode.values()),
}
}

View file

@ -26,7 +26,7 @@ function getFilterFunc(keyRegex) {
}
function search(treeNodes, searchKey) {
if (!searchKey) return
if (!searchKey) return treeNodes
/**
* if searchKey is 'abcd'
* then keyRegex will be /a.*?b.*?c.*?d/i
@ -53,27 +53,83 @@ function debounce(func, delay) {
export const debouncedSearch = debounce(search, 250)
function runIdle(func) {
return new Promise(resolve => {
window.requestIdleCallback(async () => resolve(await func()))
})
}
function promisifyAsyncFunc(func) {
return (...args) => new Promise(async resolve => resolve(await func(...args)))
}
async function asyncMap(arr, asyncCallback) {
return await Promise.all(arr.map(promisifyAsyncFunc(asyncCallback)))
}
async function getNodes(root) {
return await runIdle(async () => {
if (!root.contents) return []
return [].concat(
...await asyncMap(root.contents, async node => [node, ...await getNodes(node)])
)
})
}
function compressTree(root, prefix = []) {
if (root.contents) {
if (root.contents.length === 1) {
const singleton = root.contents[0]
if (singleton.type === 'tree') {
return compressTree(singleton, [...prefix, root.name])
}
}
}
return {
...root,
name: [...prefix, root.name].join('/'),
contents: root.contents
? root.contents.map(node => compressTree(node))
: undefined,
}
}
export default class VisibleNodesGenerator {
// LEVEL 1
root = null
nodes = null
plantTree(root, nodes) {
this.root = root
this.nodes = nodes
compressed = false
// a simplified sync 'search'
this.searchedNodes = this.root.contents
this.generateVisibleNodes()
getRoot() {
return this.compress && this.compressed
? this.compressedRoot
: this.root
}
async plantTree(root) {
this.root = root
this.nodes = await getNodes(root)
this.compressedRoot = compressTree(root)
await this.search()
}
// LEVEL 2
searchedNodes = null
async search(searchKey) {
this.searchedNodes = (await debouncedSearch(this.nodes, searchKey)) || this.root.contents
this.compressed = !Boolean(searchKey)
this.searchedNodes = searchKey
? await debouncedSearch(this.nodes, searchKey)
: this.getRoot().contents
this.expandedNodes.clear()
this.generateVisibleNodes()
}
setCompress(compress) {
this.compress = compress
}
// LEVEL 3
expandedNodes = new Set()
depths = new Map()
@ -92,15 +148,22 @@ export default class VisibleNodesGenerator {
}
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)
if (!rootNode) return
this.setExpand(rootNode, true)
let root = this.getRoot()
const findNode = (root) => {
if (path.indexOf(root.path) === 0) {
if (root.path === path) return root
this.setExpand(root, true)
if (root.contents) {
for (const content of root.contents) {
const node = findNode(content)
if (node) return node
}
}
}
}
return rootNode
const node = findNode(root)
this.focusNode(node)
return node
}
visibleNodes = null
@ -108,19 +171,26 @@ export default class VisibleNodesGenerator {
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]
})
)
const nodes = [], stack = this.searchedNodes.slice().reverse()
let current, depth = 0
while (stack.length) {
current = stack.pop()
if (current === null) {
depth -= 1
continue
}
if (nodesSet.has(current)) continue
nodes.push(current)
nodesSet.add(current)
this.depths.set(current, depth)
if (this.expandedNodes.has(current)) {
stack.push(null) // use null as pop depth flag
stack.push(...current.contents.slice().reverse())
depth += 1
}
}
this.visibleNodes = {
nodes: get(this.searchedNodes),
nodes,
depths: this.depths,
expandedNodes: this.expandedNodes,
}
@ -131,6 +201,9 @@ export default class VisibleNodesGenerator {
focusedNode = null
focusNode(node) {
this.focusedNode = node
this.visibleNodes.focusedNode = node
this.visibleNodes = {
...this.visibleNodes,
focusedNode: node,
}
}
}

View file

@ -4,6 +4,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin')
const UglifyJSWebpackPlugin = require('uglifyjs-webpack-plugin')
const srcPath = path.resolve(__dirname, 'src')
const packagesPath = path.resolve(__dirname, 'packages')
const plugins = [
new CopyWebpackPlugin([
@ -46,10 +47,7 @@ module.exports = {
filename: '[name].js',
},
resolve: {
alias: {
react: 'preact', // for svgr
},
modules: ['packages', 'node_modules']
modules: [srcPath, packagesPath, 'node_modules']
},
module: {
rules: [
@ -59,7 +57,7 @@ module.exports = {
options: {
cacheDirectory: true,
},
include: [srcPath],
include: [srcPath, packagesPath],
},
{
test: /\.less$/,
@ -78,6 +76,11 @@ module.exports = {
loader: ['babel-loader', 'svgr/webpack'],
include: [srcPath],
},
{
test: /\.json$/,
loader: ['json-loader'],
include: [srcPath],
},
],
},
plugins,

1028
yarn.lock

File diff suppressed because it is too large Load diff