diff --git a/src/components/FileExplorer/ListView.tsx b/src/components/FileExplorer/ListView.tsx index ee2ed1b..80aa362 100644 --- a/src/components/FileExplorer/ListView.tsx +++ b/src/components/FileExplorer/ListView.tsx @@ -1,5 +1,4 @@ import { useConfigs } from 'containers/ConfigsContext' -import { ConnectorState, Props } from 'driver/core/FileExplorer' import { platform } from 'platforms' import * as React from 'react' import { Align as ReactWindowAlign, FixedSizeList } from 'react-window' @@ -12,9 +11,10 @@ type ListViewProps = { height: number width: number nodeRendererContext: NodeRendererContext - scrollMode: ReactWindowAlign -} & Pick & - Pick + alignMode: ReactWindowAlign + metaData: MetaData + expandTo: (path: string[]) => void +} export function ListView({ width, @@ -22,20 +22,22 @@ export function ListView({ metaData, expandTo, nodeRendererContext, - scrollMode, + alignMode, }: ListViewProps) { const { visibleNodes } = nodeRendererContext const { focusedNode, nodes } = visibleNodes - const listRef = React.useRef(null) - // the change of depths indicates switch into/from search state + const listRef = React.useRef>(null) + + // Scroll to focused node React.useEffect(() => { if (listRef.current && focusedNode?.path) { const index = nodes.findIndex(node => node.path === focusedNode.path) if (index !== -1) { - listRef.current.scrollToItem(index, scrollMode) + listRef.current.scrollToItem(index, alignMode) } } - }, [focusedNode?.path, nodes]) + }, [focusedNode?.path, nodes, alignMode]) + // For some reason, removing the deps array above results in bug: // If scroll fast and far, then clicking on items would result in redirect // Not know the reason :( diff --git a/src/components/FileExplorer/hooks/useExpandTo.tsx b/src/components/FileExplorer/hooks/useExpandTo.tsx new file mode 100644 index 0000000..43e378a --- /dev/null +++ b/src/components/FileExplorer/hooks/useExpandTo.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' + +export function useExpandTo(visibleNodesGenerator: VisibleNodesGenerator | null) { + return React.useCallback( + async (currentPath: string[]) => { + if (!visibleNodesGenerator) return + + const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/')) + if (nodeExpandedTo) visibleNodesGenerator.focusNode(nodeExpandedTo) + }, + [visibleNodesGenerator], + ) +} diff --git a/src/components/FileExplorer/hooks/useFocusFileExplorerOnFirstRender.tsx b/src/components/FileExplorer/hooks/useFocusFileExplorerOnFirstRender.tsx new file mode 100644 index 0000000..c3b6072 --- /dev/null +++ b/src/components/FileExplorer/hooks/useFocusFileExplorerOnFirstRender.tsx @@ -0,0 +1,8 @@ +import * as React from 'react' +import * as DOMHelper from 'utils/DOMHelper' + +export function useFocusFileExplorerOnFirstRender() { + React.useEffect(() => { + DOMHelper.focusFileExplorer() + }, []) +} diff --git a/src/components/FileExplorer/hooks/useFocusNode.tsx b/src/components/FileExplorer/hooks/useFocusNode.tsx new file mode 100644 index 0000000..e988068 --- /dev/null +++ b/src/components/FileExplorer/hooks/useFocusNode.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' + +export function useFocusNode(visibleNodesGenerator: VisibleNodesGenerator | null) { + return React.useCallback( + (node: TreeNode | null) => visibleNodesGenerator?.focusNode(node), + [visibleNodesGenerator], + ) +} diff --git a/src/components/FileExplorer/hooks/useGetCurrentPath.tsx b/src/components/FileExplorer/hooks/useGetCurrentPath.tsx new file mode 100644 index 0000000..07fdcc3 --- /dev/null +++ b/src/components/FileExplorer/hooks/useGetCurrentPath.tsx @@ -0,0 +1,6 @@ +import { platform } from 'platforms' +import { useCallback } from 'react' + +export function useGetCurrentPath({ branchName }: MetaData) { + return useCallback(() => platform.getCurrentPath(branchName), [branchName]) +} diff --git a/src/components/FileExplorer/hooks/useGoTo.tsx b/src/components/FileExplorer/hooks/useGoTo.tsx new file mode 100644 index 0000000..10c5e9b --- /dev/null +++ b/src/components/FileExplorer/hooks/useGoTo.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' + +export function useGoTo( + visibleNodesGenerator: VisibleNodesGenerator | null, + updateSearchKey: React.Dispatch>, + expandTo: (currentPath: string[]) => Promise, +) { + return React.useCallback( + (path: string[]) => { + if (!visibleNodesGenerator) return + + updateSearchKey('') + visibleNodesGenerator.search(null) + visibleNodesGenerator.onNextUpdate(() => expandTo(path)) + }, + [visibleNodesGenerator, updateSearchKey, expandTo], + ) +} diff --git a/src/components/FileExplorer/hooks/useHandleKeyDown.tsx b/src/components/FileExplorer/hooks/useHandleKeyDown.tsx new file mode 100644 index 0000000..d0abee5 --- /dev/null +++ b/src/components/FileExplorer/hooks/useHandleKeyDown.tsx @@ -0,0 +1,150 @@ +import * as React from 'react' +import * as DOMHelper from 'utils/DOMHelper' +import { OperatingSystems, os } from 'utils/general' +import { loadWithPJAX } from 'utils/hooks/usePJAX' +import { VisibleNodes } from 'utils/VisibleNodesGenerator' +import { VisibleNodesGeneratorMethods } from './useOnVisibleNodesGeneratorReady' + +function wouldBlockHistoryNavigation(event: React.KeyboardEvent) { + // Cmd + left/right on macOS + // Alt + left/right on other OSes + return ( + (os === OperatingSystems.macOS && event.metaKey) || + (os !== OperatingSystems.macOS && event.altKey) + ) +} + +function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) { + let index = nodes.findIndex(node => node.path === focusedNode.path) - 1 + while (index >= 0) { + if (nodes[index].contents?.includes(focusedNode)) { + return nodes[index] + } + --index + } +} + +export function useHandleKeyDown( + visibleNodes: VisibleNodes | null, + { focusNode, toggleExpansion, goTo }: VisibleNodesGeneratorMethods, + searched: boolean, +) { + return React.useCallback( + (event: React.KeyboardEvent) => { + if (!visibleNodes) return + const { nodes, focusedNode, expandedNodes } = visibleNodes + function handleVerticalMove(index: number) { + if (0 <= index && index < nodes.length) { + DOMHelper.focusFileExplorer() + focusNode(nodes[index]) + } else { + DOMHelper.focusSearchInput() + focusNode(null) + } + } + + const { key } = event + // prevent document body scrolling if the keypress results in Gitako action + let muteEvent = true + if (focusedNode) { + const focusedNodeIndex = nodes.findIndex(node => node.path === focusedNode.path) + switch (key) { + case 'ArrowUp': + // focus on previous node + handleVerticalMove(focusedNodeIndex - 1) + break + + case 'ArrowDown': + // focus on next node + handleVerticalMove(focusedNodeIndex + 1) + break + + case 'ArrowLeft': + if (wouldBlockHistoryNavigation(event)) { + muteEvent = false + break + } + if (expandedNodes.has(focusedNode.path)) { + toggleExpansion(focusedNode, { recursive: event.altKey }) + } else { + // go forward to the start of the list, find the closest node with lower depth + const parentNode = getVisibleParentNode(nodes, focusedNode) + if (parentNode) { + focusNode(parentNode) + } + } + break + + // consider the two keys as 'confirm' key + case 'ArrowRight': + if (wouldBlockHistoryNavigation(event)) { + muteEvent = false + break + } + // expand node or focus on first content node or redirect to file page + if (focusedNode.type === 'tree') { + if (expandedNodes.has(focusedNode.path)) { + const nextNode = nodes[focusedNodeIndex + 1] + if (focusedNode.contents?.includes(nextNode)) { + focusNode(nextNode) + } + } else { + toggleExpansion(focusedNode, { recursive: event.altKey }) + } + } else if (focusedNode.type === 'blob') { + const focusedNodeElement = DOMHelper.findNodeElement(focusedNode, event.currentTarget) + if (focusedNodeElement && focusedNode.url) + loadWithPJAX(focusedNode.url, focusedNodeElement) + } else if (focusedNode.type === 'commit') { + window.open(focusedNode.url) + } + break + case 'Enter': + // expand node or redirect to file page + if (searched) { + goTo(focusedNode.path.split('/')) + } else { + if (focusedNode.type === 'tree') { + toggleExpansion(focusedNode, { recursive: event.altKey }) + } else if (focusedNode.type === 'blob') { + const focusedNodeElement = DOMHelper.findNodeElement( + focusedNode, + event.currentTarget, + ) + if (focusedNodeElement && focusedNode.url) + loadWithPJAX(focusedNode.url, focusedNodeElement) + } 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() + focusNode(nodes[0]) + break + case 'ArrowUp': + DOMHelper.focusFileExplorer() + focusNode(nodes[nodes.length - 1]) + break + default: + muteEvent = false + } + if (muteEvent) { + event.preventDefault() + } + } + } + }, + [visibleNodes, searched, goTo, focusNode, toggleExpansion], + ) +} diff --git a/src/components/FileExplorer/hooks/useNodeRenderContext.tsx b/src/components/FileExplorer/hooks/useNodeRenderContext.tsx new file mode 100644 index 0000000..d9c4776 --- /dev/null +++ b/src/components/FileExplorer/hooks/useNodeRenderContext.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import { VisibleNodes } from 'utils/VisibleNodesGenerator' +import { NodeRendererContext } from '../index' +import { useNodeRenderers } from './useNodeRenderers' +import { useHandleNodeClick } from './useOnNodeClick' +import { useRenderLabelText } from './useRenderLabelText' + +export function useNodeRenderContext( + visibleNodes: VisibleNodes | null, + onNodeClick: ReturnType, + renderActions: ReturnType, + renderLabelText: ReturnType, +): NodeRendererContext | null { + return React.useMemo( + () => + visibleNodes && { + visibleNodes, + onNodeClick, + renderActions, + renderLabelText, + }, + [visibleNodes, onNodeClick, renderActions, renderLabelText], + ) +} diff --git a/src/components/FileExplorer/useNodeRenderers.tsx b/src/components/FileExplorer/hooks/useNodeRenderers.tsx similarity index 58% rename from src/components/FileExplorer/useNodeRenderers.tsx rename to src/components/FileExplorer/hooks/useNodeRenderers.tsx index 9d7b2aa..eb51563 100644 --- a/src/components/FileExplorer/useNodeRenderers.tsx +++ b/src/components/FileExplorer/hooks/useNodeRenderers.tsx @@ -1,10 +1,10 @@ import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' import { isNotFalsy } from 'utils/general' -import { Icon } from '../Icon' -import { SearchMode } from '../searchModes' -import { DiffStatGraph } from './DiffStatGraph' -import { DiffStatText } from './DiffStatText' +import { Icon } from '../../Icon' +import { SearchMode } from '../../searchModes' +import { DiffStatGraph } from './../DiffStatGraph' +import { DiffStatText } from './../DiffStatText' export type NodeRenderer = (node: TreeNode) => React.ReactNode @@ -49,49 +49,56 @@ export function useRenderFileCommentAmounts() { ) : null } const { commentToggle } = useConfigs().value - return React.useMemo(() => (commentToggle ? renderFileCommentAmounts : null), []) + return React.useMemo(() => (commentToggle ? renderFileCommentAmounts : null), [commentToggle]) } export function useRenderFindInFolderButton( onSearch: (searchKey: string, searchMode: SearchMode) => void, ) { - function renderFindInFolderButton(node: TreeNode) { - return node.type === 'tree' ? ( - - ) : null - } const { searchMode } = useConfigs().value return React.useMemo( - () => (searchMode === 'fuzzy' ? renderFindInFolderButton : null), - [searchMode], + () => + searchMode === 'fuzzy' + ? function renderFindInFolderButton(node: TreeNode) { + return node.type === 'tree' ? ( + + ) : null + } + : null, + [searchMode, onSearch], ) } export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => void) { - function renderGoToButton(node: TreeNode): React.ReactNode { - return ( - - ) - } - return React.useMemo(() => (searched ? renderGoToButton : null), [searched]) + return React.useMemo( + () => + searched + ? function renderGoToButton(node: TreeNode): React.ReactNode { + return ( + + ) + } + : null, + [searched, goTo], + ) } diff --git a/src/components/FileExplorer/hooks/useOnNodeClick.tsx b/src/components/FileExplorer/hooks/useOnNodeClick.tsx new file mode 100644 index 0000000..6e0403b --- /dev/null +++ b/src/components/FileExplorer/hooks/useOnNodeClick.tsx @@ -0,0 +1,45 @@ +import { useConfigs } from 'containers/ConfigsContext' +import * as React from 'react' +import { isOpenInNewWindowClick } from 'utils/general' +import { loadWithPJAX } from 'utils/hooks/usePJAX' +import { VisibleNodesGeneratorMethods } from './useOnVisibleNodesGeneratorReady' + +export function useHandleNodeClick({ toggleExpansion, focusNode }: VisibleNodesGeneratorMethods) { + const { recursiveToggleFolder } = useConfigs().value + return React.useCallback( + (event: React.MouseEvent, node: TreeNode) => { + switch (node.type) { + case 'tree': { + const recursive = + (recursiveToggleFolder === 'shift' && event.shiftKey) || + (recursiveToggleFolder === 'alt' && event.altKey) + // recursive toggle action may conflict with browser default action + // e.g. shift + click is the default open in new tab action on macOS + // giving recursive toggle action higher priority than default action + if (!recursive && isOpenInNewWindowClick(event)) return + + event.preventDefault() + toggleExpansion(node, { recursive }) + break + } + case 'blob': { + if (isOpenInNewWindowClick(event)) return + + focusNode(node) + if (node.url) { + const isHashLink = node.url.includes('#') + if (!isHashLink) { + event.preventDefault() + loadWithPJAX(node.url, event.currentTarget) + } + } + break + } + case 'commit': { + // pass event, open in new tab thanks to the target="_blank" on the anchor element + } + } + }, + [toggleExpansion, recursiveToggleFolder, focusNode], + ) +} diff --git a/src/components/FileExplorer/hooks/useOnSearch.tsx b/src/components/FileExplorer/hooks/useOnSearch.tsx new file mode 100644 index 0000000..f9b9fe8 --- /dev/null +++ b/src/components/FileExplorer/hooks/useOnSearch.tsx @@ -0,0 +1,23 @@ +import { useConfigs } from 'containers/ConfigsContext' +import * as React from 'react' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { SearchMode, searchModes } from '../../searchModes' + +export function useOnSearch( + updateSearchKey: (searchKey: string) => void, + visibleNodesGenerator: VisibleNodesGenerator | null, +) { + const { restoreExpandedFolders } = useConfigs().value + return React.useCallback( + (searchKey: string, searchMode: SearchMode) => { + updateSearchKey(searchKey) + if (visibleNodesGenerator) { + visibleNodesGenerator.search( + searchModes[searchMode].getSearchParams(searchKey), + restoreExpandedFolders, + ) + } + }, + [updateSearchKey, visibleNodesGenerator, restoreExpandedFolders], + ) +} diff --git a/src/components/FileExplorer/hooks/useOnVisibleNodesGeneratorReady.tsx b/src/components/FileExplorer/hooks/useOnVisibleNodesGeneratorReady.tsx new file mode 100644 index 0000000..16cc3e5 --- /dev/null +++ b/src/components/FileExplorer/hooks/useOnVisibleNodesGeneratorReady.tsx @@ -0,0 +1,39 @@ +import { platform } from 'platforms' +import { useEffect } from 'react' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { useExpandTo } from './useExpandTo' +import { useFocusNode } from './useFocusNode' +import { useGoTo } from './useGoTo' +import { useToggleExpansion } from './useToggleExpansion' + +export function useVisibleNodesGeneratorMethods( + visibleNodesGenerator: VisibleNodesGenerator | null, + getCurrentPath: () => string[] | null, + updateSearchKey: React.Dispatch>, +) { + const expandTo = useExpandTo(visibleNodesGenerator) + const goTo = useGoTo(visibleNodesGenerator, updateSearchKey, expandTo) + const toggleExpansion = useToggleExpansion(visibleNodesGenerator) + const focusNode = useFocusNode(visibleNodesGenerator) + useEffect(() => { + if (!visibleNodesGenerator) return + + if (platform.shouldExpandAll?.()) { + visibleNodesGenerator.onNextUpdate(visibleNodes => + visibleNodes.nodes.forEach(node => toggleExpansion(node, { recursive: true })), + ) + } else { + const targetPath = getCurrentPath() + if (targetPath) goTo(targetPath) + } + }, [visibleNodesGenerator]) + + return { + expandTo, + goTo, + toggleExpansion, + focusNode, + } +} + +export type VisibleNodesGeneratorMethods = ReturnType diff --git a/src/components/FileExplorer/hooks/useReactWindowAlignMode.tsx b/src/components/FileExplorer/hooks/useReactWindowAlignMode.tsx new file mode 100644 index 0000000..331c387 --- /dev/null +++ b/src/components/FileExplorer/hooks/useReactWindowAlignMode.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' +import { Align as ReactWindowAlign } from 'react-window' +import { useStateIO } from 'utils/hooks/useStateIO' + +export function useReactWindowAlignMode(searched: boolean) { + const $scrollMode = useStateIO('start') + React.useEffect(() => { + // Use `auto` as default mode to prevent initial misalignment + // Switch to `smart` mode when start searching to make sure alignment is user-friendly when jump to files + if (searched && $scrollMode.value === 'start') $scrollMode.onChange('smart') + }, [searched, $scrollMode]) + return $scrollMode.value +} diff --git a/src/components/FileExplorer/hooks/useRenderLabelText.tsx b/src/components/FileExplorer/hooks/useRenderLabelText.tsx new file mode 100644 index 0000000..aa39638 --- /dev/null +++ b/src/components/FileExplorer/hooks/useRenderLabelText.tsx @@ -0,0 +1,11 @@ +import { useConfigs } from 'containers/ConfigsContext' +import * as React from 'react' +import { searchModes } from '../../searchModes' + +export function useRenderLabelText(searchKey: string) { + const { searchMode } = useConfigs().value + return React.useCallback( + (node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey), + [searchKey, searchMode], + ) +} diff --git a/src/components/FileExplorer/hooks/useSetupTree.tsx b/src/components/FileExplorer/hooks/useSetupTree.tsx new file mode 100644 index 0000000..f830885 --- /dev/null +++ b/src/components/FileExplorer/hooks/useSetupTree.tsx @@ -0,0 +1,62 @@ +import { useConfigs } from 'containers/ConfigsContext' +import { platform } from 'platforms' +import { useCallback, useState } from 'react' +import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError' +import { useLoadedContext } from 'utils/hooks/useLoadedContext' +import { useSequentialEffect } from 'utils/hooks/useSequentialEffect' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { SideBarStateContext } from '../../../containers/SideBarState' + +export function useVisibleNodesGenerator(metaData: MetaData) { + const [visibleNodesGenerator, setVisibleNodesGenerator] = useState( + null, + ) + + const catchNetworkErrors = useCatchNetworkError() + const config = useConfigs().value + const accessToken = config.accessToken + const setStateContext = useLoadedContext(SideBarStateContext).onChange + + useSequentialEffect( + useCallback( + checker => { + catchNetworkErrors(async () => { + if (!checker()) return + + setStateContext('tree-loading') + const { userName, repoName, branchName } = metaData + const { root: treeRoot, defer = false } = await platform.getTreeData( + { + branchName, + userName, + repoName, + }, + '/', + true, + accessToken, + ) + if (!checker()) return + + setStateContext('tree-rendering') + + const visibleNodesGenerator = new VisibleNodesGenerator({ + root: treeRoot, + defer, + compress: config.compressSingletonFolder, + async getTreeData(path) { + const { root } = await platform.getTreeData(metaData, path, false, accessToken) + return root + }, + }) + + setVisibleNodesGenerator(visibleNodesGenerator) + + setStateContext('tree-rendered') + }) + }, + [metaData, accessToken], + ), + ) + + return visibleNodesGenerator +} diff --git a/src/components/FileExplorer/hooks/useToggleExpansion.tsx b/src/components/FileExplorer/hooks/useToggleExpansion.tsx new file mode 100644 index 0000000..8cbf253 --- /dev/null +++ b/src/components/FileExplorer/hooks/useToggleExpansion.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' + +export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator | null) { + return React.useCallback( + async ( + node: TreeNode, + { + recursive = false, + }: { + recursive?: boolean + }, + ) => { + if (!visibleNodesGenerator) return + + if (node.type === 'tree') { + visibleNodesGenerator.focusNode(node) + await visibleNodesGenerator.toggleExpand(node, recursive) + } + }, + [visibleNodesGenerator], + ) +} diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx index 6dbb28b..bc85622 100644 --- a/src/components/FileExplorer/index.tsx +++ b/src/components/FileExplorer/index.tsx @@ -1,23 +1,17 @@ import { Label, Text } from '@primer/react' import { LoadingIndicator } from 'components/LoadingIndicator' import { SearchBar } from 'components/SearchBar' -import { useConfigs } from 'containers/ConfigsContext' -import { connect } from 'driver/connect' -import { FileExplorerCore } from 'driver/core' -import { ConnectorState, Props } from 'driver/core/FileExplorer' import * as React from 'react' -import { Align as ReactWindowAlign } from 'react-window' import { cx } from 'utils/cx' -import * as DOMHelper from 'utils/DOMHelper' import { run } from 'utils/general' import { useLoadedContext } from 'utils/hooks/useLoadedContext' -import { useSequentialEffect } from 'utils/hooks/useSequentialEffect' -import { useStateIO } from 'utils/hooks/useStateIO' -import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { VisibleNodes } from 'utils/VisibleNodesGenerator' import { SideBarStateContext } from '../../containers/SideBarState' -import { SearchMode, searchModes } from '../searchModes' import { SizeObserver } from '../SizeObserver' -import { ListView } from './ListView' +import { useFocusFileExplorerOnFirstRender } from './hooks/useFocusFileExplorerOnFirstRender' +import { useGetCurrentPath } from './hooks/useGetCurrentPath' +import { useHandleKeyDown } from './hooks/useHandleKeyDown' +import { useNodeRenderContext } from './hooks/useNodeRenderContext' import { NodeRenderer, useNodeRenderers, @@ -25,7 +19,15 @@ import { useRenderFileStatus, useRenderFindInFolderButton, useRenderGoToButton -} from './useNodeRenderers' +} from './hooks/useNodeRenderers' +import { useHandleNodeClick } from './hooks/useOnNodeClick' +import { useOnSearch } from './hooks/useOnSearch' +import { useVisibleNodesGeneratorMethods } from './hooks/useOnVisibleNodesGeneratorReady' +import { useReactWindowAlignMode } from './hooks/useReactWindowAlignMode' +import { useRenderLabelText } from './hooks/useRenderLabelText' +import { useVisibleNodesGenerator } from './hooks/useSetupTree' +import { ListView } from './ListView' +import { useVisibleNodes } from './useVisibleNodes' export type NodeRendererContext = { onNodeClick: (event: React.MouseEvent, node: TreeNode) => void @@ -34,113 +36,48 @@ export type NodeRendererContext = { visibleNodes: VisibleNodes } -function useSetupTree(setUpTree: ConnectorState['setUpTree'], metaData: MetaData) { - const stateContext = useLoadedContext(SideBarStateContext) - const { accessToken, compressSingletonFolder } = useConfigs().value - - useSequentialEffect( - checker => { - setUpTree( - { - metaData, - config: { - compressSingletonFolder, - accessToken, - }, - stateContext, - }, - checker, - ) - }, - [setUpTree, metaData, compressSingletonFolder, accessToken], - ) +type Props = { + metaData: MetaData + freeze: boolean } -function useFocusFileExplorerOnFirstRender() { - React.useEffect(() => { - DOMHelper.focusFileExplorer() - }, []) -} - -function useReactWindowAlignMode(searched: boolean) { - const scrollMode = useStateIO('auto') - React.useEffect(() => { - // Use `auto` as default mode to prevent initial misalignment - // Switch to `smart` mode when start searching to make sure alignment is user-friendly when jump to files - if (scrollMode.value === 'auto' && searched) scrollMode.onChange('smart') - }, [searched]) - return scrollMode -} - -function useOnSearch( - updateSearchKey: (searchKey: string) => void, - visibleNodesGenerator: VisibleNodesGenerator | null, -) { - const { restoreExpandedFolders } = useConfigs().value - return React.useCallback( - (searchKey: string, searchMode: SearchMode) => { - updateSearchKey(searchKey) - if (visibleNodesGenerator) { - visibleNodesGenerator.search( - searchModes[searchMode].getSearchParams(searchKey), - restoreExpandedFolders, - ) - } - }, - [updateSearchKey, visibleNodesGenerator, restoreExpandedFolders], - ) -} - -function useRenderLabelText(searchKey: string) { - const { searchMode } = useConfigs().value - return React.useCallback( - (node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey), - [searchKey, searchMode], - ) -} - -const RawFileExplorer: React.FC = function RawFileExplorer({ - visibleNodes, - visibleNodesGenerator, - freeze, - onNodeClick, - searchKey, - updateSearchKey, - onFocusSearchBar, - goTo, - handleKeyDown, - metaData, - expandTo, - setUpTree, - defer, - searched, -}) { - useSetupTree(setUpTree, metaData) - useFocusFileExplorerOnFirstRender() +export function FileExplorer({ freeze, metaData }: Props) { + const visibleNodesGenerator = useVisibleNodesGenerator(metaData) + const visibleNodes = useVisibleNodes(visibleNodesGenerator) + const [searchKey, updateSearchKey] = React.useState('') + const searched = !!searchKey const onSearch = useOnSearch(updateSearchKey, visibleNodesGenerator) + + const getCurrentPath = useGetCurrentPath(metaData) + const methods = useVisibleNodesGeneratorMethods( + visibleNodesGenerator, + getCurrentPath, + updateSearchKey, + ) + const { expandTo, goTo, focusNode } = methods + const handleNodeClick = useHandleNodeClick(methods) + const handleKeyDown = useHandleKeyDown(visibleNodes, methods, searched) + const handleFocusSearchBar = () => focusNode(null) + const renderActions = useNodeRenderers([ useRenderGoToButton(searched, goTo), useRenderFindInFolderButton(onSearch), useRenderFileCommentAmounts(), useRenderFileStatus(), ]) - const renderLabelText = useRenderLabelText(searchKey) - const nodeRendererContext: NodeRendererContext | null = React.useMemo( - () => - visibleNodes && { - onNodeClick, - renderActions, - renderLabelText, - visibleNodes, - }, - [onNodeClick, renderActions, renderLabelText, visibleNodes], + const nodeRendererContext = useNodeRenderContext( + visibleNodes, + handleNodeClick, + renderActions, + renderLabelText, ) const alignMode = useReactWindowAlignMode(searched) const state = useLoadedContext(SideBarStateContext).value + useFocusFileExplorerOnFirstRender() return (
@@ -155,7 +92,7 @@ const RawFileExplorer: React.FC = function RawFileExplor visibleNodes && nodeRendererContext && ( <> - {defer && ( + {visibleNodesGenerator?.defer && (
)} - + {searched && visibleNodes.nodes.length === 0 && ( <> No results found. - {defer && ( + {visibleNodesGenerator?.defer && ( Search results are limited to loaded folders in Lazy Mode. @@ -188,7 +125,7 @@ const RawFileExplorer: React.FC = function RawFileExplor nodeRendererContext={nodeRendererContext} expandTo={expandTo} metaData={metaData} - scrollMode={alignMode.value} + alignMode={alignMode} />
)} @@ -201,11 +138,3 @@ const RawFileExplorer: React.FC = function RawFileExplor ) } - -RawFileExplorer.defaultProps = { - freeze: false, - searchKey: '', - visibleNodes: null, -} - -export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer) diff --git a/src/components/FileExplorer/useVisibleNodes.tsx b/src/components/FileExplorer/useVisibleNodes.tsx new file mode 100644 index 0000000..f5b1841 --- /dev/null +++ b/src/components/FileExplorer/useVisibleNodes.tsx @@ -0,0 +1,8 @@ +import { useEffect, useState } from 'react' +import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' + +export function useVisibleNodes(visibleNodesGenerator: VisibleNodesGenerator | null) { + const [visibleNodes, setVisibleNodes] = useState(null) + useEffect(() => visibleNodesGenerator?.onUpdate(setVisibleNodes), [visibleNodesGenerator]) + return visibleNodes +} diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index f17f11d..185d580 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -11,7 +11,6 @@ import * as React from 'react' import { cx } from 'utils/cx' import * as DOMHelper from 'utils/DOMHelper' import { detectBrowser, run } from 'utils/general' -import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError' import { useLoadedContext } from 'utils/hooks/useLoadedContext' import { useOnPJAXDone, usePJAX } from 'utils/hooks/usePJAX' import { useStateIO } from 'utils/hooks/useStateIO' @@ -21,7 +20,6 @@ import { SideBarStateContext } from '../containers/SideBarState' import { Theme } from '../containers/Theme' import { useToggleSideBarWithKeyboard } from '../utils/hooks/useToggleSideBarWithKeyboard' import { Icon } from './Icon' -import { IIFC } from './IIFC' import { LoadingIndicator } from './LoadingIndicator' import { SettingsBarContent } from './settings/SettingsBar' @@ -189,21 +187,7 @@ export function SideBar() { case 'error-due-to-auth': return default: - return ( - metaData && ( - - {() => ( - - )} - - ) - ) + return metaData && } })} diff --git a/src/components/settings/AccessTokenSettings.tsx b/src/components/settings/AccessTokenSettings.tsx index 3f142fa..e26980c 100644 --- a/src/components/settings/AccessTokenSettings.tsx +++ b/src/components/settings/AccessTokenSettings.tsx @@ -15,21 +15,21 @@ type Props = {} export function AccessTokenSettings(props: React.PropsWithChildren) { const configContext = useConfigs() const hasAccessToken = Boolean(configContext.value.accessToken) - const useAccessToken = useStateIO('') + const $useAccessToken = useStateIO('') const useAccessTokenHint = useStateIO('') const focusInput = useStateIO(false) const { value: accessTokenHint } = useAccessTokenHint - const { value: accessToken } = useAccessToken + const { value: accessToken } = $useAccessToken React.useEffect(() => { // clear input when access token updates - useAccessToken.onChange('') + $useAccessToken.onChange('') }, [configContext.value.accessToken]) const onInputAccessToken = React.useCallback( ({ currentTarget: { value } }: React.FormEvent) => { - useAccessToken.onChange(value) + $useAccessToken.onChange(value) useAccessTokenHint.onChange( ACCESS_TOKEN_REGEXP.test(value) ? '' : 'Gitako does not recognize the token.', ) @@ -50,7 +50,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren) { ) => { if (accessToken) { configContext.onChange({ accessToken }) - useAccessToken.onChange('') + $useAccessToken.onChange('') useAccessTokenHint.onChange(hint) } }, diff --git a/src/driver/core/FileExplorer.ts b/src/driver/core/FileExplorer.ts deleted file mode 100644 index 5498a97..0000000 --- a/src/driver/core/FileExplorer.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { SideBarStateContextShape } from 'containers/SideBarState' -import { GetCreatedMethod, MethodCreator } from 'driver/connect' -import { platform } from 'platforms' -import { Config } from 'utils/config/helper' -import * as DOMHelper from 'utils/DOMHelper' -import { isOpenInNewWindowClick, OperatingSystems, os } from 'utils/general' -import { loadWithPJAX } from 'utils/hooks/usePJAX' -import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' - -export type Props = { - metaData: MetaData - freeze: boolean - accessToken: string | undefined - config: Config - catchNetworkErrors: (fn: () => T) => Promise -} - -export type ConnectorState = { - visibleNodesGenerator: VisibleNodesGenerator | null - visibleNodes: VisibleNodes | null - searchKey: string - searched: boolean // derived state from searchKey, = !!searchKey - defer: boolean - - handleKeyDown: GetCreatedMethod - updateSearchKey: GetCreatedMethod - onNodeClick: GetCreatedMethod - onFocusSearchBar: GetCreatedMethod - setUpTree: GetCreatedMethod - goTo: GetCreatedMethod - expandTo: GetCreatedMethod -} - -function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) { - let index = nodes.findIndex(node => node.path === focusedNode.path) - 1 - while (index >= 0) { - if (nodes[index].contents?.includes(focusedNode)) { - return nodes[index] - } - --index - } -} - -type BoundMethodCreator = MethodCreator - -export const setUpTree: BoundMethodCreator< - [ - { stateContext: SideBarStateContextShape } & Required> & { - config: Pick - }, - () => boolean, - ] -> = - dispatch => - ({ stateContext, metaData, config }, checker) => { - const { - props: { catchNetworkErrors }, - } = dispatch.get() - - catchNetworkErrors(async () => { - const { userName, repoName, branchName } = metaData - - if (!checker()) return - stateContext.onChange('tree-loading') - - const { root: treeRoot, defer = false } = await platform.getTreeData( - { - branchName, - userName, - repoName, - }, - '/', - true, - config.accessToken, - ) - - if (!checker()) return - stateContext.onChange('tree-rendering') - dispatch.set({ defer }) - - const visibleNodesGenerator = new VisibleNodesGenerator({ - root: treeRoot, - compress: config.compressSingletonFolder, - async getTreeData(path) { - const { root } = await platform.getTreeData(metaData, path, false, config.accessToken) - return root - }, - }) - - if (!checker()) return - dispatch.set({ visibleNodesGenerator }) - - visibleNodesGenerator.onUpdate(visibleNodes => { - if (!checker()) return - dispatch.set({ visibleNodes }) - }) - - if (platform.shouldExpandAll?.()) { - const unsubscribe = visibleNodesGenerator.onUpdate(visibleNodes => { - unsubscribe() - visibleNodes.nodes.forEach(node => { - if (!checker()) return - dispatch.call(toggleNodeExpansion, node, { recursive: true }) - }) - }) - } else { - const targetPath = platform.getCurrentPath(metaData.branchName) - if (targetPath && checker()) dispatch.call(goTo, targetPath) - } - - if (!checker()) return - stateContext.onChange('tree-rendered') - }) - } - -export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = - dispatch => event => { - const { - state: { searched, visibleNodes }, - } = dispatch.get() - if (!visibleNodes) return - const { nodes, focusedNode, expandedNodes } = visibleNodes - function handleVerticalMove(index: number) { - if (0 <= index && index < nodes.length) { - DOMHelper.focusFileExplorer() - dispatch.call(focusNode, nodes[index]) - } else { - DOMHelper.focusSearchInput() - dispatch.call(focusNode, null) - } - } - - const { key } = event - // prevent document body scrolling if the keypress results in Gitako action - let muteEvent = true - if (focusedNode) { - const focusedNodeIndex = nodes.findIndex(node => node.path === focusedNode.path) - switch (key) { - case 'ArrowUp': - // focus on previous node - handleVerticalMove(focusedNodeIndex - 1) - break - - case 'ArrowDown': - // focus on next node - handleVerticalMove(focusedNodeIndex + 1) - break - - case 'ArrowLeft': - if (wouldBlockHistoryNavigation(event)) { - muteEvent = false - break - } - if (expandedNodes.has(focusedNode.path)) { - dispatch.call(toggleNodeExpansion, focusedNode, { recursive: event.altKey }) - } else { - // go forward to the start of the list, find the closest node with lower depth - const parentNode = getVisibleParentNode(nodes, focusedNode) - if (parentNode) { - dispatch.call(focusNode, parentNode) - } - } - break - - // consider the two keys as 'confirm' key - case 'ArrowRight': - if (wouldBlockHistoryNavigation(event)) { - muteEvent = false - break - } - // expand node or focus on first content node or redirect to file page - if (focusedNode.type === 'tree') { - if (expandedNodes.has(focusedNode.path)) { - const nextNode = nodes[focusedNodeIndex + 1] - if (focusedNode.contents?.includes(nextNode)) { - dispatch.call(focusNode, nextNode) - } - } else { - dispatch.call(toggleNodeExpansion, focusedNode, { recursive: event.altKey }) - } - } else if (focusedNode.type === 'blob') { - const focusedNodeElement = DOMHelper.findNodeElement(focusedNode, event.currentTarget) - if (focusedNodeElement && focusedNode.url) - loadWithPJAX(focusedNode.url, focusedNodeElement) - } else if (focusedNode.type === 'commit') { - window.open(focusedNode.url) - } - break - case 'Enter': - // expand node or redirect to file page - if (searched) { - dispatch.call(goTo, focusedNode.path.split('/')) - } else { - if (focusedNode.type === 'tree') { - dispatch.call(toggleNodeExpansion, focusedNode, { recursive: event.altKey }) - } else if (focusedNode.type === 'blob') { - const focusedNodeElement = DOMHelper.findNodeElement(focusedNode, event.currentTarget) - if (focusedNodeElement && focusedNode.url) - loadWithPJAX(focusedNode.url, focusedNodeElement) - } 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.call(focusNode, nodes[0]) - break - case 'ArrowUp': - DOMHelper.focusFileExplorer() - dispatch.call(focusNode, nodes[nodes.length - 1]) - break - default: - muteEvent = false - } - if (muteEvent) { - event.preventDefault() - } - } - } - } - -export const onFocusSearchBar: BoundMethodCreator = dispatch => () => dispatch.call(focusNode, null) - -export const updateSearchKey: BoundMethodCreator<[string]> = dispatch => searchKey => { - dispatch.set({ searchKey, searched: searchKey !== '' }) -} - -export const goTo: BoundMethodCreator<[string[]]> = dispatch => path => { - const { - state: { visibleNodesGenerator }, - } = dispatch.get() - if (!visibleNodesGenerator) return - - dispatch.call(updateSearchKey, '') - visibleNodesGenerator.search(null) - visibleNodesGenerator.onNextUpdate(() => { - dispatch.call(expandTo, path) - }) -} - -export const setExpand: BoundMethodCreator<[TreeNode, boolean]> = - dispatch => - async (node, expand = false) => { - const { - state: { visibleNodesGenerator }, - } = dispatch.get() - if (!visibleNodesGenerator) return - - await visibleNodesGenerator.setExpand(node, expand) - dispatch.call(focusNode, node) - } - -export const toggleNodeExpansion: BoundMethodCreator< - [ - TreeNode, - { - recursive?: boolean - }, - ] -> = - dispatch => - async (node, { recursive = false }) => { - const { - state: { visibleNodesGenerator }, - } = dispatch.get() - if (!visibleNodesGenerator) return - - if (node.type === 'tree') { - visibleNodesGenerator.focusNode(node) - await visibleNodesGenerator.toggleExpand(node, recursive) - } - } - -export const focusNode: BoundMethodCreator<[TreeNode | null]> = - dispatch => (node: TreeNode | null) => { - const { - state: { visibleNodesGenerator }, - } = dispatch.get() - if (!visibleNodesGenerator) return - - visibleNodesGenerator.focusNode(node) - } - -export const onNodeClick: BoundMethodCreator< - [React.MouseEvent, TreeNode] -> = dispatch => (event, node) => { - switch (node.type) { - case 'tree': { - const { - props: { - config: { recursiveToggleFolder }, - }, - } = dispatch.get() - const recursive = - (recursiveToggleFolder === 'shift' && event.shiftKey) || - (recursiveToggleFolder === 'alt' && event.altKey) - // recursive toggle action may conflict with browser default action - // e.g. shift + click is the default open in new tab action on macOS - // giving recursive toggle action higher priority than default action - if (!recursive && isOpenInNewWindowClick(event)) return - - event.preventDefault() - dispatch.call(toggleNodeExpansion, node, { recursive }) - break - } - case 'blob': { - if (isOpenInNewWindowClick(event)) return - - dispatch.call(focusNode, node) - if (node.url) { - const isHashLink = node.url.includes('#') - if (!isHashLink) { - event.preventDefault() - loadWithPJAX(node.url, event.currentTarget) - } - } - break - } - case 'commit': { - // pass event, open in new tab thanks to the target="_blank" on the anchor element - } - } -} - -export const expandTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => { - const { - state: { visibleNodesGenerator }, - } = dispatch.get() - if (!visibleNodesGenerator) return - - const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/')) - if (nodeExpandedTo) { - visibleNodesGenerator.focusNode(nodeExpandedTo) - } -} - -function wouldBlockHistoryNavigation(event: React.KeyboardEvent) { - // Cmd + left/right on macOS - // Alt + left/right on other OSes - return ( - (os === OperatingSystems.macOS && event.metaKey) || - (os !== OperatingSystems.macOS && event.altKey) - ) -} diff --git a/src/driver/core/index.ts b/src/driver/core/index.ts deleted file mode 100644 index dd80f10..0000000 --- a/src/driver/core/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Sources } from 'driver/connect' -import * as FileExplorer from './FileExplorer' -import { - ConnectorState as FileExplorerConnectorState, - Props as FileExplorerProps, -} from './FileExplorer' - -export const FileExplorerCore: Sources = FileExplorer diff --git a/src/utils/VisibleNodesGenerator/index.ts b/src/utils/VisibleNodesGenerator/index.ts index 67803e7..ae1202a 100644 --- a/src/utils/VisibleNodesGenerator/index.ts +++ b/src/utils/VisibleNodesGenerator/index.ts @@ -80,15 +80,17 @@ class BaseLayer { baseRoot: TreeNode getTreeData: (path: string) => Async loading: Set = new Set() + defer: boolean baseHub = new EventHub<{ emit: BaseLayer['baseRoot'] loadingChange: BaseLayer['loading'] }>() - constructor({ root, getTreeData }: Options) { + constructor({ root, getTreeData, defer = false }: Options) { this.baseRoot = root this.getTreeData = getTreeData + this.defer = defer } loadTreeData = async (path: string) => { @@ -320,6 +322,7 @@ class FlattenLayer extends CompressLayer { type Options = { root: BaseLayer['baseRoot'] + defer?: BaseLayer['defer'] getTreeData: BaseLayer['getTreeData'] compress: CompressLayer['compress'] } diff --git a/src/utils/hooks/useSequentialEffect.ts b/src/utils/hooks/useSequentialEffect.ts index 3b7497f..581fa19 100644 --- a/src/utils/hooks/useSequentialEffect.ts +++ b/src/utils/hooks/useSequentialEffect.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' /** * This effect addresses such a problem: @@ -6,14 +6,15 @@ import { useEffect, useRef } from 'react' */ export function useSequentialEffect( effect: (checker: () => boolean) => (() => void | undefined) | void, - deps: React.DependencyList = [], ) { - const sequenceCounter = useRef(0) useEffect(() => { - // The counter is incremented every time a new effect is added. - // And the previous effect should stop going forward by finding checker returning false. - const counter = ++sequenceCounter.current - const checker = () => counter === sequenceCounter.current - return effect(checker) - }, deps) + // The previous effect should stop running when finding checker returning false. + let valid = true + const checker = () => valid + const defect = effect(checker) + return () => { + valid = false + defect?.() + } + }, [effect]) }