diff --git a/package.json b/package.json index 14cf71a..c5918cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx index 65f98cb..b2fa573 100644 --- a/src/components/FileExplorer.tsx +++ b/src/components/FileExplorer.tsx @@ -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 { - static defaultProps: Partial = { +class FileExplorer extends React.Component { + static defaultProps: Partial = { treeData: null, metaData: null, freeze: false, @@ -45,7 +36,7 @@ export default class FileExplorer extends React.Component { 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 { ) } } + +export default connect(FileExplorerCore)(FileExplorer) diff --git a/src/driver/core/FileExplorer.js b/src/driver/core/FileExplorer.js deleted file mode 100644 index e1cd0f0..0000000 --- a/src/driver/core/FileExplorer.js +++ /dev/null @@ -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, -} diff --git a/src/driver/core/FileExplorer.ts b/src/driver/core/FileExplorer.ts new file mode 100644 index 0000000..e593823 --- /dev/null +++ b/src/driver/core/FileExplorer.ts @@ -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 + +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 = 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 = 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, +} diff --git a/yarn.lock b/yarn.lock index 37ef08f..64969bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"