refactor code nav callbacks into symbolHoverPopup

This commit is contained in:
bkellam 2025-11-30 15:06:17 -08:00
parent 29994d6011
commit 2767c2b088
6 changed files with 157 additions and 270 deletions

View file

@ -3,7 +3,6 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
@ -11,14 +10,10 @@ import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { search } from "@codemirror/search";
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { EditorContextMenu } from "../../../components/editorContextMenu";
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "../../hooks/utils";
import { useBrowseState } from "../../hooks/useBrowseState";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { createAuditAction } from "@/ee/features/audit/actions";
interface PureCodePreviewPanelProps {
path: string;
@ -40,9 +35,6 @@ export const PureCodePreviewPanel = ({
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
const keymapExtension = useKeymapExtension(editorRef?.view);
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const { updateBrowseState } = useBrowseState();
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
const highlightRange = useMemo((): BrowseHighlightRange | undefined => {
@ -134,72 +126,6 @@ export const PureCodePreviewPanel = ({
});
}, [editorRef, highlightRange]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_find_references_pressed', {
source: 'browse',
});
createAuditAction({
action: "user.performed_find_references",
metadata: {
message: symbolName,
},
})
updateBrowseState({
selectedSymbolInfo: {
repoName,
symbolName,
revisionName,
language,
},
isBottomPanelCollapsed: false,
activeExploreMenuTab: "references",
})
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);
// If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_goto_definition_pressed', {
source: 'browse',
});
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
message: symbolName,
},
})
if (symbolDefinitions.length === 0) {
return;
}
if (symbolDefinitions.length === 1) {
const symbolDefinition = symbolDefinitions[0];
const { fileName, repoName } = symbolDefinition;
navigateToPath({
repoName,
revisionName,
path: fileName,
pathType: 'blob',
highlightRange: symbolDefinition.range,
})
} else {
updateBrowseState({
selectedSymbolInfo: {
symbolName,
repoName,
revisionName,
language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
})
}
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
const theme = useCodeMirrorTheme();
return (
@ -223,11 +149,12 @@ export const PureCodePreviewPanel = ({
)}
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
source="preview"
editorRef={editorRef}
revisionName={revisionName}
language={language}
onFindReferences={onFindReferences}
onGotoDefinition={onGotoDefinition}
fileName={path}
repoName={repoName}
/>
)}
</CodeMirror>

View file

@ -1,12 +1,16 @@
'use client';
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { SearchResultChunk } from "@/features/search";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
import { search } from "@codemirror/search";
@ -16,13 +20,6 @@ import { Scrollbar } from "@radix-ui/react-scroll-area";
import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror';
import { ArrowDown, ArrowUp } from "lucide-react";
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
import { createAuditAction } from "@/ee/features/audit/actions";
import useCaptureEvent from "@/hooks/useCaptureEvent";
export interface CodePreviewFile {
content: string;
@ -59,8 +56,6 @@ export const CodePreview = ({
const languageExtension = useCodeMirrorLanguageExtension(file?.language ?? '', editorRef?.view);
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
const captureEvent = useCaptureEvent();
const extensions = useMemo(() => {
return [
keymapExtension,
@ -115,81 +110,6 @@ export const CodePreview = ({
onSelectedMatchIndexChange((prev) => prev + 1);
}, [onSelectedMatchIndexChange]);
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_goto_definition_pressed', {
source: 'preview',
});
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
message: symbolName,
},
})
if (symbolDefinitions.length === 0) {
return;
}
if (symbolDefinitions.length === 1) {
const symbolDefinition = symbolDefinitions[0];
const { fileName, repoName } = symbolDefinition;
navigateToPath({
repoName,
revisionName: file.revision,
path: fileName,
pathType: 'blob',
highlightRange: symbolDefinition.range,
})
} else {
navigateToPath({
repoName,
revisionName: file.revision,
path: file.filepath,
pathType: 'blob',
setBrowseState: {
selectedSymbolInfo: {
symbolName,
repoName,
revisionName: file.revision,
language: file.language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
}
});
}
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_find_references_pressed', {
source: 'preview',
});
createAuditAction({
action: "user.performed_find_references",
metadata: {
message: symbolName,
},
})
navigateToPath({
repoName,
revisionName: file.revision,
path: file.filepath,
pathType: 'blob',
setBrowseState: {
selectedSymbolInfo: {
repoName,
symbolName,
revisionName: file.revision,
language: file.language,
},
activeExploreMenuTab: "references",
isBottomPanelCollapsed: false,
}
})
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
return (
<div className="flex flex-col h-full">
<div className="flex flex-row bg-accent items-center justify-between pr-3 py-0.5 mt-7">
@ -286,11 +206,12 @@ export const CodePreview = ({
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
source="preview"
editorRef={editorRef}
language={file.language}
revisionName={file.revision}
onFindReferences={onFindReferences}
onGotoDefinition={onGotoDefinition}
fileName={file.filepath}
repoName={repoName}
/>
)}
</CodeMirror>

View file

@ -151,7 +151,7 @@ export const ExploreMenu = ({
</span>
</TooltipTrigger>
<TooltipContent side="top" align="center">
{isGlobalSearchEnabled ? "Search in current repository only" : "Search all repositories"}
Search all repositories
<KeyboardShortcutHint
shortcut="⇧ A"
className="ml-2"

View file

@ -1,42 +1,50 @@
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { LoadingButton } from "@/components/ui/loading-button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { createAuditAction } from "@/ee/features/audit/actions";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react";
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SymbolDefinition, useHoveredOverSymbolInfo } from "./useHoveredOverSymbolInfo";
import { SymbolDefinitionPreview } from "./symbolDefinitionPreview";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useHotkeys } from "react-hotkeys-hook";
import { useToast } from "@/components/hooks/use-toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { SymbolDefinitionPreview } from "./symbolDefinitionPreview";
import { useHoveredOverSymbolInfo } from "./useHoveredOverSymbolInfo";
interface SymbolHoverPopupProps {
editorRef: ReactCodeMirrorRef;
language: string;
revisionName: string;
onFindReferences: (symbolName: string) => void;
onGotoDefinition: (symbolName: string, symbolDefinitions: SymbolDefinition[]) => void;
repoName: string;
fileName: string;
source: 'browse' | 'preview' | 'chat';
}
export const SymbolHoverPopup: React.FC<SymbolHoverPopupProps> = ({
editorRef,
revisionName,
language,
onFindReferences,
onGotoDefinition: _onGotoDefinition,
repoName,
fileName,
source,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [isSticky, setIsSticky] = useState(false);
const { toast } = useToast();
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
const symbolInfo = useHoveredOverSymbolInfo({
editorRef,
isSticky,
revisionName,
language,
repoName,
});
// Positions the popup relative to the symbol
@ -77,13 +85,121 @@ export const SymbolHoverPopup: React.FC<SymbolHoverPopupProps> = ({
}
}, [symbolInfo, editorRef]);
// Multiple symbol definitions can exist for the same symbol, but we can only navigate
// and display a preview of one. If the symbol definition exists in the current file,
// then we use that one, otherwise we fallback to the first definition in the list.
const previewedSymbolDefinition = useMemo(() => {
if (!symbolInfo?.symbolDefinitions || symbolInfo.symbolDefinitions.length === 0) {
return undefined;
}
const matchingDefinition = symbolInfo.symbolDefinitions.find(
(definition) => (
definition.fileName === fileName && definition.repoName === repoName
)
);
if (matchingDefinition) {
return matchingDefinition;
}
return symbolInfo.symbolDefinitions[0];
}, [fileName, repoName, symbolInfo?.symbolDefinitions]);
const onGotoDefinition = useCallback(() => {
if (!symbolInfo || !symbolInfo.symbolDefinitions) {
if (
!symbolInfo ||
!symbolInfo.symbolDefinitions ||
!previewedSymbolDefinition
) {
return;
}
_onGotoDefinition(symbolInfo.symbolName, symbolInfo.symbolDefinitions);
}, [symbolInfo, _onGotoDefinition]);
captureEvent('wa_goto_definition_pressed', {
source,
});
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
message: symbolInfo.symbolName,
},
});
const {
fileName,
repoName,
revisionName,
language,
range: highlightRange,
} = previewedSymbolDefinition;
navigateToPath({
// Always navigate to the preview symbol definition.
repoName,
revisionName,
path: fileName,
pathType: 'blob',
highlightRange,
// If there are multiple definitions, we should open the Explore panel with the definitions.
...(symbolInfo.symbolDefinitions.length > 1 ? {
setBrowseState: {
selectedSymbolInfo: {
symbolName: symbolInfo.symbolName,
repoName,
revisionName,
language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
}
} : {}),
});
}, [
captureEvent,
previewedSymbolDefinition,
navigateToPath,
source,
symbolInfo
]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_find_references_pressed', {
source,
});
createAuditAction({
action: "user.performed_find_references",
metadata: {
message: symbolName,
},
})
navigateToPath({
repoName,
revisionName,
path: fileName,
pathType: 'blob',
setBrowseState: {
selectedSymbolInfo: {
symbolName,
repoName,
revisionName,
language,
},
activeExploreMenuTab: "references",
isBottomPanelCollapsed: false,
}
})
}, [
captureEvent,
fileName,
language,
navigateToPath,
repoName,
revisionName,
source
]);
// @todo: We should probably make the behaviour s.t., the ctrl / cmd key needs to be held
// down to navigate to the definition. We should also only show the underline when the key
@ -147,9 +263,9 @@ export const SymbolHoverPopup: React.FC<SymbolHoverPopupProps> = ({
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
) : symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 0 ? (
) : previewedSymbolDefinition ? (
<SymbolDefinitionPreview
symbolDefinition={symbolInfo.symbolDefinitions[0]}
symbolDefinition={previewedSymbolDefinition}
/>
) : (
<p className="text-sm font-medium text-muted-foreground">No hover info found</p>
@ -160,13 +276,13 @@ export const SymbolHoverPopup: React.FC<SymbolHoverPopupProps> = ({
<TooltipTrigger asChild>
<LoadingButton
loading={symbolInfo.isSymbolDefinitionsLoading}
disabled={!symbolInfo.symbolDefinitions || symbolInfo.symbolDefinitions.length === 0}
disabled={!previewedSymbolDefinition}
variant="outline"
size="sm"
onClick={onGotoDefinition}
>
{
!symbolInfo.isSymbolDefinitionsLoading && (!symbolInfo.symbolDefinitions || symbolInfo.symbolDefinitions.length === 0) ?
!symbolInfo.isSymbolDefinitionsLoading && !previewedSymbolDefinition ?
"No definition found" :
`Go to ${symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 1 ? "definitions" : "definition"}`
}

View file

@ -12,6 +12,7 @@ interface UseHoveredOverSymbolInfoProps {
isSticky: boolean;
revisionName: string;
language: string;
repoName: string;
}
export type SymbolDefinition = {
@ -19,6 +20,7 @@ export type SymbolDefinition = {
language: string;
fileName: string;
repoName: string;
revisionName: string;
range: SourceRange;
}
@ -37,6 +39,7 @@ export const useHoveredOverSymbolInfo = ({
isSticky,
revisionName,
language,
repoName,
}: UseHoveredOverSymbolInfoProps): HoveredOverSymbolInfo | undefined => {
const mouseOverTimerRef = useRef<NodeJS.Timeout | null>(null);
const mouseOutTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -50,12 +53,13 @@ export const useHoveredOverSymbolInfo = ({
}, [symbolElement]);
const { data: symbolDefinitions, isLoading: isSymbolDefinitionsLoading } = useQuery({
queryKey: ["definitions", symbolName, revisionName, language, domain],
queryKey: ["definitions", symbolName, revisionName, language, domain, repoName],
queryFn: () => unwrapServiceError(
findSearchBasedSymbolDefinitions({
symbolName: symbolName!,
language,
revisionName,
repoName,
})
),
select: ((data) => {
@ -66,6 +70,7 @@ export const useHoveredOverSymbolInfo = ({
language: file.language,
fileName: file.fileName,
repoName: file.repository,
revisionName: revisionName,
range: match.range,
}
})

View file

@ -1,10 +1,8 @@
'use client';
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { SymbolHoverPopup } from '@/ee/features/codeNav/components/symbolHoverPopup';
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { SymbolDefinition } from '@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo';
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
@ -12,15 +10,13 @@ import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { cn } from "@/lib/utils";
import { Range } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
import { CodeHostType } from "@sourcebot/db";
import CodeMirror, { ReactCodeMirrorRef, StateField } from '@uiw/react-codemirror';
import isEqual from "fast-deep-equal/react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef, memo, Ref, useCallback, useImperativeHandle, useMemo, useState } from "react";
import { FileReference } from "../../types";
import { createCodeFoldingExtension } from "./codeFoldingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { CodeHostType } from "@sourcebot/db";
import { createAuditAction } from "@/ee/features/audit/actions";
import isEqual from "fast-deep-equal/react";
const lineDecoration = Decoration.line({
attributes: { class: "cm-range-border-radius chat-lineHighlight" },
@ -74,7 +70,6 @@ const ReferencedFileSourceListItem = ({
}: ReferencedFileSourceListItemProps, forwardedRef: Ref<ReactCodeMirrorRef>) => {
const theme = useCodeMirrorTheme();
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
const captureEvent = useCaptureEvent();
useImperativeHandle(
forwardedRef,
@ -84,7 +79,6 @@ const ReferencedFileSourceListItem = ({
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
const { navigateToPath } = useBrowseNavigation();
const getReferenceAtPos = useCallback((x: number, y: number, view: EditorView): FileReference | undefined => {
const pos = view.posAtCoords({ x, y });
@ -217,83 +211,6 @@ const ReferencedFileSourceListItem = ({
codeFoldingExtension,
]);
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
if (symbolDefinitions.length === 0) {
return;
}
captureEvent('wa_goto_definition_pressed', {
source: 'chat',
});
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
message: symbolName,
},
});
if (symbolDefinitions.length === 1) {
const symbolDefinition = symbolDefinitions[0];
const { fileName, repoName } = symbolDefinition;
navigateToPath({
repoName,
revisionName: revision,
path: fileName,
pathType: 'blob',
highlightRange: symbolDefinition.range,
})
} else {
navigateToPath({
repoName,
revisionName: revision,
path: fileName,
pathType: 'blob',
setBrowseState: {
selectedSymbolInfo: {
symbolName,
repoName,
revisionName: revision,
language: language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
}
});
}
}, [captureEvent, navigateToPath, revision, repoName, fileName, language]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_find_references_pressed', {
source: 'chat',
});
createAuditAction({
action: "user.performed_find_references",
metadata: {
message: symbolName,
},
});
navigateToPath({
repoName,
revisionName: revision,
path: fileName,
pathType: 'blob',
setBrowseState: {
selectedSymbolInfo: {
symbolName,
repoName,
revisionName: revision,
language: language,
},
activeExploreMenuTab: "references",
isBottomPanelCollapsed: false,
}
})
}, [captureEvent, fileName, language, navigateToPath, repoName, revision]);
const ExpandCollapseIcon = useMemo(() => {
return isExpanded ? ChevronDown : ChevronRight;
}, [isExpanded]);
@ -341,11 +258,12 @@ const ReferencedFileSourceListItem = ({
>
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
source="chat"
editorRef={editorRef}
revisionName={revision}
language={language}
onFindReferences={onFindReferences}
onGotoDefinition={onGotoDefinition}
repoName={repoName}
fileName={fileName}
/>
)}
</CodeMirror>