diff --git a/CHANGELOG.md b/CHANGELOG.md index d07ef32a..6a295a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Changed repository link in search to file tree + move external link to code host logo. [#340](https://github.com/sourcebot-dev/sourcebot/pull/340) +- Added a basic file search dialog when browsing a repository. [#341](https://github.com/sourcebot-dev/sourcebot/pull/341) ## [4.2.0] - 2025-06-09 diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx index 78d8d5d2..a3dea45b 100644 --- a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -12,6 +12,7 @@ export interface BrowseState { } isBottomPanelCollapsed: boolean; isFileTreePanelCollapsed: boolean; + isFileSearchOpen: boolean; activeExploreMenuTab: "references" | "definitions"; bottomPanelSize: number; } @@ -20,6 +21,7 @@ const defaultState: BrowseState = { selectedSymbolInfo: undefined, isBottomPanelCollapsed: true, isFileTreePanelCollapsed: false, + isFileSearchOpen: false, activeExploreMenuTab: "references", bottomPanelSize: 35, }; diff --git a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx new file mode 100644 index 00000000..27e0261f --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { useState, useRef, useMemo, useEffect, useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useQuery } from "@tanstack/react-query"; +import { unwrapServiceError } from "@/lib/utils"; +import { FileTreeItem, getFiles } from "@/features/fileTree/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { useBrowseNavigation } from "../hooks/useBrowseNavigation"; +import { useBrowseState } from "../hooks/useBrowseState"; +import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; +import { useBrowseParams } from "../hooks/useBrowseParams"; +import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon"; +import { useLocalStorage } from "usehooks-ts"; +import { Skeleton } from "@/components/ui/skeleton"; + +const MAX_RESULTS = 100; + +type SearchResult = { + file: FileTreeItem; + match?: { + from: number; + to: number; + }; +} + + +export const FileSearchCommandDialog = () => { + const { repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState(); + + const commandListRef = useRef(null); + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + const { navigateToPath } = useBrowseNavigation(); + const { prefetchFileSource } = usePrefetchFileSource(); + + const [recentlyOpened, setRecentlyOpened] = useLocalStorage(`recentlyOpenedFiles-${repoName}`, []); + + useHotkeys("mod+p", (event) => { + event.preventDefault(); + updateBrowseState({ + isFileSearchOpen: !isFileSearchOpen, + }); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open File Search", + }); + + // Whenever we open the dialog, clear the search query + useEffect(() => { + if (isFileSearchOpen) { + setSearchQuery(''); + } + }, [isFileSearchOpen]); + + const { data: files, isLoading, isError } = useQuery({ + queryKey: ['files', repoName, revisionName, domain], + queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)), + enabled: isFileSearchOpen, + }); + + const { filteredFiles, maxResultsHit } = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => { + if (!files || isLoading) { + return { + filteredFiles: [], + maxResultsHit: false, + }; + } + + const matches = files + .map((file) => { + return { + file, + matchIndex: file.path.toLowerCase().indexOf(searchQuery.toLowerCase()), + } + }) + .filter(({ matchIndex }) => { + return matchIndex !== -1; + }); + + return { + filteredFiles: matches + .slice(0, MAX_RESULTS) + .map(({ file, matchIndex }) => { + return { + file, + match: { + from: matchIndex, + to: matchIndex + searchQuery.length - 1, + }, + } + }), + maxResultsHit: matches.length > MAX_RESULTS, + } + }, [searchQuery, files, isLoading]); + + // Scroll to the top of the list whenever the search query changes + useEffect(() => { + commandListRef.current?.scrollTo({ + top: 0, + }) + }, [searchQuery]); + + const onSelect = useCallback((file: FileTreeItem) => { + setRecentlyOpened((prev) => { + const filtered = prev.filter(f => f.path !== file.path); + return [file, ...filtered]; + }); + navigateToPath({ + repoName, + revisionName, + path: file.path, + pathType: 'blob', + }); + updateBrowseState({ + isFileSearchOpen: false, + }); + }, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]); + + const onMouseEnter = useCallback((file: FileTreeItem) => { + prefetchFileSource( + repoName, + revisionName ?? 'HEAD', + file.path + ); + }, [prefetchFileSource, repoName, revisionName]); + + // @note: We were hitting issues when the user types into the input field while the files are still + // loading. The workaround was to set `disabled` when loading and then focus the input field when + // the files are loaded, hence the `useEffect` below. + useEffect(() => { + if (!isLoading) { + inputRef.current?.focus(); + } + }, [isLoading]); + + return ( + { + updateBrowseState({ + isFileSearchOpen: isOpen, + }); + }} + modal={true} + > + + Search for files + {`Search for files in the repository ${repoName}.`} + + + { + isLoading ? ( + + ) : isError ? ( +

Error loading files.

+ ) : ( + + {searchQuery.length === 0 ? ( + + No recently opened files. + {recentlyOpened.map((file) => { + return ( + onSelect(file)} + onMouseEnter={() => onMouseEnter(file)} + /> + ); + })} + + ) : ( + <> + No results found. + {filteredFiles.map(({ file, match }) => { + return ( + onSelect(file)} + onMouseEnter={() => onMouseEnter(file)} + /> + ); + })} + {maxResultsHit && ( +
+ Maximum results hit. Please refine your search. +
+ )} + + )} +
+ ) + } +
+
+
+ ) +} + +interface SearchResultComponentProps { + file: FileTreeItem; + match?: { + from: number; + to: number; + }; + onSelect: () => void; + onMouseEnter: () => void; +} + +const SearchResultComponent = ({ + file, + match, + onSelect, + onMouseEnter, +}: SearchResultComponentProps) => { + return ( + +
+ +
+ + {file.name} + + + {match ? ( + + ) : ( + file.path + )} + +
+
+
+ ); +} + +const Highlight = ({ text, range }: { text: string, range: { from: number; to: number } }) => { + return ( + + {text.slice(0, range.from)} + {text.slice(range.from, range.to + 1)} + {text.slice(range.to + 1)} + + ) +} + +const ResultsSkeleton = () => { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ); +}; \ 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 83d92c53..f4c15c66 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -8,6 +8,7 @@ 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"; +import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog"; interface LayoutProps { children: React.ReactNode; @@ -62,6 +63,7 @@ export default function Layout({ + ); -} \ No newline at end of file +} diff --git a/packages/web/src/components/ui/dialog.tsx b/packages/web/src/components/ui/dialog.tsx index 01ff19c7..4d013a60 100644 --- a/packages/web/src/components/ui/dialog.tsx +++ b/packages/web/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< { if (licenseKey) { const expiryDate = new Date(licenseKey.expiryDate); if (expiryDate.getTime() < new Date().getTime()) { - logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); + logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); process.exit(1); } diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index cc91a89e..8b4ca224 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -155,7 +155,58 @@ export const getFolderContents = async (params: { repoName: string, revisionName return contents; }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) -) +); + +export const getFiles = 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', + // only return the names of the files + '--name-only', + ]); + } catch (error) { + logger.error('git ls-tree failed.', { error }); + return unexpectedError('git ls-tree command failed.'); + } + + const paths = result.split('\n').filter(line => line.trim()); + + const files: FileTreeItem[] = paths.map(path => { + const name = path.split('/').pop() ?? ''; + return { + type: 'blob', + path, + name, + } + }); + + return files; + + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) +); const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { diff --git a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx index 3bbe9c83..c914d102 100644 --- a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx +++ b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx @@ -1,12 +1,11 @@ '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 { useEffect, useRef } from "react"; import clsx from "clsx"; import scrollIntoView from 'scroll-into-view-if-needed'; import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { FileTreeItemIcon } from "./fileTreeItemIcon"; export const FileTreeItemComponent = ({ node, @@ -53,24 +52,6 @@ export const FileTreeItemComponent = ({ } }, [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 (
- + {node.name}
) diff --git a/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx b/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx new file mode 100644 index 00000000..1d481e3d --- /dev/null +++ b/packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { FileTreeItem } from "../actions"; +import { useMemo } from "react"; +import { getIconForFile, getIconForFolder } from "vscode-icons-js"; +import { Icon } from '@iconify/react'; +import { cn } from "@/lib/utils"; + +interface FileTreeItemIconProps { + item: FileTreeItem; + className?: string; +} + +export const FileTreeItemIcon = ({ item, className }: FileTreeItemIconProps) => { + const iconName = useMemo(() => { + if (item.type === 'tree') { + const icon = getIconForFolder(item.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } else if (item.type === 'blob') { + const icon = getIconForFile(item.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } + + return "vscode-icons:file-type-unknown"; + }, [item.name, item.type]); + + return ; +} \ No newline at end of file diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index 13ea71a3..fffabeba 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -21,6 +21,7 @@ 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"; +import { SearchIcon } from "lucide-react"; interface FileTreePanelProps { @@ -103,6 +104,25 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {

File Tree

+ + + + + + + + Search files + + {isPending ? (