Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ 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)

## [4.10.9] - 2026-01-14

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/api/(client)/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,4 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
body: JSON.stringify(body),
}).then(response => response.json());
return result as GetFilesResponse | ServiceError;
}
}
118 changes: 30 additions & 88 deletions packages/web/src/features/fileTree/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ 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, isPathValid, 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 }) => sew(() =>
export const getTree = async (params: { repoName: string, revisionName: string, paths: string[] }) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
const { repoName, revisionName } = params;
const { repoName, revisionName, paths } = params;
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
Expand All @@ -33,21 +36,30 @@ 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();
}

let result: string;
const normalizedPaths = paths.map(path => normalizePath(path));

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,
// recursive
'-r',
// include trees when recursing
'-t',
// format as output as {type},{path}
'--format=%(objecttype),%(path)',
]);
// include tree nodes
'-t',
'--',
'.',
...normalizedPaths,
];

result = await git.raw(command);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
Expand Down Expand Up @@ -90,31 +102,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 {
Expand Down Expand Up @@ -145,6 +138,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;
}));

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

}));

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.
Expand Down
92 changes: 77 additions & 15 deletions packages/web/src/features/fileTree/components/fileTreePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,20 @@ 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 useCaptureEvent from "@/hooks/useCaptureEvent";
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 { FileTreeNode } from "../types";
import { PureFileTreePanel } from "./pureFileTreePanel";


interface FileTreePanelProps {
order: number;
}
Expand All @@ -30,7 +31,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: {
Expand All @@ -39,19 +39,79 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
updateBrowseState,
} = useBrowseState();

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

const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
const { data, isPending, isError } = useQuery({
queryKey: ['tree', repoName, revisionName],
queryFn: () => unwrapServiceError(
getTree({
repoName,
revisionName: revisionName ?? 'HEAD',
})
),

const { data, isError, isPending } = useQuery({
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');

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,
});


// 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(() => {
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<string>(current);
for (let i = 0; i < pathParts.length; i++) {
next.add(pathParts.slice(0, i + 1).join('/'));
}
return next;
});
}, [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 onTreeNodeClicked = 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]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale closure prevents folder toggle on rapid clicks

Low Severity

The onTreeNodeClicked callback checks openPaths.has(node.path) using the closure value of openPaths, but the state updates inside use functional updates with current. When a user clicks the same folder rapidly to toggle it, both clicks may see the stale openPaths from the closure and take the same branch (both try to add or both try to delete), preventing the expected toggle behavior. The conditional check needs to happen inside the functional update to use the actual current state.

Fix in Cursor Fix in Web


useHotkeys("mod+b", () => {
if (isFileTreePanelCollapsed) {
fileTreePanelRef.current?.expand();
Expand Down Expand Up @@ -132,7 +192,9 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
) : (
<PureFileTreePanel
tree={data.tree}
openPaths={openPaths}
path={path}
onTreeNodeClicked={onTreeNodeClicked}
/>
)}
</div>
Expand Down Expand Up @@ -323,4 +385,4 @@ const FileTreePanelSkeleton = () => {
</div>
</div>
)
}
}
Loading