This commit is contained in:
prateek singh 2025-10-31 18:49:30 +05:30 committed by GitHub
commit 5443acdccd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 712 additions and 604 deletions

View file

@ -7,76 +7,81 @@ import Image from "next/image";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
interface CodePreviewPanelProps { interface CodePreviewPanelProps {
path: string; path: string;
repoName: string; repoName: string;
revisionName?: string; revisionName?: string;
} }
export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => { export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => {
const [fileSourceResponse, repoInfoResponse] = await Promise.all([ const [fileSourceResponse, repoInfoResponse] = await Promise.all([
getFileSource({ getFileSource({
fileName: path, fileName: path,
repository: repoName, repository: repoName,
branch: revisionName, branch: revisionName,
}), }),
getRepoInfoByName(repoName), getRepoInfoByName(repoName),
]); ]);
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) { if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
return <div>Error loading file source</div> return <div>Error loading file source</div>
} }
const codeHostInfo = getCodeHostInfoForRepo({ const codeHostInfo = getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType, codeHostType: repoInfoResponse.codeHostType,
name: repoInfoResponse.name, name: repoInfoResponse.name,
displayName: repoInfoResponse.displayName, displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl, webUrl: repoInfoResponse.webUrl,
}); });
// @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. THis // @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. THis
// will break if the default branch is not main. // will break if the default branch is not main.
const fileWebUrl = repoInfoResponse.codeHostType === "azuredevops" && fileSourceResponse.webUrl ? const fileWebUrl = repoInfoResponse.codeHostType === "azuredevops" && fileSourceResponse.webUrl ?
fileSourceResponse.webUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.webUrl; fileSourceResponse.webUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.webUrl;
return ( return (
<> <>
<div className="flex flex-row py-1 px-2 items-center justify-between"> <div className="flex flex-row py-1 px-2 items-center justify-between ">
<PathHeader <div className="w-2/3">
path={path} <PathHeader
repo={{ path={path}
name: repoName, repo={{
codeHostType: repoInfoResponse.codeHostType, name: repoName,
displayName: repoInfoResponse.displayName, codeHostType: repoInfoResponse.codeHostType,
webUrl: repoInfoResponse.webUrl, displayName: repoInfoResponse.displayName,
}} webUrl: repoInfoResponse.webUrl,
branchDisplayName={revisionName} }}
/> branchDisplayName={revisionName}
/>
</div>
{(fileWebUrl && codeHostInfo) && ( <div className="w-1/3 flex justify-end">
{(fileWebUrl && codeHostInfo) && (
<a
href={fileWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex flex-row items-center gap-4 px-2 py-0.5 rounded-md"
>
<Image
src={codeHostInfo.icon}
alt={codeHostInfo.codeHostName}
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
/>
<span className="text-xs font-medium">Open in {codeHostInfo.codeHostName}</span>
</a>
)}
</div>
</div>
<a <Separator />
href={fileWebUrl} <PureCodePreviewPanel
target="_blank" source={fileSourceResponse.source}
rel="noopener noreferrer" language={fileSourceResponse.language}
className="flex flex-row items-center gap-2 px-2 py-0.5 rounded-md flex-shrink-0" repoName={repoName}
> path={path}
<Image revisionName={revisionName ?? 'HEAD'}
src={codeHostInfo.icon} />
alt={codeHostInfo.codeHostName}
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)} </>
/> )
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
</a>
)}
</div>
<Separator />
<PureCodePreviewPanel
source={fileSourceResponse.source}
language={fileSourceResponse.language}
repoName={repoName}
path={path}
revisionName={revisionName ?? 'HEAD'}
/>
</>
)
} }

View file

@ -66,44 +66,44 @@ export async function generateMetadata({ params: paramsPromise }: Props): Promis
} }
interface BrowsePageProps { interface BrowsePageProps {
params: Promise<{ params: Promise<{
path: string[]; path: string[];
}>; }>;
} }
export default async function BrowsePage(props: BrowsePageProps) { export default async function BrowsePage(props: BrowsePageProps) {
const params = await props.params; const params = await props.params;
const { const {
path: _rawPath, path: _rawPath,
} = params; } = params;
const rawPath = _rawPath.join('/'); const rawPath = _rawPath.join('/');
const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Suspense fallback={ <Suspense fallback={
<div className="flex flex-col w-full min-h-full items-center justify-center"> <div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Loading... Loading...
</div>
}>
{pathType === 'blob' ? (
<CodePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
/>
) : (
<TreePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
/>
)}
</Suspense>
</div> </div>
) }>
{pathType === 'blob' ? (
<CodePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
/>
) : (
<TreePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
/>
)}
</Suspense>
</div>
)
} }

View file

@ -4,78 +4,82 @@ import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { createContext, useCallback, useEffect, useState } from "react"; import { createContext, useCallback, useEffect, useState } from "react";
export interface BrowseState { export interface BrowseState {
selectedSymbolInfo?: { selectedSymbolInfo?: {
symbolName: string; symbolName: string;
repoName: string; repoName: string;
revisionName: string; revisionName: string;
language: string; language: string;
} }
isBottomPanelCollapsed: boolean; isBottomPanelCollapsed: boolean;
isFileTreePanelCollapsed: boolean; isChatPanelCollapsed: boolean;
isFileSearchOpen: boolean; isFileTreePanelCollapsed: boolean;
activeExploreMenuTab: "references" | "definitions"; isFileSearchOpen: boolean;
bottomPanelSize: number; activeExploreMenuTab: "references" | "definitions";
bottomPanelSize: number;
chatPanelSize: number;
} }
const defaultState: BrowseState = { const defaultState: BrowseState = {
selectedSymbolInfo: undefined, selectedSymbolInfo: undefined,
isBottomPanelCollapsed: true, isBottomPanelCollapsed: true,
isFileTreePanelCollapsed: false, isFileTreePanelCollapsed: false,
isFileSearchOpen: false, isFileSearchOpen: false,
activeExploreMenuTab: "references", activeExploreMenuTab: "references",
bottomPanelSize: 35, bottomPanelSize: 35,
isChatPanelCollapsed: true,
chatPanelSize: 20,
}; };
export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState";
export const BrowseStateContext = createContext<{ export const BrowseStateContext = createContext<{
state: BrowseState; state: BrowseState;
updateBrowseState: (state: Partial<BrowseState>) => void; updateBrowseState: (state: Partial<BrowseState>) => void;
}>({ }>({
state: defaultState, state: defaultState,
updateBrowseState: () => {}, updateBrowseState: () => { },
}); });
interface BrowseStateProviderProps { interface BrowseStateProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const BrowseStateProvider = ({ children }: BrowseStateProviderProps) => { export const BrowseStateProvider = ({ children }: BrowseStateProviderProps) => {
const [state, setState] = useState<BrowseState>(defaultState); const [state, setState] = useState<BrowseState>(defaultState);
const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM);
const onUpdateState = useCallback((state: Partial<BrowseState>) => { const onUpdateState = useCallback((state: Partial<BrowseState>) => {
setState((prevState) => ({ setState((prevState) => ({
...prevState, ...prevState,
...state, ...state,
})); }));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (hydratedBrowseState) { if (hydratedBrowseState) {
try { try {
const parsedState = JSON.parse(hydratedBrowseState) as Partial<BrowseState>; const parsedState = JSON.parse(hydratedBrowseState) as Partial<BrowseState>;
onUpdateState(parsedState); onUpdateState(parsedState);
} catch (error) { } catch (error) {
console.error("Error parsing hydratedBrowseState", error); console.error("Error parsing hydratedBrowseState", error);
} }
// Remove the query param // Remove the query param
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete(SET_BROWSE_STATE_QUERY_PARAM); url.searchParams.delete(SET_BROWSE_STATE_QUERY_PARAM);
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
} }
}, [hydratedBrowseState, onUpdateState]); }, [hydratedBrowseState, onUpdateState]);
return ( return (
<BrowseStateContext.Provider <BrowseStateContext.Provider
value={{ value={{
state, state,
updateBrowseState: onUpdateState, updateBrowseState: onUpdateState,
}} }}
> >
{children} {children}
</BrowseStateContext.Provider> </BrowseStateContext.Provider>
); );
}; };

View file

@ -21,120 +21,120 @@ export const BOTTOM_PANEL_MAX_SIZE = 65;
const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation"; const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation";
interface BottomPanelProps { interface BottomPanelProps {
order: number; order: number;
} }
export const BottomPanel = ({ order }: BottomPanelProps) => { export const BottomPanel = ({ order }: BottomPanelProps) => {
const panelRef = useRef<ImperativePanelHandle>(null); const panelRef = useRef<ImperativePanelHandle>(null);
const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const domain = useDomain(); const domain = useDomain();
const router = useRouter(); const router = useRouter();
const { const {
state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize }, state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize },
updateBrowseState, updateBrowseState,
} = useBrowseState(); } = useBrowseState();
useEffect(() => { useEffect(() => {
if (isBottomPanelCollapsed) { if (isBottomPanelCollapsed) {
panelRef.current?.collapse(); panelRef.current?.collapse();
} else { } else {
panelRef.current?.expand(); panelRef.current?.expand();
} }
}, [isBottomPanelCollapsed]); }, [isBottomPanelCollapsed]);
useHotkeys("shift+mod+e", (event) => { useHotkeys("shift+mod+e", (event) => {
event.preventDefault(); event.preventDefault();
updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed }); updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed });
}, { }, {
enableOnFormTags: true, enableOnFormTags: true,
enableOnContentEditable: true, enableOnContentEditable: true,
description: "Open Explore Panel", description: "Open Explore Panel",
}); });
return ( return (
<> <>
<div className="w-full flex flex-row justify-between"> <div className="w-full flex flex-row justify-between">
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
updateBrowseState({ updateBrowseState({
isBottomPanelCollapsed: !isBottomPanelCollapsed, isBottomPanelCollapsed: !isBottomPanelCollapsed,
}) })
}} }}
> >
<VscReferences className="w-4 h-4" /> <VscReferences className="w-4 h-4" />
Explore Explore
<KeyboardShortcutHint shortcut="⇧ ⌘ E" /> <KeyboardShortcutHint shortcut="⇧ ⌘ E" />
</Button> </Button>
</div> </div>
{!isBottomPanelCollapsed && ( {!isBottomPanelCollapsed && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
updateBrowseState({ isBottomPanelCollapsed: true }) updateBrowseState({ isBottomPanelCollapsed: true })
}} }}
> >
<FaChevronDown className="w-4 h-4" /> <FaChevronDown className="w-4 h-4" />
Hide Hide
</Button> </Button>
)} )}
</div> </div>
<Separator /> <Separator />
<ResizablePanel <ResizablePanel
minSize={BOTTOM_PANEL_MIN_SIZE} minSize={BOTTOM_PANEL_MIN_SIZE}
maxSize={BOTTOM_PANEL_MAX_SIZE} maxSize={BOTTOM_PANEL_MAX_SIZE}
collapsible={true} collapsible={true}
ref={panelRef} ref={panelRef}
defaultSize={isBottomPanelCollapsed ? 0 : bottomPanelSize} defaultSize={isBottomPanelCollapsed ? 0 : bottomPanelSize}
onCollapse={() => updateBrowseState({ isBottomPanelCollapsed: true })} onCollapse={() => updateBrowseState({ isBottomPanelCollapsed: true })}
onExpand={() => updateBrowseState({ isBottomPanelCollapsed: false })} onExpand={() => updateBrowseState({ isBottomPanelCollapsed: false })}
onResize={(size) => { onResize={(size) => {
if (!isBottomPanelCollapsed) { if (!isBottomPanelCollapsed) {
updateBrowseState({ bottomPanelSize: size }); updateBrowseState({ bottomPanelSize: size });
} }
}} }}
order={order} order={order}
id={"bottom-panel"} id={"bottom-panel"}
>
{!hasCodeNavEntitlement ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<VscSymbolMisc className="w-6 h-6" />
<p className="text-sm">
Code navigation is not enabled for <span className="text-blue-500 hover:underline cursor-pointer" onClick={() => router.push(`/${domain}/settings/license`)}>your plan</span>.
</p>
<Link
href={CODE_NAV_DOCS_URL}
target="_blank"
className="text-sm text-blue-500 hover:underline"
> >
{!hasCodeNavEntitlement ? ( Learn more
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2"> </Link>
<VscSymbolMisc className="w-6 h-6" /> </div>
<p className="text-sm"> ) : !selectedSymbolInfo ? (
Code navigation is not enabled for <span className="text-blue-500 hover:underline cursor-pointer" onClick={() => router.push(`/${domain}/settings/license`)}>your plan</span>. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
</p> <VscSymbolMisc className="w-6 h-6" />
<p className="text-sm">No symbol selected</p>
<Link <Link
href={CODE_NAV_DOCS_URL} href={CODE_NAV_DOCS_URL}
target="_blank" target="_blank"
className="text-sm text-blue-500 hover:underline" className="text-sm text-blue-500 hover:underline"
> >
Learn more Learn more
</Link> </Link>
</div> </div>
) : !selectedSymbolInfo ? ( ) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2"> <ExploreMenu
<VscSymbolMisc className="w-6 h-6" /> selectedSymbolInfo={selectedSymbolInfo}
<p className="text-sm">No symbol selected</p> />
<Link )}
href={CODE_NAV_DOCS_URL} </ResizablePanel>
target="_blank" </>
className="text-sm text-blue-500 hover:underline" )
>
Learn more
</Link>
</div>
) : (
<ExploreMenu
selectedSymbolInfo={selectedSymbolInfo}
/>
)}
</ResizablePanel>
</>
)
} }

View file

@ -0,0 +1,94 @@
'use client'
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"
import { Button } from "@/components/ui/button"
import { RiRobot3Line } from "react-icons/ri";
import { useBrowseState } from "../hooks/useBrowseState";
import { ImperativePanelHandle } from "react-resizable-panels";
import { useEffect, useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { Separator } from "@/components/ui/separator";
import { ResizablePanel } from "@/components/ui/resizable";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export const CHAT_PANEL_MIN_SIZE = 5;
export const CHAT_PANEL_MAX_SIZE = 50;
interface ChatPanelProps {
order: number;
}
export const ChatPanel = ({ order }: ChatPanelProps) => {
const panelRef = useRef<ImperativePanelHandle>(null);
const {
state: { isChatPanelCollapsed, chatPanelSize },
updateBrowseState
} = useBrowseState();
useEffect(() => {
if (isChatPanelCollapsed) {
panelRef.current?.collapse();
} else {
panelRef.current?.expand();
}
}, [isChatPanelCollapsed]);
useHotkeys("shift+mod+o", (event) => {
event.preventDefault();
updateBrowseState({ isChatPanelCollapsed: !isChatPanelCollapsed });
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Open Chat Panel"
});
return (
<>
<ResizablePanel
minSize={CHAT_PANEL_MIN_SIZE}
maxSize={CHAT_PANEL_MAX_SIZE}
collapsible={true}
ref={panelRef}
defaultSize={isChatPanelCollapsed ? 0 : chatPanelSize}
onCollapse={() => updateBrowseState({ isChatPanelCollapsed: true })}
onExpand={() => updateBrowseState({ isChatPanelCollapsed: false })}
onResize={(size) => {
if (!isChatPanelCollapsed) {
updateBrowseState({ chatPanelSize: size });
}
}}
order={order}
id={"chat-panel"}
>
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2 p-4">
<p className="text-sm">Chat goes here</p>
</div>
</ResizablePanel>
{isChatPanelCollapsed && (
<div className="flex flex-col items-center h-full p-2">
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
panelRef.current?.expand();
}}
>
<RiRobot3Line className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
<KeyboardShortcutHint shortcut="⇧ ⌘ O" />
<Separator orientation="vertical" className="h-4" />
<span>Open AI Chat</span>
</TooltipContent>
</Tooltip>
</div>
)}
</>
)
}

View file

@ -2,6 +2,7 @@
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { BottomPanel } from "./components/bottomPanel"; import { BottomPanel } from "./components/bottomPanel";
import { ChatPanel } from "./components/chatPanel";
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
import { BrowseStateProvider } from "./browseStateProvider"; import { BrowseStateProvider } from "./browseStateProvider";
import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel";
@ -12,58 +13,62 @@ import { useDomain } from "@/hooks/useDomain";
import { SearchBar } from "../components/searchBar"; import { SearchBar } from "../components/searchBar";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
export default function Layout({ export default function Layout({
children, children,
}: LayoutProps) { }: LayoutProps) {
const { repoName, revisionName } = useBrowseParams(); const { repoName, revisionName } = useBrowseParams();
const domain = useDomain(); const domain = useDomain();
return ( return (
<BrowseStateProvider> <BrowseStateProvider>
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
<TopBar <TopBar
domain={domain} domain={domain}
> >
<SearchBar <SearchBar
size="sm" size="sm"
defaultQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `} defaultQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
className="w-full" className="w-full"
/> />
</TopBar> </TopBar>
<ResizablePanelGroup <ResizablePanelGroup
direction="horizontal" direction="horizontal"
> >
<FileTreePanel order={1} /> <FileTreePanel order={1} />
<AnimatedResizableHandle /> <AnimatedResizableHandle />
<ResizablePanel <ResizablePanel
order={2} order={2}
minSize={10} minSize={20}
defaultSize={80} defaultSize={60}
id="code-preview-panel-container" id="code-preview-panel-container"
> >
<ResizablePanelGroup <ResizablePanelGroup
direction="vertical" direction="vertical"
> >
<ResizablePanel <ResizablePanel
order={1} order={1}
id="code-preview-panel" id="code-preview-panel"
> >
{children} {children}
</ResizablePanel> </ResizablePanel>
<AnimatedResizableHandle /> <AnimatedResizableHandle />
<BottomPanel <BottomPanel
order={2} order={2}
/> />
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup>
</div> <AnimatedResizableHandle />
<FileSearchCommandDialog />
</BrowseStateProvider> <ChatPanel order={3} />
); </ResizablePanelGroup>
</div>
<FileSearchCommandDialog />
</BrowseStateProvider>
);
} }

View file

@ -4,27 +4,27 @@ import { useClickListener } from "@/hooks/useClickListener";
import { SearchQueryParams } from "@/lib/types"; import { SearchQueryParams } from "@/lib/types";
import { cn, createPathWithQueryParams } from "@/lib/utils"; import { cn, createPathWithQueryParams } from "@/lib/utils";
import { import {
cursorCharLeft, cursorCharLeft,
cursorCharRight, cursorCharRight,
cursorDocEnd, cursorDocEnd,
cursorDocStart, cursorDocStart,
cursorLineBoundaryBackward, cursorLineBoundaryBackward,
cursorLineBoundaryForward, cursorLineBoundaryForward,
deleteCharBackward, deleteCharBackward,
deleteCharForward, deleteCharForward,
deleteGroupBackward, deleteGroupBackward,
deleteGroupForward, deleteGroupForward,
deleteLineBoundaryBackward, deleteLineBoundaryBackward,
deleteLineBoundaryForward, deleteLineBoundaryForward,
history, history,
historyKeymap, historyKeymap,
selectAll, selectAll,
selectCharLeft, selectCharLeft,
selectCharRight, selectCharRight,
selectDocEnd, selectDocEnd,
selectDocStart, selectDocStart,
selectLineBoundaryBackward, selectLineBoundaryBackward,
selectLineBoundaryForward selectLineBoundaryForward
} from "@codemirror/commands"; } from "@codemirror/commands";
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import { createTheme } from '@uiw/codemirror-themes'; import { createTheme } from '@uiw/codemirror-themes';
@ -47,321 +47,321 @@ import { createAuditAction } from "@/ee/features/audit/actions";
import tailwind from "@/tailwind"; import tailwind from "@/tailwind";
interface SearchBarProps { interface SearchBarProps {
className?: string; className?: string;
size?: "default" | "sm"; size?: "default" | "sm";
defaultQuery?: string; defaultQuery?: string;
autoFocus?: boolean; autoFocus?: boolean;
} }
const searchBarKeymap: readonly KeyBinding[] = ([ const searchBarKeymap: readonly KeyBinding[] = ([
{ key: "ArrowLeft", run: cursorCharLeft, shift: selectCharLeft, preventDefault: true }, { key: "ArrowLeft", run: cursorCharLeft, shift: selectCharLeft, preventDefault: true },
{ key: "ArrowRight", run: cursorCharRight, shift: selectCharRight, preventDefault: true }, { key: "ArrowRight", run: cursorCharRight, shift: selectCharRight, preventDefault: true },
{ key: "Home", run: cursorLineBoundaryBackward, shift: selectLineBoundaryBackward, preventDefault: true }, { key: "Home", run: cursorLineBoundaryBackward, shift: selectLineBoundaryBackward, preventDefault: true },
{ key: "Mod-Home", run: cursorDocStart, shift: selectDocStart }, { key: "Mod-Home", run: cursorDocStart, shift: selectDocStart },
{ key: "End", run: cursorLineBoundaryForward, shift: selectLineBoundaryForward, preventDefault: true }, { key: "End", run: cursorLineBoundaryForward, shift: selectLineBoundaryForward, preventDefault: true },
{ key: "Mod-End", run: cursorDocEnd, shift: selectDocEnd }, { key: "Mod-End", run: cursorDocEnd, shift: selectDocEnd },
{ key: "Mod-a", run: selectAll }, { key: "Mod-a", run: selectAll },
{ key: "Backspace", run: deleteCharBackward, shift: deleteCharBackward }, { key: "Backspace", run: deleteCharBackward, shift: deleteCharBackward },
{ key: "Delete", run: deleteCharForward }, { key: "Delete", run: deleteCharForward },
{ key: "Mod-Backspace", mac: "Alt-Backspace", run: deleteGroupBackward }, { key: "Mod-Backspace", mac: "Alt-Backspace", run: deleteGroupBackward },
{ key: "Mod-Delete", mac: "Alt-Delete", run: deleteGroupForward }, { key: "Mod-Delete", mac: "Alt-Delete", run: deleteGroupForward },
{ mac: "Mod-Backspace", run: deleteLineBoundaryBackward }, { mac: "Mod-Backspace", run: deleteLineBoundaryBackward },
{ mac: "Mod-Delete", run: deleteLineBoundaryForward } { mac: "Mod-Delete", run: deleteLineBoundaryForward }
] as KeyBinding[]).concat(historyKeymap); ] as KeyBinding[]).concat(historyKeymap);
const searchBarContainerVariants = cva( const searchBarContainerVariants = cva(
"search-bar-container flex items-center justify-center py-0.5 px-2 border rounded-md relative", "search-bar-container flex items-center justify-center py-0.5 px-2 border rounded-md relative",
{ {
variants: { variants: {
size: { size: {
default: "min-h-10", default: "min-h-10",
sm: "min-h-8" sm: "min-h-8"
} }
}, },
defaultVariants: { defaultVariants: {
size: "default", size: "default",
}
} }
}
); );
export const SearchBar = ({ export const SearchBar = ({
className, className,
size, size,
defaultQuery, defaultQuery,
autoFocus, autoFocus,
}: SearchBarProps) => { }: SearchBarProps) => {
const router = useRouter(); const router = useRouter();
const domain = useDomain(); const domain = useDomain();
const suggestionBoxRef = useRef<HTMLDivElement>(null); const suggestionBoxRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null); const editorRef = useRef<ReactCodeMirrorRef>(null);
const [cursorPosition, setCursorPosition] = useState(0); const [cursorPosition, setCursorPosition] = useState(0);
const [isSuggestionsEnabled, setIsSuggestionsEnabled] = useState(false); const [isSuggestionsEnabled, setIsSuggestionsEnabled] = useState(false);
const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false); const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false);
const [isHistorySearchEnabled, setIsHistorySearchEnabled] = useState(false); const [isHistorySearchEnabled, setIsHistorySearchEnabled] = useState(false);
const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []); const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []);
const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []); const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []);
const [_query, setQuery] = useState(defaultQuery ?? ""); const [_query, setQuery] = useState(defaultQuery ?? "");
const query = useMemo(() => { const query = useMemo(() => {
// Replace any newlines with spaces to handle // Replace any newlines with spaces to handle
// copy & pasting text with newlines. // copy & pasting text with newlines.
return _query.replaceAll(/\n/g, " "); return _query.replaceAll(/\n/g, " ");
}, [_query]); }, [_query]);
// When the user navigates backwards/forwards while on the // When the user navigates backwards/forwards while on the
// search page (causing the `query` search param to change), // search page (causing the `query` search param to change),
// we want to update what query is displayed in the search bar. // we want to update what query is displayed in the search bar.
useEffect(() => { useEffect(() => {
if (defaultQuery) { if (defaultQuery) {
setQuery(defaultQuery); setQuery(defaultQuery);
}
}, [defaultQuery])
const { suggestionMode, suggestionQuery } = useSuggestionModeAndQuery({
isSuggestionsEnabled,
isHistorySearchEnabled,
cursorPosition,
query,
});
const suggestionData = useSuggestionsData({
suggestionMode,
suggestionQuery,
});
const theme = useMemo(() => {
return createTheme({
theme: 'light',
settings: {
background: tailwind.theme.colors.background,
foreground: tailwind.theme.colors.foreground,
caret: '#AEAFAD',
},
styles: [
{
tag: t.keyword,
color: tailwind.theme.colors.highlight,
},
{
tag: t.string,
color: '#2aa198',
},
{
tag: t.operator,
color: '#d33682',
},
{
tag: t.paren,
color: tailwind.theme.colors.highlight,
},
],
});
}, []);
const extensions = useMemo(() => {
return [
keymap.of(searchBarKeymap),
history(),
zoekt(),
EditorView.lineWrapping,
EditorView.updateListener.of(update => {
if (update.selectionSet) {
const selection = update.state.selection.main;
if (selection.empty) {
setCursorPosition(selection.anchor);
}
} }
}, [defaultQuery]) })
];
}, []);
const { suggestionMode, suggestionQuery } = useSuggestionModeAndQuery({ // Hotkey to focus the search bar.
isSuggestionsEnabled, useHotkeys('/', (event) => {
isHistorySearchEnabled, event.preventDefault();
cursorPosition, focusEditor();
query, setIsSuggestionsEnabled(true);
}); if (editorRef.current?.view) {
cursorDocEnd({
state: editorRef.current.view.state,
dispatch: editorRef.current.view.dispatch,
});
}
});
const suggestionData = useSuggestionsData({ // Collapse the suggestions box if the user clicks outside of the search bar container.
suggestionMode, useClickListener('.search-bar-container', (isElementClicked) => {
suggestionQuery, if (!isElementClicked) {
}); setIsSuggestionsEnabled(false);
} else {
setIsSuggestionsEnabled(true);
}
});
const theme = useMemo(() => { const onSubmit = useCallback((query: string) => {
return createTheme({ setIsSuggestionsEnabled(false);
theme: 'light', setIsHistorySearchEnabled(false);
settings: {
background: tailwind.theme.colors.background,
foreground: tailwind.theme.colors.foreground,
caret: '#AEAFAD',
},
styles: [
{
tag: t.keyword,
color: tailwind.theme.colors.highlight,
},
{
tag: t.string,
color: '#2aa198',
},
{
tag: t.operator,
color: '#d33682',
},
{
tag: t.paren,
color: tailwind.theme.colors.highlight,
},
],
});
}, []);
const extensions = useMemo(() => { createAuditAction({
return [ action: "user.performed_code_search",
keymap.of(searchBarKeymap), metadata: {
history(), message: query,
zoekt(), },
EditorView.lineWrapping, }, domain)
EditorView.updateListener.of(update => {
if (update.selectionSet) {
const selection = update.state.selection.main;
if (selection.empty) {
setCursorPosition(selection.anchor);
}
}
})
];
}, []);
// Hotkey to focus the search bar. const url = createPathWithQueryParams(`/${domain}/search`,
useHotkeys('/', (event) => { [SearchQueryParams.query, query],
event.preventDefault(); );
focusEditor(); router.push(url);
setIsSuggestionsEnabled(true); }, [domain, router]);
if (editorRef.current?.view) {
cursorDocEnd({ return (
state: editorRef.current.view.state, <div
dispatch: editorRef.current.view.dispatch, className={cn(searchBarContainerVariants({ size, className }))}
}); onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setIsSuggestionsEnabled(false);
onSubmit(query);
} }
});
// Collapse the suggestions box if the user clicks outside of the search bar container. if (e.key === 'Escape') {
useClickListener('.search-bar-container', (isElementClicked) => { e.preventDefault();
if (!isElementClicked) { setIsSuggestionsEnabled(false);
setIsSuggestionsEnabled(false);
} else {
setIsSuggestionsEnabled(true);
} }
});
const onSubmit = useCallback((query: string) => { if (e.key === 'ArrowDown') {
setIsSuggestionsEnabled(false); e.preventDefault();
setIsHistorySearchEnabled(false); setIsSuggestionsEnabled(true);
focusSuggestionsBox();
}
createAuditAction({ if (e.key === 'ArrowUp') {
action: "user.performed_code_search", e.preventDefault();
metadata: { }
message: query, }}
}, >
}, domain) <SearchHistoryButton
isToggled={isHistorySearchEnabled}
onClick={() => {
setQuery("");
setIsHistorySearchEnabled(!isHistorySearchEnabled);
setIsSuggestionsEnabled(true);
focusEditor();
}}
/>
<Separator
className="mx-1 h-6"
orientation="vertical"
/>
<CodeMirror
ref={editorRef}
className="w-full"
placeholder={isHistorySearchEnabled ? "Filter history..." : "Search (/) through repos..."}
value={query}
onChange={(value) => {
setQuery(value);
// Whenever the user types, we want to re-enable
// the suggestions box.
setIsSuggestionsEnabled(true);
}}
theme={theme}
basicSetup={false}
extensions={extensions}
indentWithTab={false}
autoFocus={autoFocus ?? false}
/>
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<div>
<KeyboardShortcutHint shortcut="/" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
Focus search bar
</TooltipContent>
</Tooltip>
<SearchSuggestionsBox
ref={suggestionBoxRef}
query={query}
suggestionQuery={suggestionQuery}
suggestionMode={suggestionMode}
onCompletion={(newQuery: string, newCursorPosition: number, autoSubmit = false) => {
setQuery(newQuery);
const url = createPathWithQueryParams(`/${domain}/search`, // Move the cursor to it's new position.
[SearchQueryParams.query, query], // @note : normally, react-codemirror handles syncing `query`
); // and the document state, but this happens on re-render. Since
router.push(url); // we want to move the cursor before the component re-renders,
}, [domain, router]); // we manually update the document state inline.
editorRef.current?.view?.dispatch({
changes: { from: 0, to: query.length, insert: newQuery },
annotations: [Annotation.define<boolean>().of(true)],
});
return ( editorRef.current?.view?.dispatch({
<div selection: { anchor: newCursorPosition, head: newCursorPosition },
className={cn(searchBarContainerVariants({ size, className }))} });
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setIsSuggestionsEnabled(false);
onSubmit(query);
}
if (e.key === 'Escape') { // Re-focus the editor since suggestions cause focus to be lost (both click & keyboard)
e.preventDefault(); editorRef.current?.view?.focus();
setIsSuggestionsEnabled(false);
}
if (e.key === 'ArrowDown') { if (autoSubmit) {
e.preventDefault(); onSubmit(newQuery);
setIsSuggestionsEnabled(true); }
focusSuggestionsBox(); }}
} isEnabled={isSuggestionsEnabled}
onReturnFocus={() => {
if (e.key === 'ArrowUp') { focusEditor();
e.preventDefault(); }}
} isFocused={isSuggestionsBoxFocused}
}} onFocus={() => {
> setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
<SearchHistoryButton }}
isToggled={isHistorySearchEnabled} onBlur={() => {
onClick={() => { setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
setQuery(""); }}
setIsHistorySearchEnabled(!isHistorySearchEnabled); cursorPosition={cursorPosition}
setIsSuggestionsEnabled(true); {...suggestionData}
focusEditor(); />
}} </div>
/> )
<Separator
className="mx-1 h-6"
orientation="vertical"
/>
<CodeMirror
ref={editorRef}
className="w-full"
placeholder={isHistorySearchEnabled ? "Filter history..." : "Search (/) through repos..."}
value={query}
onChange={(value) => {
setQuery(value);
// Whenever the user types, we want to re-enable
// the suggestions box.
setIsSuggestionsEnabled(true);
}}
theme={theme}
basicSetup={false}
extensions={extensions}
indentWithTab={false}
autoFocus={autoFocus ?? false}
/>
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<div>
<KeyboardShortcutHint shortcut="/" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
Focus search bar
</TooltipContent>
</Tooltip>
<SearchSuggestionsBox
ref={suggestionBoxRef}
query={query}
suggestionQuery={suggestionQuery}
suggestionMode={suggestionMode}
onCompletion={(newQuery: string, newCursorPosition: number, autoSubmit = false) => {
setQuery(newQuery);
// Move the cursor to it's new position.
// @note : normally, react-codemirror handles syncing `query`
// and the document state, but this happens on re-render. Since
// we want to move the cursor before the component re-renders,
// we manually update the document state inline.
editorRef.current?.view?.dispatch({
changes: { from: 0, to: query.length, insert: newQuery },
annotations: [Annotation.define<boolean>().of(true)],
});
editorRef.current?.view?.dispatch({
selection: { anchor: newCursorPosition, head: newCursorPosition },
});
// Re-focus the editor since suggestions cause focus to be lost (both click & keyboard)
editorRef.current?.view?.focus();
if (autoSubmit) {
onSubmit(newQuery);
}
}}
isEnabled={isSuggestionsEnabled}
onReturnFocus={() => {
focusEditor();
}}
isFocused={isSuggestionsBoxFocused}
onFocus={() => {
setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
}}
onBlur={() => {
setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
}}
cursorPosition={cursorPosition}
{...suggestionData}
/>
</div>
)
} }
const SearchHistoryButton = ({ const SearchHistoryButton = ({
isToggled, isToggled,
onClick, onClick,
}: { }: {
isToggled: boolean, isToggled: boolean,
onClick: () => void onClick: () => void
}) => { }) => {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
asChild={true} asChild={true}
> >
{/* @see : https://github.com/shadcn-ui/ui/issues/1988#issuecomment-1980597269 */} {/* @see : https://github.com/shadcn-ui/ui/issues/1988#issuecomment-1980597269 */}
<div> <div>
<Toggle <Toggle
pressed={isToggled} pressed={isToggled}
className="h-6 w-6 min-w-6 px-0 p-1 cursor-pointer" className="h-6 w-6 min-w-6 px-0 p-1 cursor-pointer"
onClick={onClick} onClick={onClick}
> >
<CounterClockwiseClockIcon /> <CounterClockwiseClockIcon />
</Toggle> </Toggle>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
side="bottom" side="bottom"
> >
Search history Search history
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) )
} }