-
Notifications
You must be signed in to change notification settings - Fork 264
fix: Improve initial file tree load performance #739
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
4889658
cfe5a7c
61a7055
aa61bd3
b304c93
ed48da4
9021ba6
3d6556d
518e8b5
279e044
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| 'use server'; | ||
|
|
||
| import { getFolderContents } from "@/features/fileTree/api"; | ||
| import { getFolderContentsRequestSchema } from "@/features/fileTree/types"; | ||
| import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; | ||
| import { isServiceError } from "@/lib/utils"; | ||
| import { NextRequest } from "next/server"; | ||
|
|
||
| export const POST = async (request: NextRequest) => { | ||
| const body = await request.json(); | ||
| const parsed = await getFolderContentsRequestSchema.safeParseAsync(body); | ||
| if (!parsed.success) { | ||
| return serviceErrorResponse(schemaValidationError(parsed.error)); | ||
| } | ||
|
|
||
| const response = await getFolderContents(parsed.data); | ||
| if (isServiceError(response)) { | ||
| return serviceErrorResponse(response); | ||
| } | ||
|
|
||
| return Response.json(response); | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,18 +9,18 @@ import { ResizablePanel } from "@/components/ui/resizable"; | |
| import { Separator } from "@/components/ui/separator"; | ||
| import { Skeleton } from "@/components/ui/skeleton"; | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; | ||
| import { unwrapServiceError } from "@/lib/utils"; | ||
| import { measure, unwrapServiceError } from "@/lib/utils"; | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { SearchIcon } from "lucide-react"; | ||
| import { useRef } from "react"; | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { useHotkeys } from "react-hotkeys-hook"; | ||
| import { | ||
| GoSidebarExpand as CollapseIcon, | ||
| GoSidebarCollapse as ExpandIcon | ||
| } from "react-icons/go"; | ||
| import { ImperativePanelHandle } from "react-resizable-panels"; | ||
| import { PureFileTreePanel } from "./pureFileTreePanel"; | ||
|
|
||
| import { FileTreeNode } from "../types"; | ||
|
|
||
| interface FileTreePanelProps { | ||
| order: number; | ||
|
|
@@ -30,7 +30,6 @@ const FILE_TREE_PANEL_DEFAULT_SIZE = 20; | |
| const FILE_TREE_PANEL_MIN_SIZE = 10; | ||
| const FILE_TREE_PANEL_MAX_SIZE = 30; | ||
|
|
||
|
|
||
| export const FileTreePanel = ({ order }: FileTreePanelProps) => { | ||
| const { | ||
| state: { | ||
|
|
@@ -41,17 +40,79 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { | |
|
|
||
| const { repoName, revisionName, path } = useBrowseParams(); | ||
|
|
||
| const [tree, setTree] = useState<FileTreeNode | null>(null); | ||
| const [openPaths, setOpenPaths] = useState<Set<string>>(new Set()); | ||
|
|
||
| const fileTreePanelRef = useRef<ImperativePanelHandle>(null); | ||
| const { data, isPending, isError } = useQuery({ | ||
| queryKey: ['tree', repoName, revisionName], | ||
| queryFn: () => unwrapServiceError( | ||
| getTree({ | ||
| repoName, | ||
| revisionName: revisionName ?? 'HEAD', | ||
| }) | ||
| ), | ||
|
|
||
| const { data, isError } = useQuery({ | ||
| queryKey: ['tree', repoName, revisionName, ...Array.from(openPaths)], | ||
|
brendan-kellam marked this conversation as resolved.
|
||
| queryFn: async () => { | ||
| const result = await measure(async () => unwrapServiceError( | ||
| getTree({ | ||
| repoName, | ||
| revisionName: revisionName ?? 'HEAD', | ||
| paths: Array.from(openPaths), | ||
| }) | ||
| ), 'getTree'); | ||
|
|
||
| return result.data; | ||
| } | ||
| }); | ||
|
|
||
|
|
||
| useEffect(() => { | ||
| if (!data) { | ||
| return; | ||
| } | ||
| setTree(data.tree); | ||
| }, [data]); | ||
|
|
||
| // Whenever the repo name or revision name changes, we will need to | ||
| // reset the open paths since they no longer reference the same repository/revision. | ||
| useEffect(() => { | ||
| setOpenPaths(new Set()); | ||
| }, [repoName, revisionName]); | ||
|
|
||
| // When the path changes (e.g., the user clicks a reference in the explore panel), | ||
| // we want this to be open and visible in the file tree. | ||
| useEffect(() => { | ||
| const pathParts = path.split('/').filter(Boolean); | ||
|
|
||
| setOpenPaths(current => { | ||
| const next = new Set<string>(current); | ||
| for (let i = 0; i < pathParts.length; i++) { | ||
| next.add(pathParts.slice(0, i + 1).join('/')); | ||
| } | ||
| return next; | ||
| }); | ||
| }, [path]); | ||
|
|
||
| // When the user clicks a file tree node, we will want to either | ||
| // add or remove it from the open paths depending on if it's already open or not. | ||
| const onNodeClicked = useCallback((node: FileTreeNode) => { | ||
| if (!openPaths.has(node.path)) { | ||
| setOpenPaths(current => { | ||
| const next = new Set(current); | ||
| next.add(node.path); | ||
| return next; | ||
| }) | ||
| } else { | ||
| setOpenPaths(current => { | ||
| const next = new Set(current); | ||
| next.delete(node.path); | ||
| return next; | ||
| }) | ||
| } | ||
| }, [openPaths]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale closure prevents folder toggle on rapid clicksLow Severity The |
||
|
|
||
| // @debug: format the tree for console output. | ||
| // useEffect(() => { | ||
| // if (!tree) { | ||
| // return; | ||
| // } | ||
| // console.debug(__debugFormatTreeForConsole(tree)); | ||
| // }, [tree]); | ||
|
|
||
| useHotkeys("mod+b", () => { | ||
| if (isFileTreePanelCollapsed) { | ||
| fileTreePanelRef.current?.expand(); | ||
|
|
@@ -122,7 +183,7 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { | |
| </Tooltip> | ||
| </div> | ||
| <Separator orientation="horizontal" className="w-full mb-2" /> | ||
| {isPending ? ( | ||
| {!tree ? ( | ||
|
brendan-kellam marked this conversation as resolved.
Outdated
|
||
| <FileTreePanelSkeleton /> | ||
| ) : | ||
| isError ? ( | ||
|
|
@@ -131,8 +192,10 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { | |
| </div> | ||
| ) : ( | ||
| <PureFileTreePanel | ||
| tree={data.tree} | ||
| tree={tree} | ||
| openPaths={openPaths} | ||
| path={path} | ||
| onNodeClicked={onNodeClicked} | ||
| /> | ||
| )} | ||
| </div> | ||
|
|
@@ -323,4 +386,19 @@ const FileTreePanelSkeleton = () => { | |
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| const __debugFormatTreeForConsole = (node: FileTreeNode): string => { | ||
| const lines: string[] = []; | ||
| const walk = (current: FileTreeNode, prefix: string, isLast: boolean, isRoot: boolean) => { | ||
| const label = current.name || current.path; | ||
| const connector = isRoot ? "" : (isLast ? "`-- " : "|-- "); | ||
| lines.push(`${prefix}${connector}${label}`); | ||
| const nextPrefix = isRoot ? "" : `${prefix}${isLast ? " " : "| "}`; | ||
| current.children.forEach((child, index) => { | ||
| walk(child, nextPrefix, index === current.children.length - 1, false); | ||
| }); | ||
| }; | ||
| walk(node, "", true, true); | ||
| return lines.join("\n"); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.