feature: File explorer (#336)

This commit is contained in:
Brendan Kellam 2025-06-06 12:38:16 -07:00 committed by GitHub
parent 8dc41a22b9
commit 27fb5ad294
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1762 additions and 501 deletions

View file

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331)
- Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332)
- Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335)
- Added support for a file explorer when browsing files. [#336](https://github.com/sourcebot-dev/sourcebot/pull/336)
## [4.1.1] - 2025-06-03

View file

@ -70,6 +70,8 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
return true;
}
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
// @todo: we should move this to a shared package.
export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => {
// If we are dealing with a local repository, then use that as the path.
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.

View file

@ -137,13 +137,16 @@
"react-hotkeys-hook": "^4.5.1",
"react-icons": "^5.3.0",
"react-resizable-panels": "^2.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
"simple-git": "^3.27.0",
"strip-json-comments": "^5.0.1",
"stripe": "^17.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"vscode-icons-js": "^11.6.1",
"zod": "^3.24.3",
"zod-to-json-schema": "^3.24.5"
},

View file

@ -1,226 +1,99 @@
'use client';
import { ResizablePanel } from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { search } from "@codemirror/search";
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
import { useCallback, useEffect, useMemo, useState } from "react";
import { EditorContextMenu } from "../../../components/editorContextMenu";
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { useBrowseState } from "../../hooks/useBrowseState";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { base64Decode, 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 { cn } from "@/lib/utils";
import Image from "next/image";
import { useMemo } from "react";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
interface CodePreviewPanelProps {
path: string;
repoName: string;
revisionName: string;
source: string;
language: string;
}
export const CodePreviewPanel = () => {
const { path, repoName, revisionName } = useBrowseParams();
const domain = useDomain();
export const CodePreviewPanel = ({
source,
language,
path,
repoName,
revisionName,
}: CodePreviewPanelProps) => {
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
const keymapExtension = useKeymapExtension(editorRef?.view);
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const { updateBrowseState } = useBrowseState();
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({
queryKey: ['fileSource', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(getFileSource({
fileName: path,
repository: repoName,
branch: revisionName
}, domain)),
});
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
const highlightRange = useMemo((): BrowseHighlightRange | undefined => {
if (!highlightRangeQuery) {
return;
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
queryKey: ['repoInfo', repoName, domain],
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
});
const codeHostInfo = useMemo(() => {
if (!repoInfoResponse) {
return undefined;
}
// Highlight ranges can be formatted in two ways:
// 1. start_line,end_line (no column specified)
// 2. start_line:start_column,end_line:end_column (column specified)
const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/;
if (!rangeRegex.test(highlightRangeQuery)) {
return;
}
const [start, end] = highlightRangeQuery.split(',').map((range) => {
if (range.includes(':')) {
return range.split(':').map((val) => parseInt(val, 10));
}
// For line-only format, use column 1 for start and last column for end
const line = parseInt(range, 10);
return [line];
return getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType,
name: repoInfoResponse.name,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
});
}, [repoInfoResponse]);
if (start.length === 1 || end.length === 1) {
return {
start: {
lineNumber: start[0],
},
end: {
lineNumber: end[0],
}
}
} else {
return {
start: {
lineNumber: start[0],
column: start[1],
},
end: {
lineNumber: end[0],
column: end[1],
}
}
}
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>
)
}
}, [highlightRangeQuery]);
const extensions = useMemo(() => {
return [
languageExtension,
EditorView.lineWrapping,
keymapExtension,
search({
top: true,
}),
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.selectionSet) {
setCurrentSelection(update.state.selection.main);
}
}),
highlightRange ? rangeHighlightingExtension(highlightRange) : [],
hasCodeNavEntitlement ? symbolHoverTargetsExtension : [],
];
}, [
keymapExtension,
languageExtension,
highlightRange,
hasCodeNavEntitlement,
]);
// Scroll the highlighted range into view.
useEffect(() => {
if (!highlightRange || !editorRef || !editorRef.state) {
return;
}
const doc = editorRef.state.doc;
const { start, end } = highlightRange;
const selection = EditorSelection.range(
doc.line(start.lineNumber).from,
doc.line(end.lineNumber).from,
);
editorRef.view?.dispatch({
effects: [
EditorView.scrollIntoView(selection, { y: "center" }),
]
});
}, [editorRef, highlightRange]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_browse_find_references_pressed', {});
updateBrowseState({
selectedSymbolInfo: {
repoName,
symbolName,
revisionName,
language,
},
isBottomPanelCollapsed: false,
activeExploreMenuTab: "references",
})
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);
// If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_browse_goto_definition_pressed', {});
if (symbolDefinitions.length === 0) {
return;
}
if (symbolDefinitions.length === 1) {
const symbolDefinition = symbolDefinitions[0];
const { fileName, repoName } = symbolDefinition;
navigateToPath({
repoName,
revisionName,
path: fileName,
pathType: 'blob',
highlightRange: symbolDefinition.range,
})
} else {
updateBrowseState({
selectedSymbolInfo: {
symbolName,
repoName,
revisionName,
language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
})
}
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
const theme = useCodeMirrorTheme();
if (isFileSourceError || isRepoInfoError) {
return <div>Error loading file source</div>
}
return (
<ResizablePanel
order={1}
id={"code-preview-panel"}
>
<ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror
className="relative"
ref={setEditorRef}
value={source}
extensions={extensions}
readOnly={true}
theme={theme}
>
{editorRef && editorRef.view && currentSelection && (
<EditorContextMenu
view={editorRef.view}
selection={currentSelection}
repoName={repoName}
path={path}
revisionName={revisionName}
<>
<div className="flex flex-row py-1 px-2 items-center justify-between">
<PathHeader
path={path}
repo={{
name: repoName,
codeHostType: repoInfoResponse.codeHostType,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
}}
/>
{(fileSourceResponse.webUrl && codeHostInfo) && (
<a
href={fileSourceResponse.webUrl}
target="_blank"
rel="noopener noreferrer"
className="flex flex-row items-center gap-2 px-2 py-0.5 rounded-md flex-shrink-0"
>
<Image
src={codeHostInfo.icon}
alt={codeHostInfo.codeHostName}
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
/>
)}
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
editorRef={editorRef}
revisionName={revisionName}
language={language}
onFindReferences={onFindReferences}
onGotoDefinition={onGotoDefinition}
/>
)}
</CodeMirror>
</ScrollArea>
</ResizablePanel>
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
</a>
)}
</div>
<Separator />
<PureCodePreviewPanel
source={base64Decode(fileSourceResponse.source)}
language={fileSourceResponse.language}
repoName={repoName}
path={path}
revisionName={revisionName ?? 'HEAD'}
/>
</>
)
}
}

View file

@ -0,0 +1,220 @@
'use client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { search } from "@codemirror/search";
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
import { useCallback, useEffect, useMemo, useState } from "react";
import { EditorContextMenu } from "../../../components/editorContextMenu";
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { useBrowseState } from "../../hooks/useBrowseState";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface PureCodePreviewPanelProps {
path: string;
repoName: string;
revisionName: string;
source: string;
language: string;
}
export const PureCodePreviewPanel = ({
source,
language,
path,
repoName,
revisionName,
}: PureCodePreviewPanelProps) => {
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
const keymapExtension = useKeymapExtension(editorRef?.view);
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const { updateBrowseState } = useBrowseState();
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
const highlightRange = useMemo((): BrowseHighlightRange | undefined => {
if (!highlightRangeQuery) {
return;
}
// Highlight ranges can be formatted in two ways:
// 1. start_line,end_line (no column specified)
// 2. start_line:start_column,end_line:end_column (column specified)
const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/;
if (!rangeRegex.test(highlightRangeQuery)) {
return;
}
const [start, end] = highlightRangeQuery.split(',').map((range) => {
if (range.includes(':')) {
return range.split(':').map((val) => parseInt(val, 10));
}
// For line-only format, use column 1 for start and last column for end
const line = parseInt(range, 10);
return [line];
});
if (start.length === 1 || end.length === 1) {
return {
start: {
lineNumber: start[0],
},
end: {
lineNumber: end[0],
}
}
} else {
return {
start: {
lineNumber: start[0],
column: start[1],
},
end: {
lineNumber: end[0],
column: end[1],
}
}
}
}, [highlightRangeQuery]);
const extensions = useMemo(() => {
return [
languageExtension,
EditorView.lineWrapping,
keymapExtension,
search({
top: true,
}),
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.selectionSet) {
setCurrentSelection(update.state.selection.main);
}
}),
highlightRange ? rangeHighlightingExtension(highlightRange) : [],
hasCodeNavEntitlement ? symbolHoverTargetsExtension : [],
];
}, [
keymapExtension,
languageExtension,
highlightRange,
hasCodeNavEntitlement,
]);
// Scroll the highlighted range into view.
useEffect(() => {
if (!highlightRange || !editorRef || !editorRef.state) {
return;
}
const doc = editorRef.state.doc;
const { start, end } = highlightRange;
const selection = EditorSelection.range(
doc.line(start.lineNumber).from,
doc.line(end.lineNumber).from,
);
editorRef.view?.dispatch({
effects: [
EditorView.scrollIntoView(selection, { y: "center" }),
]
});
}, [editorRef, highlightRange]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_browse_find_references_pressed', {});
updateBrowseState({
selectedSymbolInfo: {
repoName,
symbolName,
revisionName,
language,
},
isBottomPanelCollapsed: false,
activeExploreMenuTab: "references",
})
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);
// If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_browse_goto_definition_pressed', {});
if (symbolDefinitions.length === 0) {
return;
}
if (symbolDefinitions.length === 1) {
const symbolDefinition = symbolDefinitions[0];
const { fileName, repoName } = symbolDefinition;
navigateToPath({
repoName,
revisionName,
path: fileName,
pathType: 'blob',
highlightRange: symbolDefinition.range,
})
} else {
updateBrowseState({
selectedSymbolInfo: {
symbolName,
repoName,
revisionName,
language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
})
}
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
const theme = useCodeMirrorTheme();
return (
<ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror
className="relative"
ref={setEditorRef}
value={source}
extensions={extensions}
readOnly={true}
theme={theme}
>
{editorRef && editorRef.view && currentSelection && (
<EditorContextMenu
view={editorRef.view}
selection={currentSelection}
repoName={repoName}
path={path}
revisionName={revisionName}
/>
)}
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
editorRef={editorRef}
revisionName={revisionName}
language={language}
onFindReferences={onFindReferences}
onGotoDefinition={onGotoDefinition}
/>
)}
</CodeMirror>
</ScrollArea>
)
}

View file

@ -0,0 +1,108 @@
'use client';
import { Loader2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { useCallback, useRef } from "react";
import { FileTreeItem, getFolderContents } from "@/features/fileTree/actions";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
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 = () => {
const { path } = useBrowseParams();
const { repoName, revisionName } = useBrowseParams();
const domain = useDomain();
const { navigateToPath } = useBrowseNavigation();
const { prefetchFileSource } = usePrefetchFileSource();
const { prefetchFolderContents } = usePrefetchFolderContents();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
queryKey: ['repoInfo', repoName, domain],
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
});
const { data, isPending: isFolderContentsPending, isError: isFolderContentsError } = useQuery({
queryKey: ['tree', repoName, revisionName, path, domain],
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 (
<>
<div className="flex flex-row py-1 px-2 items-center justify-between">
<PathHeader
path={path}
repo={{
name: repoName,
codeHostType: repoInfoResponse.codeHostType,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
}}
pathType="tree"
/>
</div>
<Separator />
<ScrollArea
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,151 +1,20 @@
import { FileHeader } from "@/app/[domain]/components/fileHeader";
import { TopBar } from "@/app/[domain]/components/topBar";
import { Separator } from '@/components/ui/separator';
import { getFileSource } from '@/features/search/fileSourceApi';
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
import { base64Decode } from "@/lib/utils";
import { ErrorCode } from "@/lib/errorCodes";
import { LuFileX2, LuBookX } from "react-icons/lu";
import { notFound } from "next/navigation";
import { ServiceErrorException } from "@/lib/serviceError";
import { getRepoInfoByName } from "@/actions";
'use client';
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { CodePreviewPanel } from "./components/codePreviewPanel";
import Image from "next/image";
interface BrowsePageProps {
params: {
path: string[];
domain: string;
};
}
export default async function BrowsePage({
params,
}: BrowsePageProps) {
const rawPath = decodeURIComponent(params.path.join('/'));
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//);
if (sentinalIndex === -1) {
notFound();
}
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';
switch (pathType) {
case 'tree':
return {
path: path.substring('tree/'.length),
pathType,
};
case 'blob':
return {
path: path.substring('blob/'.length),
pathType,
};
}
})();
const repoInfo = await getRepoInfoByName(repoName, params.domain);
if (isServiceError(repoInfo)) {
if (repoInfo.errorCode === ErrorCode.NOT_FOUND) {
return (
<div className="flex h-full">
<div className="m-auto flex flex-col items-center gap-2">
<LuBookX className="h-12 w-12 text-secondary-foreground" />
<span className="font-medium text-secondary-foreground">Repository not found</span>
</div>
</div>
);
}
throw new ServiceErrorException(repoInfo);
}
if (pathType === 'tree') {
// @todo : proper tree handling
return (
<>
Tree view not supported
</>
)
}
const fileSourceResponse = await getFileSource({
fileName: path,
repository: repoName,
branch: revisionName ?? 'HEAD',
}, params.domain);
if (isServiceError(fileSourceResponse)) {
if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) {
return (
<div className="flex h-full">
<div className="m-auto flex flex-col items-center gap-2">
<LuFileX2 className="h-12 w-12 text-secondary-foreground" />
<span className="font-medium text-secondary-foreground">File not found</span>
</div>
</div>
)
}
throw new ServiceErrorException(fileSourceResponse);
}
const codeHostInfo = getCodeHostInfoForRepo({
codeHostType: repoInfo.codeHostType,
name: repoInfo.name,
displayName: repoInfo.displayName,
webUrl: repoInfo.webUrl,
});
import { TreePreviewPanel } from "./components/treePreviewPanel";
export default function BrowsePage() {
const { pathType } = useBrowseParams();
return (
<>
<div className='sticky top-0 left-0 right-0 z-10'>
<TopBar
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
domain={params.domain}
/>
<Separator />
<div className="bg-accent py-1 px-2 flex flex-row items-center">
<FileHeader
fileName={path}
repo={{
name: repoInfo.name,
displayName: repoInfo.displayName,
webUrl: repoInfo.webUrl,
codeHostType: repoInfo.codeHostType,
}}
branchDisplayName={revisionName}
/>
{(fileSourceResponse.webUrl && codeHostInfo) && (
<a
href={fileSourceResponse.webUrl}
target="_blank"
rel="noopener noreferrer"
className="flex flex-row items-center gap-2 px-2 py-0.5 rounded-md flex-shrink-0"
>
<Image
src={codeHostInfo.icon}
alt={codeHostInfo.codeHostName}
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
/>
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
</a>
)}
</div>
<Separator />
</div>
<CodePreviewPanel
source={base64Decode(fileSourceResponse.source)}
language={fileSourceResponse.language}
repoName={repoInfo.name}
path={path}
revisionName={revisionName ?? 'HEAD'}
/>
</>
<div className="flex flex-col h-full">
{pathType === 'blob' ? (
<CodePreviewPanel />
) : (
<TreePreviewPanel />
)}
</div>
)
}

View file

@ -2,7 +2,6 @@
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { createContext, useCallback, useEffect, useState } from "react";
import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel";
export interface BrowseState {
selectedSymbolInfo?: {
@ -12,6 +11,7 @@ export interface BrowseState {
language: string;
}
isBottomPanelCollapsed: boolean;
isFileTreePanelCollapsed: boolean;
activeExploreMenuTab: "references" | "definitions";
bottomPanelSize: number;
}
@ -19,8 +19,9 @@ export interface BrowseState {
const defaultState: BrowseState = {
selectedSymbolInfo: undefined,
isBottomPanelCollapsed: true,
isFileTreePanelCollapsed: false,
activeExploreMenuTab: "references",
bottomPanelSize: BOTTOM_PANEL_MIN_SIZE,
bottomPanelSize: 35,
};
export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState";
@ -33,8 +34,13 @@ export const BrowseStateContext = createContext<{
updateBrowseState: () => {},
});
export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => {
interface BrowseStateProviderProps {
children: React.ReactNode;
}
export const BrowseStateProvider = ({ children }: BrowseStateProviderProps) => {
const [state, setState] = useState<BrowseState>(defaultState);
const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM);
const onUpdateState = useCallback((state: Partial<BrowseState>) => {

View file

@ -20,7 +20,11 @@ export const BOTTOM_PANEL_MIN_SIZE = 35;
export const BOTTOM_PANEL_MAX_SIZE = 65;
const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation";
export const BottomPanel = () => {
interface BottomPanelProps {
order: number;
}
export const BottomPanel = ({ order }: BottomPanelProps) => {
const panelRef = useRef<ImperativePanelHandle>(null);
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const domain = useDomain();
@ -94,7 +98,7 @@ export const BottomPanel = () => {
updateBrowseState({ bottomPanelSize: size });
}
}}
order={2}
order={order}
id={"bottom-panel"}
>
{!hasCodeNavEntitlement ? (

View file

@ -0,0 +1,48 @@
'use client';
import { usePathname } from "next/navigation";
export const useBrowseParams = () => {
const pathname = usePathname();
const startIndex = pathname.indexOf('/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 {
repoName,
revisionName,
path,
pathType,
}
}

View file

@ -1,24 +1,65 @@
import { ResizablePanelGroup } from "@/components/ui/resizable";
'use client';
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { BottomPanel } from "./components/bottomPanel";
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
import { BrowseStateProvider } from "./browseStateProvider";
import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel";
import { TopBar } from "@/app/[domain]/components/topBar";
import { Separator } from '@/components/ui/separator';
import { useBrowseParams } from "./hooks/useBrowseParams";
interface LayoutProps {
children: React.ReactNode;
params: {
domain: string;
}
}
export default function Layout({
children,
children: codePreviewPanel,
params,
}: LayoutProps) {
const { repoName, revisionName } = useBrowseParams();
return (
<BrowseStateProvider>
<div className="flex flex-col h-screen">
<div className='sticky top-0 left-0 right-0 z-10'>
<TopBar
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
domain={params.domain}
/>
<Separator />
</div>
<ResizablePanelGroup
direction="vertical"
direction="horizontal"
>
{children}
<FileTreePanel order={1} />
<AnimatedResizableHandle />
<BottomPanel />
<ResizablePanel
order={2}
minSize={10}
defaultSize={80}
id="code-preview-panel-container"
>
<ResizablePanelGroup
direction="vertical"
>
<ResizablePanel
order={1}
id="code-preview-panel"
>
{codePreviewPanel}
</ResizablePanel>
<AnimatedResizableHandle />
<BottomPanel
order={2}
/>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</BrowseStateProvider>

View file

@ -1,125 +0,0 @@
'use client';
import { getCodeHostInfoForRepo } from "@/lib/utils";
import { LaptopIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
import { Copy, CheckCircle2 } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/components/hooks/use-toast";
interface FileHeaderProps {
fileName: string;
fileNameHighlightRange?: {
from: number;
to: number;
}
repo: {
name: string;
codeHostType: string;
displayName?: string;
webUrl?: string;
},
branchDisplayName?: string;
branchDisplayTitle?: string;
}
export const FileHeader = ({
repo,
fileName,
fileNameHighlightRange,
branchDisplayName,
branchDisplayTitle,
}: FileHeaderProps) => {
const info = getCodeHostInfoForRepo({
name: repo.name,
codeHostType: repo.codeHostType,
displayName: repo.displayName,
webUrl: repo.webUrl,
});
const { navigateToPath } = useBrowseNavigation();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(fileName);
setCopied(true);
toast({ description: "Copied file path!" });
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
{info?.icon ? (
<Image
src={info.icon}
alt={info.codeHostName}
className={`w-4 h-4 ${info.iconClassName}`}
/>
): (
<LaptopIcon className="w-4 h-4" />
)}
<Link
className={clsx("font-medium", {
"cursor-pointer hover:underline": info?.repoLink,
})}
href={info?.repoLink ?? ""}
>
{info?.displayName}
</Link>
{branchDisplayName && (
<p
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-[3px] flex items-center gap-0.5"
title={branchDisplayTitle}
style={{
marginBottom: "0.1rem",
}}
>
<span className="mr-0.5">@</span>
{`${branchDisplayName}`}
</p>
)}
<span>·</span>
<div className="flex-1 flex items-center overflow-hidden mt-0.5">
<span
className="inline-block truncate-start font-mono text-sm cursor-pointer hover:underline"
onClick={() => {
navigateToPath({
repoName: repo.name,
path: fileName,
pathType: 'blob',
revisionName: branchDisplayName,
});
}}
>
{!fileNameHighlightRange ?
fileName
: (
<>
{fileName.slice(0, fileNameHighlightRange.from)}
<span className="bg-yellow-200 dark:bg-blue-700">
{fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)}
</span>
{fileName.slice(fileNameHighlightRange.to)}
</>
)}
</span>
<button
className="ml-2 p-1 rounded transition-colors"
onClick={handleCopy}
aria-label="Copy file path"
type="button"
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,316 @@
'use client';
import { getCodeHostInfoForRepo } from "@/lib/utils";
import { LaptopIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react";
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
import { useToast } from "@/components/hooks/use-toast";
import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface FileHeaderProps {
path: string;
pathHighlightRange?: {
from: number;
to: number;
}
pathType?: 'blob' | 'tree';
repo: {
name: string;
codeHostType: string;
displayName?: string;
webUrl?: string;
},
branchDisplayName?: string;
branchDisplayTitle?: string;
}
interface BreadcrumbSegment {
name: string;
fullPath: string;
isLastSegment: boolean;
highlightRange?: {
from: number;
to: number;
};
}
export const PathHeader = ({
repo,
path,
pathHighlightRange,
branchDisplayName,
branchDisplayTitle,
pathType = 'blob',
}: FileHeaderProps) => {
const info = getCodeHostInfoForRepo({
name: repo.name,
codeHostType: repo.codeHostType,
displayName: repo.displayName,
webUrl: repo.webUrl,
});
const { navigateToPath } = useBrowseNavigation();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const { prefetchFolderContents } = usePrefetchFolderContents();
const { prefetchFileSource } = usePrefetchFileSource();
const containerRef = useRef<HTMLDivElement>(null);
const breadcrumbsRef = useRef<HTMLDivElement>(null);
const [visibleSegmentCount, setVisibleSegmentCount] = useState<number | null>(null);
// Create breadcrumb segments from file path
const breadcrumbSegments = useMemo(() => {
const pathParts = path.split('/').filter(Boolean);
const segments: BreadcrumbSegment[] = [];
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLastSegment = index === pathParts.length - 1;
// Calculate highlight range for this segment if it exists
let segmentHighlight: { from: number; to: number } | undefined;
if (pathHighlightRange) {
const segmentStart = path.indexOf(part, currentPath.length - part.length);
const segmentEnd = segmentStart + part.length;
// Check if highlight overlaps with this segment
if (pathHighlightRange.from < segmentEnd && pathHighlightRange.to > segmentStart) {
segmentHighlight = {
from: Math.max(0, pathHighlightRange.from - segmentStart),
to: Math.min(part.length, pathHighlightRange.to - segmentStart)
};
}
}
segments.push({
name: part,
fullPath: currentPath,
isLastSegment,
highlightRange: segmentHighlight
});
});
return segments;
}, [path, pathHighlightRange]);
// Calculate which segments should be visible based on available space
useEffect(() => {
const measureSegments = () => {
if (!containerRef.current || !breadcrumbsRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const availableWidth = containerWidth - 175; // Reserve space for copy button and padding
// Create a temporary element to measure segment widths
const tempElement = document.createElement('div');
tempElement.style.position = 'absolute';
tempElement.style.visibility = 'hidden';
tempElement.style.whiteSpace = 'nowrap';
tempElement.className = 'font-mono text-sm';
document.body.appendChild(tempElement);
let totalWidth = 0;
let visibleCount = breadcrumbSegments.length;
// Start from the end (most important segments) and work backwards
for (let i = breadcrumbSegments.length - 1; i >= 0; i--) {
const segment = breadcrumbSegments[i];
tempElement.textContent = segment.name;
const segmentWidth = tempElement.offsetWidth;
const separatorWidth = i < breadcrumbSegments.length - 1 ? 16 : 0; // ChevronRight width
if (totalWidth + segmentWidth + separatorWidth > availableWidth && i > 0) {
// If adding this segment would overflow and it's not the last segment
visibleCount = breadcrumbSegments.length - i;
// Add width for ellipsis dropdown (approximately 24px)
if (visibleCount < breadcrumbSegments.length) {
totalWidth += 40; // Ellipsis button + separator
}
break;
}
totalWidth += segmentWidth + separatorWidth;
}
document.body.removeChild(tempElement);
setVisibleSegmentCount(visibleCount);
};
measureSegments();
const resizeObserver = new ResizeObserver(measureSegments);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [breadcrumbSegments]);
const hiddenSegments = useMemo(() => {
if (visibleSegmentCount === null || visibleSegmentCount >= breadcrumbSegments.length) {
return [];
}
return breadcrumbSegments.slice(0, breadcrumbSegments.length - visibleSegmentCount);
}, [breadcrumbSegments, visibleSegmentCount]);
const visibleSegments = useMemo(() => {
if (visibleSegmentCount === null) {
return breadcrumbSegments;
}
return breadcrumbSegments.slice(breadcrumbSegments.length - visibleSegmentCount);
}, [breadcrumbSegments, visibleSegmentCount]);
const onCopyPath = useCallback(() => {
navigator.clipboard.writeText(path);
setCopied(true);
toast({ description: "✅ Copied to clipboard" });
setTimeout(() => setCopied(false), 1500);
}, [path, toast]);
const onBreadcrumbClick = useCallback((segment: BreadcrumbSegment) => {
navigateToPath({
repoName: repo.name,
path: segment.fullPath,
pathType: segment.isLastSegment ? pathType : 'tree',
revisionName: branchDisplayName,
});
}, [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) => {
if (!segment.highlightRange) {
return segment.name;
}
const { from, to } = segment.highlightRange;
return (
<>
{segment.name.slice(0, from)}
<span className="bg-yellow-200 dark:bg-blue-700">
{segment.name.slice(from, to)}
</span>
{segment.name.slice(to)}
</>
);
};
return (
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
{info?.icon ? (
<Image
src={info.icon}
alt={info.codeHostName}
className={`w-4 h-4 ${info.iconClassName}`}
/>
): (
<LaptopIcon className="w-4 h-4" />
)}
<Link
className={clsx("font-medium", {
"cursor-pointer hover:underline": info?.repoLink,
})}
href={info?.repoLink ?? ""}
>
{info?.displayName}
</Link>
{branchDisplayName && (
<p
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-[3px] flex items-center gap-0.5"
title={branchDisplayTitle}
style={{
marginBottom: "0.1rem",
}}
>
<span className="mr-0.5">@</span>
{`${branchDisplayName}`}
</p>
)}
<span>·</span>
<div ref={containerRef} className="flex-1 flex items-center overflow-hidden mt-0.5">
<div ref={breadcrumbsRef} className="flex items-center overflow-hidden">
{hiddenSegments.length > 0 && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="font-mono text-sm cursor-pointer hover:underline p-1 rounded transition-colors"
aria-label="Show hidden path segments"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[200px]">
{hiddenSegments.map((segment) => (
<DropdownMenuItem
key={segment.fullPath}
onClick={() => onBreadcrumbClick(segment)}
onMouseEnter={() => onBreadcrumbMouseEnter(segment)}
className="font-mono text-sm cursor-pointer"
>
{renderSegmentWithHighlight(segment)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight className="h-3 w-3 mx-0.5 text-muted-foreground flex-shrink-0" />
</>
)}
{visibleSegments.map((segment, index) => (
<div key={segment.fullPath} className="flex items-center">
<span
className={clsx(
"font-mono text-sm truncate cursor-pointer hover:underline",
)}
onClick={() => onBreadcrumbClick(segment)}
onMouseEnter={() => onBreadcrumbMouseEnter(segment)}
>
{renderSegmentWithHighlight(segment)}
</span>
{index < visibleSegments.length - 1 && (
<ChevronRight className="h-3 w-3 mx-0.5 text-muted-foreground flex-shrink-0" />
)}
</div>
))}
</div>
<button
className="ml-2 p-1 rounded transition-colors flex-shrink-0"
onClick={onCopyPath}
aria-label="Copy file path"
type="button"
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</div>
)
}

View file

@ -1,6 +1,6 @@
'use client';
import { FileHeader } from "@/app/[domain]/components/fileHeader";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { Separator } from "@/components/ui/separator";
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
import { useMemo } from "react";
@ -92,15 +92,15 @@ export const FileMatchContainer = ({
top: `-${yOffset}px`,
}}
>
<FileHeader
<PathHeader
repo={{
name: repo.name,
codeHostType: repo.codeHostType,
displayName: repo.displayName,
webUrl: repo.webUrl,
}}
fileName={file.fileName.text}
fileNameHighlightRange={fileNameRange}
path={file.fileName.text}
pathHighlightRange={fileNameRange}
branchDisplayName={branchDisplayName}
branchDisplayTitle={branches.join(", ")}
/>

View file

@ -267,7 +267,7 @@ const PanelGroup = ({
>
<TooltipTrigger asChild>
<Button
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {

View file

@ -1,7 +1,7 @@
'use client';
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { FileHeader } from "@/app/[domain]/components/fileHeader";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
import { RepositoryInfo, SourceRange } from "@/features/search/types";
@ -9,6 +9,7 @@ import { base64Decode } from "@/lib/utils";
import { useMemo, useRef } from "react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useVirtualizer } from "@tanstack/react-virtual";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
interface ReferenceListProps {
data: FindRelatedSymbolsResponse;
@ -31,6 +32,7 @@ export const ReferenceList = ({
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
const { prefetchFileSource } = usePrefetchFileSource();
// Virtualization setup
const parentRef = useRef<HTMLDivElement>(null);
@ -89,14 +91,14 @@ export const ReferenceList = ({
top: `-${virtualRow.start}px`,
}}
>
<FileHeader
<PathHeader
repo={{
name: repoInfo.name,
displayName: repoInfo.displayName,
codeHostType: repoInfo.codeHostType,
webUrl: repoInfo.webUrl,
}}
fileName={file.fileName}
path={file.fileName}
branchDisplayName={revisionName === "HEAD" ? undefined : revisionName}
/>
</div>
@ -119,6 +121,13 @@ export const ReferenceList = ({
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>
@ -136,6 +145,7 @@ interface ReferenceListItemProps {
range: SourceRange;
language: string;
onClick: () => void;
onMouseEnter: () => void;
}
const ReferenceListItem = ({
@ -143,6 +153,7 @@ const ReferenceListItem = ({
range,
language,
onClick,
onMouseEnter,
}: ReferenceListItemProps) => {
const decodedLineContent = useMemo(() => {
return base64Decode(lineContent);
@ -154,6 +165,7 @@ const ReferenceListItem = ({
<div
className="w-full hover:bg-accent py-1 cursor-pointer"
onClick={onClick}
onMouseEnter={onMouseEnter}
>
<LightweightCodeHighlighter
language={language}

View file

@ -0,0 +1,234 @@
'use server';
import { sew, withAuth, withOrgMembership } from '@/actions';
import { env } from '@/env.mjs';
import { OrgRole, Repo } from '@sourcebot/db';
import { prisma } from '@/prisma';
import { notFound, unexpectedError } from '@/lib/serviceError';
import { simpleGit } from 'simple-git';
import path from 'path';
import { createLogger } from '@sourcebot/logger';
const logger = createLogger('file-tree');
export type FileTreeItem = {
type: string;
path: string;
name: string;
}
export type FileTreeNode = FileTreeItem & {
children: FileTreeNode[];
}
/**
* Returns the tree of files (blobs) and directories (trees) for a given repository,
* at a given revision.
*/
export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ org }) => {
const { repoName, revisionName } = params;
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});
if (!repo) {
return notFound();
}
const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// recursive
'-r',
// include trees when recursing
'-t',
// format as output as {type},{path}
'--format=%(objecttype),%(path)',
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const lines = result.split('\n').filter(line => line.trim());
const flatList = lines.map(line => {
const [type, path] = line.split(',');
return {
type,
path,
}
});
const tree = buildFileTree(flatList);
return {
tree,
}
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
);
/**
* Returns the contents of a folder at a given path in a given repository,
* at a given revision.
*/
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ org }) => {
const { repoName, revisionName, path } = params;
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});
if (!repo) {
return notFound();
}
const { path: repoPath } = getRepoPath(repo);
// @note: we don't allow directory traversal
// or null bytes in the path.
if (path.includes('..') || path.includes('\0')) {
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);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// format as output as {type},{path}
'--format=%(objecttype),%(path)',
...(normalizedPath.length === 0 ? [] : [normalizedPath]),
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const lines = result.split('\n').filter(line => line.trim());
const contents: FileTreeItem[] = lines.map(line => {
const [type, path] = line.split(',');
const name = path.split('/').pop() ?? '';
return {
type,
path,
name,
}
});
return contents;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
)
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 => 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, b) => {
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.
const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => {
// If we are dealing with a local repository, then use that as the path.
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
const cloneUrl = new URL(repo.cloneUrl);
if (repo.external_codeHostType === 'generic-git-host' && cloneUrl.protocol === 'file:') {
return {
path: cloneUrl.pathname,
isReadOnly: true,
}
}
const reposPath = path.join(env.DATA_CACHE_DIR, 'repos');
return {
path: path.join(reposPath, repo.id.toString()),
isReadOnly: false,
}
}

View file

@ -0,0 +1,106 @@
'use client';
import { FileTreeItem } from "../actions";
import { useMemo, useEffect, useRef } from "react";
import { getIconForFile, getIconForFolder } from "vscode-icons-js";
import { Icon } from '@iconify/react';
import clsx from "clsx";
import scrollIntoView from 'scroll-into-view-if-needed';
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
export const FileTreeItemComponent = ({
node,
isActive,
depth,
isCollapsed = false,
isCollapseChevronVisible = true,
onClick,
onMouseEnter,
parentRef,
}: {
node: FileTreeItem,
isActive: boolean,
depth: number,
isCollapsed?: boolean,
isCollapseChevronVisible?: boolean,
onClick: () => void,
onMouseEnter: () => void,
parentRef: React.RefObject<HTMLDivElement>,
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive && ref.current) {
scrollIntoView(ref.current, {
scrollMode: 'if-needed',
block: 'center',
behavior: 'instant',
// We only want to scroll if the element is hidden vertically
// in the parent element.
boundary: () => {
if (!parentRef.current || !ref.current) {
return false;
}
const rect = ref.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
const completelyAbove = rect.bottom <= parentRect.top;
const completelyBelow = rect.top >= parentRect.bottom;
return completelyAbove || completelyBelow;
}
});
}
}, [isActive, parentRef]);
const iconName = useMemo(() => {
if (node.type === 'tree') {
const icon = getIconForFolder(node.name);
if (icon) {
const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
return iconName;
}
} else if (node.type === 'blob') {
const icon = getIconForFile(node.name);
if (icon) {
const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
return iconName;
}
}
return "vscode-icons:file-type-unknown";
}, [node.name, node.type]);
return (
<div
ref={ref}
className={clsx("flex flex-row gap-1 items-center hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer p-0.5", {
'bg-accent': isActive,
})}
style={{ paddingLeft: `${depth * 16}px` }}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onClick();
}
}}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
<div
className="flex flex-row gap-1 cursor-pointer w-4 h-4 flex-shrink-0"
>
{isCollapseChevronVisible && (
isCollapsed ? (
<ChevronRightIcon className="w-4 h-4 flex-shrink-0" />
) : (
<ChevronDownIcon className="w-4 h-4 flex-shrink-0" />
)
)}
</div>
<Icon icon={iconName} className="w-4 h-4 flex-shrink-0" />
<span className="text-sm">{node.name}</span>
</div>
)
}

View file

@ -0,0 +1,309 @@
'use client';
import { getTree } from "../actions";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";
import { ResizablePanel } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
import { PureFileTreePanel } from "./pureFileTreePanel";
import { Button } from "@/components/ui/button";
import { ImperativePanelHandle } from "react-resizable-panels";
import { useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { Separator } from "@/components/ui/separator";
import {
GoSidebarCollapse as ExpandIcon,
GoSidebarExpand as CollapseIcon
} from "react-icons/go";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { TooltipTrigger } from "@/components/ui/tooltip";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
interface FileTreePanelProps {
order: number;
}
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: {
isFileTreePanelCollapsed,
},
updateBrowseState,
} = useBrowseState();
const domain = useDomain();
const { repoName, revisionName, path } = useBrowseParams();
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
const { data, isPending, isError } = useQuery({
queryKey: ['tree', repoName, revisionName, domain],
queryFn: () => unwrapServiceError(
getTree({
repoName,
revisionName: revisionName ?? 'HEAD',
}, domain)
),
});
useHotkeys("mod+b", () => {
if (isFileTreePanelCollapsed) {
fileTreePanelRef.current?.expand();
} else {
fileTreePanelRef.current?.collapse();
}
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Toggle file tree panel",
});
return (
<>
<ResizablePanel
ref={fileTreePanelRef}
order={order}
minSize={FILE_TREE_PANEL_MIN_SIZE}
maxSize={FILE_TREE_PANEL_MAX_SIZE}
defaultSize={isFileTreePanelCollapsed ? 0 : FILE_TREE_PANEL_DEFAULT_SIZE}
collapsible={true}
id="file-tree-panel"
onCollapse={() => updateBrowseState({ isFileTreePanelCollapsed: true })}
onExpand={() => updateBrowseState({ isFileTreePanelCollapsed: false })}
>
<div className="flex flex-col h-full">
<div className="flex flex-row items-center p-2 gap-2">
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
fileTreePanelRef.current?.collapse();
}}
>
<CollapseIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
<KeyboardShortcutHint shortcut="⌘ B" />
<Separator orientation="vertical" className="h-4" />
<span>Close file tree</span>
</TooltipContent>
</Tooltip>
<p className="font-medium">File Tree</p>
</div>
<Separator orientation="horizontal" className="w-full mb-2" />
{isPending ? (
<FileTreePanelSkeleton />
) :
isError ? (
<div className="flex flex-col items-center justify-center h-full">
<p>Error loading file tree</p>
</div>
) : (
<PureFileTreePanel
tree={data.tree}
path={path}
/>
)}
</div>
</ResizablePanel>
{isFileTreePanelCollapsed && (
<div className="flex flex-col items-center h-full p-2">
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
fileTreePanelRef.current?.expand();
}}
>
<ExpandIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
<KeyboardShortcutHint shortcut="⌘ B" />
<Separator orientation="vertical" className="h-4" />
<span>Open file tree</span>
</TooltipContent>
</Tooltip>
</div>
)}
</>
)
}
const FileTreePanelSkeleton = () => {
return (
<div className="p-2 space-y-2">
{/* Root level items */}
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
<div className="flex items-center gap-2 pl-12">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center gap-2 pl-12">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex items-center gap-2 pl-8">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex items-center gap-2 pl-4">
<Skeleton className="w-4 h-4" />
<Skeleton className="w-4 h-4" />
<Skeleton className="h-4 w-28" />
</div>
</div>
)
}

View file

@ -0,0 +1,141 @@
'use client';
import { FileTreeNode as RawFileTreeNode } from "../actions";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import React, { useCallback, useMemo, useState, useEffect, useRef } from "react";
import { FileTreeItemComponent } from "./fileTreeItemComponent";
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
export type FileTreeNode = Omit<RawFileTreeNode, 'children'> & {
isCollapsed: boolean;
children: FileTreeNode[];
}
const buildCollapsableTree = (tree: RawFileTreeNode): FileTreeNode => {
return {
...tree,
isCollapsed: true,
children: tree.children.map(buildCollapsableTree),
}
}
const transformTree = (
tree: FileTreeNode,
transform: (node: FileTreeNode) => FileTreeNode
): FileTreeNode => {
const newNode = transform(tree);
const newChildren = tree.children.map(child => transformTree(child, transform));
return {
...newNode,
children: newChildren,
}
}
interface PureFileTreePanelProps {
tree: RawFileTreeNode;
path: string;
}
export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) => {
const [tree, setTree] = useState<FileTreeNode>(buildCollapsableTree(_tree));
const scrollAreaRef = useRef<HTMLDivElement>(null);
const { navigateToPath } = useBrowseNavigation();
const { repoName, revisionName } = useBrowseParams();
const { prefetchFileSource } = usePrefetchFileSource();
// @note: When `_tree` changes, it indicates that a new tree has been loaded.
// In that case, we need to rebuild the collapsable tree.
useEffect(() => {
setTree(buildCollapsableTree(_tree));
}, [_tree]);
const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => {
setTree(currentTree => transformTree(currentTree, (currentNode) => {
if (currentNode.path === path) {
currentNode.isCollapsed = isCollapsed;
}
return currentNode;
}));
}, []);
// When the path changes, expand all the folders up to the path
useEffect(() => {
const pathParts = path.split('/');
let currentPath = '';
for (let i = 0; i < pathParts.length; i++) {
currentPath += pathParts[i];
setIsCollapsed(currentPath, false);
if (i < pathParts.length - 1) {
currentPath += '/';
}
}
}, [path, setIsCollapsed]);
const onNodeClicked = useCallback((node: FileTreeNode) => {
if (node.type === 'tree') {
setIsCollapsed(node.path, !node.isCollapsed);
}
else if (node.type === 'blob') {
navigateToPath({
repoName: repoName,
revisionName: revisionName,
path: node.path,
pathType: 'blob',
});
}
}, [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 => {
return (
<>
{nodes.children.map((node) => {
return (
<React.Fragment key={node.path}>
<FileTreeItemComponent
key={node.path}
node={node}
isActive={node.path === path}
depth={depth}
isCollapsed={node.isCollapsed}
isCollapseChevronVisible={node.type === 'tree'}
onClick={() => onNodeClicked(node)}
onMouseEnter={() => onNodeMouseEnter(node)}
parentRef={scrollAreaRef}
/>
{node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)}
</React.Fragment>
);
})}
</>
);
}, [path, onNodeClicked, onNodeMouseEnter]);
const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);
return (
<ScrollArea
className="h-full w-full overflow-auto p-0.5"
ref={scrollAreaRef}
>
{renderedTree}
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View file

@ -0,0 +1,25 @@
'use client';
import { useQueryClient } from "@tanstack/react-query";
import { useDomain } from "./useDomain";
import { unwrapServiceError } from "@/lib/utils";
import { getFileSource } from "@/features/search/fileSourceApi";
import { useCallback } from "react";
export const usePrefetchFileSource = () => {
const queryClient = useQueryClient();
const domain = useDomain();
const prefetchFileSource = useCallback((repoName: string, revisionName: string, path: string) => {
queryClient.prefetchQuery({
queryKey: ['fileSource', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(getFileSource({
fileName: path,
repository: repoName,
branch: revisionName,
}, domain)),
});
}, [queryClient, domain]);
return { prefetchFileSource };
}

View file

@ -0,0 +1,27 @@
'use client';
import { useQueryClient } from "@tanstack/react-query";
import { useDomain } from "./useDomain";
import { unwrapServiceError } from "@/lib/utils";
import { useCallback } from "react";
import { getFolderContents } from "@/features/fileTree/actions";
export const usePrefetchFolderContents = () => {
const queryClient = useQueryClient();
const domain = useDomain();
const prefetchFolderContents = useCallback((repoName: string, revisionName: string, path: string) => {
queryClient.prefetchQuery({
queryKey: ['tree', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(
getFolderContents({
repoName,
revisionName,
path,
}, domain)
),
});
}, [queryClient, domain]);
return { prefetchFolderContents };
}

View file

@ -1,6 +1,12 @@
import { NewsItem } from "./types";
export const newsData: NewsItem[] = [
{
unique_id: "file-explorer",
header: "File explorer",
sub_header: "We've added support for a file explorer when browsing files.",
url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0"
},
{
unique_id: "structured-logging",
header: "Structured logging",

View file

@ -6031,8 +6031,10 @@ __metadata:
react-hotkeys-hook: "npm:^4.5.1"
react-icons: "npm:^5.3.0"
react-resizable-panels: "npm:^2.1.1"
scroll-into-view-if-needed: "npm:^3.1.0"
server-only: "npm:^0.0.1"
sharp: "npm:^0.33.5"
simple-git: "npm:^3.27.0"
strip-json-comments: "npm:^5.0.1"
stripe: "npm:^17.6.0"
tailwind-merge: "npm:^2.5.2"
@ -6043,6 +6045,7 @@ __metadata:
usehooks-ts: "npm:^3.1.0"
vite-tsconfig-paths: "npm:^5.1.3"
vitest: "npm:^2.1.5"
vscode-icons-js: "npm:^11.6.1"
zod: "npm:^3.24.3"
zod-to-json-schema: "npm:^3.24.5"
languageName: unknown
@ -6331,6 +6334,13 @@ __metadata:
languageName: node
linkType: hard
"@types/jasmine@npm:^3.6.3":
version: 3.10.18
resolution: "@types/jasmine@npm:3.10.18"
checksum: 10c0/09914c65b09cb90536debd21c90000b27a26b730881d45e32c5c19cd429cdcf7407cf27dad2b11a5319bbba7e7e6d756b4a6f1f3c854b691bd1f0ac4f4ea1226
languageName: node
linkType: hard
"@types/json-schema@npm:^7.0.15":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
@ -8296,6 +8306,13 @@ __metadata:
languageName: node
linkType: hard
"compute-scroll-into-view@npm:^3.0.2":
version: 3.1.1
resolution: "compute-scroll-into-view@npm:3.1.1"
checksum: 10c0/59761ed62304a9599b52ad75d0d6fbf0669ee2ab7dd472fdb0ad9da36628414c014dea7b5810046560180ad30ffec52a953d19297f66a1d4f3aa0999b9d2521d
languageName: node
linkType: hard
"concat-map@npm:0.0.1":
version: 0.0.1
resolution: "concat-map@npm:0.0.1"
@ -14537,6 +14554,15 @@ __metadata:
languageName: node
linkType: hard
"scroll-into-view-if-needed@npm:^3.1.0":
version: 3.1.0
resolution: "scroll-into-view-if-needed@npm:3.1.0"
dependencies:
compute-scroll-into-view: "npm:^3.0.2"
checksum: 10c0/1f46b090e1e04fcfdef1e384f6d7e615f9f84d4176faf4dbba7347cc0a6e491e5d578eaf4dbe9618dd3d8d38efafde58535b3e00f2a21ce4178c14be364850ff
languageName: node
linkType: hard
"selderee@npm:^0.11.0":
version: 0.11.0
resolution: "selderee@npm:0.11.0"
@ -16320,6 +16346,15 @@ __metadata:
languageName: node
linkType: hard
"vscode-icons-js@npm:^11.6.1":
version: 11.6.1
resolution: "vscode-icons-js@npm:11.6.1"
dependencies:
"@types/jasmine": "npm:^3.6.3"
checksum: 10c0/bae04f20e3a981cd730f92fed29ff07f389837a12f22c7c36656f7ed005bd672d19dd2213c3db16f8dbbf9c4e0655ccb303a847616fbdb290071c7ec4dc99b99
languageName: node
linkType: hard
"vscode-languageserver-types@npm:^3.17.1":
version: 3.17.5
resolution: "vscode-languageserver-types@npm:3.17.5"