diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx index 548bb38..977ac90 100644 --- a/src/components/FileExplorer/index.tsx +++ b/src/components/FileExplorer/index.tsx @@ -7,7 +7,7 @@ import { PortalContext } from 'containers/PortalContext' import { RepoContext } from 'containers/RepoContext' import { platform } from 'platforms' import * as React from 'react' -import { usePrevious } from 'react-use' +import { usePrevious, useUpdateEffect } from 'react-use' import { cx } from 'utils/cx' import { run } from 'utils/general' import { useElementSize } from 'utils/hooks/useElementSize' @@ -86,16 +86,24 @@ function LoadedFileExplorer({ visibleNodesGenerator: VisibleNodesGenerator visibleNodes: VisibleNodes }) { + const config = useConfigs().value + const [searchKey, updateSearchKey] = React.useState('') const searched = !!searchKey const onSearch = useOnSearch(updateSearchKey, visibleNodesGenerator) const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes + // re-search when the compress option update + const { compressSingletonFolder } = config + useUpdateEffect(() => { + visibleNodesGenerator.setCompression(compressSingletonFolder) + }, [compressSingletonFolder]) + const { ref: filesRef, size: [, height], } = useElementSize() - const { compactFileTree } = useConfigs().value + const { compactFileTree } = config const { ref: scrollElementRef, onScroll, diff --git a/src/utils/VisibleNodesGenerator/BaseLayer.ts b/src/utils/VisibleNodesGenerator/BaseLayer.ts new file mode 100644 index 0000000..d4db586 --- /dev/null +++ b/src/utils/VisibleNodesGenerator/BaseLayer.ts @@ -0,0 +1,49 @@ +import { EventHub } from '../EventHub' +import { findNode } from '../general' +import { Options } from './index' + +function mergeNodes(target: TreeNode, source: TreeNode) { + for (const node of source.contents || []) { + const dup = target.contents?.find($node => $node.path === node.path) + if (dup) { + mergeNodes(dup, node) + } else { + if (!target.contents) target.contents = [] + target.contents.push(node) + } + } +} + +export 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, defer = false }: Options) { + this.baseRoot = root + this.getTreeData = getTreeData + this.defer = defer + } + + loadTreeData = async (path: string) => { + const node = await findNode(this.baseRoot, path) + if (node && node.type !== 'tree') return node + if (node?.contents?.length) return node // check in memory + if (this.loading.has(path)) return + + this.loading.add(path) + this.baseHub.emit('loadingChange', this.loading) + mergeNodes(this.baseRoot, await this.getTreeData(path)) + this.loading.delete(path) + this.baseHub.emit('loadingChange', this.loading) + this.baseHub.emit('emit', this.baseRoot) + + return await findNode(this.baseRoot, path) + } +} diff --git a/src/utils/VisibleNodesGenerator/CompressLayer.ts b/src/utils/VisibleNodesGenerator/CompressLayer.ts new file mode 100644 index 0000000..226403f --- /dev/null +++ b/src/utils/VisibleNodesGenerator/CompressLayer.ts @@ -0,0 +1,79 @@ +import { EventHub } from '../EventHub' +import { withEffect } from '../general' +import { Options } from './index' +import { ShakeLayer } from './ShakeLayer' + +function compressTree(root: TreeNode, prefix: string[] = []): TreeNode { + if (root.contents) { + if (root.contents.length === 1) { + const [singleton] = root.contents + if (singleton.type === 'tree') { + return compressTree(singleton, [...prefix, root.name]) + } + } + + let compressed = false + const contents = [] + for (const node of root.contents) { + const $node = compressTree(node) + if ($node !== node) compressed = true + contents.push($node) + } + if (compressed) + return { + ...root, + name: [...prefix, root.name].join('/'), + contents, + } + } + return prefix.length + ? { + ...root, + name: [...prefix, root.name].join('/'), + } + : root +} + +export class CompressLayer extends ShakeLayer { + private compress: boolean + depths = new Map() + compressedRoot: TreeNode | null = null + compressHub = new EventHub<{ emit: TreeNode | null }>() + + constructor(options: Options) { + super(options) + this.compress = Boolean(options.compress) + + this.shakeHub.addEventListener('emit', () => this.compressTree()) + } + + setCompression(compress: CompressLayer['compress']) { + this.compress = compress + this.compressTree() + } + + private compressTree = withEffect( + () => { + this.compressedRoot = + this.compress && this.shackedRoot + ? { + ...this.shackedRoot, + contents: this.shackedRoot.contents?.map(node => compressTree(node)), + } + : this.shackedRoot + + if (this.compressedRoot) { + const depths = new Map() + const recordDepth = (node: TreeNode, depth = 0) => { + depths.set(node, depth) + for (const $node of node.contents || []) { + recordDepth($node, depth + 1) + } + } + recordDepth(this.compressedRoot, -1) + this.depths = depths + } + }, + () => this.compressHub.emit('emit', this.compressedRoot), + ) +} diff --git a/src/utils/VisibleNodesGenerator/FlattenLayer.ts b/src/utils/VisibleNodesGenerator/FlattenLayer.ts new file mode 100644 index 0000000..f26c454 --- /dev/null +++ b/src/utils/VisibleNodesGenerator/FlattenLayer.ts @@ -0,0 +1,140 @@ +import { EventHub } from '../EventHub' +import { findNode, traverse, withEffect } from '../general' +import { CompressLayer } from './CompressLayer' +import { Options, SearchParams } from './index' + +export class FlattenLayer extends CompressLayer { + focusedNode: TreeNode | null = null + nodes: TreeNode[] = [] + expandedNodes: Set = new Set() + backupExpandedNodes: Set = new Set() + flattenHub = new EventHub<{ emit: null }>() + + constructor(options: Options) { + super(options) + + this.compressHub.addEventListener('emit', () => this.generateVisibleNodes()) + } + + generateVisibleNodes = withEffect( + async () => { + const nodes: TreeNode[] = [] + const focusedNode = this.focusedNode + + if ( + focusedNode && + this.compressedRoot && + !(await findNode(this.compressedRoot, focusedNode.path)) + ) { + // rescue the focus after expanding async singleton folder + await traverse( + this.compressedRoot.contents, + node => { + if (node.type === 'tree' && node.path.startsWith(focusedNode.path)) { + this.focusNode(node) + } + + return node.type === 'tree' && this.expandedNodes.has(node.path) + }, + node => node.contents || [], + ) + } + + await traverse( + this.compressedRoot?.contents, + node => { + nodes.push(node) + return node.type === 'tree' && this.expandedNodes.has(node.path) + }, + node => node.contents || [], + ) + this.nodes = nodes + }, + () => this.flattenHub.emit('emit', null), + ) + + focusNode = (node: TreeNode | null) => { + if (this.focusedNode !== node) { + this.focusedNode = node + this.flattenHub.emit('emit', null) + } + } + + barelySetExpand = (node: TreeNode, expand: boolean) => { + // expanding non-tree node could cause unexpected UX + if (node.type === 'tree') { + if (expand) this.expandedNodes.add(node.path) + else this.expandedNodes.delete(node.path) + } + } + + $setExpand = (node: TreeNode, expand: boolean) => { + this.barelySetExpand(node, expand) + // The `node.contents?.length` condition is critical to search performance as it reduces lots of function calls + if (expand && node.type === 'tree' && !node.contents?.length) + return this.loadTreeData(node.path) + } + setExpand = withEffect(this.$setExpand, this.generateVisibleNodes) + + toggleExpand = withEffect(async (node: TreeNode, recursive = false) => { + const expand = !this.expandedNodes.has(node.path) + await traverse( + [node], + node => { + this.$setExpand(node, expand) + return recursive + }, + node => node.contents || [], + ) + }, this.generateVisibleNodes) + + expandTo = withEffect(async (path: string) => { + const rootNode = this.compressedRoot + if (rootNode) { + await traverse( + rootNode.contents, + async node => { + const overflowChar = path[node.path.length] + const match = path.startsWith(node.path) && (overflowChar === '/' || !overflowChar) + if (match) { + if (node.path === path) { + // do not wait for expansion for the exact node as that will block "jumping from search" + this.$setExpand(node, true) + } else await this.$setExpand(node, true) + } + return match + }, + node => node?.contents || [], + ) + + const node = await findNode(rootNode, path) + return node + } + }, this.generateVisibleNodes) + + search = ( + searchParams: Pick | null, + restoreExpandedFolders?: boolean, + ) => { + if (searchParams) { + if (!this.isSearching) { + // backup expansion when start search + this.backupExpandedNodes.clear() + this.expandedNodes.forEach(path => this.backupExpandedNodes.add(path)) + } + this.expandedNodes.clear() // Reset expansion on every search, ensure cleanest search result expansion + this.shake({ + matchNode: searchParams.matchNode, + onChildMatch: node => this.$setExpand(node, true), + }) + } else { + this.shake(null) + // collapse all nodes on clearing search key + this.expandedNodes.clear() + if (restoreExpandedFolders) { + this.backupExpandedNodes.forEach(path => this.expandedNodes.add(path)) + this.backupExpandedNodes.clear() + } + } + } +} diff --git a/src/utils/VisibleNodesGenerator/ShakeLayer.ts b/src/utils/VisibleNodesGenerator/ShakeLayer.ts new file mode 100644 index 0000000..e85ab55 --- /dev/null +++ b/src/utils/VisibleNodesGenerator/ShakeLayer.ts @@ -0,0 +1,66 @@ +import { EventHub } from '../EventHub' +import { withEffect } from '../general' +import { BaseLayer } from './BaseLayer' +import { Options, SearchParams } from './index' + +function search( + root: TreeNode, + match: (node: TreeNode) => boolean, + onChildMatch: (node: TreeNode) => void, +): TreeNode | null { + // go traverse no matter whether root matches to make sure find & expand the related nodes + // The `related nodes` are the nodes that either itself matches or any of direct or indirect children match + const contents = [] + + if (root.type === 'tree' && root.contents) { + for (const node of root.contents) { + const $node = search(node, match, onChildMatch) + if ($node) contents.push($node) + } + + if (contents.length) onChildMatch(root) + } + + // Return root if itself matches + if (match(root)) return root + + // Otherwise, but when deeper nodes match, return partial root + if (contents.length) { + return { + ...root, + contents, + } + } + + return null +} + +export class ShakeLayer extends BaseLayer { + shackedRoot: TreeNode | null = this.baseRoot + lastSearchParams: SearchParams | null = null + shakeHub = new EventHub<{ emit: TreeNode | null }>() + + get isSearching() { + return this.lastSearchParams !== null + } + + constructor(options: Options) { + super(options) + + this.baseHub.addEventListener('emit', () => this.shake(this.lastSearchParams)) + } + + shake = withEffect( + (searchParams: ShakeLayer['lastSearchParams']) => { + this.lastSearchParams = searchParams + + let root: ShakeLayer['shackedRoot'] = this.baseRoot + if (searchParams) { + const { matchNode, onChildMatch } = searchParams + root = search(this.baseRoot, matchNode, onChildMatch) + } + this.shackedRoot = root + }, + () => this.shakeHub.emit('emit', this.shackedRoot), + ) +} diff --git a/src/utils/VisibleNodesGenerator/index.ts b/src/utils/VisibleNodesGenerator/index.ts index ae1202a..12f7fde 100644 --- a/src/utils/VisibleNodesGenerator/index.ts +++ b/src/utils/VisibleNodesGenerator/index.ts @@ -1,326 +1,14 @@ import { EventHub } from '../EventHub' -import { findNode, traverse, withEffect } from '../general' - -function search( - root: TreeNode, - match: (node: TreeNode) => boolean, - onChildMatch: (node: TreeNode) => void, -): TreeNode | null { - // go traverse no matter whether root matches to make sure find & expand the related nodes - // The `related nodes` are the nodes that either itself matches or any of direct or indirect children match - const contents = [] - - if (root.type === 'tree' && root.contents) { - for (const node of root.contents) { - const $node = search(node, match, onChildMatch) - if ($node) contents.push($node) - } - - if (contents.length) onChildMatch(root) - } - - // Return root if itself matches - if (match(root)) return root - - // Otherwise, but when deeper nodes match, return partial root - if (contents.length) { - return { - ...root, - contents, - } - } - - return null -} - -function compressTree(root: TreeNode, prefix: string[] = []): TreeNode { - if (root.contents) { - if (root.contents.length === 1) { - const [singleton] = root.contents - if (singleton.type === 'tree') { - return compressTree(singleton, [...prefix, root.name]) - } - } - - let compressed = false - const contents = [] - for (const node of root.contents) { - const $node = compressTree(node) - if ($node !== node) compressed = true - contents.push($node) - } - if (compressed) - return { - ...root, - name: [...prefix, root.name].join('/'), - contents, - } - } - return prefix.length - ? { - ...root, - name: [...prefix, root.name].join('/'), - } - : root -} - -function mergeNodes(target: TreeNode, source: TreeNode) { - for (const node of source.contents || []) { - const dup = target.contents?.find($node => $node.path === node.path) - if (dup) { - mergeNodes(dup, node) - } else { - if (!target.contents) target.contents = [] - target.contents.push(node) - } - } -} - -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, defer = false }: Options) { - this.baseRoot = root - this.getTreeData = getTreeData - this.defer = defer - } - - loadTreeData = async (path: string) => { - const node = await findNode(this.baseRoot, path) - if (node && node.type !== 'tree') return node - if (node?.contents?.length) return node // check in memory - if (this.loading.has(path)) return - - this.loading.add(path) - this.baseHub.emit('loadingChange', this.loading) - mergeNodes(this.baseRoot, await this.getTreeData(path)) - this.loading.delete(path) - this.baseHub.emit('loadingChange', this.loading) - this.baseHub.emit('emit', this.baseRoot) - - return await findNode(this.baseRoot, path) - } -} - -class ShakeLayer extends BaseLayer { - shackedRoot: TreeNode | null = this.baseRoot - lastSearchParams: SearchParams | null = null - shakeHub = new EventHub<{ emit: TreeNode | null }>() - - get isSearching() { - return this.lastSearchParams !== null - } - - constructor(options: Options) { - super(options) - - this.baseHub.addEventListener('emit', () => this.shake(this.lastSearchParams)) - } - - shake = withEffect( - (searchParams: ShakeLayer['lastSearchParams']) => { - this.lastSearchParams = searchParams - - let root: ShakeLayer['shackedRoot'] = this.baseRoot - if (searchParams) { - const { matchNode, onChildMatch } = searchParams - root = search(this.baseRoot, matchNode, onChildMatch) - } - this.shackedRoot = root - }, - () => this.shakeHub.emit('emit', this.shackedRoot), - ) -} - -class CompressLayer extends ShakeLayer { - private compress: boolean - depths = new Map() - compressedRoot: TreeNode | null = null - compressHub = new EventHub<{ emit: TreeNode | null }>() - - constructor(options: Options) { - super(options) - this.compress = Boolean(options.compress) - - this.shakeHub.addEventListener('emit', () => this.compressTree()) - } - - private compressTree = withEffect( - () => { - this.compressedRoot = - this.shackedRoot && this.compress - ? { - ...this.shackedRoot, - contents: this.shackedRoot.contents?.map(node => compressTree(node)), - } - : this.shackedRoot - - if (this.compressedRoot) { - const depths = new Map() - const recordDepth = (node: TreeNode, depth = 0) => { - depths.set(node, depth) - for (const $node of node.contents || []) { - recordDepth($node, depth + 1) - } - } - recordDepth(this.compressedRoot, -1) - this.depths = depths - } - }, - () => this.compressHub.emit('emit', this.compressedRoot), - ) -} +import { BaseLayer } from './BaseLayer' +import { CompressLayer } from './CompressLayer' +import { FlattenLayer } from './FlattenLayer' export type SearchParams = { matchNode(node: TreeNode): boolean onChildMatch(node: TreeNode): void } -class FlattenLayer extends CompressLayer { - focusedNode: TreeNode | null = null - nodes: TreeNode[] = [] - expandedNodes: Set = new Set() - backupExpandedNodes: Set = new Set() - flattenHub = new EventHub<{ emit: null }>() - - constructor(options: Options) { - super(options) - - this.compressHub.addEventListener('emit', () => this.generateVisibleNodes()) - } - - generateVisibleNodes = withEffect( - async () => { - const nodes: TreeNode[] = [] - const focusedNode = this.focusedNode - - if ( - focusedNode && - this.compressedRoot && - !(await findNode(this.compressedRoot, focusedNode.path)) - ) { - // rescue the focus after expanding async singleton folder - await traverse( - this.compressedRoot.contents, - node => { - if (node.type === 'tree' && node.path.startsWith(focusedNode.path)) { - this.focusNode(node) - } - - return node.type === 'tree' && this.expandedNodes.has(node.path) - }, - node => node.contents || [], - ) - } - - await traverse( - this.compressedRoot?.contents, - node => { - nodes.push(node) - return node.type === 'tree' && this.expandedNodes.has(node.path) - }, - node => node.contents || [], - ) - this.nodes = nodes - }, - () => this.flattenHub.emit('emit', null), - ) - - focusNode = (node: TreeNode | null) => { - if (this.focusedNode !== node) { - this.focusedNode = node - this.flattenHub.emit('emit', null) - } - } - - barelySetExpand = (node: TreeNode, expand: boolean) => { - // expanding non-tree node could cause unexpected UX - if (node.type === 'tree') { - if (expand) this.expandedNodes.add(node.path) - else this.expandedNodes.delete(node.path) - } - } - - $setExpand = (node: TreeNode, expand: boolean) => { - this.barelySetExpand(node, expand) - // The `node.contents?.length` condition is critical to search performance as it reduces lots of function calls - if (expand && node.type === 'tree' && !node.contents?.length) - return this.loadTreeData(node.path) - } - setExpand = withEffect(this.$setExpand, this.generateVisibleNodes) - - toggleExpand = withEffect(async (node: TreeNode, recursive = false) => { - const expand = !this.expandedNodes.has(node.path) - await traverse( - [node], - node => { - this.$setExpand(node, expand) - return recursive - }, - node => node.contents || [], - ) - }, this.generateVisibleNodes) - - expandTo = withEffect(async (path: string) => { - const rootNode = this.compressedRoot - if (rootNode) { - await traverse( - rootNode.contents, - async node => { - const overflowChar = path[node.path.length] - const match = path.startsWith(node.path) && (overflowChar === '/' || !overflowChar) - if (match) { - if (node.path === path) { - // do not wait for expansion for the exact node as that will block "jumping from search" - this.$setExpand(node, true) - } else await this.$setExpand(node, true) - } - return match - }, - node => node?.contents || [], - ) - - const node = await findNode(rootNode, path) - return node - } - }, this.generateVisibleNodes) - - search = ( - searchParams: Pick | null, - restoreExpandedFolders?: boolean, - ) => { - if (searchParams) { - if (!this.isSearching) { - // backup expansion when start search - this.backupExpandedNodes.clear() - this.expandedNodes.forEach(path => this.backupExpandedNodes.add(path)) - } - this.expandedNodes.clear() // Reset expansion on every search, ensure cleanest search result expansion - this.shake({ - matchNode: searchParams.matchNode, - onChildMatch: node => this.$setExpand(node, true), - }) - } else { - this.shake(null) - // collapse all nodes on clearing search key - this.expandedNodes.clear() - if (restoreExpandedFolders) { - this.backupExpandedNodes.forEach(path => this.expandedNodes.add(path)) - this.backupExpandedNodes.clear() - } - } - } -} - -type Options = { +export type Options = { root: BaseLayer['baseRoot'] defer?: BaseLayer['defer'] getTreeData: BaseLayer['getTreeData'] @@ -339,6 +27,7 @@ export class VisibleNodesGenerator extends FlattenLayer { hub = new EventHub<{ emit: VisibleNodes }>() + constructor(options: Options) { super(options)