mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
fix(browse): Fix issue where files would sometimes never load (#365)
This commit is contained in:
parent
d9d0146c48
commit
1384dd870e
15 changed files with 379 additions and 318 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,39 @@
|
||||||
'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],
|
|
||||||
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const codeHostInfo = useMemo(() => {
|
|
||||||
if (!repoInfoResponse) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getCodeHostInfoForRepo({
|
|
||||||
codeHostType: repoInfoResponse.codeHostType,
|
|
||||||
name: repoInfoResponse.name,
|
|
||||||
displayName: repoInfoResponse.displayName,
|
|
||||||
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 <div>Error loading file source</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeHostInfo = getCodeHostInfoForRepo({
|
||||||
|
codeHostType: repoInfoResponse.codeHostType,
|
||||||
|
name: repoInfoResponse.name,
|
||||||
|
displayName: repoInfoResponse.displayName,
|
||||||
|
webUrl: repoInfoResponse.webUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row py-1 px-2 items-center justify-between">
|
<div className="flex flex-row py-1 px-2 items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
});
|
getFolderContents({
|
||||||
|
repoName,
|
||||||
|
revisionName: revisionName ?? 'HEAD',
|
||||||
|
path,
|
||||||
|
}, domain)
|
||||||
|
]);
|
||||||
|
|
||||||
const { data, isPending: isFolderContentsPending, isError: isFolderContentsError } = useQuery({
|
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
|
||||||
queryKey: ['tree', repoName, revisionName, path, domain],
|
return <div>Error loading tree preview</div>
|
||||||
queryFn: () => unwrapServiceError(
|
|
||||||
getFolderContents({
|
|
||||||
repoName,
|
|
||||||
revisionName: revisionName ?? 'HEAD',
|
|
||||||
path,
|
|
||||||
}, domain)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onNodeClicked = useCallback((node: FileTreeItem) => {
|
|
||||||
navigateToPath({
|
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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={
|
||||||
{pathType === 'blob' ? (
|
<div className="flex flex-col w-full min-h-full items-center justify-center">
|
||||||
<CodePreviewPanel />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
Loading...
|
||||||
<TreePreviewPanel />
|
</div>
|
||||||
)}
|
}>
|
||||||
|
{pathType === 'blob' ? (
|
||||||
|
<CodePreviewPanel
|
||||||
|
path={path}
|
||||||
|
repoName={repoName}
|
||||||
|
revisionName={revisionName}
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TreePreviewPanel
|
||||||
|
path={path}
|
||||||
|
repoName={repoName}
|
||||||
|
revisionName={revisionName}
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
const startIndex = pathname.indexOf('/browse/');
|
return useMemo(() => {
|
||||||
if (startIndex === -1) {
|
const startIndex = pathname.indexOf('/browse/');
|
||||||
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`);
|
if (startIndex === -1) {
|
||||||
}
|
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`);
|
||||||
|
|
||||||
const rawPath = pathname.substring(startIndex + '/browse/'.length);
|
|
||||||
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//);
|
|
||||||
if (sentinalIndex === -1) {
|
|
||||||
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 {
|
const rawPath = pathname.substring(startIndex + '/browse/'.length);
|
||||||
repoName,
|
return getBrowseParamsFromPathParam(rawPath);
|
||||||
revisionName,
|
}, [pathname]);
|
||||||
path,
|
|
||||||
pathType,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
194
packages/web/src/app/[domain]/browse/hooks/utils.test.ts
Normal file
194
packages/web/src/app/[domain]/browse/hooks/utils.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/web/src/app/[domain]/browse/hooks/utils.ts
Normal file
43
packages/web/src/app/[domain]/browse/hooks/utils.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue