mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
commit
d362e8e88a
41 changed files with 2730 additions and 896 deletions
1
.babelrc
1
.babelrc
|
|
@ -11,6 +11,7 @@
|
|||
"react"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-decorators-legacy",
|
||||
"transform-class-properties",
|
||||
["transform-object-rest-spread", { "useBuiltIns": true }]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -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
18
jsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"target": "es2017",
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"*" : ["src/*", "packages/*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
],
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"packages/**/*",
|
||||
]
|
||||
}
|
||||
23
package.json
23
package.json
|
|
@ -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
10
scripts/version.js
Normal 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
34
src/analytics.js
Normal 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,
|
||||
})}`
|
||||
)
|
||||
}
|
||||
|
|
@ -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
23
src/components/Gitako.js
Normal 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 />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
13
src/components/LoadingIndicator.js
Normal file
13
src/components/LoadingIndicator.js
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
/
|
||||
{metaData.branchName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
15
src/components/Portal.js
Normal 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
|
||||
29
src/components/Resizable.js
Normal file
29
src/components/Resizable.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import preact from 'preact'
|
||||
/** @jsx preact.h */
|
||||
import React from 'react'
|
||||
|
||||
export default function SearchBar({ onSearchKeyChange }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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>
|
||||
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()}
|
||||
/>
|
||||
{option.label}
|
||||
<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 />
|
||||
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>
|
||||
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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
493
src/content.less
493
src/content.less
|
|
@ -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
71
src/driver/connect.js
Normal 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} />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
269
src/driver/core/FileExplorer.js
Normal file
269
src/driver/core/FileExplorer.js
Normal 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,
|
||||
}
|
||||
6
src/driver/core/Gitako.js
Normal file
6
src/driver/core/Gitako.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const init = dispatch => () => {
|
||||
}
|
||||
|
||||
export default {
|
||||
init,
|
||||
}
|
||||
137
src/driver/core/SideBar.js
Normal file
137
src/driver/core/SideBar.js
Normal 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
3
src/driver/core/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Gitako } from './Gitako'
|
||||
export { default as SideBar } from './SideBar'
|
||||
export { default as FileExplorer } from './FileExplorer'
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
37
src/utils/configHelper.js
Normal 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,
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue