mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
feature: File explorer (#336)
This commit is contained in:
parent
8dc41a22b9
commit
27fb5ad294
24 changed files with 1762 additions and 501 deletions
|
|
@ -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)
|
- 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)
|
- 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 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
|
## [4.1.1] - 2025-06-03
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
|
||||||
return true;
|
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 } => {
|
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.
|
// 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.
|
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
|
||||||
|
|
|
||||||
|
|
@ -137,13 +137,16 @@
|
||||||
"react-hotkeys-hook": "^4.5.1",
|
"react-hotkeys-hook": "^4.5.1",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-resizable-panels": "^2.1.1",
|
"react-resizable-panels": "^2.1.1",
|
||||||
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
|
"simple-git": "^3.27.0",
|
||||||
"strip-json-comments": "^5.0.1",
|
"strip-json-comments": "^5.0.1",
|
||||||
"stripe": "^17.6.0",
|
"stripe": "^17.6.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"vscode-icons-js": "^11.6.1",
|
||||||
"zod": "^3.24.3",
|
"zod": "^3.24.3",
|
||||||
"zod-to-json-schema": "^3.24.5"
|
"zod-to-json-schema": "^3.24.5"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,226 +1,99 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ResizablePanel } from "@/components/ui/resizable";
|
import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
|
||||||
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
|
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||||
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
import { getRepoInfoByName } from "@/actions";
|
||||||
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
import { cn } from "@/lib/utils";
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
import Image from "next/image";
|
||||||
import { search } from "@codemirror/search";
|
import { useMemo } from "react";
|
||||||
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
|
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { PathHeader } from "@/app/[domain]/components/pathHeader";
|
||||||
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 CodePreviewPanelProps {
|
export const CodePreviewPanel = () => {
|
||||||
path: string;
|
const { path, repoName, revisionName } = useBrowseParams();
|
||||||
repoName: string;
|
const domain = useDomain();
|
||||||
revisionName: string;
|
|
||||||
source: string;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodePreviewPanel = ({
|
const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({
|
||||||
source,
|
queryKey: ['fileSource', repoName, revisionName, path, domain],
|
||||||
language,
|
queryFn: () => unwrapServiceError(getFileSource({
|
||||||
path,
|
fileName: path,
|
||||||
repoName,
|
repository: repoName,
|
||||||
revisionName,
|
branch: revisionName
|
||||||
}: CodePreviewPanelProps) => {
|
}, domain)),
|
||||||
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) {
|
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
|
||||||
return {
|
queryKey: ['repoInfo', repoName, domain],
|
||||||
start: {
|
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
|
||||||
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) => {
|
const codeHostInfo = useMemo(() => {
|
||||||
captureEvent('wa_browse_find_references_pressed', {});
|
if (!repoInfoResponse) {
|
||||||
|
return undefined;
|
||||||
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) {
|
return getCodeHostInfoForRepo({
|
||||||
const symbolDefinition = symbolDefinitions[0];
|
codeHostType: repoInfoResponse.codeHostType,
|
||||||
const { fileName, repoName } = symbolDefinition;
|
name: repoInfoResponse.name,
|
||||||
|
displayName: repoInfoResponse.displayName,
|
||||||
|
webUrl: repoInfoResponse.webUrl,
|
||||||
|
});
|
||||||
|
}, [repoInfoResponse]);
|
||||||
|
|
||||||
navigateToPath({
|
if (isFileSourcePending || isRepoInfoPending) {
|
||||||
repoName,
|
return (
|
||||||
revisionName,
|
<div className="flex flex-col w-full min-h-full items-center justify-center">
|
||||||
path: fileName,
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
pathType: 'blob',
|
Loading...
|
||||||
highlightRange: symbolDefinition.range,
|
</div>
|
||||||
})
|
)
|
||||||
} 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 (
|
return (
|
||||||
<ResizablePanel
|
<>
|
||||||
order={1}
|
<div className="flex flex-row py-1 px-2 items-center justify-between">
|
||||||
id={"code-preview-panel"}
|
<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"
|
||||||
>
|
>
|
||||||
<ScrollArea className="h-full overflow-auto flex-1">
|
<Image
|
||||||
<CodeMirror
|
src={codeHostInfo.icon}
|
||||||
className="relative"
|
alt={codeHostInfo.codeHostName}
|
||||||
ref={setEditorRef}
|
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
|
||||||
value={source}
|
/>
|
||||||
extensions={extensions}
|
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
|
||||||
readOnly={true}
|
</a>
|
||||||
theme={theme}
|
)}
|
||||||
>
|
</div>
|
||||||
{editorRef && editorRef.view && currentSelection && (
|
<Separator />
|
||||||
<EditorContextMenu
|
<PureCodePreviewPanel
|
||||||
view={editorRef.view}
|
source={base64Decode(fileSourceResponse.source)}
|
||||||
selection={currentSelection}
|
language={fileSourceResponse.language}
|
||||||
repoName={repoName}
|
repoName={repoName}
|
||||||
path={path}
|
path={path}
|
||||||
revisionName={revisionName}
|
revisionName={revisionName ?? 'HEAD'}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
{editorRef && hasCodeNavEntitlement && (
|
|
||||||
<SymbolHoverPopup
|
|
||||||
editorRef={editorRef}
|
|
||||||
revisionName={revisionName}
|
|
||||||
language={language}
|
|
||||||
onFindReferences={onFindReferences}
|
|
||||||
onGotoDefinition={onGotoDefinition}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CodeMirror>
|
|
||||||
|
|
||||||
</ScrollArea>
|
|
||||||
</ResizablePanel>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,151 +1,20 @@
|
||||||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
'use client';
|
||||||
import { TopBar } from "@/app/[domain]/components/topBar";
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
|
||||||
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";
|
|
||||||
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||||
import Image from "next/image";
|
import { TreePreviewPanel } from "./components/treePreviewPanel";
|
||||||
|
|
||||||
interface BrowsePageProps {
|
export default function BrowsePage() {
|
||||||
params: {
|
const { pathType } = useBrowseParams();
|
||||||
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex flex-col 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);
|
{pathType === 'blob' ? (
|
||||||
}
|
<CodePreviewPanel />
|
||||||
|
) : (
|
||||||
if (pathType === 'tree') {
|
<TreePreviewPanel />
|
||||||
// @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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
</div>
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<CodePreviewPanel
|
|
||||||
source={base64Decode(fileSourceResponse.source)}
|
|
||||||
language={fileSourceResponse.language}
|
|
||||||
repoName={repoInfo.name}
|
|
||||||
path={path}
|
|
||||||
revisionName={revisionName ?? 'HEAD'}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
import { createContext, useCallback, useEffect, useState } from "react";
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel";
|
|
||||||
|
|
||||||
export interface BrowseState {
|
export interface BrowseState {
|
||||||
selectedSymbolInfo?: {
|
selectedSymbolInfo?: {
|
||||||
|
|
@ -12,6 +11,7 @@ export interface BrowseState {
|
||||||
language: string;
|
language: string;
|
||||||
}
|
}
|
||||||
isBottomPanelCollapsed: boolean;
|
isBottomPanelCollapsed: boolean;
|
||||||
|
isFileTreePanelCollapsed: boolean;
|
||||||
activeExploreMenuTab: "references" | "definitions";
|
activeExploreMenuTab: "references" | "definitions";
|
||||||
bottomPanelSize: number;
|
bottomPanelSize: number;
|
||||||
}
|
}
|
||||||
|
|
@ -19,8 +19,9 @@ export interface BrowseState {
|
||||||
const defaultState: BrowseState = {
|
const defaultState: BrowseState = {
|
||||||
selectedSymbolInfo: undefined,
|
selectedSymbolInfo: undefined,
|
||||||
isBottomPanelCollapsed: true,
|
isBottomPanelCollapsed: true,
|
||||||
|
isFileTreePanelCollapsed: false,
|
||||||
activeExploreMenuTab: "references",
|
activeExploreMenuTab: "references",
|
||||||
bottomPanelSize: BOTTOM_PANEL_MIN_SIZE,
|
bottomPanelSize: 35,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState";
|
export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState";
|
||||||
|
|
@ -33,8 +34,13 @@ export const BrowseStateContext = createContext<{
|
||||||
updateBrowseState: () => {},
|
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 [state, setState] = useState<BrowseState>(defaultState);
|
||||||
|
|
||||||
const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM);
|
const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM);
|
||||||
|
|
||||||
const onUpdateState = useCallback((state: Partial<BrowseState>) => {
|
const onUpdateState = useCallback((state: Partial<BrowseState>) => {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@ export const BOTTOM_PANEL_MIN_SIZE = 35;
|
||||||
export const BOTTOM_PANEL_MAX_SIZE = 65;
|
export const BOTTOM_PANEL_MAX_SIZE = 65;
|
||||||
const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation";
|
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 panelRef = useRef<ImperativePanelHandle>(null);
|
||||||
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
@ -94,7 +98,7 @@ export const BottomPanel = () => {
|
||||||
updateBrowseState({ bottomPanelSize: size });
|
updateBrowseState({ bottomPanelSize: size });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
order={2}
|
order={order}
|
||||||
id={"bottom-panel"}
|
id={"bottom-panel"}
|
||||||
>
|
>
|
||||||
{!hasCodeNavEntitlement ? (
|
{!hasCodeNavEntitlement ? (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 { BottomPanel } from "./components/bottomPanel";
|
||||||
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||||
import { BrowseStateProvider } from "./browseStateProvider";
|
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 {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
params: {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout({
|
||||||
children,
|
children: codePreviewPanel,
|
||||||
|
params,
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
|
const { repoName, revisionName } = useBrowseParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowseStateProvider>
|
<BrowseStateProvider>
|
||||||
<div className="flex flex-col h-screen">
|
<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="horizontal"
|
||||||
|
>
|
||||||
|
<FileTreePanel order={1} />
|
||||||
|
|
||||||
|
<AnimatedResizableHandle />
|
||||||
|
|
||||||
|
<ResizablePanel
|
||||||
|
order={2}
|
||||||
|
minSize={10}
|
||||||
|
defaultSize={80}
|
||||||
|
id="code-preview-panel-container"
|
||||||
|
>
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
>
|
>
|
||||||
{children}
|
<ResizablePanel
|
||||||
|
order={1}
|
||||||
|
id="code-preview-panel"
|
||||||
|
>
|
||||||
|
{codePreviewPanel}
|
||||||
|
</ResizablePanel>
|
||||||
<AnimatedResizableHandle />
|
<AnimatedResizableHandle />
|
||||||
<BottomPanel />
|
<BottomPanel
|
||||||
|
order={2}
|
||||||
|
/>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
</BrowseStateProvider>
|
</BrowseStateProvider>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
316
packages/web/src/app/[domain]/components/pathHeader.tsx
Normal file
316
packages/web/src/app/[domain]/components/pathHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
import { PathHeader } from "@/app/[domain]/components/pathHeader";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
@ -92,15 +92,15 @@ export const FileMatchContainer = ({
|
||||||
top: `-${yOffset}px`,
|
top: `-${yOffset}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileHeader
|
<PathHeader
|
||||||
repo={{
|
repo={{
|
||||||
name: repo.name,
|
name: repo.name,
|
||||||
codeHostType: repo.codeHostType,
|
codeHostType: repo.codeHostType,
|
||||||
displayName: repo.displayName,
|
displayName: repo.displayName,
|
||||||
webUrl: repo.webUrl,
|
webUrl: repo.webUrl,
|
||||||
}}
|
}}
|
||||||
fileName={file.fileName.text}
|
path={file.fileName.text}
|
||||||
fileNameHighlightRange={fileNameRange}
|
pathHighlightRange={fileNameRange}
|
||||||
branchDisplayName={branchDisplayName}
|
branchDisplayName={branchDisplayName}
|
||||||
branchDisplayTitle={branches.join(", ")}
|
branchDisplayTitle={branches.join(", ")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,7 @@ const PanelGroup = ({
|
||||||
>
|
>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
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 { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||||
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
|
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
|
||||||
import { RepositoryInfo, SourceRange } from "@/features/search/types";
|
import { RepositoryInfo, SourceRange } from "@/features/search/types";
|
||||||
|
|
@ -9,6 +9,7 @@ import { base64Decode } from "@/lib/utils";
|
||||||
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,6 +32,7 @@ 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);
|
||||||
|
|
@ -89,14 +91,14 @@ export const ReferenceList = ({
|
||||||
top: `-${virtualRow.start}px`,
|
top: `-${virtualRow.start}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileHeader
|
<PathHeader
|
||||||
repo={{
|
repo={{
|
||||||
name: repoInfo.name,
|
name: repoInfo.name,
|
||||||
displayName: repoInfo.displayName,
|
displayName: repoInfo.displayName,
|
||||||
codeHostType: repoInfo.codeHostType,
|
codeHostType: repoInfo.codeHostType,
|
||||||
webUrl: repoInfo.webUrl,
|
webUrl: repoInfo.webUrl,
|
||||||
}}
|
}}
|
||||||
fileName={file.fileName}
|
path={file.fileName}
|
||||||
branchDisplayName={revisionName === "HEAD" ? undefined : revisionName}
|
branchDisplayName={revisionName === "HEAD" ? undefined : revisionName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,6 +121,13 @@ 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>
|
||||||
|
|
@ -136,6 +145,7 @@ interface ReferenceListItemProps {
|
||||||
range: SourceRange;
|
range: SourceRange;
|
||||||
language: string;
|
language: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReferenceListItem = ({
|
const ReferenceListItem = ({
|
||||||
|
|
@ -143,6 +153,7 @@ const ReferenceListItem = ({
|
||||||
range,
|
range,
|
||||||
language,
|
language,
|
||||||
onClick,
|
onClick,
|
||||||
|
onMouseEnter,
|
||||||
}: ReferenceListItemProps) => {
|
}: ReferenceListItemProps) => {
|
||||||
const decodedLineContent = useMemo(() => {
|
const decodedLineContent = useMemo(() => {
|
||||||
return base64Decode(lineContent);
|
return base64Decode(lineContent);
|
||||||
|
|
@ -154,6 +165,7 @@ 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}
|
||||||
|
|
|
||||||
234
packages/web/src/features/fileTree/actions.ts
Normal file
234
packages/web/src/features/fileTree/actions.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
309
packages/web/src/features/fileTree/components/fileTreePanel.tsx
Normal file
309
packages/web/src/features/fileTree/components/fileTreePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
25
packages/web/src/hooks/usePrefetchFileSource.ts
Normal file
25
packages/web/src/hooks/usePrefetchFileSource.ts
Normal 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 };
|
||||||
|
}
|
||||||
27
packages/web/src/hooks/usePrefetchFolderContents.ts
Normal file
27
packages/web/src/hooks/usePrefetchFolderContents.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { NewsItem } from "./types";
|
import { NewsItem } from "./types";
|
||||||
|
|
||||||
export const newsData: NewsItem[] = [
|
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",
|
unique_id: "structured-logging",
|
||||||
header: "Structured logging",
|
header: "Structured logging",
|
||||||
|
|
|
||||||
35
yarn.lock
35
yarn.lock
|
|
@ -6031,8 +6031,10 @@ __metadata:
|
||||||
react-hotkeys-hook: "npm:^4.5.1"
|
react-hotkeys-hook: "npm:^4.5.1"
|
||||||
react-icons: "npm:^5.3.0"
|
react-icons: "npm:^5.3.0"
|
||||||
react-resizable-panels: "npm:^2.1.1"
|
react-resizable-panels: "npm:^2.1.1"
|
||||||
|
scroll-into-view-if-needed: "npm:^3.1.0"
|
||||||
server-only: "npm:^0.0.1"
|
server-only: "npm:^0.0.1"
|
||||||
sharp: "npm:^0.33.5"
|
sharp: "npm:^0.33.5"
|
||||||
|
simple-git: "npm:^3.27.0"
|
||||||
strip-json-comments: "npm:^5.0.1"
|
strip-json-comments: "npm:^5.0.1"
|
||||||
stripe: "npm:^17.6.0"
|
stripe: "npm:^17.6.0"
|
||||||
tailwind-merge: "npm:^2.5.2"
|
tailwind-merge: "npm:^2.5.2"
|
||||||
|
|
@ -6043,6 +6045,7 @@ __metadata:
|
||||||
usehooks-ts: "npm:^3.1.0"
|
usehooks-ts: "npm:^3.1.0"
|
||||||
vite-tsconfig-paths: "npm:^5.1.3"
|
vite-tsconfig-paths: "npm:^5.1.3"
|
||||||
vitest: "npm:^2.1.5"
|
vitest: "npm:^2.1.5"
|
||||||
|
vscode-icons-js: "npm:^11.6.1"
|
||||||
zod: "npm:^3.24.3"
|
zod: "npm:^3.24.3"
|
||||||
zod-to-json-schema: "npm:^3.24.5"
|
zod-to-json-schema: "npm:^3.24.5"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
|
|
@ -6331,6 +6334,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/json-schema@npm:^7.0.15":
|
||||||
version: 7.0.15
|
version: 7.0.15
|
||||||
resolution: "@types/json-schema@npm:7.0.15"
|
resolution: "@types/json-schema@npm:7.0.15"
|
||||||
|
|
@ -8296,6 +8306,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"concat-map@npm:0.0.1":
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
resolution: "concat-map@npm:0.0.1"
|
resolution: "concat-map@npm:0.0.1"
|
||||||
|
|
@ -14537,6 +14554,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"selderee@npm:^0.11.0":
|
||||||
version: 0.11.0
|
version: 0.11.0
|
||||||
resolution: "selderee@npm:0.11.0"
|
resolution: "selderee@npm:0.11.0"
|
||||||
|
|
@ -16320,6 +16346,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"vscode-languageserver-types@npm:^3.17.1":
|
||||||
version: 3.17.5
|
version: 3.17.5
|
||||||
resolution: "vscode-languageserver-types@npm:3.17.5"
|
resolution: "vscode-languageserver-types@npm:3.17.5"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue