Skip to content

Commit 81a061a

Browse files
fix: Improve initial file tree load performance (#739)
1 parent dacbe5d commit 81a061a

File tree

9 files changed

+333
-164
lines changed

9 files changed

+333
-164
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- [EE] Add Ask chat usage metrics to analytics dashboard [#736](https://github.com/sourcebot-dev/sourcebot/pull/736)
1212

13+
### Changed
14+
- Improved initial file tree load times, especially for larger repositories. [#739](https://github.com/sourcebot-dev/sourcebot/pull/739)
15+
1316
## [4.10.9] - 2026-01-14
1417

1518
### Changed

packages/web/src/app/api/(client)/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,4 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
101101
body: JSON.stringify(body),
102102
}).then(response => response.json());
103103
return result as GetFilesResponse | ServiceError;
104-
}
104+
}

packages/web/src/features/fileTree/api.ts

Lines changed: 30 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ import { Repo } from '@sourcebot/db';
88
import { createLogger } from '@sourcebot/shared';
99
import path from 'path';
1010
import { simpleGit } from 'simple-git';
11-
import { FileTreeItem, FileTreeNode } from './types';
11+
import { FileTreeItem } from './types';
12+
import { buildFileTree, isPathValid, normalizePath } from './utils';
13+
import { compareFileTreeItems } from './utils';
1214

1315
const logger = createLogger('file-tree');
1416

1517
/**
16-
* Returns the tree of files (blobs) and directories (trees) for a given repository,
17-
* at a given revision.
18+
* Returns a file tree spanning the union of all provided paths for the given
19+
* repo/revision, including intermediate directories needed to connect them
20+
* into a single tree.
1821
*/
19-
export const getTree = async (params: { repoName: string, revisionName: string }) => sew(() =>
22+
export const getTree = async (params: { repoName: string, revisionName: string, paths: string[] }) => sew(() =>
2023
withOptionalAuthV2(async ({ org, prisma }) => {
21-
const { repoName, revisionName } = params;
24+
const { repoName, revisionName, paths } = params;
2225
const repo = await prisma.repo.findFirst({
2326
where: {
2427
name: repoName,
@@ -33,21 +36,30 @@ export const getTree = async (params: { repoName: string, revisionName: string }
3336
const { path: repoPath } = getRepoPath(repo);
3437

3538
const git = simpleGit().cwd(repoPath);
39+
if (!paths.every(path => isPathValid(path))) {
40+
return notFound();
41+
}
3642

37-
let result: string;
43+
const normalizedPaths = paths.map(path => normalizePath(path));
44+
45+
let result: string = '';
3846
try {
39-
result = await git.raw([
47+
48+
const command = [
4049
// Disable quoting of non-ASCII characters in paths
4150
'-c', 'core.quotePath=false',
4251
'ls-tree',
4352
revisionName,
44-
// recursive
45-
'-r',
46-
// include trees when recursing
47-
'-t',
4853
// format as output as {type},{path}
4954
'--format=%(objecttype),%(path)',
50-
]);
55+
// include tree nodes
56+
'-t',
57+
'--',
58+
'.',
59+
...normalizedPaths,
60+
];
61+
62+
result = await git.raw(command);
5163
} catch (error) {
5264
logger.error('git ls-tree failed.', { error });
5365
return unexpectedError('git ls-tree command failed.');
@@ -90,31 +102,12 @@ export const getFolderContents = async (params: { repoName: string, revisionName
90102
}
91103

92104
const { path: repoPath } = getRepoPath(repo);
105+
const git = simpleGit().cwd(repoPath);
93106

94-
// @note: we don't allow directory traversal
95-
// or null bytes in the path.
96-
if (path.includes('..') || path.includes('\0')) {
107+
if (!isPathValid(path)) {
97108
return notFound();
98109
}
99-
100-
// Normalize the path by...
101-
let normalizedPath = path;
102-
103-
// ... adding a trailing slash if it doesn't have one.
104-
// This is important since ls-tree won't return the contents
105-
// of a directory if it doesn't have a trailing slash.
106-
if (!normalizedPath.endsWith('/')) {
107-
normalizedPath = `${normalizedPath}/`;
108-
}
109-
110-
// ... removing any leading slashes. This is needed since
111-
// the path is relative to the repository's root, so we
112-
// need a relative path.
113-
if (normalizedPath.startsWith('/')) {
114-
normalizedPath = normalizedPath.slice(1);
115-
}
116-
117-
const git = simpleGit().cwd(repoPath);
110+
const normalizedPath = normalizePath(path);
118111

119112
let result: string;
120113
try {
@@ -145,6 +138,9 @@ export const getFolderContents = async (params: { repoName: string, revisionName
145138
}
146139
});
147140

141+
// Sort the contents in place, first by type (trees before blobs), then by name.
142+
contents.sort(compareFileTreeItems);
143+
148144
return contents;
149145
}));
150146

@@ -199,60 +195,6 @@ export const getFiles = async (params: { repoName: string, revisionName: string
199195

200196
}));
201197

202-
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
203-
const root: FileTreeNode = {
204-
name: 'root',
205-
path: '',
206-
type: 'tree',
207-
children: [],
208-
};
209-
210-
for (const item of flatList) {
211-
const parts = item.path.split('/');
212-
let current: FileTreeNode = root;
213-
214-
for (let i = 0; i < parts.length; i++) {
215-
const part = parts[i];
216-
const isLeaf = i === parts.length - 1;
217-
const nodeType = isLeaf ? item.type : 'tree';
218-
let next = current.children.find((child: FileTreeNode) => child.name === part && child.type === nodeType);
219-
220-
if (!next) {
221-
next = {
222-
name: part,
223-
path: item.path,
224-
type: nodeType,
225-
children: [],
226-
};
227-
current.children.push(next);
228-
}
229-
current = next;
230-
}
231-
}
232-
233-
const sortTree = (node: FileTreeNode): FileTreeNode => {
234-
if (node.type === 'blob') {
235-
return node;
236-
}
237-
238-
const sortedChildren = node.children
239-
.map(sortTree)
240-
.sort((a: FileTreeNode, b: FileTreeNode) => {
241-
if (a.type !== b.type) {
242-
return a.type === 'tree' ? -1 : 1;
243-
}
244-
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
245-
});
246-
247-
return {
248-
...node,
249-
children: sortedChildren,
250-
};
251-
};
252-
253-
return sortTree(root);
254-
}
255-
256198
// @todo: this is duplicated from the `getRepoPath` function in the
257199
// backend's `utils.ts` file. Eventually we should move this to a shared
258200
// package.

packages/web/src/features/fileTree/components/fileTreePanel.tsx

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@ import { ResizablePanel } from "@/components/ui/resizable";
99
import { Separator } from "@/components/ui/separator";
1010
import { Skeleton } from "@/components/ui/skeleton";
1111
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
12-
import { unwrapServiceError } from "@/lib/utils";
12+
import useCaptureEvent from "@/hooks/useCaptureEvent";
13+
import { measure, unwrapServiceError } from "@/lib/utils";
1314
import { useQuery } from "@tanstack/react-query";
1415
import { SearchIcon } from "lucide-react";
15-
import { useRef } from "react";
16+
import { useCallback, useEffect, useRef, useState } from "react";
1617
import { useHotkeys } from "react-hotkeys-hook";
1718
import {
1819
GoSidebarExpand as CollapseIcon,
1920
GoSidebarCollapse as ExpandIcon
2021
} from "react-icons/go";
2122
import { ImperativePanelHandle } from "react-resizable-panels";
23+
import { FileTreeNode } from "../types";
2224
import { PureFileTreePanel } from "./pureFileTreePanel";
2325

24-
2526
interface FileTreePanelProps {
2627
order: number;
2728
}
@@ -30,7 +31,6 @@ const FILE_TREE_PANEL_DEFAULT_SIZE = 20;
3031
const FILE_TREE_PANEL_MIN_SIZE = 10;
3132
const FILE_TREE_PANEL_MAX_SIZE = 30;
3233

33-
3434
export const FileTreePanel = ({ order }: FileTreePanelProps) => {
3535
const {
3636
state: {
@@ -39,19 +39,79 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
3939
updateBrowseState,
4040
} = useBrowseState();
4141

42-
const { repoName, revisionName, path } = useBrowseParams();
42+
const { repoName, revisionName, path, pathType } = useBrowseParams();
43+
const [openPaths, setOpenPaths] = useState<Set<string>>(new Set());
44+
const captureEvent = useCaptureEvent();
4345

4446
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
45-
const { data, isPending, isError } = useQuery({
46-
queryKey: ['tree', repoName, revisionName],
47-
queryFn: () => unwrapServiceError(
48-
getTree({
49-
repoName,
50-
revisionName: revisionName ?? 'HEAD',
51-
})
52-
),
47+
48+
const { data, isError, isPending } = useQuery({
49+
queryKey: ['tree', repoName, revisionName, ...Array.from(openPaths)],
50+
queryFn: async () => {
51+
const result = await measure(async () => unwrapServiceError(
52+
getTree({
53+
repoName,
54+
revisionName: revisionName ?? 'HEAD',
55+
paths: Array.from(openPaths),
56+
})
57+
), 'getTree');
58+
59+
captureEvent('wa_file_tree_loaded', {
60+
durationMs: result.durationMs,
61+
});
62+
63+
return result.data;
64+
},
65+
// The tree changes only when the query key changes (repo/revision/openPaths),
66+
// so we can treat it as perpetually fresh and avoid background refetches.
67+
staleTime: Infinity,
68+
// Reuse the last tree during refetches (openPaths changes) to avoid UI flicker.
69+
placeholderData: (previousData) => previousData,
5370
});
54-
71+
72+
// Whenever the repo name or revision name changes, we will need to
73+
// reset the open paths since they no longer reference the same repository/revision.
74+
useEffect(() => {
75+
setOpenPaths(new Set());
76+
}, [repoName, revisionName]);
77+
78+
// When the path changes (e.g., the user clicks a reference in the explore panel),
79+
// we want this to be open and visible in the file tree.
80+
useEffect(() => {
81+
let pathParts = path.split('/').filter(Boolean);
82+
83+
// If the path is a blob, we want to open the parent directory.
84+
if (pathType === 'blob') {
85+
pathParts = pathParts.slice(0, -1);
86+
}
87+
88+
setOpenPaths(current => {
89+
const next = new Set<string>(current);
90+
for (let i = 0; i < pathParts.length; i++) {
91+
next.add(pathParts.slice(0, i + 1).join('/'));
92+
}
93+
return next;
94+
});
95+
}, [path, pathType]);
96+
97+
// When the user clicks a file tree node, we will want to either
98+
// add or remove it from the open paths depending on if it's already open or not.
99+
const onTreeNodeClicked = useCallback((node: FileTreeNode) => {
100+
if (!openPaths.has(node.path)) {
101+
setOpenPaths(current => {
102+
const next = new Set(current);
103+
next.add(node.path);
104+
return next;
105+
})
106+
} else {
107+
setOpenPaths(current => {
108+
const next = new Set(current);
109+
next.delete(node.path);
110+
return next;
111+
})
112+
}
113+
}, [openPaths]);
114+
55115
useHotkeys("mod+b", () => {
56116
if (isFileTreePanelCollapsed) {
57117
fileTreePanelRef.current?.expand();
@@ -132,7 +192,9 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
132192
) : (
133193
<PureFileTreePanel
134194
tree={data.tree}
195+
openPaths={openPaths}
135196
path={path}
197+
onTreeNodeClicked={onTreeNodeClicked}
136198
/>
137199
)}
138200
</div>
@@ -323,4 +385,4 @@ const FileTreePanelSkeleton = () => {
323385
</div>
324386
</div>
325387
)
326-
}
388+
}

0 commit comments

Comments
 (0)