From 3c8981250d37f9d0632994c642825c0e48867edf Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 16 May 2025 10:37:53 -0700 Subject: [PATCH 1/5] Initial POC --- .../[domain]/browse/[...path]/codePreview.tsx | 46 ++++++++++++++++++- packages/web/src/app/globals.css | 14 ++++++ .../lib/extensions/underlineNodesExtension.ts | 42 +++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/lib/extensions/underlineNodesExtension.ts diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx index 8f6243c7..6052934b 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx @@ -9,7 +9,11 @@ import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, Rea import { useEffect, useMemo, useRef, useState } from "react"; import { EditorContextMenu } from "../../components/editorContextMenu"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - +import { underlineNodesExtension } from "@/lib/extensions/underlineNodesExtension"; +import { useRouter } from "next/navigation"; +import { SearchQueryParams } from "@/lib/types"; +import { useDomain } from "@/hooks/useDomain"; +import { createPathWithQueryParams } from "@/lib/utils"; interface CodePreviewProps { path: string; repoName: string; @@ -30,6 +34,8 @@ export const CodePreview = ({ const [currentSelection, setCurrentSelection] = useState(); const keymapExtension = useKeymapExtension(editorRef.current?.view); const [isEditorCreated, setIsEditorCreated] = useState(false); + const router = useRouter(); + const domain = useDomain(); const highlightRangeQuery = useNonEmptyQueryParam('highlightRange'); const highlightRange = useMemo(() => { @@ -61,7 +67,7 @@ export const CodePreview = ({ const extensions = useMemo(() => { const highlightDecoration = Decoration.mark({ class: "cm-searchMatch-selected", - }); + }); return [ syntaxHighlighting, @@ -94,6 +100,15 @@ export const CodePreview = ({ }, provide: (field) => EditorView.decorations.from(field), }), + underlineNodesExtension([ + "VariableName", + "VariableDefinition", + "TypeDefinition", + "TypeName", + "PropertyName", + "PropertyDefinition", + "JSXIdentifier" + ]), ]; }, [keymapExtension, syntaxHighlighting, highlightRange]); @@ -119,6 +134,33 @@ export const CodePreview = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [highlightRange, isEditorCreated]); + useEffect(() => { + const view = editorRef.current?.view; + if (!view) return; + + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest('[data-underline-node="true"]')) { + // You can get more info here, e.g., the text, position, etc. + // For example, get the text: + const text = target.textContent; + // Do something with the text or event + console.log("Clicked node:", text); + + const query = `sym:${text}`; + const url = createPathWithQueryParams(`/${domain}/search`, + [SearchQueryParams.query, query], + ); + router.push(url); + } + }; + + view.dom.addEventListener("click", handleClick); + return () => { + view.dom.removeEventListener("click", handleClick); + }; + }, [isEditorCreated]); + const theme = useCodeMirrorTheme(); return ( diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index feabe357..ac09c639 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -138,4 +138,18 @@ .no-scrollbar { -ms-overflow-style: none; /* IE dan Edge */ scrollbar-width: none; /* Firefox */ +} + +.cm-underline-hover { + text-decoration: none; + transition: text-decoration 0.1s; +} + +.cm-underline-hover:hover { + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + /* Optionally, customize color or thickness: */ + /* text-decoration-color: #0070f3; */ + /* text-decoration-thickness: 2px; */ } \ No newline at end of file diff --git a/packages/web/src/lib/extensions/underlineNodesExtension.ts b/packages/web/src/lib/extensions/underlineNodesExtension.ts new file mode 100644 index 00000000..9ada7673 --- /dev/null +++ b/packages/web/src/lib/extensions/underlineNodesExtension.ts @@ -0,0 +1,42 @@ +import { StateField } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; + +/** + * Returns a CodeMirror extension that underlines all nodes of the given types on hover. + * @param nodeTypeNames Array of node type names to underline (e.g., ["VariableName", "TypeDefinition"]) + */ +export function underlineNodesExtension(nodeTypeNames: string[]) { + const underlineDecoration = Decoration.mark({ + class: "cm-underline-hover", + attributes: { "data-underline-node": "true" } + }); + + + return StateField.define({ + create(state) { + const tree = syntaxTree(state); + const decorations: any[] = []; + + const getTextAt = (from: number, to: number) => { + const doc = state.doc; + return doc.sliceString(from, to); + } + + tree.iterate({ + enter: (node) => { + const text = getTextAt(node.from, node.to); + console.log(node.type.name, text); + if (nodeTypeNames.includes(node.type.name)) { + decorations.push(underlineDecoration.range(node.from, node.to)); + } + } + }); + return Decoration.set(decorations); + }, + update(deco, tr) { + return deco.map(tr.changes); + }, + provide: field => EditorView.decorations.from(field), + }); +} \ No newline at end of file From 7b45fac5d2e11d8c436aa45b693acb859588b9cd Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 16 May 2025 12:49:51 -0700 Subject: [PATCH 2/5] Add cursor style rule --- .cursor/rules/style.mdc | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .cursor/rules/style.mdc diff --git a/.cursor/rules/style.mdc b/.cursor/rules/style.mdc new file mode 100644 index 00000000..6d3e8046 --- /dev/null +++ b/.cursor/rules/style.mdc @@ -0,0 +1,7 @@ +--- +description: +globs: +alwaysApply: true +--- +- Always use 4 spaces for indentation +- Filenames should always be camelCase. Exception: if there are filenames in the same directory with a format other than camelCase, use that format to keep things consistent. \ No newline at end of file From d4ce3707a2efee45cc2d1a885a6236b7a75c1c1d Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 16 May 2025 12:50:50 -0700 Subject: [PATCH 3/5] wip: resolve symbol definition in pop-up box --- .../[domain]/browse/[...path]/codePreview.tsx | 39 ++--- .../browse/[...path]/symbolHoverPopup.tsx | 154 ++++++++++++++++++ .../lib/extensions/underlineNodesExtension.ts | 14 +- 3 files changed, 178 insertions(+), 29 deletions(-) create mode 100644 packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx index 6052934b..b0505e23 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx @@ -6,7 +6,7 @@ import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { search } from "@codemirror/search"; import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { EditorContextMenu } from "../../components/editorContextMenu"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; import { underlineNodesExtension } from "@/lib/extensions/underlineNodesExtension"; @@ -14,6 +14,8 @@ import { useRouter } from "next/navigation"; import { SearchQueryParams } from "@/lib/types"; import { useDomain } from "@/hooks/useDomain"; import { createPathWithQueryParams } from "@/lib/utils"; +import { SymbolHoverPopup } from "./symbolHoverPopup"; + interface CodePreviewProps { path: string; repoName: string; @@ -29,11 +31,10 @@ export const CodePreview = ({ repoName, revisionName, }: CodePreviewProps) => { - const editorRef = useRef(null); - const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); + const [editorRef, setEditorRef] = useState(null); + const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef?.view); const [currentSelection, setCurrentSelection] = useState(); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const [isEditorCreated, setIsEditorCreated] = useState(false); + const keymapExtension = useKeymapExtension(editorRef?.view); const router = useRouter(); const domain = useDomain(); @@ -113,29 +114,25 @@ export const CodePreview = ({ }, [keymapExtension, syntaxHighlighting, highlightRange]); useEffect(() => { - if (!highlightRange || !editorRef.current || !editorRef.current.state) { + if (!highlightRange || !editorRef || !editorRef.state) { return; } - const doc = editorRef.current.state.doc; + const doc = editorRef.state.doc; const { start, end } = highlightRange; const from = doc.line(start.line).from + start.character - 1; const to = doc.line(end.line).from + end.character - 1; const selection = EditorSelection.range(from, to); - editorRef.current.view?.dispatch({ + editorRef.view?.dispatch({ effects: [ EditorView.scrollIntoView(selection, { y: "center" }), ] }); - // @note: we need to include `isEditorCreated` in the dependency array since - // a race-condition can happen if the `highlightRange` is resolved before the - // editor is created. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [highlightRange, isEditorCreated]); + }, [editorRef, highlightRange]); useEffect(() => { - const view = editorRef.current?.view; + const view = editorRef?.view; if (!view) return; const handleClick = (event: MouseEvent) => { @@ -159,7 +156,7 @@ export const CodePreview = ({ return () => { view.dom.removeEventListener("click", handleClick); }; - }, [isEditorCreated]); + }, [domain, router, editorRef]); const theme = useCodeMirrorTheme(); @@ -167,18 +164,15 @@ export const CodePreview = ({ { - setIsEditorCreated(true); - }} + ref={setEditorRef} value={source} extensions={extensions} readOnly={true} theme={theme} > - {editorRef.current && editorRef.current.view && currentSelection && ( + {editorRef && editorRef.view && currentSelection && ( )} + {editorRef && ( + + )} ) } diff --git a/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx b/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx new file mode 100644 index 00000000..9c076879 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx @@ -0,0 +1,154 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@floating-ui/react"; +import { search } from "@/app/api/(client)/client"; +import { useDomain } from "@/hooks/useDomain"; +import { base64Decode, isServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; + +interface SymbolHoverPopupProps { + editorRef: ReactCodeMirrorRef; +} + +const SYMBOL_HOVER_POPUP_TIMEOUT = 500; + +export const SymbolHoverPopup: React.FC = ({ editorRef }) => { + const ref = useRef(null); + const element = useHoveredSymbolElement(editorRef); + + useEffect(() => { + if (!element) { + return; + } + + const virtualElement: VirtualElement = { + getBoundingClientRect: () => { + return element.hoveredOverElement.getBoundingClientRect(); + } + } + + if (ref.current) { + computePosition(virtualElement, ref.current, { + middleware: [ + offset(5), + autoPlacement({ + boundary: editorRef.view?.dom, + padding: 5, + allowedPlacements: ['top'], + }), + shift({ + padding: 5 + }) + ] + }).then(({ x, y }) => { + if (ref.current) { + ref.current.style.left = `${x}px`; + ref.current.style.top = `${y}px`; + } + }) + } + }, [element, editorRef]); + + return element ? ( +
+

{element.content}

+
+ ) : null; +}; + +const useHoveredSymbolElement = (editorRef: ReactCodeMirrorRef) => { + const hoverTimerRef = useRef(null); + const domain = useDomain(); + const [isVisible, setIsVisible] = useState(false); + + const [hoveredOverElement, setHoveredOverElement] = useState(null); + const hoveredOverElementContent = useMemo(() => { + return (hoveredOverElement && hoveredOverElement.textContent) ?? undefined; + }, [hoveredOverElement]); + + const { data, isPending } = useQuery({ + queryKey: ["symbol-hover", hoveredOverElementContent], + queryFn: () => { + if (!hoveredOverElementContent) { + return null; + } + const query = `sym:${hoveredOverElementContent} repo:^github\\.com/sourcebot\x2ddev/sourcebot$`; + + return search({ + query, + matches: 1, + contextLines: 0, + }, domain).then((result) => { + if (isServiceError(result)) { + return null; + } + + if (result.files.length > 0) { + const file = result.files[0]; + const chunk = file.chunks[0]; + const content = base64Decode(chunk.content); + return content; + } else { + return null; + } + }); + }, + staleTime: Infinity, + }); + + useEffect(() => { + const view = editorRef.view; + if (!view) { + return; + } + + const handleMouseOver = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest('[data-underline-node="true"]') as HTMLElement; + if (!target) { + return; + } + setHoveredOverElement(target); + + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + } + + hoverTimerRef.current = setTimeout(() => { + setIsVisible(true); + }, SYMBOL_HOVER_POPUP_TIMEOUT); + }; + + const handleMouseOut = () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + } + setHoveredOverElement(null); + setIsVisible(false); + }; + + view.dom.addEventListener("mouseover", handleMouseOver); + view.dom.addEventListener("mouseout", handleMouseOut); + + return () => { + view.dom.removeEventListener("mouseover", handleMouseOver); + view.dom.removeEventListener("mouseout", handleMouseOut); + }; + }, [editorRef, domain]); + + + if (!isVisible || !hoveredOverElement) { + return undefined; + } + + return { + hoveredOverElement, + content: isPending ? + "loading..." : + data ? + data : + "no results found", + }; +} diff --git a/packages/web/src/lib/extensions/underlineNodesExtension.ts b/packages/web/src/lib/extensions/underlineNodesExtension.ts index 9ada7673..8955890f 100644 --- a/packages/web/src/lib/extensions/underlineNodesExtension.ts +++ b/packages/web/src/lib/extensions/underlineNodesExtension.ts @@ -1,4 +1,4 @@ -import { StateField } from "@codemirror/state"; +import { StateField, Range } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; @@ -16,17 +16,15 @@ export function underlineNodesExtension(nodeTypeNames: string[]) { return StateField.define({ create(state) { const tree = syntaxTree(state); - const decorations: any[] = []; + const decorations: Range[] = []; - const getTextAt = (from: number, to: number) => { - const doc = state.doc; - return doc.sliceString(from, to); - } + // const getTextAt = (from: number, to: number) => { + // const doc = state.doc; + // return doc.sliceString(from, to); + // } tree.iterate({ enter: (node) => { - const text = getTextAt(node.from, node.to); - console.log(node.type.name, text); if (nodeTypeNames.includes(node.type.name)) { decorations.push(underlineDecoration.range(node.from, node.to)); } From cd91c9deda92a5e4133fa87c88ab0792e08948b1 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 16 May 2025 16:24:14 -0700 Subject: [PATCH 4/5] further wip --- .../[domain]/browse/[...path]/codePreview.tsx | 8 +- .../browse/[...path]/symbolHoverPopup.tsx | 216 +++++++++++++----- 2 files changed, 171 insertions(+), 53 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx index b0505e23..153759a7 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx @@ -108,7 +108,8 @@ export const CodePreview = ({ "TypeName", "PropertyName", "PropertyDefinition", - "JSXIdentifier" + "JSXIdentifier", + "Identifier" ]), ]; }, [keymapExtension, syntaxHighlighting, highlightRange]); @@ -181,7 +182,10 @@ export const CodePreview = ({ )} {editorRef && ( - + )} ) diff --git a/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx b/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx index 9c076879..41c327b0 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx @@ -1,43 +1,61 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; -import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@floating-ui/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; import { search } from "@/app/api/(client)/client"; import { useDomain } from "@/hooks/useDomain"; import { base64Decode, isServiceError } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; +import CodeMirror, { EditorView, minimalSetup, ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { javascript } from "@codemirror/lang-javascript"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Loader2 } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import escapeStringRegexp from "escape-string-regexp"; + interface SymbolHoverPopupProps { editorRef: ReactCodeMirrorRef; + repoName: string; } -const SYMBOL_HOVER_POPUP_TIMEOUT = 500; - -export const SymbolHoverPopup: React.FC = ({ editorRef }) => { +export const SymbolHoverPopup: React.FC = ({ + editorRef, + repoName +}) => { const ref = useRef(null); - const element = useHoveredSymbolElement(editorRef); + const [isSticky, setIsSticky] = useState(false); + + const symbolInfo = useHoveredOverSymbolInfo({ + editorRef, + isSticky, + repoName, + }); useEffect(() => { - if (!element) { + if (!symbolInfo) { return; } const virtualElement: VirtualElement = { getBoundingClientRect: () => { - return element.hoveredOverElement.getBoundingClientRect(); + return symbolInfo.element.getBoundingClientRect(); } } if (ref.current) { computePosition(virtualElement, ref.current, { + placement: 'top', middleware: [ - offset(5), - autoPlacement({ + offset(2), + flip({ + mainAxis: true, + crossAxis: false, + fallbackPlacements: ['bottom'], boundary: editorRef.view?.dom, - padding: 5, - allowedPlacements: ['top'], }), shift({ - padding: 5 + padding: 5, }) ] }).then(({ x, y }) => { @@ -47,35 +65,121 @@ export const SymbolHoverPopup: React.FC = ({ editorRef }) } }) } - }, [element, editorRef]); + }, [symbolInfo, editorRef]); - return element ? ( + return symbolInfo ? (
setIsSticky(true)} + onMouseOut={() => setIsSticky(false)} > -

{element.content}

+ {symbolInfo.isSymbolDefInfoLoading ? ( +
+ + Loading... +
+ ) : + symbolInfo.symbolDefInfo ? ( +
+ + + + Search Based + + + + Search based on the symbol name. + + + +
+ ) : ( +

No hover info found

+ )} + +
+ + +
) : null; }; -const useHoveredSymbolElement = (editorRef: ReactCodeMirrorRef) => { - const hoverTimerRef = useRef(null); +interface UseHoveredOverSymbolInfoProps { + editorRef: ReactCodeMirrorRef; + isSticky: boolean; + repoName: string; +} + +interface HoveredOverSymbolInfo { + element: HTMLElement; + symbolName: string; + isSymbolDefInfoLoading: boolean; + symbolDefInfo?: { + lineContent: string; + } +} + +const SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT = 500; +const SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT = 100; + +const useHoveredOverSymbolInfo = ({ + editorRef, + isSticky, + repoName, +}: UseHoveredOverSymbolInfoProps): HoveredOverSymbolInfo | undefined => { + const mouseOverTimerRef = useRef(null); + const mouseOutTimerRef = useRef(null); + const domain = useDomain(); const [isVisible, setIsVisible] = useState(false); - - const [hoveredOverElement, setHoveredOverElement] = useState(null); - const hoveredOverElementContent = useMemo(() => { - return (hoveredOverElement && hoveredOverElement.textContent) ?? undefined; - }, [hoveredOverElement]); - - const { data, isPending } = useQuery({ - queryKey: ["symbol-hover", hoveredOverElementContent], + + const [symbolElement, setSymbolElement] = useState(null); + const symbolName = useMemo(() => { + return (symbolElement && symbolElement.textContent) ?? undefined; + }, [symbolElement]); + + const { data, isPending: isSymbolDefinitionLoading } = useQuery({ + queryKey: ["symbol-hover", symbolName], queryFn: () => { - if (!hoveredOverElementContent) { + if (!symbolName) { return null; } - const query = `sym:${hoveredOverElementContent} repo:^github\\.com/sourcebot\x2ddev/sourcebot$`; + const query = `sym:\\b${symbolName}\\b repo:^${escapeStringRegexp(repoName)}$`; return search({ query, @@ -90,7 +194,7 @@ const useHoveredSymbolElement = (editorRef: ReactCodeMirrorRef) => { const file = result.files[0]; const chunk = file.chunks[0]; const content = base64Decode(chunk.content); - return content; + return content.trim(); } else { return null; } @@ -99,6 +203,16 @@ const useHoveredSymbolElement = (editorRef: ReactCodeMirrorRef) => { staleTime: Infinity, }); + const clearTimers = useCallback(() => { + if (mouseOverTimerRef.current) { + clearTimeout(mouseOverTimerRef.current); + } + + if (mouseOutTimerRef.current) { + clearTimeout(mouseOutTimerRef.current); + } + }, []); + useEffect(() => { const view = editorRef.view; if (!view) { @@ -110,23 +224,20 @@ const useHoveredSymbolElement = (editorRef: ReactCodeMirrorRef) => { if (!target) { return; } - setHoveredOverElement(target); - - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - } + clearTimers(); + setSymbolElement(target); - hoverTimerRef.current = setTimeout(() => { + mouseOverTimerRef.current = setTimeout(() => { setIsVisible(true); - }, SYMBOL_HOVER_POPUP_TIMEOUT); + }, SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT); }; const handleMouseOut = () => { - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - } - setHoveredOverElement(null); - setIsVisible(false); + clearTimers(); + + mouseOutTimerRef.current = setTimeout(() => { + setIsVisible(false); + }, SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT); }; view.dom.addEventListener("mouseover", handleMouseOver); @@ -136,19 +247,22 @@ const useHoveredSymbolElement = (editorRef: ReactCodeMirrorRef) => { view.dom.removeEventListener("mouseover", handleMouseOver); view.dom.removeEventListener("mouseout", handleMouseOut); }; - }, [editorRef, domain]); + }, [editorRef, domain, clearTimers]); + if (!isVisible && !isSticky) { + return undefined; + } - if (!isVisible || !hoveredOverElement) { + if (!symbolElement || !symbolName) { return undefined; } return { - hoveredOverElement, - content: isPending ? - "loading..." : - data ? - data : - "no results found", + element: symbolElement, + symbolName, + isSymbolDefInfoLoading: isSymbolDefinitionLoading, + symbolDefInfo: data ? { + lineContent: data, + } : undefined, }; } From ee0069c76d444c3b04ed9b7c6fdfdcb575e51684 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 16 May 2025 23:20:35 -0700 Subject: [PATCH 5/5] wip --- .../[domain]/browse/[...path]/codePreview.tsx | 33 -- .../app/[domain]/browse/[...path]/page.tsx | 1 - .../browse/[...path]/symbolHoverPopup.tsx | 285 +++++++----------- .../[...path]/useHoveredOverSymbolInfo.ts | 157 ++++++++++ 4 files changed, 265 insertions(+), 211 deletions(-) create mode 100644 packages/web/src/app/[domain]/browse/[...path]/useHoveredOverSymbolInfo.ts diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx index 153759a7..66a9291e 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx @@ -10,10 +10,6 @@ import { useEffect, useMemo, useState } from "react"; import { EditorContextMenu } from "../../components/editorContextMenu"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; import { underlineNodesExtension } from "@/lib/extensions/underlineNodesExtension"; -import { useRouter } from "next/navigation"; -import { SearchQueryParams } from "@/lib/types"; -import { useDomain } from "@/hooks/useDomain"; -import { createPathWithQueryParams } from "@/lib/utils"; import { SymbolHoverPopup } from "./symbolHoverPopup"; interface CodePreviewProps { @@ -35,8 +31,6 @@ export const CodePreview = ({ const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef?.view); const [currentSelection, setCurrentSelection] = useState(); const keymapExtension = useKeymapExtension(editorRef?.view); - const router = useRouter(); - const domain = useDomain(); const highlightRangeQuery = useNonEmptyQueryParam('highlightRange'); const highlightRange = useMemo(() => { @@ -132,33 +126,6 @@ export const CodePreview = ({ }); }, [editorRef, highlightRange]); - useEffect(() => { - const view = editorRef?.view; - if (!view) return; - - const handleClick = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.closest('[data-underline-node="true"]')) { - // You can get more info here, e.g., the text, position, etc. - // For example, get the text: - const text = target.textContent; - // Do something with the text or event - console.log("Clicked node:", text); - - const query = `sym:${text}`; - const url = createPathWithQueryParams(`/${domain}/search`, - [SearchQueryParams.query, query], - ); - router.push(url); - } - }; - - view.dom.addEventListener("click", handleClick); - return () => { - view.dom.removeEventListener("click", handleClick); - }; - }, [domain, router, editorRef]); - const theme = useCodeMirrorTheme(); return ( diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 38ddedf1..4e89ff3f 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -7,7 +7,6 @@ import { base64Decode } from "@/lib/utils"; import { CodePreview } from "./codePreview"; import { ErrorCode } from "@/lib/errorCodes"; import { LuFileX2, LuBookX } from "react-icons/lu"; -import { getOrgFromDomain } from "@/data/org"; import { notFound } from "next/navigation"; import { ServiceErrorException } from "@/lib/serviceError"; import { getRepoInfoByName } from "@/actions"; diff --git a/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx b/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx index 41c327b0..da76eddf 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/symbolHoverPopup.tsx @@ -1,18 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; -import { search } from "@/app/api/(client)/client"; -import { useDomain } from "@/hooks/useDomain"; -import { base64Decode, isServiceError } from "@/lib/utils"; -import { useQuery } from "@tanstack/react-query"; -import CodeMirror, { EditorView, minimalSetup, ReactCodeMirrorRef } from "@uiw/react-codemirror"; -import { javascript } from "@codemirror/lang-javascript"; -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import escapeStringRegexp from "escape-string-regexp"; - +import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; +import CodeMirror, { EditorView, minimalSetup, ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { Loader2, Router } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SymbolDefInfo, useHoveredOverSymbolInfo } from "./useHoveredOverSymbolInfo"; +import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; +import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; +import { createPathWithQueryParams } from "@/lib/utils"; +import { useDomain } from "@/hooks/useDomain"; +import { useRouter } from "next/navigation"; interface SymbolHoverPopupProps { editorRef: ReactCodeMirrorRef; @@ -25,6 +24,8 @@ export const SymbolHoverPopup: React.FC = ({ }) => { const ref = useRef(null); const [isSticky, setIsSticky] = useState(false); + const domain = useDomain(); + const router = useRouter(); const symbolInfo = useHoveredOverSymbolInfo({ editorRef, @@ -32,6 +33,7 @@ export const SymbolHoverPopup: React.FC = ({ repoName, }); + // Positions the popup relative to the symbol useEffect(() => { if (!symbolInfo) { return; @@ -67,6 +69,38 @@ export const SymbolHoverPopup: React.FC = ({ } }, [symbolInfo, editorRef]); + // 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(() => { + if (!symbolInfo || !symbolInfo.symbolDefInfo) { + return; + } + + const { symbolDefInfo } = symbolInfo; + const { fileName, repoName } = symbolDefInfo; + const { start, end } = symbolDefInfo.range; + const highlightRange = `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`; + + const url = createPathWithQueryParams(`/${domain}/browse/${repoName}@HEAD/-/blob/${fileName}`, + ['highlightRange', highlightRange] + ); + router.push(url); + }, [symbolInfo, domain, router]); + + // @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 + // is held, hover is active, and we have found the symbol definition. + useEffect(() => { + if (!symbolInfo) { + return; + } + + symbolInfo.element.addEventListener("click", onGotoDefinition); + return () => { + symbolInfo.element.removeEventListener("click", onGotoDefinition); + } + }, [symbolInfo, onGotoDefinition]); + return symbolInfo ? (
= ({ Loading...
- ) : - symbolInfo.symbolDefInfo ? ( -
- - - - Search Based - - - - Search based on the symbol name. - - - -
- ) : ( -

No hover info found

- )} + ) : symbolInfo.symbolDefInfo ? ( + + ) : ( +

No hover info found

+ )}