From 3930d4c3d3ce18f83232ad11d238d2cdee834b1a Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Fri, 24 Jul 2026 22:14:18 -0400 Subject: [PATCH] RepoTree: clean up and cache commits --- src/components/repo/RepoTree.tsx | 308 ++++---------------- src/components/repo/RepoTreeEntry.tsx | 65 +++++ src/components/repo/RepoTreePath.tsx | 39 +++ src/components/repo/RepoWorkspace.tsx | 2 +- src/components/repo/WorkspacePaneHeader.tsx | 2 +- src/components/repo/repoTreeUtils.ts | 40 +++ src/components/repo/useRepoTree.ts | 103 +++++++ src/pages/RepoPage.tsx | 21 +- 8 files changed, 318 insertions(+), 262 deletions(-) create mode 100644 src/components/repo/RepoTreeEntry.tsx create mode 100644 src/components/repo/RepoTreePath.tsx create mode 100644 src/components/repo/repoTreeUtils.ts create mode 100644 src/components/repo/useRepoTree.ts diff --git a/src/components/repo/RepoTree.tsx b/src/components/repo/RepoTree.tsx index a14eb9e..32b5685 100644 --- a/src/components/repo/RepoTree.tsx +++ b/src/components/repo/RepoTree.tsx @@ -1,11 +1,14 @@ -import { IconChevronRight, IconCornerLeftUp, IconFile, IconFolder } from '@tabler/icons-react' +import { IconCornerLeftUp } from '@tabler/icons-react' import type { $output as RepoTreeResponse } from '@atcute/tangled/types/repo/tree' -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 { getRepoName } from '../../lib/tangled/repo' import { LoadingPanel } from '../shared/LoadingPanel' +import { RepoTreeEntry } from './RepoTreeEntry' +import { RepoTreePath } from './RepoTreePath' import { WorkspacePaneHeader } from './WorkspacePaneHeader' +import { getParentPath, sortTreeEntries } from './repoTreeUtils' +import { useRepoTree } from './useRepoTree' type RepoTreeProps = { initialTree?: RepoTreeResponse @@ -13,12 +16,10 @@ type RepoTreeProps = { } export function RepoTree({ initialTree, repo }: RepoTreeProps) { - 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) + const repoName = getRepoName(repo) + const { error, tree } = useRepoTree({ initialTree, path, repo }) function navigateToPath(nextPath: string) { setSearchParams( @@ -35,55 +36,6 @@ export function RepoTree({ initialTree, repo }: RepoTreeProps) { ) } - useEffect(() => { - 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 (cachedTree !== undefined) { - setTree(cachedTree) - setError(null) - void prefetchDirectories(repo, cachedTree, path, cache.current, prefetching.current) - return - } - - async function loadTree() { - setTree(null) - setError(null) - - 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) { - setError(caught instanceof Error ? caught : new Error('Unable to load repository tree')) - } - } - } - - void loadTree() - - return () => { - cancelled = true - } - }, [path, repo]) - if (error) { return

Could not load files: {error.message}

} @@ -92,217 +44,57 @@ export function RepoTree({ initialTree, repo }: RepoTreeProps) { return } - const sortedFiles = [...tree.files].sort((a, b) => { - const aIsDirectory = isDirectoryMode(a.mode) - const bIsDirectory = isDirectoryMode(b.mode) - - if (aIsDirectory !== bIsDirectory) { - return aIsDirectory ? -1 : 1 - } - - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - }) + const sortedEntries = sortTreeEntries(tree.files) return (
-
- - Tree - - - } - trailing={ - - {tree.ref} - - } - /> - -
    - {path !== '' && ( -
  • - -
  • - )} - - {sortedFiles.map((entry) => ( - - ))} -
- - {tree.files.length === 0 && ( -

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)} + + Tree + -
    - )} -
  • - ) -} - -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('/') + } + trailing={ + + {tree.ref} + + } + /> - return ( - - / +
      + {path !== '' && ( +
    • - - ) - })} - - ) -} - -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() +
    • + )} - return ( - normalized === 'tree' || - normalized === 'dir' || - normalized === 'directory' || - normalized === '40000' || - normalized === '040000' || - normalized.endsWith('40000') + {sortedEntries.map((entry) => ( + + ))} +
    + + {tree.files.length === 0 && ( +

    This directory is empty.

    + )} + ) } diff --git a/src/components/repo/RepoTreeEntry.tsx b/src/components/repo/RepoTreeEntry.tsx new file mode 100644 index 0000000..03cda6f --- /dev/null +++ b/src/components/repo/RepoTreeEntry.tsx @@ -0,0 +1,65 @@ +import { IconChevronRight, IconFile, IconFolder } from '@tabler/icons-react' +import type { $output as RepoTreeResponse } from '@atcute/tangled/types/repo/tree' +import { formatBytes, isDirectoryMode } from './repoTreeUtils' + +type TreeEntry = RepoTreeResponse['files'][number] + +type RepoTreeEntryProps = { + currentPath: string + entry: TreeEntry + onNavigate: (path: string) => void +} + +export function RepoTreeEntry({ currentPath, entry, onNavigate }: RepoTreeEntryProps) { + const isDirectory = isDirectoryMode(entry.mode) + const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name + const entryContent = + + return ( +
  • + {isDirectory ? ( + + ) : ( +
    + {entryContent} + + {formatBytes(entry.size)} + +
    + )} +
  • + ) +} + +type EntryContentProps = { + entry: TreeEntry + isDirectory: boolean +} + +function EntryContent({ entry, isDirectory }: EntryContentProps) { + return ( + + {isDirectory ? ( + + ) +} diff --git a/src/components/repo/RepoTreePath.tsx b/src/components/repo/RepoTreePath.tsx new file mode 100644 index 0000000..fd08aee --- /dev/null +++ b/src/components/repo/RepoTreePath.tsx @@ -0,0 +1,39 @@ +type RepoTreePathProps = { + onNavigate: (path: string) => void + path: string + repoName: string +} + +export function RepoTreePath({ onNavigate, path, repoName }: RepoTreePathProps) { + const segments = path.split('/').filter(Boolean) + + return ( + + / + + {segments.map((segment, index) => { + const segmentPath = segments.slice(0, index + 1).join('/') + + return ( + + / + + + ) + })} + + ) +} diff --git a/src/components/repo/RepoWorkspace.tsx b/src/components/repo/RepoWorkspace.tsx index 941a584..6a70bb3 100644 --- a/src/components/repo/RepoWorkspace.tsx +++ b/src/components/repo/RepoWorkspace.tsx @@ -10,7 +10,7 @@ type RepoWorkspaceProps = { export function RepoWorkspace({ initialTree, repo }: RepoWorkspaceProps) { return ( -
    +
    diff --git a/src/components/repo/WorkspacePaneHeader.tsx b/src/components/repo/WorkspacePaneHeader.tsx index 5d63a94..9947e0d 100644 --- a/src/components/repo/WorkspacePaneHeader.tsx +++ b/src/components/repo/WorkspacePaneHeader.tsx @@ -8,7 +8,7 @@ type WorkspacePaneHeaderProps = { export function WorkspacePaneHeader({ title, labelledBy, trailing }: WorkspacePaneHeaderProps) { return ( -
    +

    {title}

    diff --git a/src/components/repo/repoTreeUtils.ts b/src/components/repo/repoTreeUtils.ts new file mode 100644 index 0000000..6a1a7ba --- /dev/null +++ b/src/components/repo/repoTreeUtils.ts @@ -0,0 +1,40 @@ +import type { $output as RepoTreeResponse } from '@atcute/tangled/types/repo/tree' + +type TreeEntry = RepoTreeResponse['files'][number] + +export function sortTreeEntries(entries: TreeEntry[]): TreeEntry[] { + return [...entries].sort((firstEntry, secondEntry) => { + const firstIsDirectory = isDirectoryMode(firstEntry.mode) + const secondIsDirectory = isDirectoryMode(secondEntry.mode) + + if (firstIsDirectory !== secondIsDirectory) { + return firstIsDirectory ? -1 : 1 + } + + return firstEntry.name.localeCompare(secondEntry.name, undefined, { sensitivity: 'base' }) + }) +} + +export function getParentPath(path: string): string { + const separatorIndex = path.lastIndexOf('/') + return separatorIndex === -1 ? '' : path.slice(0, separatorIndex) +} + +export 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` +} + +export function isDirectoryMode(mode: string): boolean { + const normalizedMode = mode.toLowerCase() + + return ( + normalizedMode === 'tree' || + normalizedMode === 'dir' || + normalizedMode === 'directory' || + normalizedMode === '40000' || + normalizedMode === '040000' || + normalizedMode.endsWith('40000') + ) +} diff --git a/src/components/repo/useRepoTree.ts b/src/components/repo/useRepoTree.ts new file mode 100644 index 0000000..e745a2f --- /dev/null +++ b/src/components/repo/useRepoTree.ts @@ -0,0 +1,103 @@ +import type { $output as RepoTreeResponse } from '@atcute/tangled/types/repo/tree' +import { useEffect, useRef, useState } from 'react' +import type { Repo } from '../../lib/tangled' +import { getRepoTree } from '../../lib/tangled/repo' +import { isDirectoryMode } from './repoTreeUtils' + +type UseRepoTreeOptions = { + initialTree?: RepoTreeResponse + path: string + repo: Repo +} + +type UseRepoTreeResult = { + error: Error | null + tree: RepoTreeResponse | null +} + +export function useRepoTree({ initialTree, path, repo }: UseRepoTreeOptions): UseRepoTreeResult { + const cache = useRef(new Map()) + const prefetching = useRef(new Set()) + const [tree, setTree] = useState(initialTree ?? null) + const [error, setError] = useState(null) + + useEffect(() => { + 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 (cachedTree !== undefined) { + setTree(cachedTree) + setError(null) + void prefetchDirectories(repo, cachedTree, path, cache.current, prefetching.current) + return + } + + async function loadTree() { + setTree(null) + setError(null) + + 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) { + setError(caught instanceof Error ? caught : new Error('Unable to load repository tree')) + } + } + } + + void loadTree() + + return () => { + cancelled = true + } + }, [path, repo]) + + return { error, tree } +} + +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) + } + }), + ) +} diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index f7c9fde..cc615f5 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -29,6 +29,18 @@ export function RepoPage() { const [repo, setRepo] = useState(null) const [rootTree, setRootTree] = useState(null) const [error, setError] = useState(null) + const [hasVisitedCode, setHasVisitedCode] = useState(false) + + const requestedSection = searchParams.get('view') + + useEffect(() => { + const defaultIsCode = + requestedSection === null && rootTree !== null && rootTree.readme === undefined + + if (requestedSection === 'code' || defaultIsCode) { + setHasVisitedCode(true) + } + }, [requestedSection, rootTree]) useEffect(() => { if (handle === null || routeRepo === undefined) return @@ -80,7 +92,8 @@ export function RepoPage() { return } - const activeSection = parseRepoSection(searchParams.get('view'), rootTree.readme !== undefined) + const activeSection = parseRepoSection(requestedSection, rootTree.readme !== undefined) + const shouldRenderWorkspace = hasVisitedCode || activeSection === 'code' return (
    @@ -102,7 +115,11 @@ export function RepoPage() { /> {activeSection === 'readme' && } - {activeSection === 'code' && } + {shouldRenderWorkspace && ( + + )} {activeSection === 'issues' && } {activeSection === 'pulls' && } {activeSection === 'pipelines' && } -- 2.51.2