From 48896587e2b6b85d9e2fb297d21fa6281d8e1814 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 14 Jan 2026 12:39:30 -0800 Subject: [PATCH 1/9] wip: modified getTree to only fetch the minimum subset of directories & files that are visible --- .../web/src/app/api/(server)/tree/route.ts | 4 +- packages/web/src/features/fileTree/api.ts | 91 ++++++++++++------- .../fileTree/components/fileTreePanel.tsx | 6 +- packages/web/src/features/fileTree/types.ts | 1 + 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/packages/web/src/app/api/(server)/tree/route.ts b/packages/web/src/app/api/(server)/tree/route.ts index efe63bffe..4fd20cf27 100644 --- a/packages/web/src/app/api/(server)/tree/route.ts +++ b/packages/web/src/app/api/(server)/tree/route.ts @@ -3,7 +3,7 @@ import { getTree } from "@/features/fileTree/api"; import { getTreeRequestSchema } from "@/features/fileTree/types"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; -import { isServiceError } from "@/lib/utils"; +import { isServiceError, measure } from "@/lib/utils"; import { NextRequest } from "next/server"; export const POST = async (request: NextRequest) => { @@ -13,7 +13,7 @@ export const POST = async (request: NextRequest) => { return serviceErrorResponse(schemaValidationError(parsed.error)); } - const response = await getTree(parsed.data); + const { data: response } = await measure(() => getTree(parsed.data), 'getTree'); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/features/fileTree/api.ts b/packages/web/src/features/fileTree/api.ts index ed4c7aede..627d7d060 100644 --- a/packages/web/src/features/fileTree/api.ts +++ b/packages/web/src/features/fileTree/api.ts @@ -16,9 +16,9 @@ const logger = createLogger('file-tree'); * Returns the tree of files (blobs) and directories (trees) for a given repository, * at a given revision. */ -export const getTree = async (params: { repoName: string, revisionName: string }) => sew(() => +export const getTree = async (params: { repoName: string, revisionName: string, path: string }) => sew(() => withOptionalAuthV2(async ({ org, prisma }) => { - const { repoName, revisionName } = params; + const { repoName, revisionName, path } = params; const repo = await prisma.repo.findFirst({ where: { name: repoName, @@ -33,21 +33,25 @@ export const getTree = async (params: { repoName: string, revisionName: string } const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); + if (!isPathValid(path)) { + return notFound(); + } - let result: string; + const pathSpecs = getPathspecs(path); + + let result: string = ''; try { - result = await git.raw([ + result= await git.raw([ // Disable quoting of non-ASCII characters in paths '-c', 'core.quotePath=false', 'ls-tree', revisionName, - // recursive - '-r', - // include trees when recursing - '-t', // format as output as {type},{path} '--format=%(objecttype),%(path)', - ]); + '--', + '.', + ...pathSpecs, + ]) } catch (error) { logger.error('git ls-tree failed.', { error }); return unexpectedError('git ls-tree command failed.'); @@ -90,31 +94,12 @@ export const getFolderContents = async (params: { repoName: string, revisionName } const { path: repoPath } = getRepoPath(repo); + const git = simpleGit().cwd(repoPath); - // @note: we don't allow directory traversal - // or null bytes in the path. - if (path.includes('..') || path.includes('\0')) { + if (!isPathValid(path)) { return notFound(); } - - // Normalize the path by... - let normalizedPath = path; - - // ... adding a trailing slash if it doesn't have one. - // This is important since ls-tree won't return the contents - // of a directory if it doesn't have a trailing slash. - if (!normalizedPath.endsWith('/')) { - normalizedPath = `${normalizedPath}/`; - } - - // ... removing any leading slashes. This is needed since - // the path is relative to the repository's root, so we - // need a relative path. - if (normalizedPath.startsWith('/')) { - normalizedPath = normalizedPath.slice(1); - } - - const git = simpleGit().cwd(repoPath); + const normalizedPath = normalizePath(path); let result: string; try { @@ -199,6 +184,50 @@ export const getFiles = async (params: { repoName: string, revisionName: string })); +// @note: we don't allow directory traversal +// or null bytes in the path. +const isPathValid = (path: string) => { + return !path.includes('..') && !path.includes('\0'); +} + +const normalizePath = (path: string): string => { + // Normalize the path by... + let normalizedPath = path; + + // ... adding a trailing slash if it doesn't have one. + // This is important since ls-tree won't return the contents + // of a directory if it doesn't have a trailing slash. + if (!normalizedPath.endsWith('/')) { + normalizedPath = `${normalizedPath}/`; + } + + // ... removing any leading slashes. This is needed since + // the path is relative to the repository's root, so we + // need a relative path. + if (normalizedPath.startsWith('/')) { + normalizedPath = normalizedPath.slice(1); + } + + return normalizedPath; +} + +const getPathspecs = (path: string): string[] => { + const normalizedPath = normalizePath(path); + if (normalizedPath.length === 0) { + return []; + } + + const parts = normalizedPath.split('/').filter(part => part.length > 0); + const pathspecs: string[] = []; + + for (let i = 0; i < parts.length; i++) { + const prefix = parts.slice(0, i + 1).join('/'); + pathspecs.push(`${prefix}/`); + } + + return pathspecs; +} + const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { name: 'root', diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index eb751bacf..4ea306aa8 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -21,7 +21,6 @@ import { import { ImperativePanelHandle } from "react-resizable-panels"; import { PureFileTreePanel } from "./pureFileTreePanel"; - interface FileTreePanelProps { order: number; } @@ -43,15 +42,16 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { const fileTreePanelRef = useRef(null); const { data, isPending, isError } = useQuery({ - queryKey: ['tree', repoName, revisionName], + queryKey: ['tree', repoName, revisionName, path], queryFn: () => unwrapServiceError( getTree({ repoName, revisionName: revisionName ?? 'HEAD', + path }) ), }); - + useHotkeys("mod+b", () => { if (isFileTreePanelCollapsed) { fileTreePanelRef.current?.expand(); diff --git a/packages/web/src/features/fileTree/types.ts b/packages/web/src/features/fileTree/types.ts index 0f0318f45..24dc69112 100644 --- a/packages/web/src/features/fileTree/types.ts +++ b/packages/web/src/features/fileTree/types.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const getTreeRequestSchema = z.object({ repoName: z.string(), revisionName: z.string(), + path: z.string(), }); export type GetTreeRequest = z.infer; From cfe5a7ccdfb118bc0a6d1b49bf2dcdecb8c08008 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 14 Jan 2026 20:25:30 -0800 Subject: [PATCH 2/9] wip --- packages/web/src/app/api/(client)/client.ts | 10 ++ .../app/api/(server)/folder_contents/route.ts | 23 +++ packages/web/src/features/fileTree/api.ts | 110 ++------------ .../fileTree/components/fileTreePanel.tsx | 32 +++- .../fileTree/components/pureFileTreePanel.tsx | 138 +++++++++++++++--- packages/web/src/features/fileTree/types.ts | 10 ++ .../web/src/features/fileTree/utils.test.ts | 94 ++++++++++++ packages/web/src/features/fileTree/utils.ts | 100 +++++++++++++ 8 files changed, 390 insertions(+), 127 deletions(-) create mode 100644 packages/web/src/app/api/(server)/folder_contents/route.ts create mode 100644 packages/web/src/features/fileTree/utils.test.ts create mode 100644 packages/web/src/features/fileTree/utils.ts diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 08a01b5b4..36ed1e70c 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -18,6 +18,8 @@ import { import { GetFilesRequest, GetFilesResponse, + GetFolderContentsRequest, + GetFolderContentsResponse, GetTreeRequest, GetTreeResponse, } from "@/features/fileTree/types"; @@ -101,4 +103,12 @@ export const getFiles = async (body: GetFilesRequest): Promise response.json()); return result as GetFilesResponse | ServiceError; +} + +export const getFolderContents = async (body: GetFolderContentsRequest): Promise => { + const result = await fetch("/api/folder_contents", { + method: "POST", + body: JSON.stringify(body), + }).then(response => response.json()); + return result as GetFolderContentsResponse | ServiceError; } \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/folder_contents/route.ts b/packages/web/src/app/api/(server)/folder_contents/route.ts new file mode 100644 index 000000000..a1a1ef1bf --- /dev/null +++ b/packages/web/src/app/api/(server)/folder_contents/route.ts @@ -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); +} + diff --git a/packages/web/src/features/fileTree/api.ts b/packages/web/src/features/fileTree/api.ts index 627d7d060..a01411e62 100644 --- a/packages/web/src/features/fileTree/api.ts +++ b/packages/web/src/features/fileTree/api.ts @@ -8,7 +8,8 @@ import { Repo } from '@sourcebot/db'; import { createLogger } from '@sourcebot/shared'; import path from 'path'; import { simpleGit } from 'simple-git'; -import { FileTreeItem, FileTreeNode } from './types'; +import { FileTreeItem } from './types'; +import { buildFileTree, getPathspecs, isPathValid, normalizePath } from './utils'; const logger = createLogger('file-tree'); @@ -41,17 +42,22 @@ export const getTree = async (params: { repoName: string, revisionName: string, let result: string = ''; try { - result= await git.raw([ + + const command = [ // Disable quoting of non-ASCII characters in paths '-c', 'core.quotePath=false', 'ls-tree', revisionName, // format as output as {type},{path} '--format=%(objecttype),%(path)', + // include tree nodes + '-t', '--', '.', ...pathSpecs, - ]) + ]; + + result = await git.raw(command); } catch (error) { logger.error('git ls-tree failed.', { error }); return unexpectedError('git ls-tree command failed.'); @@ -184,104 +190,6 @@ export const getFiles = async (params: { repoName: string, revisionName: string })); -// @note: we don't allow directory traversal -// or null bytes in the path. -const isPathValid = (path: string) => { - return !path.includes('..') && !path.includes('\0'); -} - -const normalizePath = (path: string): string => { - // Normalize the path by... - let normalizedPath = path; - - // ... adding a trailing slash if it doesn't have one. - // This is important since ls-tree won't return the contents - // of a directory if it doesn't have a trailing slash. - if (!normalizedPath.endsWith('/')) { - normalizedPath = `${normalizedPath}/`; - } - - // ... removing any leading slashes. This is needed since - // the path is relative to the repository's root, so we - // need a relative path. - if (normalizedPath.startsWith('/')) { - normalizedPath = normalizedPath.slice(1); - } - - return normalizedPath; -} - -const getPathspecs = (path: string): string[] => { - const normalizedPath = normalizePath(path); - if (normalizedPath.length === 0) { - return []; - } - - const parts = normalizedPath.split('/').filter(part => part.length > 0); - const pathspecs: string[] = []; - - for (let i = 0; i < parts.length; i++) { - const prefix = parts.slice(0, i + 1).join('/'); - pathspecs.push(`${prefix}/`); - } - - return pathspecs; -} - -const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { - const root: FileTreeNode = { - name: 'root', - path: '', - type: 'tree', - children: [], - }; - - for (const item of flatList) { - const parts = item.path.split('/'); - let current: FileTreeNode = root; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLeaf = i === parts.length - 1; - const nodeType = isLeaf ? item.type : 'tree'; - let next = current.children.find((child: FileTreeNode) => child.name === part && child.type === nodeType); - - if (!next) { - next = { - name: part, - path: item.path, - type: nodeType, - children: [], - }; - current.children.push(next); - } - current = next; - } - } - - const sortTree = (node: FileTreeNode): FileTreeNode => { - if (node.type === 'blob') { - return node; - } - - const sortedChildren = node.children - .map(sortTree) - .sort((a: FileTreeNode, b: FileTreeNode) => { - if (a.type !== b.type) { - return a.type === 'tree' ? -1 : 1; - } - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); - }); - - return { - ...node, - children: sortedChildren, - }; - }; - - return sortTree(root); -} - // @todo: this is duplicated from the `getRepoPath` function in the // backend's `utils.ts` file. Eventually we should move this to a shared // package. diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index 4ea306aa8..24a6aa9fd 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -2,7 +2,7 @@ import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; -import { getTree } from "@/app/api/(client)/client"; +import { getFolderContents, getTree } from "@/app/api/(client)/client"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { Button } from "@/components/ui/button"; import { ResizablePanel } from "@/components/ui/resizable"; @@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { 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, @@ -20,6 +20,7 @@ import { } from "react-icons/go"; import { ImperativePanelHandle } from "react-resizable-panels"; import { PureFileTreePanel } from "./pureFileTreePanel"; +import { FileTreeNode } from "../types"; interface FileTreePanelProps { order: number; @@ -29,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: { @@ -40,8 +40,20 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { const { repoName, revisionName, path } = useBrowseParams(); + const [tree, setTree] = useState(null); + const fileTreePanelRef = useRef(null); - const { data, isPending, isError } = useQuery({ + const loadFolderContents = useCallback(async (folderPath: string) => { + return unwrapServiceError( + getFolderContents({ + repoName, + revisionName: revisionName ?? 'HEAD', + path: folderPath + }) + ); + }, [repoName, revisionName]); + + const { data, isError } = useQuery({ queryKey: ['tree', repoName, revisionName, path], queryFn: () => unwrapServiceError( getTree({ @@ -64,6 +76,13 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { description: "Toggle file tree panel", }); + useEffect(() => { + if (!data) { + return; + } + setTree(data.tree); + }, [data]); + return ( <> { - {isPending ? ( + {!tree ? ( ) : isError ? ( @@ -131,8 +150,9 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { ) : ( )} diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index 9e8811292..8e848c794 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FileTreeNode as RawFileTreeNode } from "../types"; +import { FileTreeItem, FileTreeNode as RawFileTreeNode } from "../types"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import React, { useCallback, useMemo, useState, useEffect, useRef } from "react"; import { FileTreeItemComponent } from "./fileTreeItemComponent"; @@ -21,25 +21,77 @@ const buildCollapsibleTree = (tree: RawFileTreeNode): FileTreeNode => { } } -const transformTree = ( +const buildTreeNodeFromItem = (item: FileTreeItem): FileTreeNode => { + return { + ...item, + isCollapsed: true, + children: [], + }; +} + +const renderLoadingSkeleton = (depth: number) => { + return ( +
+
+
+ Loading... +
+ ); +} + +const updateTreeNode = ( tree: FileTreeNode, + targetPath: string, transform: (node: FileTreeNode) => FileTreeNode ): FileTreeNode => { - const newNode = transform(tree); - const newChildren = tree.children.map(child => transformTree(child, transform)); + if (tree.path === targetPath) { + return transform(tree); + } + return { - ...newNode, - children: newChildren, + ...tree, + children: tree.children.map(child => updateTreeNode(child, targetPath, transform)), + }; +} + +const findNodeByPath = (tree: FileTreeNode, targetPath: string): FileTreeNode | null => { + if (tree.path === targetPath) { + return tree; } + + for (const child of tree.children) { + const found = findNodeByPath(child, targetPath); + if (found) { + return found; + } + } + + return null; +} + +const collectLoadedPaths = (tree: RawFileTreeNode, paths: Set = new Set()): Set => { + if (tree.type === 'tree' && tree.children.length > 0) { + paths.add(tree.path); + } + + for (const child of tree.children) { + collectLoadedPaths(child, paths); + } + + return paths; } interface PureFileTreePanelProps { tree: RawFileTreeNode; path: string; + onLoadChildren: (path: string) => Promise; } -export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) => { +export const PureFileTreePanel = ({ tree: _tree, path, onLoadChildren }: PureFileTreePanelProps) => { const [tree, setTree] = useState(buildCollapsibleTree(_tree)); + const [loadedPaths, setLoadedPaths] = useState>(() => collectLoadedPaths(_tree)); + const [loadingPaths, setLoadingPaths] = useState>(() => new Set()); + const treeRef = useRef(tree); const scrollAreaRef = useRef(null); const { repoName, revisionName } = useBrowseParams(); const domain = useDomain(); @@ -48,29 +100,71 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) // In that case, we need to rebuild the collapsible tree. useEffect(() => { setTree(buildCollapsibleTree(_tree)); + setLoadedPaths(collectLoadedPaths(_tree)); + setLoadingPaths(new Set()); }, [_tree]); + useEffect(() => { + treeRef.current = tree; + }, [tree]); + const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { - setTree(currentTree => transformTree(currentTree, (currentNode) => { - if (currentNode.path === path) { - currentNode.isCollapsed = isCollapsed; - } - return currentNode; - })); + setTree(currentTree => updateTreeNode(currentTree, path, (currentNode) => ({ + ...currentNode, + isCollapsed, + }))); }, []); + // Loads the children of a given path, if they haven't been loaded yet. + const handleExpand = useCallback(async (targetPath: string) => { + if (loadedPaths.has(targetPath) || loadingPaths.has(targetPath)) { + return; + } + + const currentNode = findNodeByPath(treeRef.current, targetPath); + if (!currentNode || currentNode.type !== 'tree') { + return; + } + + setLoadingPaths(current => { + const next = new Set(current); + next.add(targetPath); + return next; + }); + + try { + const children = await onLoadChildren(targetPath); + const childNodes = children.map(buildTreeNodeFromItem); + setTree(currentTree => updateTreeNode(currentTree, targetPath, (node) => ({ + ...node, + children: childNodes, + }))); + setLoadedPaths(current => { + const next = new Set(current); + next.add(targetPath); + return next; + }); + } catch (error) { + console.error('Failed to load folder contents.', { error, targetPath }); + } finally { + setLoadingPaths(current => { + const next = new Set(current); + next.delete(targetPath); + return next; + }); + } + }, [loadedPaths, loadingPaths, onLoadChildren]); + // When the path changes, expand all the folders up to the path useEffect(() => { - const pathParts = path.split('/'); + const pathParts = path.split('/').filter(Boolean); let currentPath = ''; for (let i = 0; i < pathParts.length; i++) { - currentPath += pathParts[i]; + currentPath = currentPath.length === 0 ? pathParts[i] : `${currentPath}/${pathParts[i]}`; setIsCollapsed(currentPath, false); - if (i < pathParts.length - 1) { - currentPath += '/'; - } + void handleExpand(currentPath); } - }, [path, setIsCollapsed]); + }, [path, setIsCollapsed, handleExpand]); const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => { return ( @@ -97,6 +191,9 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) onClick={(e) => { const isMetaOrCtrlKey = e.metaKey || e.ctrlKey; if (node.type === 'tree' && !isMetaOrCtrlKey) { + if (node.isCollapsed) { + handleExpand(node.path); + } setIsCollapsed(node.path, !node.isCollapsed); } }} @@ -111,12 +208,13 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) parentRef={scrollAreaRef} /> {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} + {node.type === 'tree' && !node.isCollapsed && loadingPaths.has(node.path) && renderLoadingSkeleton(depth)} ); })} ); - }, [domain, path, repoName, revisionName, setIsCollapsed]); + }, [domain, handleExpand, loadingPaths, path, repoName, revisionName, setIsCollapsed]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); diff --git a/packages/web/src/features/fileTree/types.ts b/packages/web/src/features/fileTree/types.ts index 24dc69112..810463d08 100644 --- a/packages/web/src/features/fileTree/types.ts +++ b/packages/web/src/features/fileTree/types.ts @@ -13,6 +13,13 @@ export const getFilesRequestSchema = z.object({ }); export type GetFilesRequest = z.infer; +export const getFolderContentsRequestSchema = z.object({ + repoName: z.string(), + revisionName: z.string(), + path: z.string(), +}); +export type GetFolderContentsRequest = z.infer; + export const fileTreeItemSchema = z.object({ type: z.string(), path: z.string(), @@ -43,3 +50,6 @@ export type GetTreeResponse = z.infer; export const getFilesResponseSchema = z.array(fileTreeItemSchema); export type GetFilesResponse = z.infer; +export const getFolderContentsResponseSchema = z.array(fileTreeItemSchema); +export type GetFolderContentsResponse = z.infer; + diff --git a/packages/web/src/features/fileTree/utils.test.ts b/packages/web/src/features/fileTree/utils.test.ts new file mode 100644 index 000000000..9d87e1ac2 --- /dev/null +++ b/packages/web/src/features/fileTree/utils.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'vitest'; +import { buildFileTree, getPathspecs, isPathValid, normalizePath } from './utils'; + +test('normalizePath adds a trailing slash and strips leading slashes', () => { + expect(normalizePath('/a/b')).toBe('a/b/'); +}); + +test('normalizePath keeps an existing trailing slash', () => { + expect(normalizePath('a/b/')).toBe('a/b/'); +}); + +test('normalizePath returns empty string for root', () => { + expect(normalizePath('/')).toBe(''); +}); + +test('isPathValid rejects traversal and null bytes', () => { + expect(isPathValid('a/../b')).toBe(false); + expect(isPathValid('a/\0b')).toBe(false); +}); + +test('isPathValid allows normal paths', () => { + expect(isPathValid('a/b')).toBe(true); +}); + +test('getPathspecs returns path prefixes with trailing slashes', () => { + expect(getPathspecs('a/b/c')).toEqual(['a/', 'a/b/', 'a/b/c/']); +}); + +test('getPathspecs normalizes leading/trailing slashes', () => { + expect(getPathspecs('/a/b/')).toEqual(['a/', 'a/b/']); +}); + +test('buildFileTree handles a empty flat list', () => { + const flatList: { type: string, path: string }[] = []; + const tree = buildFileTree(flatList); + expect(tree).toMatchObject({ + name: 'root', + type: 'tree', + path: '', + }); +}); + +test('buildFileTree builds a sorted tree from a flat list', () => { + const flatList: { type: string, path: string }[] = [ + { type: 'blob', path: 'a' }, + { type: 'tree', path: 'b' }, + { type: 'tree', path: 'b/c' }, + { type: 'tree', path: 'd' }, + { type: 'blob', path: 'd/e' } + ]; + + const tree = buildFileTree(flatList); + + expect(tree).toMatchObject({ + name: 'root', + type: 'tree', + path: '', + children: [ + { + name: 'b', + type: 'tree', + path: 'b', + children: [ + { + name: 'c', + type: 'tree', + path: 'b/c', + children: [], + }, + ], + }, + { + name: 'd', + type: 'tree', + path: 'd', + children: [ + { + name: 'e', + type: 'blob', + path: 'd/e', + children: [], + }, + ], + }, + { + name: 'a', + type: 'blob', + path: 'a', + children: [], + } + ], + }); +}); + diff --git a/packages/web/src/features/fileTree/utils.ts b/packages/web/src/features/fileTree/utils.ts new file mode 100644 index 000000000..04039c7d1 --- /dev/null +++ b/packages/web/src/features/fileTree/utils.ts @@ -0,0 +1,100 @@ +import { FileTreeNode } from "./types"; + +export const normalizePath = (path: string): string => { + // Normalize the path by... + let normalizedPath = path; + + // ... adding a trailing slash if it doesn't have one. + // This is important since ls-tree won't return the contents + // of a directory if it doesn't have a trailing slash. + if (!normalizedPath.endsWith('/')) { + normalizedPath = `${normalizedPath}/`; + } + + // ... removing any leading slashes. This is needed since + // the path is relative to the repository's root, so we + // need a relative path. + if (normalizedPath.startsWith('/')) { + normalizedPath = normalizedPath.slice(1); + } + + return normalizedPath; +} + +// @note: we don't allow directory traversal +// or null bytes in the path. +export const isPathValid = (path: string) => { + return !path.includes('..') && !path.includes('\0'); +} + +export const getPathspecs = (path: string): string[] => { + const normalizedPath = normalizePath(path); + if (normalizedPath.length === 0) { + return []; + } + + const parts = normalizedPath.split('/').filter((part: string) => part.length > 0); + const pathspecs: string[] = []; + + for (let i = 0; i < parts.length; i++) { + const prefix = parts.slice(0, i + 1).join('/'); + pathspecs.push(`${prefix}/`); + } + + return pathspecs; +} + + +export const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { + const root: FileTreeNode = { + name: 'root', + path: '', + type: 'tree', + children: [], + }; + + for (const item of flatList) { + const parts = item.path.split('/'); + let current: FileTreeNode = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLeaf = i === parts.length - 1; + const nodeType = isLeaf ? item.type : 'tree'; + let next = current.children.find((child: FileTreeNode) => child.name === part && child.type === nodeType); + + if (!next) { + next = { + name: part, + path: item.path, + type: nodeType, + children: [], + }; + current.children.push(next); + } + current = next; + } + } + + const sortTree = (node: FileTreeNode): FileTreeNode => { + if (node.type === 'blob') { + return node; + } + + const sortedChildren = node.children + .map(sortTree) + .sort((a: FileTreeNode, b: FileTreeNode) => { + if (a.type !== b.type) { + return a.type === 'tree' ? -1 : 1; + } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + + return { + ...node, + children: sortedChildren, + }; + }; + + return sortTree(root); +} From 61a7055621fdb91d4c7e74cc78333162bd84113a Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 10:14:51 -0800 Subject: [PATCH 3/9] sort folder contents the same as we do for the tree --- packages/web/src/features/fileTree/api.ts | 4 ++++ packages/web/src/features/fileTree/utils.ts | 16 +++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/web/src/features/fileTree/api.ts b/packages/web/src/features/fileTree/api.ts index a01411e62..32cb3d1c0 100644 --- a/packages/web/src/features/fileTree/api.ts +++ b/packages/web/src/features/fileTree/api.ts @@ -10,6 +10,7 @@ import path from 'path'; import { simpleGit } from 'simple-git'; import { FileTreeItem } from './types'; import { buildFileTree, getPathspecs, isPathValid, normalizePath } from './utils'; +import { compareFileTreeItems } from './utils'; const logger = createLogger('file-tree'); @@ -136,6 +137,9 @@ export const getFolderContents = async (params: { repoName: string, revisionName } }); + // Sort the contents in place, first by type (trees before blobs), then by name. + contents.sort(compareFileTreeItems); + return contents; })); diff --git a/packages/web/src/features/fileTree/utils.ts b/packages/web/src/features/fileTree/utils.ts index 04039c7d1..7486aa752 100644 --- a/packages/web/src/features/fileTree/utils.ts +++ b/packages/web/src/features/fileTree/utils.ts @@ -1,4 +1,4 @@ -import { FileTreeNode } from "./types"; +import { FileTreeItem, FileTreeNode } from "./types"; export const normalizePath = (path: string): string => { // Normalize the path by... @@ -83,12 +83,7 @@ export const buildFileTree = (flatList: { type: string, path: string }[]): FileT const sortedChildren = node.children .map(sortTree) - .sort((a: FileTreeNode, b: FileTreeNode) => { - if (a.type !== b.type) { - return a.type === 'tree' ? -1 : 1; - } - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); - }); + .sort(compareFileTreeItems); return { ...node, @@ -98,3 +93,10 @@ export const buildFileTree = (flatList: { type: string, path: string }[]): FileT return sortTree(root); } + +export const compareFileTreeItems = (a: FileTreeItem, b: FileTreeItem): number => { + if (a.type !== b.type) { + return a.type === 'tree' ? -1 : 1; + } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} From aa61bd325d7fb7108e6e654c283654d7cf0c2aa5 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 13:48:54 -0800 Subject: [PATCH 4/9] wip: switch to requesting a sub-tree depending on the opened paths --- packages/web/src/features/fileTree/api.ts | 12 +- .../fileTree/components/fileTreePanel.tsx | 114 +++++++++--- .../fileTree/components/pureFileTreePanel.tsx | 164 ++---------------- packages/web/src/features/fileTree/types.ts | 2 +- .../web/src/features/fileTree/utils.test.ts | 10 +- packages/web/src/features/fileTree/utils.ts | 18 -- 6 files changed, 107 insertions(+), 213 deletions(-) diff --git a/packages/web/src/features/fileTree/api.ts b/packages/web/src/features/fileTree/api.ts index 32cb3d1c0..28db99156 100644 --- a/packages/web/src/features/fileTree/api.ts +++ b/packages/web/src/features/fileTree/api.ts @@ -9,7 +9,7 @@ import { createLogger } from '@sourcebot/shared'; import path from 'path'; import { simpleGit } from 'simple-git'; import { FileTreeItem } from './types'; -import { buildFileTree, getPathspecs, isPathValid, normalizePath } from './utils'; +import { buildFileTree, isPathValid, normalizePath } from './utils'; import { compareFileTreeItems } from './utils'; const logger = createLogger('file-tree'); @@ -18,9 +18,9 @@ const logger = createLogger('file-tree'); * Returns the tree of files (blobs) and directories (trees) for a given repository, * at a given revision. */ -export const getTree = async (params: { repoName: string, revisionName: string, path: string }) => sew(() => +export const getTree = async (params: { repoName: string, revisionName: string, paths: string[] }) => sew(() => withOptionalAuthV2(async ({ org, prisma }) => { - const { repoName, revisionName, path } = params; + const { repoName, revisionName, paths } = params; const repo = await prisma.repo.findFirst({ where: { name: repoName, @@ -35,11 +35,11 @@ export const getTree = async (params: { repoName: string, revisionName: string, const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); - if (!isPathValid(path)) { + if (!paths.every(path => isPathValid(path))) { return notFound(); } - const pathSpecs = getPathspecs(path); + const normalizedPaths = paths.map(path => normalizePath(path)); let result: string = ''; try { @@ -55,7 +55,7 @@ export const getTree = async (params: { repoName: string, revisionName: string, '-t', '--', '.', - ...pathSpecs, + ...normalizedPaths, ]; result = await git.raw(command); diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index 24a6aa9fd..5db2fe423 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -2,14 +2,14 @@ import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; -import { getFolderContents, getTree } from "@/app/api/(client)/client"; +import { getTree } from "@/app/api/(client)/client"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { Button } from "@/components/ui/button"; 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 { useCallback, useEffect, useRef, useState } from "react"; @@ -41,29 +41,78 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { const { repoName, revisionName, path } = useBrowseParams(); const [tree, setTree] = useState(null); + const [openPaths, setOpenPaths] = useState>(new Set()); const fileTreePanelRef = useRef(null); - const loadFolderContents = useCallback(async (folderPath: string) => { - return unwrapServiceError( - getFolderContents({ - repoName, - revisionName: revisionName ?? 'HEAD', - path: folderPath - }) - ); - }, [repoName, revisionName]); const { data, isError } = useQuery({ - queryKey: ['tree', repoName, revisionName, path], - queryFn: () => unwrapServiceError( - getTree({ - repoName, - revisionName: revisionName ?? 'HEAD', - path - }) - ), + queryKey: ['tree', repoName, revisionName, ...Array.from(openPaths)], + 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(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]); + + // @debug: format the tree for console output. + // useEffect(() => { + // if (!tree) { + // return; + // } + // console.debug(__debugFormatTreeForConsole(tree)); + // }, [tree]); + useHotkeys("mod+b", () => { if (isFileTreePanelCollapsed) { fileTreePanelRef.current?.expand(); @@ -76,13 +125,6 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { description: "Toggle file tree panel", }); - useEffect(() => { - if (!data) { - return; - } - setTree(data.tree); - }, [data]); - return ( <> { ) : ( )}
@@ -343,4 +386,19 @@ const FileTreePanelSkeleton = () => {
) -} \ No newline at end of file +} + +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"); +}; diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index 8e848c794..3ec947621 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -1,34 +1,13 @@ 'use client'; -import { FileTreeItem, FileTreeNode as RawFileTreeNode } from "../types"; +import { FileTreeNode } from "../types"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import React, { useCallback, useMemo, useState, useEffect, useRef } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { FileTreeItemComponent } from "./fileTreeItemComponent"; import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useDomain } from "@/hooks/useDomain"; -export type FileTreeNode = Omit & { - isCollapsed: boolean; - children: FileTreeNode[]; -} - -const buildCollapsibleTree = (tree: RawFileTreeNode): FileTreeNode => { - return { - ...tree, - isCollapsed: true, - children: tree.children.map(buildCollapsibleTree), - } -} - -const buildTreeNodeFromItem = (item: FileTreeItem): FileTreeNode => { - return { - ...item, - isCollapsed: true, - children: [], - }; -} - const renderLoadingSkeleton = (depth: number) => { return (
@@ -39,133 +18,18 @@ const renderLoadingSkeleton = (depth: number) => { ); } -const updateTreeNode = ( - tree: FileTreeNode, - targetPath: string, - transform: (node: FileTreeNode) => FileTreeNode -): FileTreeNode => { - if (tree.path === targetPath) { - return transform(tree); - } - - return { - ...tree, - children: tree.children.map(child => updateTreeNode(child, targetPath, transform)), - }; -} - -const findNodeByPath = (tree: FileTreeNode, targetPath: string): FileTreeNode | null => { - if (tree.path === targetPath) { - return tree; - } - - for (const child of tree.children) { - const found = findNodeByPath(child, targetPath); - if (found) { - return found; - } - } - - return null; -} - -const collectLoadedPaths = (tree: RawFileTreeNode, paths: Set = new Set()): Set => { - if (tree.type === 'tree' && tree.children.length > 0) { - paths.add(tree.path); - } - - for (const child of tree.children) { - collectLoadedPaths(child, paths); - } - - return paths; -} - interface PureFileTreePanelProps { - tree: RawFileTreeNode; + tree: FileTreeNode; + openPaths: Set; path: string; - onLoadChildren: (path: string) => Promise; + onNodeClicked: (node: FileTreeNode) => void; } -export const PureFileTreePanel = ({ tree: _tree, path, onLoadChildren }: PureFileTreePanelProps) => { - const [tree, setTree] = useState(buildCollapsibleTree(_tree)); - const [loadedPaths, setLoadedPaths] = useState>(() => collectLoadedPaths(_tree)); - const [loadingPaths, setLoadingPaths] = useState>(() => new Set()); - const treeRef = useRef(tree); +export const PureFileTreePanel = ({ tree, openPaths, path, onNodeClicked }: PureFileTreePanelProps) => { const scrollAreaRef = useRef(null); const { repoName, revisionName } = useBrowseParams(); const domain = useDomain(); - // @note: When `_tree` changes, it indicates that a new tree has been loaded. - // In that case, we need to rebuild the collapsible tree. - useEffect(() => { - setTree(buildCollapsibleTree(_tree)); - setLoadedPaths(collectLoadedPaths(_tree)); - setLoadingPaths(new Set()); - }, [_tree]); - - useEffect(() => { - treeRef.current = tree; - }, [tree]); - - const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { - setTree(currentTree => updateTreeNode(currentTree, path, (currentNode) => ({ - ...currentNode, - isCollapsed, - }))); - }, []); - - // Loads the children of a given path, if they haven't been loaded yet. - const handleExpand = useCallback(async (targetPath: string) => { - if (loadedPaths.has(targetPath) || loadingPaths.has(targetPath)) { - return; - } - - const currentNode = findNodeByPath(treeRef.current, targetPath); - if (!currentNode || currentNode.type !== 'tree') { - return; - } - - setLoadingPaths(current => { - const next = new Set(current); - next.add(targetPath); - return next; - }); - - try { - const children = await onLoadChildren(targetPath); - const childNodes = children.map(buildTreeNodeFromItem); - setTree(currentTree => updateTreeNode(currentTree, targetPath, (node) => ({ - ...node, - children: childNodes, - }))); - setLoadedPaths(current => { - const next = new Set(current); - next.add(targetPath); - return next; - }); - } catch (error) { - console.error('Failed to load folder contents.', { error, targetPath }); - } finally { - setLoadingPaths(current => { - const next = new Set(current); - next.delete(targetPath); - return next; - }); - } - }, [loadedPaths, loadingPaths, onLoadChildren]); - - // When the path changes, expand all the folders up to the path - useEffect(() => { - const pathParts = path.split('/').filter(Boolean); - let currentPath = ''; - for (let i = 0; i < pathParts.length; i++) { - currentPath = currentPath.length === 0 ? pathParts[i] : `${currentPath}/${pathParts[i]}`; - setIsCollapsed(currentPath, false); - void handleExpand(currentPath); - } - }, [path, setIsCollapsed, handleExpand]); - const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => { return ( <> @@ -184,17 +48,14 @@ export const PureFileTreePanel = ({ tree: _tree, path, onLoadChildren }: PureFil node={node} isActive={node.path === path} depth={depth} - isCollapsed={node.isCollapsed} + isCollapsed={!openPaths.has(node.path)} isCollapseChevronVisible={node.type === 'tree'} // Only collapse the tree when a regular click happens. // (i.e., not ctrl/cmd click). onClick={(e) => { const isMetaOrCtrlKey = e.metaKey || e.ctrlKey; - if (node.type === 'tree' && !isMetaOrCtrlKey) { - if (node.isCollapsed) { - handleExpand(node.path); - } - setIsCollapsed(node.path, !node.isCollapsed); + if (!isMetaOrCtrlKey) { + onNodeClicked(node); } }} // @note: onNavigate _won't_ be called when the user ctrl/cmd clicks on a tree node. @@ -207,14 +68,15 @@ export const PureFileTreePanel = ({ tree: _tree, path, onLoadChildren }: PureFil }} parentRef={scrollAreaRef} /> - {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} - {node.type === 'tree' && !node.isCollapsed && loadingPaths.has(node.path) && renderLoadingSkeleton(depth)} + {node.type === 'tree' && node.children.length > 0 && openPaths.has(node.path) && renderTree(node, depth + 1)} + {/* @note: a empty tree indicates that the contents are beaing loaded. Render a loading skeleton to indicate that. */} + {node.type === 'tree' && node.children.length === 0 && openPaths.has(node.path) && renderLoadingSkeleton(depth)} ); })} ); - }, [domain, handleExpand, loadingPaths, path, repoName, revisionName, setIsCollapsed]); + }, [domain, onNodeClicked, path, repoName, revisionName, openPaths]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); diff --git a/packages/web/src/features/fileTree/types.ts b/packages/web/src/features/fileTree/types.ts index 810463d08..29aeae121 100644 --- a/packages/web/src/features/fileTree/types.ts +++ b/packages/web/src/features/fileTree/types.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const getTreeRequestSchema = z.object({ repoName: z.string(), revisionName: z.string(), - path: z.string(), + paths: z.array(z.string()), }); export type GetTreeRequest = z.infer; diff --git a/packages/web/src/features/fileTree/utils.test.ts b/packages/web/src/features/fileTree/utils.test.ts index 9d87e1ac2..df2c10b36 100644 --- a/packages/web/src/features/fileTree/utils.test.ts +++ b/packages/web/src/features/fileTree/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { buildFileTree, getPathspecs, isPathValid, normalizePath } from './utils'; +import { buildFileTree, isPathValid, normalizePath } from './utils'; test('normalizePath adds a trailing slash and strips leading slashes', () => { expect(normalizePath('/a/b')).toBe('a/b/'); @@ -22,14 +22,6 @@ test('isPathValid allows normal paths', () => { expect(isPathValid('a/b')).toBe(true); }); -test('getPathspecs returns path prefixes with trailing slashes', () => { - expect(getPathspecs('a/b/c')).toEqual(['a/', 'a/b/', 'a/b/c/']); -}); - -test('getPathspecs normalizes leading/trailing slashes', () => { - expect(getPathspecs('/a/b/')).toEqual(['a/', 'a/b/']); -}); - test('buildFileTree handles a empty flat list', () => { const flatList: { type: string, path: string }[] = []; const tree = buildFileTree(flatList); diff --git a/packages/web/src/features/fileTree/utils.ts b/packages/web/src/features/fileTree/utils.ts index 7486aa752..55ca90aef 100644 --- a/packages/web/src/features/fileTree/utils.ts +++ b/packages/web/src/features/fileTree/utils.ts @@ -27,24 +27,6 @@ export const isPathValid = (path: string) => { return !path.includes('..') && !path.includes('\0'); } -export const getPathspecs = (path: string): string[] => { - const normalizedPath = normalizePath(path); - if (normalizedPath.length === 0) { - return []; - } - - const parts = normalizedPath.split('/').filter((part: string) => part.length > 0); - const pathspecs: string[] = []; - - for (let i = 0; i < parts.length; i++) { - const prefix = parts.slice(0, i + 1).join('/'); - pathspecs.push(`${prefix}/`); - } - - return pathspecs; -} - - export const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { name: 'root', From ed48da40e8ba45d2b2de078f05b7ac1d097020ea Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 15:15:10 -0800 Subject: [PATCH 5/9] feedback --- packages/web/src/app/api/(client)/client.ts | 10 --- .../app/api/(server)/folder_contents/route.ts | 23 ------- .../web/src/app/api/(server)/tree/route.ts | 4 +- .../fileTree/components/fileTreePanel.tsx | 68 +++++++------------ .../fileTree/components/pureFileTreePanel.tsx | 16 +++-- packages/web/src/lib/posthogEvents.ts | 4 ++ 6 files changed, 42 insertions(+), 83 deletions(-) delete mode 100644 packages/web/src/app/api/(server)/folder_contents/route.ts diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 36ed1e70c..2bf319935 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -18,8 +18,6 @@ import { import { GetFilesRequest, GetFilesResponse, - GetFolderContentsRequest, - GetFolderContentsResponse, GetTreeRequest, GetTreeResponse, } from "@/features/fileTree/types"; @@ -104,11 +102,3 @@ export const getFiles = async (body: GetFilesRequest): Promise response.json()); return result as GetFilesResponse | ServiceError; } - -export const getFolderContents = async (body: GetFolderContentsRequest): Promise => { - const result = await fetch("/api/folder_contents", { - method: "POST", - body: JSON.stringify(body), - }).then(response => response.json()); - return result as GetFolderContentsResponse | ServiceError; -} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/folder_contents/route.ts b/packages/web/src/app/api/(server)/folder_contents/route.ts deleted file mode 100644 index a1a1ef1bf..000000000 --- a/packages/web/src/app/api/(server)/folder_contents/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -'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); -} - diff --git a/packages/web/src/app/api/(server)/tree/route.ts b/packages/web/src/app/api/(server)/tree/route.ts index 4fd20cf27..efe63bffe 100644 --- a/packages/web/src/app/api/(server)/tree/route.ts +++ b/packages/web/src/app/api/(server)/tree/route.ts @@ -3,7 +3,7 @@ import { getTree } from "@/features/fileTree/api"; import { getTreeRequestSchema } from "@/features/fileTree/types"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; -import { isServiceError, measure } from "@/lib/utils"; +import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; export const POST = async (request: NextRequest) => { @@ -13,7 +13,7 @@ export const POST = async (request: NextRequest) => { return serviceErrorResponse(schemaValidationError(parsed.error)); } - const { data: response } = await measure(() => getTree(parsed.data), 'getTree'); + const response = await getTree(parsed.data); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index 5db2fe423..e96e3dbc4 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -9,6 +9,7 @@ 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 useCaptureEvent from "@/hooks/useCaptureEvent"; import { measure, unwrapServiceError } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { SearchIcon } from "lucide-react"; @@ -19,8 +20,8 @@ import { GoSidebarCollapse as ExpandIcon } from "react-icons/go"; import { ImperativePanelHandle } from "react-resizable-panels"; -import { PureFileTreePanel } from "./pureFileTreePanel"; import { FileTreeNode } from "../types"; +import { PureFileTreePanel } from "./pureFileTreePanel"; interface FileTreePanelProps { order: number; @@ -38,14 +39,13 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { updateBrowseState, } = useBrowseState(); - const { repoName, revisionName, path } = useBrowseParams(); - - const [tree, setTree] = useState(null); + const { repoName, revisionName, path, pathType } = useBrowseParams(); const [openPaths, setOpenPaths] = useState>(new Set()); + const captureEvent = useCaptureEvent(); const fileTreePanelRef = useRef(null); - const { data, isError } = useQuery({ + const { data, isError, isPending } = useQuery({ queryKey: ['tree', repoName, revisionName, ...Array.from(openPaths)], queryFn: async () => { const result = await measure(async () => unwrapServiceError( @@ -56,17 +56,19 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { }) ), 'getTree'); + captureEvent('wa_file_tree_loaded', { + durationMs: result.durationMs, + }); + return result.data; - } + }, + // The tree changes only when the query key changes (repo/revision/openPaths), + // so we can treat it as perpetually fresh and avoid background refetches. + staleTime: Infinity, + // Reuse the last tree during refetches (openPaths changes) to avoid UI flicker. + placeholderData: (previousData) => previousData, }); - 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(() => { @@ -76,7 +78,12 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { // 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); + let pathParts = path.split('/').filter(Boolean); + + // If the path is a blob, we want to open the parent directory. + if (pathType === 'blob') { + pathParts = pathParts.slice(0, -1); + } setOpenPaths(current => { const next = new Set(current); @@ -85,11 +92,11 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { } return next; }); - }, [path]); + }, [path, pathType]); // 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) => { + const onTreeNodeClicked = useCallback((node: FileTreeNode) => { if (!openPaths.has(node.path)) { setOpenPaths(current => { const next = new Set(current); @@ -105,14 +112,6 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { } }, [openPaths]); - // @debug: format the tree for console output. - // useEffect(() => { - // if (!tree) { - // return; - // } - // console.debug(__debugFormatTreeForConsole(tree)); - // }, [tree]); - useHotkeys("mod+b", () => { if (isFileTreePanelCollapsed) { fileTreePanelRef.current?.expand(); @@ -183,7 +182,7 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
- {!tree ? ( + {isPending ? ( ) : isError ? ( @@ -192,10 +191,10 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { ) : ( )} @@ -387,18 +386,3 @@ const FileTreePanelSkeleton = () => { ) } - -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"); -}; diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index 3ec947621..0f7a15c1b 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -22,10 +22,10 @@ interface PureFileTreePanelProps { tree: FileTreeNode; openPaths: Set; path: string; - onNodeClicked: (node: FileTreeNode) => void; + onTreeNodeClicked: (node: FileTreeNode) => void; } -export const PureFileTreePanel = ({ tree, openPaths, path, onNodeClicked }: PureFileTreePanelProps) => { +export const PureFileTreePanel = ({ tree, openPaths, path, onTreeNodeClicked }: PureFileTreePanelProps) => { const scrollAreaRef = useRef(null); const { repoName, revisionName } = useBrowseParams(); const domain = useDomain(); @@ -54,8 +54,8 @@ export const PureFileTreePanel = ({ tree, openPaths, path, onNodeClicked }: Pure // (i.e., not ctrl/cmd click). onClick={(e) => { const isMetaOrCtrlKey = e.metaKey || e.ctrlKey; - if (!isMetaOrCtrlKey) { - onNodeClicked(node); + if (node.type === 'tree' && !isMetaOrCtrlKey) { + onTreeNodeClicked(node); } }} // @note: onNavigate _won't_ be called when the user ctrl/cmd clicks on a tree node. @@ -69,14 +69,18 @@ export const PureFileTreePanel = ({ tree, openPaths, path, onNodeClicked }: Pure parentRef={scrollAreaRef} /> {node.type === 'tree' && node.children.length > 0 && openPaths.has(node.path) && renderTree(node, depth + 1)} - {/* @note: a empty tree indicates that the contents are beaing loaded. Render a loading skeleton to indicate that. */} + {/* + @note: a empty tree indicates that the contents are beaing loaded. Render a loading skeleton to indicate that. + This relies on the fact that you cannot have empty tress in git. + @see: https://archive.kernel.org/oldwiki/git.wiki.kernel.org/index.php/GitFaq.html#Can_I_add_empty_directories.3F + */} {node.type === 'tree' && node.children.length === 0 && openPaths.has(node.path) && renderLoadingSkeleton(depth)} ); })} ); - }, [domain, onNodeClicked, path, repoName, revisionName, openPaths]); + }, [domain, onTreeNodeClicked, path, repoName, revisionName, openPaths]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 82d21eb41..36df22a71 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -181,6 +181,10 @@ export type PosthogEventMap = { isGlobalSearchEnabled: boolean, }, ////////////////////////////////////////////////////////////////// + wa_file_tree_loaded: { + durationMs: number, + }, + ////////////////////////////////////////////////////////////////// api_code_search_request: { source: string; type: 'streamed' | 'blocking'; From 9021ba6643a34ec6f8907efe977936a948afd149 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 15:26:15 -0800 Subject: [PATCH 6/9] Fix #531 --- packages/web/src/features/fileTree/api.ts | 14 ++++---------- packages/web/src/features/fileTree/utils.test.ts | 11 +---------- packages/web/src/features/fileTree/utils.ts | 6 ------ 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/packages/web/src/features/fileTree/api.ts b/packages/web/src/features/fileTree/api.ts index 28db99156..9bf570e0f 100644 --- a/packages/web/src/features/fileTree/api.ts +++ b/packages/web/src/features/fileTree/api.ts @@ -9,14 +9,15 @@ import { createLogger } from '@sourcebot/shared'; import path from 'path'; import { simpleGit } from 'simple-git'; import { FileTreeItem } from './types'; -import { buildFileTree, isPathValid, normalizePath } from './utils'; +import { buildFileTree, normalizePath } from './utils'; import { compareFileTreeItems } from './utils'; const logger = createLogger('file-tree'); /** - * Returns the tree of files (blobs) and directories (trees) for a given repository, - * at a given revision. + * Returns a file tree spanning the union of all provided paths for the given + * repo/revision, including intermediate directories needed to connect them + * into a single tree. */ export const getTree = async (params: { repoName: string, revisionName: string, paths: string[] }) => sew(() => withOptionalAuthV2(async ({ org, prisma }) => { @@ -35,10 +36,6 @@ export const getTree = async (params: { repoName: string, revisionName: string, const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); - if (!paths.every(path => isPathValid(path))) { - return notFound(); - } - const normalizedPaths = paths.map(path => normalizePath(path)); let result: string = ''; @@ -103,9 +100,6 @@ export const getFolderContents = async (params: { repoName: string, revisionName const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); - if (!isPathValid(path)) { - return notFound(); - } const normalizedPath = normalizePath(path); let result: string; diff --git a/packages/web/src/features/fileTree/utils.test.ts b/packages/web/src/features/fileTree/utils.test.ts index df2c10b36..1f60ea9a9 100644 --- a/packages/web/src/features/fileTree/utils.test.ts +++ b/packages/web/src/features/fileTree/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { buildFileTree, isPathValid, normalizePath } from './utils'; +import { buildFileTree, normalizePath } from './utils'; test('normalizePath adds a trailing slash and strips leading slashes', () => { expect(normalizePath('/a/b')).toBe('a/b/'); @@ -13,15 +13,6 @@ test('normalizePath returns empty string for root', () => { expect(normalizePath('/')).toBe(''); }); -test('isPathValid rejects traversal and null bytes', () => { - expect(isPathValid('a/../b')).toBe(false); - expect(isPathValid('a/\0b')).toBe(false); -}); - -test('isPathValid allows normal paths', () => { - expect(isPathValid('a/b')).toBe(true); -}); - test('buildFileTree handles a empty flat list', () => { const flatList: { type: string, path: string }[] = []; const tree = buildFileTree(flatList); diff --git a/packages/web/src/features/fileTree/utils.ts b/packages/web/src/features/fileTree/utils.ts index 55ca90aef..cee3f4701 100644 --- a/packages/web/src/features/fileTree/utils.ts +++ b/packages/web/src/features/fileTree/utils.ts @@ -21,12 +21,6 @@ export const normalizePath = (path: string): string => { return normalizedPath; } -// @note: we don't allow directory traversal -// or null bytes in the path. -export const isPathValid = (path: string) => { - return !path.includes('..') && !path.includes('\0'); -} - export const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { name: 'root', From 3d6556dc21edba5fca3041d4053e3f6ddf9782be Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 15:31:59 -0800 Subject: [PATCH 7/9] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bea08bd56..74422219b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [EE] Add Ask chat usage metrics to analytics dashboard [#736](https://github.com/sourcebot-dev/sourcebot/pull/736) +### Changed +- Improved initial file tree load times, especially for larger repositories. [#739](https://github.com/sourcebot-dev/sourcebot/pull/739) + +### Fixed +- Fixed "error loading tree preview" error when viewing a directory with `...`. [#531](https://github.com/sourcebot-dev/sourcebot/issues/531) + ## [4.10.9] - 2026-01-14 ### Changed From 518e8b5f2067a6ad13937e79a32649f8c2635597 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 15:38:16 -0800 Subject: [PATCH 8/9] improved fix --- packages/web/src/features/fileTree/api.ts | 9 ++++++++- .../web/src/features/fileTree/utils.test.ts | 19 ++++++++++++++++++- packages/web/src/features/fileTree/utils.ts | 11 +++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/web/src/features/fileTree/api.ts b/packages/web/src/features/fileTree/api.ts index 9bf570e0f..0e9ea5111 100644 --- a/packages/web/src/features/fileTree/api.ts +++ b/packages/web/src/features/fileTree/api.ts @@ -9,7 +9,7 @@ import { createLogger } from '@sourcebot/shared'; import path from 'path'; import { simpleGit } from 'simple-git'; import { FileTreeItem } from './types'; -import { buildFileTree, normalizePath } from './utils'; +import { buildFileTree, isPathValid, normalizePath } from './utils'; import { compareFileTreeItems } from './utils'; const logger = createLogger('file-tree'); @@ -36,6 +36,10 @@ export const getTree = async (params: { repoName: string, revisionName: string, const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); + if (!paths.every(path => isPathValid(path))) { + return notFound(); + } + const normalizedPaths = paths.map(path => normalizePath(path)); let result: string = ''; @@ -100,6 +104,9 @@ export const getFolderContents = async (params: { repoName: string, revisionName const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); + if (!isPathValid(path)) { + return notFound(); + } const normalizedPath = normalizePath(path); let result: string; diff --git a/packages/web/src/features/fileTree/utils.test.ts b/packages/web/src/features/fileTree/utils.test.ts index 1f60ea9a9..4785689c8 100644 --- a/packages/web/src/features/fileTree/utils.test.ts +++ b/packages/web/src/features/fileTree/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { buildFileTree, normalizePath } from './utils'; +import { buildFileTree, isPathValid, normalizePath } from './utils'; test('normalizePath adds a trailing slash and strips leading slashes', () => { expect(normalizePath('/a/b')).toBe('a/b/'); @@ -13,6 +13,23 @@ test('normalizePath returns empty string for root', () => { expect(normalizePath('/')).toBe(''); }); +test('isPathValid rejects traversal and null bytes', () => { + expect(isPathValid('a/../b')).toBe(false); + expect(isPathValid('a/\0b')).toBe(false); +}); + +test('isPathValid allows normal paths', () => { + expect(isPathValid('a/b')).toBe(true); +}); + +test('isPathValid allows paths with dots', () => { + expect(isPathValid('a/b/c.txt')).toBe(true); + expect(isPathValid('a/b/c...')).toBe(true); + expect(isPathValid('a/b/c.../d')).toBe(true); + expect(isPathValid('a/b/..c')).toBe(true); + expect(isPathValid('a/b/[..path]')).toBe(true); +}); + test('buildFileTree handles a empty flat list', () => { const flatList: { type: string, path: string }[] = []; const tree = buildFileTree(flatList); diff --git a/packages/web/src/features/fileTree/utils.ts b/packages/web/src/features/fileTree/utils.ts index cee3f4701..b654a6a29 100644 --- a/packages/web/src/features/fileTree/utils.ts +++ b/packages/web/src/features/fileTree/utils.ts @@ -21,6 +21,17 @@ export const normalizePath = (path: string): string => { return normalizedPath; } +// @note: we don't allow directory traversal +// or null bytes in the path. +export const isPathValid = (path: string) => { + const pathSegments = path.split('/'); + if (pathSegments.some(segment => segment === '..') || path.includes('\0')) { + return false; + } + + return true; +} + export const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { name: 'root', From 279e04435e9dc4478468823032f2370059d29785 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 15 Jan 2026 15:45:08 -0800 Subject: [PATCH 9/9] fixed changelog --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74422219b..cc31dbbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved initial file tree load times, especially for larger repositories. [#739](https://github.com/sourcebot-dev/sourcebot/pull/739) -### Fixed -- Fixed "error loading tree preview" error when viewing a directory with `...`. [#531](https://github.com/sourcebot-dev/sourcebot/issues/531) - ## [4.10.9] - 2026-01-14 ### Changed