diff --git a/CHANGELOG.md b/CHANGELOG.md index eac726646..4f593c1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331) - Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332) - Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335) +- Added support for a file explorer when browsing files. [#336](https://github.com/sourcebot-dev/sourcebot/pull/336) ## [4.1.1] - 2025-06-03 diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 5157c022e..3afcfe09e 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -70,6 +70,8 @@ export const arraysEqualShallow = (a?: readonly T[], b?: readonly T[]) => { return true; } +// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`. +// @todo: we should move this to a shared package. export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => { // If we are dealing with a local repository, then use that as the path. // Mark as read-only since we aren't guaranteed to have write access to the local filesystem. diff --git a/packages/web/package.json b/packages/web/package.json index 4ce595500..67db67506 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -137,13 +137,16 @@ "react-hotkeys-hook": "^4.5.1", "react-icons": "^5.3.0", "react-resizable-panels": "^2.1.1", + "scroll-into-view-if-needed": "^3.1.0", "server-only": "^0.0.1", "sharp": "^0.33.5", + "simple-git": "^3.27.0", "strip-json-comments": "^5.0.1", "stripe": "^17.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "usehooks-ts": "^3.1.0", + "vscode-icons-js": "^11.6.1", "zod": "^3.24.3", "zod-to-json-schema": "^3.24.5" }, diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx index 14726b473..13c32a62d 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -1,226 +1,99 @@ 'use client'; -import { ResizablePanel } from "@/components/ui/resizable"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; -import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; -import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; -import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; -import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; -import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; -import { search } from "@codemirror/search"; -import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { EditorContextMenu } from "../../../components/editorContextMenu"; -import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation"; -import { useBrowseState } from "../../hooks/useBrowseState"; -import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; - -interface CodePreviewPanelProps { - path: string; - repoName: string; - revisionName: string; - source: string; - language: string; -} - -export const CodePreviewPanel = ({ - source, - language, - path, - repoName, - revisionName, -}: CodePreviewPanelProps) => { - const [editorRef, setEditorRef] = useState(null); - const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); - const [currentSelection, setCurrentSelection] = useState(); - 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; +import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils"; +import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; +import { useQuery } from "@tanstack/react-query"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { useDomain } from "@/hooks/useDomain"; +import { Loader2 } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getRepoInfoByName } from "@/actions"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { useMemo } from "react"; +import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; + +export const CodePreviewPanel = () => { + const { path, repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + + const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({ + queryKey: ['fileSource', repoName, revisionName, path, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: path, + repository: repoName, + branch: revisionName + }, domain)), + }); + + const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ + queryKey: ['repoInfo', repoName, domain], + queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)), + }); + + const codeHostInfo = useMemo(() => { + if (!repoInfoResponse) { + return undefined; } - // Highlight ranges can be formatted in two ways: - // 1. start_line,end_line (no column specified) - // 2. start_line:start_column,end_line:end_column (column specified) - const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/; - if (!rangeRegex.test(highlightRangeQuery)) { - return; - } - - const [start, end] = highlightRangeQuery.split(',').map((range) => { - if (range.includes(':')) { - return range.split(':').map((val) => parseInt(val, 10)); - } - // For line-only format, use column 1 for start and last column for end - const line = parseInt(range, 10); - return [line]; + return getCodeHostInfoForRepo({ + codeHostType: repoInfoResponse.codeHostType, + name: repoInfoResponse.name, + displayName: repoInfoResponse.displayName, + webUrl: repoInfoResponse.webUrl, }); + }, [repoInfoResponse]); - if (start.length === 1 || end.length === 1) { - return { - start: { - lineNumber: start[0], - }, - end: { - lineNumber: end[0], - } - } - } else { - return { - start: { - lineNumber: start[0], - column: start[1], - }, - end: { - lineNumber: end[0], - column: end[1], - } - } - } - - }, [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]); + if (isFileSourcePending || isRepoInfoPending) { + return ( +
+ + Loading... +
+ ) + } - const onFindReferences = useCallback((symbolName: string) => { - captureEvent('wa_browse_find_references_pressed', {}); - - updateBrowseState({ - selectedSymbolInfo: { - repoName, - symbolName, - revisionName, - language, - }, - isBottomPanelCollapsed: false, - activeExploreMenuTab: "references", - }) - }, [captureEvent, updateBrowseState, repoName, revisionName, language]); - - - // If we resolve multiple matches, instead of navigating to the first match, we should - // instead popup the bottom sheet with the list of matches. - const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { - captureEvent('wa_browse_goto_definition_pressed', {}); - - if (symbolDefinitions.length === 0) { - return; - } - - if (symbolDefinitions.length === 1) { - const symbolDefinition = symbolDefinitions[0]; - const { fileName, repoName } = symbolDefinition; - - navigateToPath({ - repoName, - revisionName, - path: fileName, - pathType: 'blob', - highlightRange: symbolDefinition.range, - }) - } else { - updateBrowseState({ - selectedSymbolInfo: { - symbolName, - repoName, - revisionName, - language, - }, - activeExploreMenuTab: "definitions", - isBottomPanelCollapsed: false, - }) - } - }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]); - - const theme = useCodeMirrorTheme(); + if (isFileSourceError || isRepoInfoError) { + return
Error loading file source
+ } return ( - - - - {editorRef && editorRef.view && currentSelection && ( - + + + + ) -} - +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx new file mode 100644 index 000000000..c8f8384f4 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx @@ -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(null); + const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); + const [currentSelection, setCurrentSelection] = useState(); + 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 ( + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + + ) +} + diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx new file mode 100644 index 000000000..33336ad3f --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx @@ -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(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 ( +
+ + Loading... +
+ ) + } + + if (isFolderContentsError || isRepoInfoError) { + return
Error loading tree
+ } + + return ( + <> +
+ +
+ + + {data.map((item) => ( + onNodeClicked(item)} + onMouseEnter={() => onNodeMouseEnter(item)} + parentRef={scrollAreaRef} + /> + ))} + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 12a290a77..3099f8161 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,151 +1,20 @@ -import { FileHeader } from "@/app/[domain]/components/fileHeader"; -import { TopBar } from "@/app/[domain]/components/topBar"; -import { Separator } from '@/components/ui/separator'; -import { getFileSource } from '@/features/search/fileSourceApi'; -import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; -import { base64Decode } from "@/lib/utils"; -import { ErrorCode } from "@/lib/errorCodes"; -import { LuFileX2, LuBookX } from "react-icons/lu"; -import { notFound } from "next/navigation"; -import { ServiceErrorException } from "@/lib/serviceError"; -import { getRepoInfoByName } from "@/actions"; -import { CodePreviewPanel } from "./components/codePreviewPanel"; -import Image from "next/image"; - -interface BrowsePageProps { - params: { - path: string[]; - domain: string; - }; -} - -export default async function BrowsePage({ - params, -}: BrowsePageProps) { - const rawPath = decodeURIComponent(params.path.join('/')); - const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); - if (sentinalIndex === -1) { - notFound(); - } - - const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); - const repoName = repoAndRevisionName[0]; - const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; - - const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { - const path = rawPath.substring(sentinalIndex + '/-/'.length); - const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; - switch (pathType) { - case 'tree': - return { - path: path.substring('tree/'.length), - pathType, - }; - case 'blob': - return { - path: path.substring('blob/'.length), - pathType, - }; - } - })(); - - const repoInfo = await getRepoInfoByName(repoName, params.domain); - if (isServiceError(repoInfo)) { - if (repoInfo.errorCode === ErrorCode.NOT_FOUND) { - return ( -
-
- - Repository not found -
-
- ); - } +'use client'; - throw new ServiceErrorException(repoInfo); - } - - if (pathType === 'tree') { - // @todo : proper tree handling - return ( - <> - Tree view not supported - - ) - } - - const fileSourceResponse = await getFileSource({ - fileName: path, - repository: repoName, - branch: revisionName ?? 'HEAD', - }, params.domain); - - if (isServiceError(fileSourceResponse)) { - if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { - return ( -
-
- - File not found -
-
- ) - } - - throw new ServiceErrorException(fileSourceResponse); - } - - const codeHostInfo = getCodeHostInfoForRepo({ - codeHostType: repoInfo.codeHostType, - name: repoInfo.name, - displayName: repoInfo.displayName, - webUrl: repoInfo.webUrl, - }); +import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; +import { CodePreviewPanel } from "./components/codePreviewPanel"; +import { TreePreviewPanel } from "./components/treePreviewPanel"; +export default function BrowsePage() { + const { pathType } = useBrowseParams(); return ( - <> -
- - -
- - {(fileSourceResponse.webUrl && codeHostInfo) && ( - - {codeHostInfo.codeHostName} - Open in {codeHostInfo.codeHostName} - - )} -
- -
- - +
+ + {pathType === 'blob' ? ( + + ) : ( + + )} +
) } + diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx index 9a4bb4b3e..78d8d5d26 100644 --- a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -2,7 +2,6 @@ import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { createContext, useCallback, useEffect, useState } from "react"; -import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel"; export interface BrowseState { selectedSymbolInfo?: { @@ -12,6 +11,7 @@ export interface BrowseState { language: string; } isBottomPanelCollapsed: boolean; + isFileTreePanelCollapsed: boolean; activeExploreMenuTab: "references" | "definitions"; bottomPanelSize: number; } @@ -19,8 +19,9 @@ export interface BrowseState { const defaultState: BrowseState = { selectedSymbolInfo: undefined, isBottomPanelCollapsed: true, + isFileTreePanelCollapsed: false, activeExploreMenuTab: "references", - bottomPanelSize: BOTTOM_PANEL_MIN_SIZE, + bottomPanelSize: 35, }; export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; @@ -33,8 +34,13 @@ export const BrowseStateContext = createContext<{ updateBrowseState: () => {}, }); -export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => { +interface BrowseStateProviderProps { + children: React.ReactNode; +} + +export const BrowseStateProvider = ({ children }: BrowseStateProviderProps) => { const [state, setState] = useState(defaultState); + const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); const onUpdateState = useCallback((state: Partial) => { diff --git a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx index 90ec4b742..4a155207a 100644 --- a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx +++ b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx @@ -20,7 +20,11 @@ export const BOTTOM_PANEL_MIN_SIZE = 35; export const BOTTOM_PANEL_MAX_SIZE = 65; const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation"; -export const BottomPanel = () => { +interface BottomPanelProps { + order: number; +} + +export const BottomPanel = ({ order }: BottomPanelProps) => { const panelRef = useRef(null); const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const domain = useDomain(); @@ -94,7 +98,7 @@ export const BottomPanel = () => { updateBrowseState({ bottomPanelSize: size }); } }} - order={2} + order={order} id={"bottom-panel"} > {!hasCodeNavEntitlement ? ( diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts new file mode 100644 index 000000000..b671d3fcf --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts @@ -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, + } +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index 4f23d9cc1..83d92c53b 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -1,24 +1,65 @@ -import { ResizablePanelGroup } from "@/components/ui/resizable"; +'use client'; + +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { BottomPanel } from "./components/bottomPanel"; import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; import { BrowseStateProvider } from "./browseStateProvider"; +import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; +import { TopBar } from "@/app/[domain]/components/topBar"; +import { Separator } from '@/components/ui/separator'; +import { useBrowseParams } from "./hooks/useBrowseParams"; interface LayoutProps { children: React.ReactNode; + params: { + domain: string; + } } export default function Layout({ - children, + children: codePreviewPanel, + params, }: LayoutProps) { + const { repoName, revisionName } = useBrowseParams(); + return (
+
+ + +
- {children} + + - + + + + + {codePreviewPanel} + + + + +
diff --git a/packages/web/src/app/[domain]/components/fileHeader.tsx b/packages/web/src/app/[domain]/components/fileHeader.tsx deleted file mode 100644 index 949c345a0..000000000 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ /dev/null @@ -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 ( -
- {info?.icon ? ( - {info.codeHostName} - ): ( - - )} - - {info?.displayName} - - {branchDisplayName && ( -

- @ - {`${branchDisplayName}`} -

- )} - · -
- { - navigateToPath({ - repoName: repo.name, - path: fileName, - pathType: 'blob', - revisionName: branchDisplayName, - }); - }} - > - {!fileNameHighlightRange ? - fileName - : ( - <> - {fileName.slice(0, fileNameHighlightRange.from)} - - {fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)} - - {fileName.slice(fileNameHighlightRange.to)} - - )} - - -
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx new file mode 100644 index 000000000..801979f7d --- /dev/null +++ b/packages/web/src/app/[domain]/components/pathHeader.tsx @@ -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(null); + const breadcrumbsRef = useRef(null); + const [visibleSegmentCount, setVisibleSegmentCount] = useState(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)} + + {segment.name.slice(from, to)} + + {segment.name.slice(to)} + + ); + }; + + return ( +
+ {info?.icon ? ( + {info.codeHostName} + ): ( + + )} + + {info?.displayName} + + {branchDisplayName && ( +

+ @ + {`${branchDisplayName}`} +

+ )} + · +
+
+ {hiddenSegments.length > 0 && ( + <> + + + + + + {hiddenSegments.map((segment) => ( + onBreadcrumbClick(segment)} + onMouseEnter={() => onBreadcrumbMouseEnter(segment)} + className="font-mono text-sm cursor-pointer" + > + {renderSegmentWithHighlight(segment)} + + ))} + + + + + )} + {visibleSegments.map((segment, index) => ( +
+ onBreadcrumbClick(segment)} + onMouseEnter={() => onBreadcrumbMouseEnter(segment)} + > + {renderSegmentWithHighlight(segment)} + + {index < visibleSegments.length - 1 && ( + + )} +
+ ))} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index c179aaccf..820521b9f 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { Separator } from "@/components/ui/separator"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; import { useMemo } from "react"; @@ -92,15 +92,15 @@ export const FileMatchContainer = ({ top: `-${yOffset}px`, }} > - diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index e307d74c6..b44342ac4 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -267,7 +267,7 @@ const PanelGroup = ({ > + + + + + Close file tree + + +

File Tree

+ + + {isPending ? ( + + ) : + isError ? ( +
+

Error loading file tree

+
+ ) : ( + + )} + +
+ {isFileTreePanelCollapsed && ( +
+ + + + + + + + Open file tree + + +
+ )} + + ) +} + + +const FileTreePanelSkeleton = () => { + return ( +
+ {/* Root level items */} +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx new file mode 100644 index 000000000..d0c1deb1c --- /dev/null +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -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 & { + 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(buildCollapsableTree(_tree)); + const scrollAreaRef = useRef(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 ( + + onNodeClicked(node)} + onMouseEnter={() => onNodeMouseEnter(node)} + parentRef={scrollAreaRef} + /> + {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} + + ); + })} + + ); + }, [path, onNodeClicked, onNodeMouseEnter]); + + const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); + + return ( + + {renderedTree} + + + ) +} + diff --git a/packages/web/src/hooks/usePrefetchFileSource.ts b/packages/web/src/hooks/usePrefetchFileSource.ts new file mode 100644 index 000000000..755d7fdb9 --- /dev/null +++ b/packages/web/src/hooks/usePrefetchFileSource.ts @@ -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 }; +} \ No newline at end of file diff --git a/packages/web/src/hooks/usePrefetchFolderContents.ts b/packages/web/src/hooks/usePrefetchFolderContents.ts new file mode 100644 index 000000000..8bbcc5f8d --- /dev/null +++ b/packages/web/src/hooks/usePrefetchFolderContents.ts @@ -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 }; +} \ No newline at end of file diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 48ecdcf08..fe0701366 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,6 +1,12 @@ import { NewsItem } from "./types"; export const newsData: NewsItem[] = [ + { + unique_id: "file-explorer", + header: "File explorer", + sub_header: "We've added support for a file explorer when browsing files.", + url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0" + }, { unique_id: "structured-logging", header: "Structured logging", diff --git a/yarn.lock b/yarn.lock index 76773d746..7205c98fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6031,8 +6031,10 @@ __metadata: react-hotkeys-hook: "npm:^4.5.1" react-icons: "npm:^5.3.0" react-resizable-panels: "npm:^2.1.1" + scroll-into-view-if-needed: "npm:^3.1.0" server-only: "npm:^0.0.1" sharp: "npm:^0.33.5" + simple-git: "npm:^3.27.0" strip-json-comments: "npm:^5.0.1" stripe: "npm:^17.6.0" tailwind-merge: "npm:^2.5.2" @@ -6043,6 +6045,7 @@ __metadata: usehooks-ts: "npm:^3.1.0" vite-tsconfig-paths: "npm:^5.1.3" vitest: "npm:^2.1.5" + vscode-icons-js: "npm:^11.6.1" zod: "npm:^3.24.3" zod-to-json-schema: "npm:^3.24.5" languageName: unknown @@ -6331,6 +6334,13 @@ __metadata: languageName: node linkType: hard +"@types/jasmine@npm:^3.6.3": + version: 3.10.18 + resolution: "@types/jasmine@npm:3.10.18" + checksum: 10c0/09914c65b09cb90536debd21c90000b27a26b730881d45e32c5c19cd429cdcf7407cf27dad2b11a5319bbba7e7e6d756b4a6f1f3c854b691bd1f0ac4f4ea1226 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -8296,6 +8306,13 @@ __metadata: languageName: node linkType: hard +"compute-scroll-into-view@npm:^3.0.2": + version: 3.1.1 + resolution: "compute-scroll-into-view@npm:3.1.1" + checksum: 10c0/59761ed62304a9599b52ad75d0d6fbf0669ee2ab7dd472fdb0ad9da36628414c014dea7b5810046560180ad30ffec52a953d19297f66a1d4f3aa0999b9d2521d + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -14537,6 +14554,15 @@ __metadata: languageName: node linkType: hard +"scroll-into-view-if-needed@npm:^3.1.0": + version: 3.1.0 + resolution: "scroll-into-view-if-needed@npm:3.1.0" + dependencies: + compute-scroll-into-view: "npm:^3.0.2" + checksum: 10c0/1f46b090e1e04fcfdef1e384f6d7e615f9f84d4176faf4dbba7347cc0a6e491e5d578eaf4dbe9618dd3d8d38efafde58535b3e00f2a21ce4178c14be364850ff + languageName: node + linkType: hard + "selderee@npm:^0.11.0": version: 0.11.0 resolution: "selderee@npm:0.11.0" @@ -16320,6 +16346,15 @@ __metadata: languageName: node linkType: hard +"vscode-icons-js@npm:^11.6.1": + version: 11.6.1 + resolution: "vscode-icons-js@npm:11.6.1" + dependencies: + "@types/jasmine": "npm:^3.6.3" + checksum: 10c0/bae04f20e3a981cd730f92fed29ff07f389837a12f22c7c36656f7ed005bd672d19dd2213c3db16f8dbbf9c4e0655ccb303a847616fbdb290071c7ec4dc99b99 + languageName: node + linkType: hard + "vscode-languageserver-types@npm:^3.17.1": version: 3.17.5 resolution: "vscode-languageserver-types@npm:3.17.5"