fix(browse): Fix issue where files would sometimes never load (#365)

This commit is contained in:
Brendan Kellam 2025-07-14 16:07:09 -07:00 committed by GitHub
parent d9d0146c48
commit 1384dd870e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 379 additions and 318 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364) - Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364)
- Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377) - Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377)
- Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365)
## [4.5.0] - 2025-06-21 ## [4.5.0] - 2025-06-21

View file

@ -1,62 +1,38 @@
'use client';
import { getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { useQuery } from "@tanstack/react-query";
import { getFileSource } from "@/features/search/fileSourceApi";
import { useDomain } from "@/hooks/useDomain";
import { Loader2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions"; import { getRepoInfoByName } from "@/actions";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useMemo } from "react";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { Separator } from "@/components/ui/separator";
import { getFileSource } from "@/features/search/fileSourceApi";
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
import Image from "next/image";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
export const CodePreviewPanel = () => { interface CodePreviewPanelProps {
const { path, repoName, revisionName } = useBrowseParams(); path: string;
const domain = useDomain(); repoName: string;
revisionName?: string;
domain: string;
}
const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({ export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }: CodePreviewPanelProps) => {
queryKey: ['fileSource', repoName, revisionName, path, domain], const [fileSourceResponse, repoInfoResponse] = await Promise.all([
queryFn: () => unwrapServiceError(getFileSource({ getFileSource({
fileName: path, fileName: path,
repository: repoName, repository: repoName,
branch: revisionName branch: revisionName,
}, domain)), }, domain),
}); getRepoInfoByName(repoName, domain),
]);
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
queryKey: ['repoInfo', repoName, domain], return <div>Error loading file source</div>
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
});
const codeHostInfo = useMemo(() => {
if (!repoInfoResponse) {
return undefined;
} }
return getCodeHostInfoForRepo({ const codeHostInfo = getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType, codeHostType: repoInfoResponse.codeHostType,
name: repoInfoResponse.name, name: repoInfoResponse.name,
displayName: repoInfoResponse.displayName, displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl, webUrl: repoInfoResponse.webUrl,
}); });
}, [repoInfoResponse]);
if (isFileSourcePending || isRepoInfoPending) {
return (
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
)
}
if (isFileSourceError || isRepoInfoError) {
return <div>Error loading file source</div>
}
return ( return (
<> <>

View file

@ -0,0 +1,46 @@
'use client';
import { useCallback, useRef } from "react";
import { FileTreeItem } from "@/features/fileTree/actions";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useBrowseParams } from "../../hooks/useBrowseParams";
interface PureTreePreviewPanelProps {
items: FileTreeItem[];
}
export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => {
const { repoName, revisionName } = useBrowseParams();
const { navigateToPath } = useBrowseNavigation();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const onNodeClicked = useCallback((node: FileTreeItem) => {
navigateToPath({
repoName: repoName,
revisionName: revisionName,
path: node.path,
pathType: node.type === 'tree' ? 'tree' : 'blob',
});
}, [navigateToPath, repoName, revisionName]);
return (
<ScrollArea
className="flex flex-col p-0.5"
ref={scrollAreaRef}
>
{items.map((item) => (
<FileTreeItemComponent
key={item.path}
node={item}
isActive={false}
depth={0}
isCollapseChevronVisible={false}
onClick={() => onNodeClicked(item)}
parentRef={scrollAreaRef}
/>
))}
</ScrollArea>
)
}

View file

@ -1,74 +1,30 @@
'use client';
import { Loader2 } from "lucide-react";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions"; import { getRepoInfoByName } from "@/actions";
import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { useCallback, useRef } from "react"; import { getFolderContents } from "@/features/fileTree/actions";
import { FileTreeItem, getFolderContents } from "@/features/fileTree/actions"; import { isServiceError } from "@/lib/utils";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent"; import { PureTreePreviewPanel } from "./pureTreePreviewPanel";
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { ScrollArea } from "@/components/ui/scroll-area";
import { unwrapServiceError } from "@/lib/utils";
import { useBrowseParams } from "../../hooks/useBrowseParams";
import { useDomain } from "@/hooks/useDomain";
import { useQuery } from "@tanstack/react-query";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents";
export const TreePreviewPanel = () => { interface TreePreviewPanelProps {
const { path } = useBrowseParams(); path: string;
const { repoName, revisionName } = useBrowseParams(); repoName: string;
const domain = useDomain(); revisionName?: string;
const { navigateToPath } = useBrowseNavigation(); domain: string;
const { prefetchFileSource } = usePrefetchFileSource(); }
const { prefetchFolderContents } = usePrefetchFolderContents();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }: TreePreviewPanelProps) => {
queryKey: ['repoInfo', repoName, domain], const [repoInfoResponse, folderContentsResponse] = await Promise.all([
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)), getRepoInfoByName(repoName, domain),
});
const { data, isPending: isFolderContentsPending, isError: isFolderContentsError } = useQuery({
queryKey: ['tree', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(
getFolderContents({ getFolderContents({
repoName, repoName,
revisionName: revisionName ?? 'HEAD', revisionName: revisionName ?? 'HEAD',
path, path,
}, domain) }, domain)
), ]);
});
const onNodeClicked = useCallback((node: FileTreeItem) => { if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
navigateToPath({ return <div>Error loading tree preview</div>
repoName: repoName,
revisionName: revisionName,
path: node.path,
pathType: node.type === 'tree' ? 'tree' : 'blob',
});
}, [navigateToPath, repoName, revisionName]);
const onNodeMouseEnter = useCallback((node: FileTreeItem) => {
if (node.type === 'blob') {
prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path);
} else if (node.type === 'tree') {
prefetchFolderContents(repoName, revisionName ?? 'HEAD', node.path);
}
}, [prefetchFileSource, prefetchFolderContents, repoName, revisionName]);
if (isFolderContentsPending || isRepoInfoPending) {
return (
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
)
}
if (isFolderContentsError || isRepoInfoError) {
return <div>Error loading tree</div>
} }
return ( return (
@ -86,23 +42,7 @@ export const TreePreviewPanel = () => {
/> />
</div> </div>
<Separator /> <Separator />
<ScrollArea <PureTreePreviewPanel items={folderContentsResponse} />
className="flex flex-col p-0.5"
ref={scrollAreaRef}
>
{data.map((item) => (
<FileTreeItemComponent
key={item.path}
node={item}
isActive={false}
depth={0}
isCollapseChevronVisible={false}
onClick={() => onNodeClicked(item)}
onMouseEnter={() => onNodeMouseEnter(item)}
parentRef={scrollAreaRef}
/>
))}
</ScrollArea>
</> </>
) )
} }

View file

@ -1,19 +1,44 @@
'use client'; import { Suspense } from "react";
import { getBrowseParamsFromPathParam } from "../hooks/utils";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { CodePreviewPanel } from "./components/codePreviewPanel"; import { CodePreviewPanel } from "./components/codePreviewPanel";
import { Loader2 } from "lucide-react";
import { TreePreviewPanel } from "./components/treePreviewPanel"; import { TreePreviewPanel } from "./components/treePreviewPanel";
export default function BrowsePage() { interface BrowsePageProps {
const { pathType } = useBrowseParams(); params: {
path: string[];
domain: string;
};
}
export default async function BrowsePage({ params: { path: _rawPath, domain } }: BrowsePageProps) {
const rawPath = decodeURIComponent(_rawPath.join('/'));
const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Suspense fallback={
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
}>
{pathType === 'blob' ? ( {pathType === 'blob' ? (
<CodePreviewPanel /> <CodePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
domain={domain}
/>
) : ( ) : (
<TreePreviewPanel /> <TreePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
domain={domain}
/>
)} )}
</Suspense>
</div> </div>
) )
} }

View file

@ -10,7 +10,6 @@ import { useDomain } from "@/hooks/useDomain";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { useBrowseNavigation } from "../hooks/useBrowseNavigation"; import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
import { useBrowseState } from "../hooks/useBrowseState"; import { useBrowseState } from "../hooks/useBrowseState";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import { useBrowseParams } from "../hooks/useBrowseParams"; import { useBrowseParams } from "../hooks/useBrowseParams";
import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon"; import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
@ -36,7 +35,6 @@ export const FileSearchCommandDialog = () => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation(); const { navigateToPath } = useBrowseNavigation();
const { prefetchFileSource } = usePrefetchFileSource();
const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []); const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []);
@ -122,14 +120,6 @@ export const FileSearchCommandDialog = () => {
}); });
}, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]); }, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]);
const onMouseEnter = useCallback((file: FileTreeItem) => {
prefetchFileSource(
repoName,
revisionName ?? 'HEAD',
file.path
);
}, [prefetchFileSource, repoName, revisionName]);
// @note: We were hitting issues when the user types into the input field while the files are still // @note: We were hitting issues when the user types into the input field while the files are still
// loading. The workaround was to set `disabled` when loading and then focus the input field when // loading. The workaround was to set `disabled` when loading and then focus the input field when
// the files are loaded, hence the `useEffect` below. // the files are loaded, hence the `useEffect` below.
@ -181,7 +171,6 @@ export const FileSearchCommandDialog = () => {
key={file.path} key={file.path}
file={file} file={file}
onSelect={() => onSelect(file)} onSelect={() => onSelect(file)}
onMouseEnter={() => onMouseEnter(file)}
/> />
); );
})} })}
@ -196,7 +185,6 @@ export const FileSearchCommandDialog = () => {
file={file} file={file}
match={match} match={match}
onSelect={() => onSelect(file)} onSelect={() => onSelect(file)}
onMouseEnter={() => onMouseEnter(file)}
/> />
); );
})} })}
@ -223,20 +211,17 @@ interface SearchResultComponentProps {
to: number; to: number;
}; };
onSelect: () => void; onSelect: () => void;
onMouseEnter: () => void;
} }
const SearchResultComponent = ({ const SearchResultComponent = ({
file, file,
match, match,
onSelect, onSelect,
onMouseEnter,
}: SearchResultComponentProps) => { }: SearchResultComponentProps) => {
return ( return (
<CommandItem <CommandItem
key={file.path} key={file.path}
onSelect={onSelect} onSelect={onSelect}
onMouseEnter={onMouseEnter}
> >
<div className="flex flex-row gap-2 w-full cursor-pointer relative"> <div className="flex flex-row gap-2 w-full cursor-pointer relative">
<FileTreeItemIcon item={file} className="mt-1" /> <FileTreeItemIcon item={file} className="mt-1" />

View file

@ -1,48 +1,18 @@
'use client';
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { getBrowseParamsFromPathParam } from "./utils";
export const useBrowseParams = () => { export const useBrowseParams = () => {
const pathname = usePathname(); const pathname = usePathname();
return useMemo(() => {
const startIndex = pathname.indexOf('/browse/'); const startIndex = pathname.indexOf('/browse/');
if (startIndex === -1) { if (startIndex === -1) {
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`); throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`);
} }
const rawPath = pathname.substring(startIndex + '/browse/'.length); const rawPath = pathname.substring(startIndex + '/browse/'.length);
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); return getBrowseParamsFromPathParam(rawPath);
if (sentinalIndex === -1) { }, [pathname]);
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/-/(tree|blob)/" pattern`);
}
const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@');
const repoName = repoAndRevisionName[0];
const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined;
const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => {
const path = rawPath.substring(sentinalIndex + '/-/'.length);
const pathType = path.startsWith('tree/') ? 'tree' : 'blob';
// @note: decodedURIComponent is needed here incase the path contains a space.
switch (pathType) {
case 'tree':
return {
path: decodeURIComponent(path.substring('tree/'.length)),
pathType,
};
case 'blob':
return {
path: decodeURIComponent(path.substring('blob/'.length)),
pathType,
};
}
})();
return {
repoName,
revisionName,
path,
pathType,
}
} }

View file

@ -0,0 +1,194 @@
import { describe, it, expect } from 'vitest';
import { getBrowseParamsFromPathParam } from './utils';
describe('getBrowseParamsFromPathParam', () => {
describe('tree paths', () => {
it('should parse tree path with trailing slash', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: '',
pathType: 'tree',
});
});
it('should parse tree path without trailing slash', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: '',
pathType: 'tree',
});
});
it('should parse tree path with nested directory', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/packages/web/src');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'packages/web/src',
pathType: 'tree',
});
});
it('should parse tree path without revision', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt/-/tree/docs');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: undefined,
path: 'docs',
pathType: 'tree',
});
});
});
describe('blob paths', () => {
it('should parse blob path with file', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/README.md');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'README.md',
pathType: 'blob',
});
});
it('should parse blob path with nested file', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/packages/web/src/app/page.tsx');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'packages/web/src/app/page.tsx',
pathType: 'blob',
});
});
it('should parse blob path without revision', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt/-/blob/main.go');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: undefined,
path: 'main.go',
pathType: 'blob',
});
});
});
describe('URL decoding', () => {
it('should decode URL-encoded spaces in path', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/folder%20with%20spaces');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'folder with spaces',
pathType: 'tree',
});
});
it('should decode URL-encoded special characters in path', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/file%20with%20%26%20symbols.txt');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'file with & symbols.txt',
pathType: 'blob',
});
});
});
describe('different revision formats', () => {
it('should parse with branch name', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@main/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'main',
path: '',
pathType: 'tree',
});
});
it('should parse with commit hash', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@a1b2c3d/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'a1b2c3d',
path: '',
pathType: 'tree',
});
});
it('should parse with tag', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@v1.0.0/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'v1.0.0',
path: '',
pathType: 'tree',
});
});
});
describe('edge cases', () => {
it('should handle repo name with multiple @ symbols', () => {
const result = getBrowseParamsFromPathParam('gitlab.com/user@domain/repo@main/-/tree/');
expect(result).toEqual({
repoName: 'gitlab.com/user@domain/repo',
revisionName: 'main',
path: '',
pathType: 'tree',
});
});
it('should handle paths with @ symbols', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/file@v1.0.0.txt');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'file@v1.0.0.txt',
pathType: 'blob',
});
});
});
describe('error cases', () => {
it('should throw error for blob path with trailing slash and no path', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/');
}).toThrow();
});
it('should throw error for blob path without trailing slash and no path', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob');
}).toThrow();
});
it('should throw error for invalid pattern - missing /-/', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/tree/');
}).toThrow();
});
it('should throw error for invalid pattern - missing tree/blob', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/invalid/');
}).toThrow();
});
it('should throw error for completely invalid format', () => {
expect(() => {
getBrowseParamsFromPathParam('invalid-path');
}).toThrow();
});
it('should throw error for empty string', () => {
expect(() => {
getBrowseParamsFromPathParam('');
}).toThrow();
});
});
});

View file

@ -0,0 +1,43 @@
export const getBrowseParamsFromPathParam = (pathParam: string) => {
const sentinalIndex = pathParam.search(/\/-\/(tree|blob)/);
if (sentinalIndex === -1) {
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob)/" pattern`);
}
const repoAndRevisionPart = pathParam.substring(0, sentinalIndex);
const lastAtIndex = repoAndRevisionPart.lastIndexOf('@');
const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex);
const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1);
const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => {
const path = pathParam.substring(sentinalIndex + '/-/'.length);
const pathType = path.startsWith('tree') ? 'tree' : 'blob';
// @note: decodedURIComponent is needed here incase the path contains a space.
switch (pathType) {
case 'tree':
return {
path: decodeURIComponent(path.startsWith('tree/') ? path.substring('tree/'.length) : path.substring('tree'.length)),
pathType,
};
case 'blob':
return {
path: decodeURIComponent(path.startsWith('blob/') ? path.substring('blob/'.length) : path.substring('blob'.length)),
pathType,
};
}
})();
if (pathType === 'blob' && path === '') {
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`);
}
return {
repoName,
revisionName,
path,
pathType,
}
}

View file

@ -8,8 +8,6 @@ import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react"; import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react";
import { useCallback, useState, useMemo, useRef, useEffect } from "react"; import { useCallback, useState, useMemo, useRef, useEffect } from "react";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -62,8 +60,6 @@ export const PathHeader = ({
const { navigateToPath } = useBrowseNavigation(); const { navigateToPath } = useBrowseNavigation();
const { toast } = useToast(); const { toast } = useToast();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const { prefetchFolderContents } = usePrefetchFolderContents();
const { prefetchFileSource } = usePrefetchFileSource();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const breadcrumbsRef = useRef<HTMLDivElement>(null); const breadcrumbsRef = useRef<HTMLDivElement>(null);
@ -188,19 +184,6 @@ export const PathHeader = ({
}); });
}, [repo.name, branchDisplayName, navigateToPath, pathType]); }, [repo.name, branchDisplayName, navigateToPath, pathType]);
const onBreadcrumbMouseEnter = useCallback((segment: BreadcrumbSegment) => {
if (segment.isLastSegment && pathType === 'blob') {
prefetchFileSource(repo.name, branchDisplayName ?? 'HEAD', segment.fullPath);
} else {
prefetchFolderContents(repo.name, branchDisplayName ?? 'HEAD', segment.fullPath);
}
}, [
repo.name,
branchDisplayName,
prefetchFolderContents,
pathType,
prefetchFileSource,
]);
const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => { const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => {
if (!segment.highlightRange) { if (!segment.highlightRange) {
@ -274,7 +257,6 @@ export const PathHeader = ({
<DropdownMenuItem <DropdownMenuItem
key={segment.fullPath} key={segment.fullPath}
onClick={() => onBreadcrumbClick(segment)} onClick={() => onBreadcrumbClick(segment)}
onMouseEnter={() => onBreadcrumbMouseEnter(segment)}
className="font-mono text-sm cursor-pointer" className="font-mono text-sm cursor-pointer"
> >
{renderSegmentWithHighlight(segment)} {renderSegmentWithHighlight(segment)}
@ -292,7 +274,6 @@ export const PathHeader = ({
"font-mono text-sm truncate cursor-pointer hover:underline", "font-mono text-sm truncate cursor-pointer hover:underline",
)} )}
onClick={() => onBreadcrumbClick(segment)} onClick={() => onBreadcrumbClick(segment)}
onMouseEnter={() => onBreadcrumbMouseEnter(segment)}
> >
{renderSegmentWithHighlight(segment)} {renderSegmentWithHighlight(segment)}
</span> </span>

View file

@ -8,7 +8,6 @@ import { RepositoryInfo, SourceRange } from "@/features/search/types";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
interface ReferenceListProps { interface ReferenceListProps {
data: FindRelatedSymbolsResponse; data: FindRelatedSymbolsResponse;
@ -31,7 +30,6 @@ export const ReferenceList = ({
const { navigateToPath } = useBrowseNavigation(); const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const { prefetchFileSource } = usePrefetchFileSource();
// Virtualization setup // Virtualization setup
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@ -120,13 +118,6 @@ export const ReferenceList = ({
highlightRange: match.range, highlightRange: match.range,
}) })
}} }}
// @note: We prefetch the file source when the user hovers over a file.
// This is to try and mitigate having a loading spinner appear when
// the user clicks on a file to open it.
// @see: /browse/[...path]/page.tsx
onMouseEnter={() => {
prefetchFileSource(file.repository, revisionName, file.fileName);
}}
/> />
))} ))}
</div> </div>
@ -144,7 +135,6 @@ interface ReferenceListItemProps {
range: SourceRange; range: SourceRange;
language: string; language: string;
onClick: () => void; onClick: () => void;
onMouseEnter: () => void;
} }
const ReferenceListItem = ({ const ReferenceListItem = ({
@ -152,7 +142,6 @@ const ReferenceListItem = ({
range, range,
language, language,
onClick, onClick,
onMouseEnter,
}: ReferenceListItemProps) => { }: ReferenceListItemProps) => {
const highlightRanges = useMemo(() => [range], [range]); const highlightRanges = useMemo(() => [range], [range]);
@ -160,7 +149,6 @@ const ReferenceListItem = ({
<div <div
className="w-full hover:bg-accent py-1 cursor-pointer" className="w-full hover:bg-accent py-1 cursor-pointer"
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter}
> >
<LightweightCodeHighlighter <LightweightCodeHighlighter
language={language} language={language}

View file

@ -14,7 +14,6 @@ export const FileTreeItemComponent = ({
isCollapsed = false, isCollapsed = false,
isCollapseChevronVisible = true, isCollapseChevronVisible = true,
onClick, onClick,
onMouseEnter,
parentRef, parentRef,
}: { }: {
node: FileTreeItem, node: FileTreeItem,
@ -23,7 +22,6 @@ export const FileTreeItemComponent = ({
isCollapsed?: boolean, isCollapsed?: boolean,
isCollapseChevronVisible?: boolean, isCollapseChevronVisible?: boolean,
onClick: () => void, onClick: () => void,
onMouseEnter: () => void,
parentRef: React.RefObject<HTMLDivElement>, parentRef: React.RefObject<HTMLDivElement>,
}) => { }) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -67,7 +65,6 @@ export const FileTreeItemComponent = ({
} }
}} }}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter}
> >
<div <div
className="flex flex-row gap-1 cursor-pointer w-4 h-4 flex-shrink-0" className="flex flex-row gap-1 cursor-pointer w-4 h-4 flex-shrink-0"

View file

@ -6,7 +6,6 @@ import React, { useCallback, useMemo, useState, useEffect, useRef } from "react"
import { FileTreeItemComponent } from "./fileTreeItemComponent"; import { FileTreeItemComponent } from "./fileTreeItemComponent";
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
export type FileTreeNode = Omit<RawFileTreeNode, 'children'> & { export type FileTreeNode = Omit<RawFileTreeNode, 'children'> & {
@ -44,7 +43,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
const { navigateToPath } = useBrowseNavigation(); const { navigateToPath } = useBrowseNavigation();
const { repoName, revisionName } = useBrowseParams(); const { repoName, revisionName } = useBrowseParams();
const { prefetchFileSource } = usePrefetchFileSource();
// @note: When `_tree` changes, it indicates that a new tree has been loaded. // @note: When `_tree` changes, it indicates that a new tree has been loaded.
// In that case, we need to rebuild the collapsable tree. // In that case, we need to rebuild the collapsable tree.
@ -89,18 +87,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
} }
}, [setIsCollapsed, navigateToPath, repoName, revisionName]); }, [setIsCollapsed, navigateToPath, repoName, revisionName]);
// @note: We prefetch the file source when the user hovers over a file.
// This is to try and mitigate having a loading spinner appear when
// the user clicks on a file to open it.
// @see: /browse/[...path]/page.tsx
const onNodeMouseEnter = useCallback((node: FileTreeNode) => {
if (node.type !== 'blob') {
return;
}
prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path);
}, [prefetchFileSource, repoName, revisionName]);
const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => { const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => {
return ( return (
<> <>
@ -115,7 +101,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
isCollapsed={node.isCollapsed} isCollapsed={node.isCollapsed}
isCollapseChevronVisible={node.type === 'tree'} isCollapseChevronVisible={node.type === 'tree'}
onClick={() => onNodeClicked(node)} onClick={() => onNodeClicked(node)}
onMouseEnter={() => onNodeMouseEnter(node)}
parentRef={scrollAreaRef} parentRef={scrollAreaRef}
/> />
{node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)}
@ -124,7 +109,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
})} })}
</> </>
); );
}, [path, onNodeClicked, onNodeMouseEnter]); }, [path, onNodeClicked]);
const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);

View file

@ -1,34 +0,0 @@
'use client';
import { useQueryClient } from "@tanstack/react-query";
import { useDomain } from "./useDomain";
import { unwrapServiceError } from "@/lib/utils";
import { getFileSource } from "@/features/search/fileSourceApi";
import { useDebounceCallback } from "usehooks-ts";
interface UsePrefetchFileSourceProps {
debounceDelay?: number;
staleTime?: number;
}
export const usePrefetchFileSource = ({
debounceDelay = 200,
staleTime = 5 * 60 * 1000, // 5 minutes
}: UsePrefetchFileSourceProps = {}) => {
const queryClient = useQueryClient();
const domain = useDomain();
const prefetchFileSource = useDebounceCallback((repoName: string, revisionName: string, path: string) => {
queryClient.prefetchQuery({
queryKey: ['fileSource', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(getFileSource({
fileName: path,
repository: repoName,
branch: revisionName,
}, domain)),
staleTime,
});
}, debounceDelay);
return { prefetchFileSource };
}

View file

@ -1,36 +0,0 @@
'use client';
import { useQueryClient } from "@tanstack/react-query";
import { useDomain } from "./useDomain";
import { unwrapServiceError } from "@/lib/utils";
import { getFolderContents } from "@/features/fileTree/actions";
import { useDebounceCallback } from "usehooks-ts";
interface UsePrefetchFolderContentsProps {
debounceDelay?: number;
staleTime?: number;
}
export const usePrefetchFolderContents = ({
debounceDelay = 200,
staleTime = 5 * 60 * 1000, // 5 minutes
}: UsePrefetchFolderContentsProps = {}) => {
const queryClient = useQueryClient();
const domain = useDomain();
const prefetchFolderContents = useDebounceCallback((repoName: string, revisionName: string, path: string) => {
queryClient.prefetchQuery({
queryKey: ['tree', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(
getFolderContents({
repoName,
revisionName,
path,
}, domain)
),
staleTime,
});
}, debounceDelay);
return { prefetchFolderContents };
}