refactor: transform FileExplorer

This commit is contained in:
EnixCoda 2019-02-18 22:12:38 +08:00
parent 8b0f331f27
commit 6de067a72f
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
5 changed files with 322 additions and 300 deletions

View file

@ -23,6 +23,7 @@
},
"devDependencies": {
"@types/chrome": "^0.0.77",
"@types/ini": "^1.3.30",
"@types/nprogress": "^0.0.29",
"@types/react": "^16.8.2",
"@types/react-dom": "^16.8.0",

View file

@ -6,28 +6,19 @@ import Node from 'components/Node'
import LoadingIndicator from 'components/LoadingIndicator'
import cx from 'utils/cx'
import { TreeData, MetaData, VisibleNodes } from './SideBar'
import { ConnectorState } from 'driver/core/FileExplorer'
type Props = {
export type Props = {
treeData: TreeData
metaData: MetaData
visibleNodes: VisibleNodes
freeze: boolean
stateText: string
init: () => void
execAfterRender: () => void
handleKeyDown: React.KeyboardEventHandler
handleSearchKeyChange: React.FormEventHandler
onNodeClick: Node['props']['onClick']
compressSingletonFolder: boolean
accessToken: string
toggleShowSettings: React.MouseEventHandler
onFocusSearchBar: React.FocusEventHandler
setUpTree: (treeData?: TreeData) => void
}
@(connect(FileExplorerCore) as any)
export default class FileExplorer extends React.Component<Props> {
static defaultProps: Partial<Props> = {
class FileExplorer extends React.Component<Props & ConnectorState> {
static defaultProps: Partial<Props & ConnectorState> = {
treeData: null,
metaData: null,
freeze: false,
@ -45,7 +36,7 @@ export default class FileExplorer extends React.Component<Props> {
execAfterRender()
}
componentWillReceiveProps(nextProps: Props) {
componentWillReceiveProps(nextProps: Props & ConnectorState) {
if (nextProps.treeData !== this.props.treeData) {
const { setUpTree } = nextProps
setUpTree()
@ -110,3 +101,5 @@ export default class FileExplorer extends React.Component<Props> {
)
}
}
export default connect<Props, ConnectorState>(FileExplorerCore)(FileExplorer)

View file

@ -1,284 +0,0 @@
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)
if (node) {
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(metaData.branchName)
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 => event => dispatch(({ visibleNodes: { nodes, focusedNode, expandedNodes, depths } }) => {
function handleVerticalMove(index) {
if (0 <= index && index < nodes.length) {
DOMHelper.focusFileExplorer()
dispatch(focusNode, nodes[index])
} else {
DOMHelper.focusSearchInput()
dispatch(focusNode, null)
}
}
const { key } = event
// prevent document body scrolling if the keypress results in Gitako action
let muteEvent = true
if (focusedNode) {
const focusedNodeIndex = nodes.indexOf(focusedNode)
switch (key) {
case 'ArrowUp':
// focus on previous node
handleVerticalMove(focusedNodeIndex - 1)
break
case 'ArrowDown':
// focus on next node
handleVerticalMove(focusedNodeIndex + 1)
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') {
window.open(focusedNode.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') {
window.open(focusedNode.url)
}
break
default:
muteEvent = false
}
if (muteEvent) {
event.preventDefault()
}
} else {
// now search input is focused
if (nodes.length) {
switch (key) {
case 'ArrowDown':
DOMHelper.focusFileExplorer()
dispatch(focusNode, nodes[0])
break
case 'ArrowUp':
DOMHelper.focusFileExplorer()
dispatch(focusNode, nodes[nodes.length - 1])
break
default:
muteEvent = false
}
if (muteEvent) {
event.preventDefault()
}
}
}
})
const onFocusSearchBar = dispatch => () => dispatch(focusNode, null)
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)
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))
}
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,
onFocusSearchBar,
handleSearchKeyChange,
setExpand,
toggleNodeExpansion,
focusNode,
onNodeClick,
updateVisibleNodes,
mountExpandingIndicator,
}

View file

@ -0,0 +1,307 @@
import * as ini from 'ini'
import DOMHelper from 'utils/DOMHelper'
import treeParser from 'utils/treeParser'
import URLHelper from 'utils/URLHelper'
import VisibleNodesGenerator, { TreeNode } from 'utils/VisibleNodesGenerator'
import GitHubHelper, { BlobData, TreeData, MetaData } from 'utils/GitHubHelper'
import { MethodCreator } from 'driver/connect'
import { Props } from 'components/FileExplorer'
import Node from 'components/Node'
import { VisibleNodes } from 'components/SideBar'
export type ConnectorState = {
stateText: string
visibleNodes: VisibleNodes
init: () => void
execAfterRender: () => void
handleKeyDown: React.KeyboardEventHandler
handleSearchKeyChange: React.FormEventHandler
onNodeClick: Node['props']['onClick']
onFocusSearchBar: React.FocusEventHandler
setUpTree: (treeData?: TreeData) => void
}
type DepthMap = Map<TreeNode, number>
function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode, depths: DepthMap) {
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
}
type Task = () => void
const tasksAfterRender: (Task)[] = []
const visibleNodesGenerator = new VisibleNodesGenerator()
const init: MethodCreator = dispatch => () => dispatch.for(setStateText, 'Fetching File List...')
function resolveGitModules(root: TreeNode, blobData: BlobData) {
if (blobData) {
if (blobData.encoding === 'base64') {
const content = atob(blobData.content)
const parsed = ini.parse(content)
Object.values(parsed).map((value: { url: string; path: string }) => {
const { url, path } = value
// for now, handle modules at root only
const node = root.contents.find(node => node.path === path)
if (node) {
node.url = url
}
})
}
}
}
const setUpTree: MethodCreator<Props> = dispatch => () =>
dispatch.prepare(async ({ treeData, metaData, compressSingletonFolder, accessToken }) => {
if (!treeData) return
dispatch.for(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 as TreeNode, blobData)
}
visibleNodesGenerator.setCompress(compressSingletonFolder)
await visibleNodesGenerator.plantTree(root as TreeNode)
tasksAfterRender.push(DOMHelper.focusSearchInput)
dispatch.for(setStateText, null)
const currentPath = URLHelper.getCurrentPath(metaData.branchName)
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.for(updateVisibleNodes)
})
const execAfterRender: MethodCreator = dispatch => () => {
for (const task of tasksAfterRender) {
task()
}
tasksAfterRender.length = 0
}
const setStateText: MethodCreator<Props> = dispatch => (text: string) =>
dispatch.state({
stateText: text,
})
const handleKeyDown: MethodCreator = dispatch => event =>
dispatch.prepare((_, { visibleNodes: { nodes, focusedNode, expandedNodes, depths } }) => {
function handleVerticalMove(index: number) {
if (0 <= index && index < nodes.length) {
DOMHelper.focusFileExplorer()
dispatch.for(focusNode, nodes[index])
} else {
DOMHelper.focusSearchInput()
dispatch.for(focusNode, null)
}
}
const { key } = event
// prevent document body scrolling if the keypress results in Gitako action
let muteEvent = true
if (focusedNode) {
const focusedNodeIndex = nodes.indexOf(focusedNode)
switch (key) {
case 'ArrowUp':
// focus on previous node
handleVerticalMove(focusedNodeIndex - 1)
break
case 'ArrowDown':
// focus on next node
handleVerticalMove(focusedNodeIndex + 1)
break
case 'ArrowLeft':
// collapse node or go to parent node
if (expandedNodes.has(focusedNode)) {
dispatch.for(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.for(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.for(focusNode, nextNode)
}
} else {
dispatch.for(setExpand, focusedNode, true)
}
} else if (focusedNode.type === 'blob') {
DOMHelper.loadWithPJAX(focusedNode.url)
} else if (focusedNode.type === 'commit') {
window.open(focusedNode.url)
}
break
case 'Enter':
// expand node or redirect to file page
if (focusedNode.type === 'tree') {
dispatch.for(setExpand, focusedNode, true)
} else if (focusedNode.type === 'blob') {
DOMHelper.loadWithPJAX(focusedNode.url)
} else if (focusedNode.type === 'commit') {
window.open(focusedNode.url)
}
break
default:
muteEvent = false
}
if (muteEvent) {
event.preventDefault()
}
} else {
// now search input is focused
if (nodes.length) {
switch (key) {
case 'ArrowDown':
DOMHelper.focusFileExplorer()
dispatch.for(focusNode, nodes[0])
break
case 'ArrowUp':
DOMHelper.focusFileExplorer()
dispatch.for(focusNode, nodes[nodes.length - 1])
break
default:
muteEvent = false
}
if (muteEvent) {
event.preventDefault()
}
}
}
})
const onFocusSearchBar: MethodCreator = dispatch => () => dispatch.for(focusNode, null)
const handleSearchKeyChange: MethodCreator = dispatch => {
let i = 0
return async event => {
const searchKey = event.target.value
const j = (i += 1)
await visibleNodesGenerator.search(searchKey)
if (i === j) dispatch.for(updateVisibleNodes)
}
}
const delayExpandThreshold = 400
function shouldDelayExpand(node: TreeNode) {
return (
visibleNodesGenerator.visibleNodes.expandedNodes.has(node) &&
node.contents.length > delayExpandThreshold
)
}
const setExpand: MethodCreator = dispatch => (node, expand) => {
visibleNodesGenerator.setExpand(node, expand)
const applyChanges = () => dispatch.for(focusNode, node)
if (shouldDelayExpand(node)) {
dispatch.for(mountExpandingIndicator, node)
tasksAfterRender.push(() => setTimeout(applyChanges, 0))
} else {
applyChanges()
}
}
const toggleNodeExpansion: MethodCreator = dispatch => (node, skipScrollToNode) => {
visibleNodesGenerator.toggleExpand(node)
const applyChanges = () => {
dispatch.for(focusNode, node, skipScrollToNode)
tasksAfterRender.push(DOMHelper.focusFileExplorer)
}
if (shouldDelayExpand(node)) {
dispatch.for(mountExpandingIndicator, node)
tasksAfterRender.push(() => setTimeout(applyChanges, 0))
} else {
applyChanges()
}
}
const focusNode: MethodCreator = dispatch => (node, skipScroll) =>
dispatch.prepare((_, { 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))
}
dispatch.for(updateVisibleNodes)
})
const onNodeClick: MethodCreator = dispatch => node => {
if (node.type === 'tree') {
dispatch.for(toggleNodeExpansion, node, true)
} else if (node.type === 'blob') {
dispatch.for(focusNode, node, true)
DOMHelper.loadWithPJAX(node.url)
} else if (node.type === 'commit') {
window.open(node.url, '_blank')
}
}
const mountExpandingIndicator: MethodCreator = dispatch => node =>
dispatch.prepare((_, { visibleNodes }) => {
const dummyVisibleNodes = {
...visibleNodes,
nodes: visibleNodes.nodes.slice(),
}
dummyVisibleNodes.nodes.splice(dummyVisibleNodes.nodes.indexOf(node) + 1, 0, {
virtual: true,
name: 'Loading',
path: '-',
type: 'virtual',
})
dispatch.state({
visibleNodes: dummyVisibleNodes,
})
})
const updateVisibleNodes: MethodCreator = dispatch => () => {
const { visibleNodes } = visibleNodesGenerator
dispatch.state({ visibleNodes })
}
export default {
init,
setUpTree,
execAfterRender,
setStateText,
handleKeyDown,
onFocusSearchBar,
handleSearchKeyChange,
setExpand,
toggleNodeExpansion,
focusNode,
onNodeClick,
updateVisibleNodes,
mountExpandingIndicator,
}

View file

@ -762,6 +762,11 @@
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.28.tgz#c054e8af4d9dd75db4e63abc76f885168714d4b3"
integrity sha1-wFTor02d11205jq8dviFFocU1LM=
"@types/ini@^1.3.30":
version "1.3.30"
resolved "https://registry.yarnpkg.com/@types/ini/-/ini-1.3.30.tgz#d1485459c9fad84e937414b832a2adb649eab379"
integrity sha512-2+iF8zPSbpU83UKE+PNd4r/MhwNAdyGpk3H+VMgEH3EhjFZq1kouLgRoZrmIcmoGX97xFvqdS44DkICR5Nz3tQ==
"@types/nprogress@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/nprogress/-/nprogress-0.0.29.tgz#060bd510022a005f1840234030d3132fb9195471"