diff --git a/src/components/repo/RepoTree.tsx b/src/components/repo/RepoTree.tsx index 0ca7986..a14eb9e 100644 --- a/src/components/repo/RepoTree.tsx +++ b/src/components/repo/RepoTree.tsx @@ -1,5 +1,7 @@ +import { IconChevronRight, IconCornerLeftUp, IconFile, IconFolder } from '@tabler/icons-react' import type { $output as RepoTreeResponse } from '@atcute/tangled/types/repo/tree' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import type { Repo } from '../../lib/tangled' import { getRepoTree } from '../../lib/tangled/repo' import { LoadingPanel } from '../shared/LoadingPanel' @@ -11,19 +13,48 @@ type RepoTreeProps = { } export function RepoTree({ initialTree, repo }: RepoTreeProps) { - const [path, setPath] = useState('') + const cache = useRef(new Map()) + const prefetching = useRef(new Set()) + const [searchParams, setSearchParams] = useSearchParams() + const path = searchParams.get('path') ?? '' const [tree, setTree] = useState(initialTree ?? null) const [error, setError] = useState(null) + function navigateToPath(nextPath: string) { + setSearchParams( + (current) => { + const next = new URLSearchParams(current) + if (nextPath === '') { + next.delete('path') + } else { + next.set('path', nextPath) + } + return next + }, + { replace: true }, + ) + } + useEffect(() => { - setPath('') + cache.current.clear() + prefetching.current.clear() + + if (initialTree !== undefined) { + cache.current.set('', initialTree) + } + setTree(initialTree ?? null) + setError(null) }, [initialTree, repo.uri]) useEffect(() => { let cancelled = false + const cachedTree = cache.current.get(path) - if (path === '' && initialTree !== undefined) { + if (cachedTree !== undefined) { + setTree(cachedTree) + setError(null) + void prefetchDirectories(repo, cachedTree, path, cache.current, prefetching.current) return } @@ -33,8 +64,11 @@ export function RepoTree({ initialTree, repo }: RepoTreeProps) { try { const response = await getRepoTree(repo, path) + cache.current.set(path, response) + if (!cancelled) { setTree(response) + void prefetchDirectories(repo, response, path, cache.current, prefetching.current) } } catch (caught) { if (!cancelled) { @@ -48,7 +82,7 @@ export function RepoTree({ initialTree, repo }: RepoTreeProps) { return () => { cancelled = true } - }, [initialTree, repo, path]) + }, [path, repo]) if (error) { return

Could not load files: {error.message}

@@ -75,9 +109,9 @@ export function RepoTree({ initialTree, repo }: RepoTreeProps) { + Tree - /{path} + } trailing={ @@ -88,71 +122,178 @@ export function RepoTree({ initialTree, repo }: RepoTreeProps) { />
    - {(tree.parent ?? tree.dotdot) && ( + {path !== '' && (
  • )} - {sortedFiles.map((entry) => { - const isDirectory = isDirectoryMode(entry.mode) - const entryPath = path ? `${path}/${entry.name}` : entry.name - - return ( -
  • - {isDirectory ? ( - - ) : ( - - - {entry.name} - - )} - - {isDirectory ? 'directory' : formatBytes(entry.size)} - -
  • - ) - })} + {sortedFiles.map((entry) => ( + + ))}
{tree.files.length === 0 && ( -

This directory is empty.

+

This directory is empty.

)} ) } +type TreeEntry = RepoTreeResponse['files'][number] + +type TreeEntryRowProps = { + entry: TreeEntry + currentPath: string + onNavigate: (path: string) => void +} + +function TreeEntryRow({ entry, currentPath, onNavigate }: TreeEntryRowProps) { + const isDirectory = isDirectoryMode(entry.mode) + const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name + const content = ( + + {isDirectory ? ( + + ) + + return ( +
  • + {isDirectory ? ( + + ) : ( +
    + {content} + + {formatBytes(entry.size)} + +
    + )} +
  • + ) +} + +type TreePathProps = { + path: string + onNavigate: (path: string) => void +} + +function TreePath({ path, onNavigate }: TreePathProps) { + const segments = path.split('/').filter(Boolean) + + if (segments.length === 0) { + return / + } + + return ( + + / + + {segments.map((segment, index) => { + const segmentPath = segments.slice(0, index + 1).join('/') + + return ( + + / + + + ) + })} + + ) +} + +async function prefetchDirectories( + repo: Repo, + tree: RepoTreeResponse, + currentPath: string, + cache: Map, + prefetching: Set, +): Promise { + const directories = tree.files + .filter((entry) => isDirectoryMode(entry.mode)) + .map((entry) => (currentPath ? `${currentPath}/${entry.name}` : entry.name)) + + await Promise.all( + directories.map(async (directoryPath) => { + if (cache.has(directoryPath) || prefetching.has(directoryPath)) return + + prefetching.add(directoryPath) + try { + const childTree = await getRepoTree(repo, directoryPath) + cache.set(directoryPath, childTree) + await prefetchDirectories(repo, childTree, directoryPath, cache, prefetching) + } catch { + // Navigation retries directories whose background prefetch failed. + } finally { + prefetching.delete(directoryPath) + } + }), + ) +} + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / 1024 ** 2).toFixed(1)} MB` } +function getParentPath(path: string): string { + const separatorIndex = path.lastIndexOf('/') + return separatorIndex === -1 ? '' : path.slice(0, separatorIndex) +} + function isDirectoryMode(mode: string): boolean { const normalized = mode.toLowerCase()