From fb6ba8c4cf1cf2d0ddb5c0f9bdaf27220a0bd308 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 6 Jun 2025 13:22:37 -0700 Subject: [PATCH 1/9] wip --- .../web/src/app/[domain]/browse/layout.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index 83d92c53..92cd0c2b 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -8,6 +8,9 @@ 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 { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; interface LayoutProps { children: React.ReactNode; @@ -62,6 +65,35 @@ export default function Layout({ + ); +} + +const FileSearchCommandDialog = () => { + + const [isOpen, setIsOpen] = useState(false); + + useHotkeys("mod+p", (event) => { + event.preventDefault(); + setIsOpen((prev) => !prev); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open File Search", + }); + + return ( + + + + No results found. + Ok + Letsgo + + + ) } \ No newline at end of file From d16bb79be17cb943ad9e8a9a5f021aa213e4ca9b Mon Sep 17 00:00:00 2001 From: bkellam Date: Sat, 7 Jun 2025 14:08:31 -0700 Subject: [PATCH 2/9] further wip --- .../web/src/app/[domain]/browse/layout.tsx | 194 ++++++++++++++++-- packages/web/src/features/fileTree/actions.ts | 53 ++++- 2 files changed, 234 insertions(+), 13 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index 92cd0c2b..23320e49 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -8,9 +8,15 @@ 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 { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { useState } from "react"; +import { Command, CommandEmpty, 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 { getFiles } from "@/features/fileTree/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import Fuse from "fuse.js"; interface LayoutProps { children: React.ReactNode; @@ -70,9 +76,14 @@ export default function Layout({ ); } + const FileSearchCommandDialog = () => { + const { repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); const [isOpen, setIsOpen] = useState(false); + const commandListRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); useHotkeys("mod+p", (event) => { event.preventDefault(); @@ -83,17 +94,176 @@ const FileSearchCommandDialog = () => { description: "Open File Search", }); + const { data: files, isLoading, isError } = useQuery({ + queryKey: ['files', repoName, revisionName, domain], + queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)), + enabled: isOpen, + }); + + const fuse = useMemo(() => { + return new Fuse(files ?? [], { + keys: [ + { + name: 'path', + weight: 0.3, + }, + { + name: 'name', + weight: 0.7, + }, + ], + threshold: 0.3, + minMatchCharLength: 2, + isCaseSensitive: false, + includeMatches: true, + }); + }, [files]); + + const filteredFiles = useMemo(() => { + if (searchQuery.length === 0) { + return files?.map((file) => ({ + file, + matches: [], + })) ?? []; + } + + return fuse + .search(searchQuery) + .map((result) => { + const { item, matches } = result; + return { + file: item, + matches: matches!, + } + }); + }, [files, searchQuery, fuse]); + + // Scroll to the top of the list when the user types + useEffect(() => { + commandListRef.current?.scrollTo({ + top: 0, + }) + }, [searchQuery]); + + const onOpenChange = useCallback(() => { + setIsOpen(false); + setSearchQuery(''); + }, []); + return ( - - - - No results found. - Ok - Letsgo - - + + + + { + isLoading ? ( +
Loading...
+ ) : + isError ? ( + Error loading files. + ) : ( + + {filteredFiles.map(({ file, matches }) => { + const nameMatch = matches.find(m => m.key === 'name'); + const pathMatch = matches.find(m => m.key === 'path'); + + return ( + +
+ + {nameMatch ? + : + file.name + } + + + {pathMatch ? + : + file.path + } + +
+
+ ); + })} +
+ ) + } +
+
+ ) -} \ No newline at end of file +} + +interface HighlightedTextProps { + text: string; + indices: readonly [number, number][]; +} + +const HighlightedText = ({ text, indices }: HighlightedTextProps) => { + if (!indices || indices.length === 0) { + return <>{text}; + } + + // Create an array of segments with their highlight status + const segments: { text: string; highlighted: boolean }[] = []; + let lastIndex = 0; + + // Sort indices by start position + const sortedIndices = [...indices].sort((a, b) => a[0] - b[0]); + + sortedIndices.forEach(([start, end]) => { + // Add non-highlighted text before this match + if (start > lastIndex) { + segments.push({ + text: text.slice(lastIndex, start), + highlighted: false + }); + } + + // Add highlighted text + segments.push({ + text: text.slice(start, end + 1), + highlighted: true + }); + + lastIndex = end + 1; + }); + + // Add remaining non-highlighted text + if (lastIndex < text.length) { + segments.push({ + text: text.slice(lastIndex), + highlighted: false + }); + } + + return ( + <> + {segments.map((segment, index) => ( + segment.highlighted ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + ))} + + ); +}; diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index cc91a89e..261560e6 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-files failed.', { error }); + return unexpectedError('git ls-files 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 = { From cfed5cae4733c48725317141bb18b232fe03cf62 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 9 Jun 2025 10:38:29 -0700 Subject: [PATCH 3/9] further wip --- .../[domain]/browse/browseStateProvider.tsx | 2 + .../components/fileSearchCommandDialog.tsx | 215 ++++++++++++++++++ .../web/src/app/[domain]/browse/layout.tsx | 202 +--------------- .../components/fileTreeItemComponent.tsx | 25 +- .../fileTree/components/fileTreeItemIcon.tsx | 34 +++ 5 files changed, 255 insertions(+), 223 deletions(-) create mode 100644 packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx create mode 100644 packages/web/src/features/fileTree/components/fileTreeItemIcon.tsx 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..aaf8e6f9 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { Command, CommandEmpty, 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"; + +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 [searchQuery, setSearchQuery] = useState(''); + const { navigateToPath } = useBrowseNavigation(); + const { prefetchFileSource } = usePrefetchFileSource(); + + const onOpenChange = useCallback((isOpen: boolean) => { + updateBrowseState({ + isFileSearchOpen: isOpen, + }); + + if (isOpen) { + setSearchQuery(''); + } + }, [updateBrowseState]); + + useHotkeys("mod+p", (event) => { + event.preventDefault(); + onOpenChange(!isFileSearchOpen); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open File Search", + }); + + const { data: files, isLoading, isError } = useQuery({ + queryKey: ['files', repoName, revisionName, domain], + queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)), + enabled: isFileSearchOpen, + }); + + const filteredFiles = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => { + if (!files || isLoading) { + return { + filteredFiles: [], + maxResultsHit: false, + }; + } + + if (searchQuery.length === 0) { + return { + filteredFiles: files.slice(0, MAX_RESULTS).map((file) => ({ file })), + 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]); + + return ( + + + Search for files + {`Search for files in the repository ${repoName}.`} + + + { + isLoading ? ( + + ) : isError ? ( +

Error loading files.

+ ) : ( + + No results found. + {filteredFiles.filteredFiles.map(({ file, match }) => { + return ( + { + navigateToPath({ + repoName, + revisionName, + path: file.path, + pathType: 'blob', + }); + onOpenChange(false); + }} + onMouseEnter={() => { + prefetchFileSource( + repoName, + revisionName ?? 'HEAD', + file.path + ); + }} + > +
+ +
+ + {file.name} + + + {match ? ( + + ) : ( + file.path + )} + +
+
+
+ ); + })} + {filteredFiles.maxResultsHit && ( +
+ Maximum results hit. Please refine your search. +
+ )} +
+ ) + } +
+
+
+ ) +} + +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 23320e49..f4c15c66 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -8,15 +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 { Command, CommandEmpty, 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 { getFiles } from "@/features/fileTree/actions"; -import { useDomain } from "@/hooks/useDomain"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; -import Fuse from "fuse.js"; +import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog"; interface LayoutProps { children: React.ReactNode; @@ -75,195 +67,3 @@ export default function Layout({ ); } - - -const FileSearchCommandDialog = () => { - const { repoName, revisionName } = useBrowseParams(); - const domain = useDomain(); - - const [isOpen, setIsOpen] = useState(false); - const commandListRef = useRef(null); - const [searchQuery, setSearchQuery] = useState(''); - - useHotkeys("mod+p", (event) => { - event.preventDefault(); - setIsOpen((prev) => !prev); - }, { - enableOnFormTags: true, - enableOnContentEditable: true, - description: "Open File Search", - }); - - const { data: files, isLoading, isError } = useQuery({ - queryKey: ['files', repoName, revisionName, domain], - queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)), - enabled: isOpen, - }); - - const fuse = useMemo(() => { - return new Fuse(files ?? [], { - keys: [ - { - name: 'path', - weight: 0.3, - }, - { - name: 'name', - weight: 0.7, - }, - ], - threshold: 0.3, - minMatchCharLength: 2, - isCaseSensitive: false, - includeMatches: true, - }); - }, [files]); - - const filteredFiles = useMemo(() => { - if (searchQuery.length === 0) { - return files?.map((file) => ({ - file, - matches: [], - })) ?? []; - } - - return fuse - .search(searchQuery) - .map((result) => { - const { item, matches } = result; - return { - file: item, - matches: matches!, - } - }); - }, [files, searchQuery, fuse]); - - // Scroll to the top of the list when the user types - useEffect(() => { - commandListRef.current?.scrollTo({ - top: 0, - }) - }, [searchQuery]); - - const onOpenChange = useCallback(() => { - setIsOpen(false); - setSearchQuery(''); - }, []); - - return ( - - - - - { - isLoading ? ( -
Loading...
- ) : - isError ? ( - Error loading files. - ) : ( - - {filteredFiles.map(({ file, matches }) => { - const nameMatch = matches.find(m => m.key === 'name'); - const pathMatch = matches.find(m => m.key === 'path'); - - return ( - -
- - {nameMatch ? - : - file.name - } - - - {pathMatch ? - : - file.path - } - -
-
- ); - })} -
- ) - } -
-
-
- ) -} - -interface HighlightedTextProps { - text: string; - indices: readonly [number, number][]; -} - -const HighlightedText = ({ text, indices }: HighlightedTextProps) => { - if (!indices || indices.length === 0) { - return <>{text}; - } - - // Create an array of segments with their highlight status - const segments: { text: string; highlighted: boolean }[] = []; - let lastIndex = 0; - - // Sort indices by start position - const sortedIndices = [...indices].sort((a, b) => a[0] - b[0]); - - sortedIndices.forEach(([start, end]) => { - // Add non-highlighted text before this match - if (start > lastIndex) { - segments.push({ - text: text.slice(lastIndex, start), - highlighted: false - }); - } - - // Add highlighted text - segments.push({ - text: text.slice(start, end + 1), - highlighted: true - }); - - lastIndex = end + 1; - }); - - // Add remaining non-highlighted text - if (lastIndex < text.length) { - segments.push({ - text: text.slice(lastIndex), - highlighted: false - }); - } - - return ( - <> - {segments.map((segment, index) => ( - segment.highlighted ? ( - - {segment.text} - - ) : ( - {segment.text} - ) - ))} - - ); -}; 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 From e8940027670f63cbed1efcd7613e4e75517f3f10 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 9 Jun 2025 10:45:58 -0700 Subject: [PATCH 4/9] Add search button --- .../fileTree/components/fileTreePanel.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 ? ( From d92d20b8e1ae4eee7d1427ae55256f91a17fc672 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 9 Jun 2025 11:00:56 -0700 Subject: [PATCH 5/9] more nits --- .../components/fileSearchCommandDialog.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx index aaf8e6f9..2076bcc8 100644 --- a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx +++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx @@ -35,25 +35,24 @@ export const FileSearchCommandDialog = () => { const { navigateToPath } = useBrowseNavigation(); const { prefetchFileSource } = usePrefetchFileSource(); - const onOpenChange = useCallback((isOpen: boolean) => { - updateBrowseState({ - isFileSearchOpen: isOpen, - }); - - if (isOpen) { - setSearchQuery(''); - } - }, [updateBrowseState]); - useHotkeys("mod+p", (event) => { event.preventDefault(); - onOpenChange(!isFileSearchOpen); + 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)), @@ -70,7 +69,7 @@ export const FileSearchCommandDialog = () => { if (searchQuery.length === 0) { return { - filteredFiles: files.slice(0, MAX_RESULTS).map((file) => ({ file })), + filteredFiles: [], maxResultsHit: false, }; } @@ -112,11 +111,15 @@ export const FileSearchCommandDialog = () => { return ( { + updateBrowseState({ + isFileSearchOpen: isOpen, + }); + }} modal={true} > Search for files {`Search for files in the repository ${repoName}.`} @@ -134,7 +137,9 @@ export const FileSearchCommandDialog = () => {

Error loading files.

) : ( - No results found. + {searchQuery.length > 0 && ( + No results found. + )} {filteredFiles.filteredFiles.map(({ file, match }) => { return ( { path: file.path, pathType: 'blob', }); - onOpenChange(false); + updateBrowseState({ + isFileSearchOpen: false, + }); }} onMouseEnter={() => { prefetchFileSource( From 4970e038e5af586db2a6b58703fe1a9717fae603 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 9 Jun 2025 12:01:19 -0700 Subject: [PATCH 6/9] Add recently opened files to local storage --- .../components/fileSearchCommandDialog.tsx | 173 ++++++++++++------ 1 file changed, 117 insertions(+), 56 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx index 2076bcc8..388c31cd 100644 --- a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx +++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +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"; @@ -13,6 +13,7 @@ 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"; const MAX_RESULTS = 100; @@ -31,10 +32,13 @@ export const FileSearchCommandDialog = () => { 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({ @@ -59,7 +63,7 @@ export const FileSearchCommandDialog = () => { enabled: isFileSearchOpen, }); - const filteredFiles = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => { + const { filteredFiles, maxResultsHit } = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => { if (!files || isLoading) { return { filteredFiles: [], @@ -67,13 +71,6 @@ export const FileSearchCommandDialog = () => { }; } - if (searchQuery.length === 0) { - return { - filteredFiles: [], - maxResultsHit: false, - }; - } - const matches = files .map((file) => { return { @@ -108,6 +105,39 @@ export const FileSearchCommandDialog = () => { }) }, [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 ( { { isLoading ? ( @@ -137,54 +169,42 @@ export const FileSearchCommandDialog = () => {

Error loading files.

) : ( - {searchQuery.length > 0 && ( - No results found. - )} - {filteredFiles.filteredFiles.map(({ file, match }) => { - return ( - { - navigateToPath({ - repoName, - revisionName, - path: file.path, - pathType: 'blob', - }); - updateBrowseState({ - isFileSearchOpen: false, - }); - }} - onMouseEnter={() => { - prefetchFileSource( - repoName, - revisionName ?? 'HEAD', - file.path - ); - }} - > -
- -
- - {file.name} - - - {match ? ( - - ) : ( - file.path - )} - -
+ {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.
- - ); - })} - {filteredFiles.maxResultsHit && ( -
- Maximum results hit. Please refine your search. -
+ )} + )} ) @@ -195,6 +215,47 @@ export const FileSearchCommandDialog = () => { ) } +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 ( From df1491c841c7740c55d1285056a57f848cc647f9 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 9 Jun 2025 12:27:56 -0700 Subject: [PATCH 7/9] changelog + feedback --- CHANGELOG.md | 1 + packages/web/src/features/fileTree/actions.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index 261560e6..8b4ca224 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -188,8 +188,8 @@ export const getFiles = async (params: { repoName: string, revisionName: string '--name-only', ]); } catch (error) { - logger.error('git ls-files failed.', { error }); - return unexpectedError('git ls-files command failed.'); + logger.error('git ls-tree failed.', { error }); + return unexpectedError('git ls-tree command failed.'); } const paths = result.split('\n').filter(line => line.trim()); From cd4e250ce1c18baa5beffeece23fb4dea89df87d Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 9 Jun 2025 12:30:33 -0700 Subject: [PATCH 8/9] unrelated license key log nit --- packages/web/src/features/entitlements/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/features/entitlements/server.ts b/packages/web/src/features/entitlements/server.ts index 40490b0c..61b851ce 100644 --- a/packages/web/src/features/entitlements/server.ts +++ b/packages/web/src/features/entitlements/server.ts @@ -73,7 +73,7 @@ export const getPlan = (): Plan => { 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); } From 0fbd3b5ec87921745bcc8488aac8b360128b6c0f Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 9 Jun 2025 12:45:19 -0700 Subject: [PATCH 9/9] remove dialog animation + fix skeleton styling in darkmode --- .../[domain]/browse/components/fileSearchCommandDialog.tsx | 7 ++++--- packages/web/src/components/ui/dialog.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx index 388c31cd..27e0261f 100644 --- a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx +++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx @@ -14,6 +14,7 @@ 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; @@ -271,10 +272,10 @@ const ResultsSkeleton = () => {
{Array.from({ length: 6 }).map((_, index) => (
-
+
-
-
+ +
))} 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<