mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 05:15:19 +00:00
Merge ca0fd3ec4c into 4899c9fbc7
This commit is contained in:
commit
5443acdccd
7 changed files with 712 additions and 604 deletions
|
|
@ -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'}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue