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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8b551b..4b9375c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [Sourcebot EE] Added code navigation (find all references / go to definition). [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) + +### Fixed +- Improved scroll performance for large numbers of search results. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) + ## [3.2.1] - 2025-05-15 ### Added diff --git a/docs/docs.json b/docs/docs.json index 59087d5f..21d6dba4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,7 +50,8 @@ "pages": [ "docs/search/syntax-reference", "docs/search/multi-branch-indexing", - "docs/search/search-contexts" + "docs/search/search-contexts", + "docs/search/code-navigation" ] }, { diff --git a/docs/docs/search/code-navigation.mdx b/docs/docs/search/code-navigation.mdx new file mode 100644 index 00000000..fc931fc2 --- /dev/null +++ b/docs/docs/search/code-navigation.mdx @@ -0,0 +1,44 @@ +--- +title: Code navigation +sidebarTitle: Code navigation (EE) +--- + +import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' + + +This feature is only available with an active Enterprise license. Please add your [license key](/self-hosting/license-key) to activate it. + + +**Code navigation** allows you to jump between symbol definition and references when viewing source files in Sourcebot. This feature is enabled **automatically** when a valid license key is present and works with all popular programming languages. + + + + +## Features + +| Feature | Description | +|:--------|:------------| +| **Hover popover** | Hovering over a symbol reveals the symbol's definition signature as a inline preview. | +| **Go to definition** | Clicking the "go to definition" button in the popover or clicking the symbol name navigates to the symbol's definition. | +| **Find references** | Clicking the "find all references" button in the popover lists all references in the explore panel. | +| **Explore panel** | Lists all references and definitions for the symbol selected in the popover. | + +## How does it work? + +Code navigation is **search-based**, meaning it uses the same code search engine and [query language](/docs/search/syntax-reference) to estimate a symbol's references and definitions. We refer to these estimations as "search heuristics". We have two search heuristics to enable the following operations: + +### Find references +Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search: + +```bash +\\b{symbolName}\\b rev:{git_revision} lang:{language} case:yes +``` + +### Find definitions +Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search: + +```bash +sym:\\b{symbolName}\\b rev:{git_revision} lang:{language} +``` + +Note that the `sym:` prefix is used to filter the search by symbol definitions. These are created at index time by [universal ctags](https://ctags.io/). diff --git a/docs/images/demo.mp4 b/docs/images/demo.mp4 deleted file mode 100644 index e6162d19..00000000 Binary files a/docs/images/demo.mp4 and /dev/null differ diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index 6b1e43a1..1808f80a 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -7,7 +7,8 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "next/core-web-vitals" + "next/core-web-vitals", + "plugin:@tanstack/query/recommended" ], "rules": { "react-hooks/exhaustive-deps": "warn", diff --git a/packages/web/package.json b/packages/web/package.json index d108b94e..15e17fe4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,6 +34,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.0.0", + "@codemirror/language-data": "^6.5.1", "@codemirror/legacy-modes": "^6.4.2", "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", @@ -81,6 +82,7 @@ "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.8", + "@uidotdev/usehooks": "^2.4.1", "@uiw/codemirror-themes": "^4.23.6", "@uiw/react-codemirror": "^4.23.0", "@viz-js/lang-dot": "^1.0.4", @@ -144,6 +146,7 @@ "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.74.7", "@types/micromatch": "^4.0.9", "@types/node": "^20", "@types/nodemailer": "^6.4.17", diff --git a/packages/web/src/app/[domain]/browse/README.md b/packages/web/src/app/[domain]/browse/README.md new file mode 100644 index 00000000..8613d6da --- /dev/null +++ b/packages/web/src/app/[domain]/browse/README.md @@ -0,0 +1,12 @@ +# File browser + +This directory contains Sourcebot's file browser implementation. URL paths are used to determine what file the user wants to view. The following template is used: + +```sh +/browse/[@]/-/(blob|tree)/ +``` + +For example, to view `packages/backend/src/env.ts` in Sourcebot, we would use the following path: +```sh +/browse/github.com/sourcebot-dev/sourcebot@HEAD/-/blob/packages/backend/src/env.ts +``` diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx deleted file mode 100644 index 8f6243c7..00000000 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -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 { EditorContextMenu } from "../../components/editorContextMenu"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - -interface CodePreviewProps { - path: string; - repoName: string; - revisionName: string; - source: string; - language: string; -} - -export const CodePreview = ({ - source, - language, - path, - repoName, - revisionName, -}: CodePreviewProps) => { - const editorRef = useRef(null); - const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); - const [currentSelection, setCurrentSelection] = useState(); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const [isEditorCreated, setIsEditorCreated] = useState(false); - - const highlightRangeQuery = useNonEmptyQueryParam('highlightRange'); - const highlightRange = useMemo(() => { - if (!highlightRangeQuery) { - return; - } - - const rangeRegex = /^\d+:\d+,\d+:\d+$/; - if (!rangeRegex.test(highlightRangeQuery)) { - return; - } - - const [start, end] = highlightRangeQuery.split(',').map((range) => { - return range.split(':').map((val) => parseInt(val, 10)); - }); - - return { - start: { - line: start[0], - character: start[1], - }, - end: { - line: end[0], - character: end[1], - } - } - }, [highlightRangeQuery]); - - const extensions = useMemo(() => { - const highlightDecoration = Decoration.mark({ - class: "cm-searchMatch-selected", - }); - - return [ - syntaxHighlighting, - EditorView.lineWrapping, - keymapExtension, - search({ - top: true, - }), - EditorView.updateListener.of((update: ViewUpdate) => { - if (update.selectionSet) { - setCurrentSelection(update.state.selection.main); - } - }), - StateField.define({ - create(state) { - if (!highlightRange) { - return Decoration.none; - } - - const { start, end } = highlightRange; - const from = state.doc.line(start.line).from + start.character - 1; - const to = state.doc.line(end.line).from + end.character - 1; - - return Decoration.set([ - highlightDecoration.range(from, to), - ]); - }, - update(deco, tr) { - return deco.map(tr.changes); - }, - provide: (field) => EditorView.decorations.from(field), - }), - ]; - }, [keymapExtension, syntaxHighlighting, highlightRange]); - - useEffect(() => { - if (!highlightRange || !editorRef.current || !editorRef.current.state) { - return; - } - - const doc = editorRef.current.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({ - 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]); - - const theme = useCodeMirrorTheme(); - - return ( - - { - setIsEditorCreated(true); - }} - value={source} - extensions={extensions} - readOnly={true} - theme={theme} - > - {editorRef.current && editorRef.current.view && currentSelection && ( - - )} - - - ) -} - diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx new file mode 100644 index 00000000..14726b47 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { ResizablePanel } from "@/components/ui/resizable"; +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"; +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 { EditorContextMenu } from "../../../components/editorContextMenu"; +import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation"; +import { useBrowseState } from "../../hooks/useBrowseState"; +import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface CodePreviewPanelProps { + path: string; + repoName: string; + revisionName: string; + source: string; + language: string; +} + +export const CodePreviewPanel = ({ + source, + language, + path, + repoName, + revisionName, +}: CodePreviewPanelProps) => { + const [editorRef, setEditorRef] = useState(null); + const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); + const [currentSelection, setCurrentSelection] = useState(); + 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 => { + if (!highlightRangeQuery) { + return; + } + + // Highlight ranges can be formatted in two ways: + // 1. start_line,end_line (no column specified) + // 2. start_line:start_column,end_line:end_column (column specified) + const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/; + if (!rangeRegex.test(highlightRangeQuery)) { + return; + } + + const [start, end] = highlightRangeQuery.split(',').map((range) => { + if (range.includes(':')) { + return range.split(':').map((val) => parseInt(val, 10)); + } + // For line-only format, use column 1 for start and last column for end + const line = parseInt(range, 10); + return [line]; + }); + + if (start.length === 1 || end.length === 1) { + return { + start: { + lineNumber: start[0], + }, + end: { + lineNumber: end[0], + } + } + } else { + return { + start: { + lineNumber: start[0], + column: start[1], + }, + end: { + lineNumber: end[0], + column: end[1], + } + } + } + + }, [highlightRangeQuery]); + + const extensions = useMemo(() => { + return [ + languageExtension, + EditorView.lineWrapping, + keymapExtension, + search({ + top: true, + }), + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.selectionSet) { + setCurrentSelection(update.state.selection.main); + } + }), + highlightRange ? rangeHighlightingExtension(highlightRange) : [], + hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], + ]; + }, [ + keymapExtension, + languageExtension, + highlightRange, + hasCodeNavEntitlement, + ]); + + // Scroll the highlighted range into view. + useEffect(() => { + if (!highlightRange || !editorRef || !editorRef.state) { + return; + } + + const doc = editorRef.state.doc; + const { start, end } = highlightRange; + const selection = EditorSelection.range( + doc.line(start.lineNumber).from, + doc.line(end.lineNumber).from, + ); + + editorRef.view?.dispatch({ + effects: [ + EditorView.scrollIntoView(selection, { y: "center" }), + ] + }); + }, [editorRef, highlightRange]); + + const onFindReferences = useCallback((symbolName: string) => { + captureEvent('wa_browse_find_references_pressed', {}); + + 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_browse_goto_definition_pressed', {}); + + 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 ( + + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + + + ) +} + diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts new file mode 100644 index 00000000..b5bba639 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts @@ -0,0 +1,39 @@ +'use client'; + +import { StateField, Range } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { BrowseHighlightRange } from "../../hooks/useBrowseNavigation"; + +const markDecoration = Decoration.mark({ + class: "searchMatch-selected", +}); + +const lineDecoration = Decoration.line({ + attributes: { class: "lineHighlight" }, +}); + +export const rangeHighlightingExtension = (range: BrowseHighlightRange) => StateField.define({ + create(state) { + const { start, end } = range; + + if ('column' in start && 'column' in end) { + const from = state.doc.line(start.lineNumber).from + start.column - 1; + const to = state.doc.line(end.lineNumber).from + end.column - 1; + + return Decoration.set([ + markDecoration.range(from, to), + ]); + } else { + const decorations: Range[] = []; + for (let line = start.lineNumber; line <= end.lineNumber; line++) { + decorations.push(lineDecoration.range(state.doc.line(line).from)); + } + + return Decoration.set(decorations); + } + }, + update(deco, tr) { + return deco.map(tr.changes); + }, + provide: (field) => EditorView.decorations.from(field), +}); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 4e89ff3f..12a290a7 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -2,14 +2,15 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; import { getFileSource } from '@/features/search/fileSourceApi'; -import { isServiceError } from "@/lib/utils"; +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; import { base64Decode } from "@/lib/utils"; -import { CodePreview } from "./codePreview"; import { ErrorCode } from "@/lib/errorCodes"; import { LuFileX2, LuBookX } from "react-icons/lu"; import { notFound } from "next/navigation"; import { ServiceErrorException } from "@/lib/serviceError"; import { getRepoInfoByName } from "@/actions"; +import { CodePreviewPanel } from "./components/codePreviewPanel"; +import Image from "next/image"; interface BrowsePageProps { params: { @@ -49,7 +50,18 @@ export default async function BrowsePage({ })(); const repoInfo = await getRepoInfoByName(repoName, params.domain); - if (isServiceError(repoInfo) && repoInfo.errorCode !== ErrorCode.NOT_FOUND) { + if (isServiceError(repoInfo)) { + if (repoInfo.errorCode === ErrorCode.NOT_FOUND) { + return ( + + + + Repository not found + + + ); + } + throw new ServiceErrorException(repoInfo); } @@ -62,70 +74,11 @@ export default async function BrowsePage({ ) } - return ( - - - - - {!isServiceError(repoInfo) && ( - <> - - - - - > - )} - - {isServiceError(repoInfo) ? ( - - - - Repository not found - - - ) : ( - - )} - - ) -} - -interface CodePreviewWrapper { - path: string, - repoName: string, - revisionName: string, - domain: string, -} - -const CodePreviewWrapper = async ({ - path, - repoName, - revisionName, - domain, -}: CodePreviewWrapper) => { - // @todo: this will depend on `pathType`. const fileSourceResponse = await getFileSource({ fileName: path, repository: repoName, - branch: revisionName, - }, domain); + branch: revisionName ?? 'HEAD', + }, params.domain); if (isServiceError(fileSourceResponse)) { if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { @@ -142,13 +95,57 @@ const CodePreviewWrapper = async ({ throw new ServiceErrorException(fileSourceResponse); } + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repoInfo.codeHostType, + name: repoInfo.name, + displayName: repoInfo.displayName, + webUrl: repoInfo.webUrl, + }); + return ( - + <> + + + + + + {(fileSourceResponse.webUrl && codeHostInfo) && ( + + + Open in {codeHostInfo.codeHostName} + + )} + + + + + > ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx new file mode 100644 index 00000000..9a4bb4b3 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { createContext, useCallback, useEffect, useState } from "react"; +import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel"; + +export interface BrowseState { + selectedSymbolInfo?: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } + isBottomPanelCollapsed: boolean; + activeExploreMenuTab: "references" | "definitions"; + bottomPanelSize: number; +} + +const defaultState: BrowseState = { + selectedSymbolInfo: undefined, + isBottomPanelCollapsed: true, + activeExploreMenuTab: "references", + bottomPanelSize: BOTTOM_PANEL_MIN_SIZE, +}; + +export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; + +export const BrowseStateContext = createContext<{ + state: BrowseState; + updateBrowseState: (state: Partial) => void; +}>({ + state: defaultState, + updateBrowseState: () => {}, +}); + +export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => { + const [state, setState] = useState(defaultState); + const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); + + const onUpdateState = useCallback((state: Partial) => { + setState((prevState) => ({ + ...prevState, + ...state, + })); + }, []); + + useEffect(() => { + if (hydratedBrowseState) { + try { + const parsedState = JSON.parse(hydratedBrowseState) as Partial; + onUpdateState(parsedState); + } catch (error) { + console.error("Error parsing hydratedBrowseState", error); + } + + // Remove the query param + const url = new URL(window.location.href); + url.searchParams.delete(SET_BROWSE_STATE_QUERY_PARAM); + window.history.replaceState({}, '', url.toString()); + } + }, [hydratedBrowseState, onUpdateState]); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx new file mode 100644 index 00000000..f4d0cee9 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { Button } from "@/components/ui/button"; +import { ResizablePanel } from "@/components/ui/resizable"; +import { Separator } from "@/components/ui/separator"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { useEffect, useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { FaChevronDown } from "react-icons/fa"; +import { VscReferences, VscSymbolMisc } from "react-icons/vsc"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { useBrowseState } from "../hooks/useBrowseState"; +import { ExploreMenu } from "@/ee/features/codeNav/components/exploreMenu"; + +export const BOTTOM_PANEL_MIN_SIZE = 35; +export const BOTTOM_PANEL_MAX_SIZE = 65; + +export const BottomPanel = () => { + const panelRef = useRef(null); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + + const { + state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize }, + updateBrowseState, + } = useBrowseState(); + + useEffect(() => { + if (isBottomPanelCollapsed) { + panelRef.current?.collapse(); + } else { + panelRef.current?.expand(); + } + }, [isBottomPanelCollapsed]); + + useHotkeys("shift+mod+e", (event) => { + event.preventDefault(); + updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed }); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open Explore Panel", + }); + + return ( + <> + + + { + updateBrowseState({ + isBottomPanelCollapsed: !isBottomPanelCollapsed, + }) + }} + > + + Explore + + + + + {!isBottomPanelCollapsed && ( + { + updateBrowseState({ isBottomPanelCollapsed: true }) + }} + > + + Hide + + )} + + + updateBrowseState({ isBottomPanelCollapsed: true })} + onExpand={() => updateBrowseState({ isBottomPanelCollapsed: false })} + onResize={(size) => { + if (!isBottomPanelCollapsed) { + updateBrowseState({ bottomPanelSize: size }); + } + }} + order={2} + id={"bottom-panel"} + > + {!hasCodeNavEntitlement ? ( + + + + Code navigation is not enabled for your plan. + + + ) : !selectedSymbolInfo ? ( + + + No symbol selected + + ) : ( + + )} + + > + ) +} + diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts new file mode 100644 index 00000000..83780153 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts @@ -0,0 +1,59 @@ +import { useRouter } from "next/navigation"; +import { useDomain } from "@/hooks/useDomain"; +import { useCallback } from "react"; +import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider"; + +export type BrowseHighlightRange = { + start: { lineNumber: number; column: number; }; + end: { lineNumber: number; column: number; }; +} | { + start: { lineNumber: number; }; + end: { lineNumber: number; }; +} + +export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange'; + +interface NavigateToPathOptions { + repoName: string; + revisionName?: string; + path: string; + pathType: 'blob' | 'tree'; + highlightRange?: BrowseHighlightRange; + setBrowseState?: Partial; +} + +export const useBrowseNavigation = () => { + const router = useRouter(); + const domain = useDomain(); + + const navigateToPath = useCallback(({ + repoName, + revisionName = 'HEAD', + path, + pathType, + highlightRange, + setBrowseState, + }: NavigateToPathOptions) => { + const params = new URLSearchParams(); + + if (highlightRange) { + const { start, end } = highlightRange; + + if ('column' in start && 'column' in end) { + params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`); + } else { + params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`); + } + } + + if (setBrowseState) { + params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState)); + } + + router.push(`/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${path}?${params.toString()}`); + }, [domain, router]); + + return { + navigateToPath, + }; +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts new file mode 100644 index 00000000..5ff4924c --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useContext } from "react"; +import { BrowseStateContext } from "../browseStateProvider"; + +export const useBrowseState = () => { + const context = useContext(BrowseStateContext); + if (!context) { + throw new Error('useBrowseState must be used within a BrowseStateProvider'); + } + return context; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx new file mode 100644 index 00000000..4f23d9cc --- /dev/null +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -0,0 +1,26 @@ +import { ResizablePanelGroup } from "@/components/ui/resizable"; +import { BottomPanel } from "./components/bottomPanel"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { BrowseStateProvider } from "./browseStateProvider"; + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ + children, +}: LayoutProps) { + return ( + + + + {children} + + + + + + ); +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx index 50560f5d..6bfe4baf 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -40,7 +40,7 @@ export const SecretCombobox = ({ const captureEvent = useCaptureEvent(); const { data: secrets, isPending, isError, refetch } = useQuery({ - queryKey: ["secrets"], + queryKey: ["secrets", domain], queryFn: () => unwrapServiceError(getSecrets(domain)), }); diff --git a/packages/web/src/app/[domain]/components/editorContextMenu.tsx b/packages/web/src/app/[domain]/components/editorContextMenu.tsx index d567198c..102f9f89 100644 --- a/packages/web/src/app/[domain]/components/editorContextMenu.tsx +++ b/packages/web/src/app/[domain]/components/editorContextMenu.tsx @@ -9,6 +9,7 @@ import { Link2Icon } from "@radix-ui/react-icons"; import { EditorView, SelectionRange } from "@uiw/react-codemirror"; import { useCallback, useEffect, useRef } from "react"; import { useDomain } from "@/hooks/useDomain"; +import { HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; interface ContextMenuProps { view: EditorView; @@ -107,7 +108,7 @@ export const EditorContextMenu = ({ const basePath = `${window.location.origin}/${domain}/browse`; const url = createPathWithQueryParams(`${basePath}/${repoName}@${revisionName}/-/blob/${path}`, - ['highlightRange', `${from?.line}:${from?.column},${to?.line}:${to?.column}`], + [HIGHLIGHT_RANGE_QUERY_PARAM, `${from?.line}:${from?.column},${to?.line}:${to?.column}`], ); navigator.clipboard.writeText(url); diff --git a/packages/web/src/app/[domain]/components/fileHeader.tsx b/packages/web/src/app/[domain]/components/fileHeader.tsx index 852e7c50..3eff5be6 100644 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ b/packages/web/src/app/[domain]/components/fileHeader.tsx @@ -1,9 +1,11 @@ +'use client'; import { getCodeHostInfoForRepo } from "@/lib/utils"; import { LaptopIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; +import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; interface FileHeaderProps { fileName: string; @@ -35,6 +37,8 @@ export const FileHeader = ({ webUrl: repo.webUrl, }); + const { navigateToPath } = useBrowseNavigation(); + return ( {info?.icon ? ( @@ -58,17 +62,11 @@ export const FileHeader = ({ - {/* hack since to make the @ symbol look more centered with the text */} - - @ - + @ {`${branchDisplayName}`} )} @@ -76,7 +74,17 @@ export const FileHeader = ({ - + { + navigateToPath({ + repoName: repo.name, + path: fileName, + pathType: 'blob', + revisionName: branchDisplayName, + }); + }} + > {!fileNameHighlightRange ? fileName : ( diff --git a/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx b/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx deleted file mode 100644 index f93209f1..00000000 --- a/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' - -interface KeyboardShortcutHintProps { - shortcut: string - label?: string -} - -export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { - return ( - - - {shortcut} - - - ) -} diff --git a/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx new file mode 100644 index 00000000..1cb01719 --- /dev/null +++ b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx @@ -0,0 +1,276 @@ +import { Parser } from '@lezer/common' +import { LanguageDescription, StreamLanguage } from '@codemirror/language' +import { Highlighter, highlightTree } from '@lezer/highlight' +import { languages as builtinLanguages } from '@codemirror/language-data' +import { memo, useEffect, useMemo, useState } from 'react' +import { useCodeMirrorHighlighter } from '@/hooks/useCodeMirrorHighlighter' +import tailwind from '@/tailwind' +import { measure } from '@/lib/utils' +import { SourceRange } from '@/features/search/types' + +// Define a plain text language +const plainTextLanguage = StreamLanguage.define({ + token(stream) { + stream.next(); + return null; + } +}); + +interface LightweightCodeHighlighter { + language: string; + children: string; + /* 1-based highlight ranges */ + highlightRanges?: SourceRange[]; + lineNumbers?: boolean; + /* 1-based line number offset */ + lineNumbersOffset?: number; + renderWhitespace?: boolean; +} + +/** + * Lightweight code highlighter that uses the Lezer parser to highlight code. + * This is helpful in scenarios where we need to highlight a ton of code snippets + * (e.g., code nav, search results, etc)., but can't use the full-blown CodeMirror + * editor because of perf issues. + * + * Inspired by: https://github.com/craftzdog/react-codemirror-runmode + */ +export const LightweightCodeHighlighter = memo((props: LightweightCodeHighlighter) => { + const { + language, + children: code, + highlightRanges, + lineNumbers = false, + lineNumbersOffset = 1, + renderWhitespace = false, + } = props; + + const unhighlightedLines = useMemo(() => { + return code.trimEnd().split('\n'); + }, [code]); + + + const [highlightedLines, setHighlightedLines] = useState(null); + + const highlightStyle = useCodeMirrorHighlighter(); + + useEffect(() => { + measure(() => Promise.all( + unhighlightedLines + .map(async (line, index) => { + const lineNumber = index + lineNumbersOffset; + + // @todo: we will need to handle the case where a range spans multiple lines. + const ranges = highlightRanges?.filter(range => { + return range.start.lineNumber === lineNumber || range.end.lineNumber === lineNumber; + }).map(range => ({ + from: range.start.column - 1, + to: range.end.column - 1, + })); + + const snippets = await highlightCode( + language, + line, + highlightStyle, + ranges, + (text: string, style: string | null, from: number) => { + return ( + + {text} + + ) + } + ); + + return {snippets} + }) + ).then(highlightedLines => { + setHighlightedLines(highlightedLines); + }), 'highlightCode', /* outputLog = */ false); + }, [ + language, + code, + highlightRanges, + highlightStyle, + unhighlightedLines, + lineNumbersOffset + ]); + + const lineCount = (highlightedLines ?? unhighlightedLines).length + lineNumbersOffset; + const lineNumberDigits = String(lineCount).length; + const lineNumberWidth = `${lineNumberDigits + 2}ch`; // +2 for padding + + return ( + + {(highlightedLines ?? unhighlightedLines).map((line, index) => ( + + {lineNumbers && ( + + {index + lineNumbersOffset} + + )} + + {line} + + + ))} + + ) +}) + +LightweightCodeHighlighter.displayName = 'LightweightCodeHighlighter'; + +async function getCodeParser( + languageName: string, +): Promise { + if (languageName) { + const parser = await (async () => { + const found = LanguageDescription.matchLanguageName( + builtinLanguages, + languageName, + true + ); + + if (!found) { + return null; + } + + if (!found.support) { + await found.load(); + } + return found.support ? found.support.language.parser : null; + })(); + + if (parser) { + return parser; + } + } + return plainTextLanguage.parser; +} + +async function highlightCode( + languageName: string, + input: string, + highlighter: Highlighter, + highlightRanges: { from: number, to: number }[] = [], + callback: ( + text: string, + style: string | null, + from: number, + to: number + ) => Output, +): Promise { + const parser = await getCodeParser(languageName); + + /** + * Converts a range to a series of highlighted subranges. + */ + const convertRangeToHighlightedSubranges = ( + from: number, + to: number, + classes: string | null, + cb: (from: number, to: number, classes: string | null) => void, + ) => { + type HighlightRange = { + from: number, + to: number, + isHighlighted: boolean, + } + + const highlightClasses = classes ? `${classes} searchMatch-selected` : 'searchMatch-selected'; + + let currentRange: HighlightRange | null = null; + for (let i = from; i < to; i++) { + const isHighlighted = isIndexHighlighted(i, highlightRanges); + + if (currentRange) { + if (currentRange.isHighlighted === isHighlighted) { + currentRange.to = i + 1; + } else { + cb( + currentRange.from, + currentRange.to, + currentRange.isHighlighted ? highlightClasses : classes, + ) + + currentRange = { from: i, to: i + 1, isHighlighted }; + } + } else { + currentRange = { from: i, to: i + 1, isHighlighted }; + } + } + + if (currentRange) { + cb( + currentRange.from, + currentRange.to, + currentRange.isHighlighted ? highlightClasses : classes, + ) + } + } + + const tree = parser.parse(input) + const output: Array = []; + + let pos = 0; + highlightTree(tree, highlighter, (from, to, classes) => { + // `highlightTree` only calls this callback when at least one style/class + // is applied to the text (i.e., `classes` is not empty). This means that + // any unstyled regions will be skipped (e.g., whitespace, `=`. `;`. etc). + // This check ensures that we process these unstyled regions as well. + // @see: https://discuss.codemirror.net/t/static-highlighting-using-cm-v6/3420/2 + if (from > pos) { + convertRangeToHighlightedSubranges(pos, from, null, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + } + + convertRangeToHighlightedSubranges(from, to, classes, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + + pos = to; + }); + + // Process any remaining unstyled regions. + if (pos != tree.length) { + convertRangeToHighlightedSubranges(pos, tree.length, null, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + } + return output; +} + +const isIndexHighlighted = (index: number, ranges: { from: number, to: number }[]) => { + return ranges.some(range => index >= range.from && index < range.to); +} diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index cb347d43..450ed74b 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx @@ -1,7 +1,6 @@ 'use client'; import { useClickListener } from "@/hooks/useClickListener"; -import { useTailwind } from "@/hooks/useTailwind"; import { SearchQueryParams } from "@/lib/types"; import { cn, createPathWithQueryParams } from "@/lib/utils"; import { @@ -43,7 +42,8 @@ import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Toggle } from "@/components/ui/toggle"; import { useDomain } from "@/hooks/useDomain"; -import { KeyboardShortcutHint } from "../keyboardShortcutHint"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import tailwind from "@/tailwind"; interface SearchBarProps { className?: string; @@ -95,7 +95,6 @@ export const SearchBar = ({ }: SearchBarProps) => { const router = useRouter(); const domain = useDomain(); - const tailwind = useTailwind(); const suggestionBoxRef = useRef(null); const editorRef = useRef(null); const [cursorPosition, setCursorPosition] = useState(0); @@ -161,7 +160,7 @@ export const SearchBar = ({ }, ], }); - }, [tailwind]); + }, []); const extensions = useMemo(() => { return [ @@ -267,7 +266,18 @@ export const SearchBar = ({ indentWithTab={false} autoFocus={autoFocus ?? false} /> - + + + + + + + + Focus search bar + + { const domain = useDomain(); const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({ - queryKey: ["repoSuggestions"], + queryKey: ["repoSuggestions", domain], queryFn: () => getRepos(domain), select: (data): Suggestion[] => { return data.repos @@ -50,7 +50,7 @@ export const useSuggestionsData = ({ const isLoadingRepos = useMemo(() => suggestionMode === "repo" && _isLoadingRepos, [_isLoadingRepos, suggestionMode]); const { data: fileSuggestions, isLoading: _isLoadingFiles } = useQuery({ - queryKey: ["fileSuggestions", suggestionQuery], + queryKey: ["fileSuggestions", suggestionQuery, domain], queryFn: () => search({ query: `file:${suggestionQuery}`, matches: 15, @@ -70,7 +70,7 @@ export const useSuggestionsData = ({ const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFiles, [_isLoadingFiles, suggestionMode]); const { data: symbolSuggestions, isLoading: _isLoadingSymbols } = useQuery({ - queryKey: ["symbolSuggestions", suggestionQuery], + queryKey: ["symbolSuggestions", suggestionQuery, domain], queryFn: () => search({ query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`, matches: 15, @@ -100,7 +100,7 @@ export const useSuggestionsData = ({ const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]); const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({ - queryKey: ["searchContexts"], + queryKey: ["searchContexts", domain], queryFn: () => getSearchContexts(domain), select: (data): Suggestion[] => { if (isServiceError(data)) { diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx index 6658d9c7..bcea40af 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx @@ -6,7 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { SearchResultChunk } from "@/features/search/types"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; +import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; import { search } from "@codemirror/search"; @@ -14,9 +14,14 @@ import { EditorView } from "@codemirror/view"; import { Cross1Icon, FileIcon } from "@radix-ui/react-icons"; import { Scrollbar } from "@radix-ui/react-scroll-area"; import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror'; -import clsx from "clsx"; import { ArrowDown, ArrowUp } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "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 useCaptureEvent from "@/hooks/useCaptureEvent"; export interface CodePreviewFile { content: string; @@ -28,10 +33,10 @@ export interface CodePreviewFile { } interface CodePreviewProps { - file?: CodePreviewFile; - repoName?: string; + file: CodePreviewFile; + repoName: string; selectedMatchIndex: number; - onSelectedMatchIndexChange: (index: number) => void; + onSelectedMatchIndexChange: Dispatch>; onClose: () => void; } @@ -43,19 +48,23 @@ export const CodePreview = ({ onClose, }: CodePreviewProps) => { const [editorRef, setEditorRef] = useState(null); + const { navigateToPath } = useBrowseNavigation(); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const [gutterWidth, setGutterWidth] = useState(0); const theme = useCodeMirrorTheme(); const keymapExtension = useKeymapExtension(editorRef?.view); - const syntaxHighlighting = useSyntaxHighlightingExtension(file?.language ?? '', editorRef?.view); + const languageExtension = useCodeMirrorLanguageExtension(file?.language ?? '', editorRef?.view); const [currentSelection, setCurrentSelection] = useState(); + const captureEvent = useCaptureEvent(); + const extensions = useMemo(() => { return [ keymapExtension, gutterWidthExtension, - syntaxHighlighting, + languageExtension, EditorView.lineWrapping, searchResultHighlightExtension(), search({ @@ -74,12 +83,13 @@ export const CodePreview = ({ if (update.selectionSet || update.docChanged) { setCurrentSelection(update.state.selection.main); } - }) + }), + hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], ]; - }, [keymapExtension, syntaxHighlighting]); + }, [hasCodeNavEntitlement, keymapExtension, languageExtension]); const ranges = useMemo(() => { - if (!file || !file.matches.length) { + if (!file.matches.length) { return []; } @@ -89,7 +99,7 @@ export const CodePreview = ({ }, [file]); useEffect(() => { - if (!file || !editorRef?.view) { + if (!editorRef?.view) { return; } @@ -97,12 +107,71 @@ export const CodePreview = ({ }, [ranges, selectedMatchIndex, file, editorRef]); const onUpClicked = useCallback(() => { - onSelectedMatchIndexChange(selectedMatchIndex - 1); - }, [onSelectedMatchIndexChange, selectedMatchIndex]); + onSelectedMatchIndexChange((prev) => prev - 1); + }, [onSelectedMatchIndexChange]); const onDownClicked = useCallback(() => { - onSelectedMatchIndexChange(selectedMatchIndex + 1); - }, [onSelectedMatchIndexChange, selectedMatchIndex]); + onSelectedMatchIndexChange((prev) => prev + 1); + }, [onSelectedMatchIndexChange]); + + const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { + captureEvent('wa_preview_panel_goto_definition_pressed', {}); + + 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_preview_panel_find_references_pressed', {}); + + 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 ( @@ -121,23 +190,24 @@ export const CodePreview = ({ {/* File path */} { - if (file?.link) { - window.open(file.link, "_blank"); - } + navigateToPath({ + repoName, + path: file.filepath, + pathType: 'blob', + revisionName: file.revision, + }); }} - title={file?.filepath} + title={file.filepath} > - {file?.filepath} + {file.filepath} {/* Match selector */} - {file && file.matches.length > 0 && ( + {file.matches.length > 0 && ( <> {`${selectedMatchIndex + 1} of ${ranges.length}`} @@ -196,6 +266,16 @@ export const CodePreview = ({ /> ) } + + {editorRef && hasCodeNavEntitlement && ( + + )} diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx index b3ea530b..537ac511 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -1,74 +1,79 @@ 'use client'; -import { fetchFileSource } from "@/app/api/(client)/client"; -import { base64Decode } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { CodePreview, CodePreviewFile } from "./codePreview"; +import { CodePreview } from "./codePreview"; import { SearchResultFile } from "@/features/search/types"; import { useDomain } from "@/hooks/useDomain"; import { SymbolIcon } from "@radix-ui/react-icons"; +import { SetStateAction, Dispatch, useMemo } from "react"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { base64Decode } from "@/lib/utils"; +import { unwrapServiceError } from "@/lib/utils"; interface CodePreviewPanelProps { - fileMatch?: SearchResultFile; - onClose: () => void; + previewedFile: SearchResultFile; selectedMatchIndex: number; - onSelectedMatchIndexChange: (index: number) => void; + onClose: () => void; + onSelectedMatchIndexChange: Dispatch>; } export const CodePreviewPanel = ({ - fileMatch, - onClose, + previewedFile, selectedMatchIndex, + onClose, onSelectedMatchIndexChange, }: CodePreviewPanelProps) => { const domain = useDomain(); - const { data: file, isLoading } = useQuery({ - queryKey: ["source", fileMatch?.fileName, fileMatch?.repository, fileMatch?.branches], - queryFn: async (): Promise => { - if (!fileMatch) { - return undefined; - } + // If there are multiple branches pointing to the same revision of this file, it doesn't + // matter which branch we use here, so use the first one. + const branch = useMemo(() => { + return previewedFile.branches && previewedFile.branches.length > 0 ? previewedFile.branches[0] : undefined; + }, [previewedFile]); - // If there are multiple branches pointing to the same revision of this file, it doesn't - // matter which branch we use here, so use the first one. - const branch = fileMatch.branches && fileMatch.branches.length > 0 ? fileMatch.branches[0] : undefined; - - return fetchFileSource({ - fileName: fileMatch.fileName.text, - repository: fileMatch.repository, + const { data: file, isLoading, isPending, isError } = useQuery({ + queryKey: ["source", previewedFile, branch, domain], + queryFn: () => unwrapServiceError( + getFileSource({ + fileName: previewedFile.fileName.text, + repository: previewedFile.repository, branch, }, domain) - .then(({ source }) => { - const decodedSource = base64Decode(source); + ), + select: (data) => { + const decodedSource = base64Decode(data.source); - return { - content: decodedSource, - filepath: fileMatch.fileName.text, - matches: fileMatch.chunks, - link: fileMatch.webUrl, - language: fileMatch.language, - revision: branch ?? "HEAD", - }; - }); - }, - enabled: fileMatch !== undefined, + return { + content: decodedSource, + filepath: previewedFile.fileName.text, + matches: previewedFile.chunks, + link: previewedFile.webUrl, + language: previewedFile.language, + revision: branch ?? "HEAD", + }; + } }); - if (isLoading) { + if (isLoading || isPending) { return Loading... } + if (isError) { + return ( + Failed to load file source + ) + } + return ( ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx index 5020e516..c5081790 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx @@ -8,6 +8,8 @@ export type Entry = { displayName: string; count: number; isSelected: boolean; + isHidden: boolean; + isDisabled: boolean; Icon?: React.ReactNode; } @@ -22,6 +24,7 @@ export const Entry = ({ displayName, count, Icon, + isDisabled, }, onClicked, }: EntryProps) => { @@ -36,6 +39,7 @@ export const Entry = ({ { "hover:bg-gray-200 dark:hover:bg-gray-700": !isSelected, "bg-blue-200 dark:bg-blue-400": isSelected, + "opacity-50": isDisabled, } )} onClick={() => onClicked()} diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx index bb799587..231cda18 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -6,39 +6,49 @@ import { cn, getCodeHostInfoForRepo } from "@/lib/utils"; import { LaptopIcon } from "@radix-ui/react-icons"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { Entry } from "./entry"; import { Filter } from "./filter"; +import { LANGUAGES_QUERY_PARAM, REPOS_QUERY_PARAM, useFilteredMatches } from "./useFilterMatches"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; interface FilePanelProps { matches: SearchResultFile[]; - onFilterChanged: (filteredMatches: SearchResultFile[]) => void, repoInfo: Record; } -const LANGUAGES_QUERY_PARAM = "langs"; -const REPOS_QUERY_PARAM = "repos"; - +/** + * FilterPanel Component + * + * A bidirectional filtering component that allows users to filter search results by repository and language. + * The filtering is bidirectional, meaning: + * 1. When repositories are selected, the language filter will only show languages that exist in those repositories + * 2. When languages are selected, the repository filter will only show repositories that contain those languages + * + * This prevents users from selecting filter combinations that would yield no results. For example: + * - If Repository A only contains Python and JavaScript files, selecting it will only enable these languages + * - If Language Python is selected, only repositories containing Python files will be enabled + * + * @param matches - Array of search result files to filter + * @param repoInfo - Information about repositories including their display names and icons + */ export const FilterPanel = ({ matches, - onFilterChanged, repoInfo, }: FilePanelProps) => { const router = useRouter(); const searchParams = useSearchParams(); - // Helper to parse query params into sets - const getSelectedFromQuery = useCallback((param: string) => { - const value = searchParams.get(param); - return value ? new Set(value.split(',')) : new Set(); - }, [searchParams]); + const { getSelectedFromQuery } = useGetSelectedFromQuery(); + const matchesFilteredByRepository = useFilteredMatches(matches, 'repository'); + const matchesFilteredByLanguage = useFilteredMatches(matches, 'language'); const repos = useMemo(() => { const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); return aggregateMatches( "repository", matches, - ({ key, match }) => { + /* createEntry = */ ({ key: repository, match }) => { const repo: RepositoryInfo | undefined = repoInfo[match.repositoryId]; const info = repo ? getCodeHostInfoForRepo({ @@ -58,63 +68,72 @@ export const FilterPanel = ({ ); + const isSelected = selectedRepos.has(repository); + + // If the matches filtered by language don't contain this repository, then this entry is disabled + const isDisabled = !matchesFilteredByLanguage.some((match) => match.repository === repository); + const isHidden = isDisabled && !isSelected; + return { - key, - displayName: info?.displayName ?? key, + key: repository, + displayName: info?.displayName ?? repository, count: 0, - isSelected: selectedRepos.has(key), + isSelected, + isDisabled, + isHidden, Icon, }; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByLanguage.some((value) => value.language === match.language) } ) - }, [getSelectedFromQuery, matches, repoInfo]); + }, [getSelectedFromQuery, matches, repoInfo, matchesFilteredByLanguage]); const languages = useMemo(() => { const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); return aggregateMatches( "language", matches, - ({ key }) => { + /* createEntry = */ ({ key: language }) => { const Icon = ( - + ) + const isSelected = selectedLanguages.has(language); + + // If the matches filtered by repository don't contain this language, then this entry is disabled + const isDisabled = !matchesFilteredByRepository.some((match) => match.language === language); + const isHidden = isDisabled && !isSelected; + return { - key, - displayName: key, + key: language, + displayName: language, count: 0, - isSelected: selectedLanguages.has(key), + isSelected, + isDisabled, + isHidden, Icon: Icon, } satisfies Entry; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByRepository.some((value) => value.repository === match.repository) } ); - }, [getSelectedFromQuery, matches]); - - // Calls `onFilterChanged` with the filtered list of matches - // whenever the filter state changes. - useEffect(() => { - const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected)); - const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected)); - - const filteredMatches = matches.filter((match) => - ( - (selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) && - (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language)) - ) - ); - onFilterChanged(filteredMatches); + }, [getSelectedFromQuery, matches, matchesFilteredByRepository]); - }, [matches, repos, languages, onFilterChanged, searchParams, router]); + const visibleRepos = useMemo(() => Object.values(repos).filter((entry) => !entry.isHidden), [repos]); + const visibleLanguages = useMemo(() => Object.values(languages).filter((entry) => !entry.isHidden), [languages]); - const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]); - const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]); + const numRepos = useMemo(() => visibleRepos.length > 100 ? '100+' : visibleRepos.length, [visibleRepos]); + const numLanguages = useMemo(() => visibleLanguages.length > 100 ? '100+' : visibleLanguages.length, [visibleLanguages]); return ( { const newRepos = { ...repos }; newRepos[key].isSelected = !newRepos[key].isSelected; @@ -136,7 +155,7 @@ export const FilterPanel = ({ { const newLanguages = { ...languages }; newLanguages[key].isSelected = !newLanguages[key].isSelected; @@ -175,7 +194,8 @@ export const FilterPanel = ({ const aggregateMatches = ( propName: 'repository' | 'language', matches: SearchResultFile[], - createEntry: (props: { key: string, match: SearchResultFile }) => Entry + createEntry: (props: { key: string, match: SearchResultFile }) => Entry, + shouldCount: (props: { key: string, match: SearchResultFile }) => boolean, ) => { return matches .map((match) => ({ key: match[propName], match })) @@ -184,7 +204,11 @@ const aggregateMatches = ( if (!aggregation[key]) { aggregation[key] = createEntry({ key, match }); } - aggregation[key].count += 1; + + if (!aggregation[key].isDisabled && shouldCount({ key, match })) { + aggregation[key].count += 1; + } + return aggregation; }, {} as Record) } diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts b/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts new file mode 100644 index 00000000..5951d8ea --- /dev/null +++ b/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts @@ -0,0 +1,36 @@ +'use client'; + +import { SearchResultFile } from "@/features/search/types"; +import { useMemo } from "react"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; + +export const LANGUAGES_QUERY_PARAM = "langs"; +export const REPOS_QUERY_PARAM = "repos"; + + +export const useFilteredMatches = ( + matches: SearchResultFile[], + filterBy: 'repository' | 'language' | 'all' = 'all' +) => { + const { getSelectedFromQuery } = useGetSelectedFromQuery(); + + const filteredMatches = useMemo(() => { + const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); + const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); + + const isInRepoSet = (repo: string) => selectedRepos.size === 0 || selectedRepos.has(repo); + const isInLanguageSet = (language: string) => selectedLanguages.size === 0 || selectedLanguages.has(language); + + switch (filterBy) { + case 'repository': + return matches.filter((match) => isInRepoSet(match.repository)); + case 'language': + return matches.filter((match) => isInLanguageSet(match.language)); + case 'all': + return matches.filter((match) => isInRepoSet(match.repository) && isInLanguageSet(match.language)); + } + + }, [filterBy, getSelectedFromQuery, matches]); + + return filteredMatches; +} diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts b/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts new file mode 100644 index 00000000..5fefcb82 --- /dev/null +++ b/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts @@ -0,0 +1,17 @@ +'use client'; + +import { useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +// Helper to parse query params into sets +export const useGetSelectedFromQuery = () => { + const searchParams = useSearchParams(); + const getSelectedFromQuery = useCallback((param: string): Set => { + const value = searchParams.get(param); + return value ? new Set(value.split(',')) : new Set(); + }, [searchParams]); + + return { + getSelectedFromQuery, + } +} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx deleted file mode 100644 index 47cb2678..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; -import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension"; -import { SearchResultRange } from "@/features/search/types"; -import { EditorState, StateField, Transaction } from "@codemirror/state"; -import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view"; -import { useMemo, useRef } from "react"; -import { LightweightCodeMirror, CodeMirrorRef } from "./lightweightCodeMirror"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - -const markDecoration = Decoration.mark({ - class: "cm-searchMatch-selected" -}); - -interface CodePreviewProps { - content: string, - language: string, - ranges: SearchResultRange[], - lineOffset: number, -} - -export const CodePreview = ({ - content, - language, - ranges, - lineOffset, -}: CodePreviewProps) => { - const editorRef = useRef(null); - const theme = useCodeMirrorTheme(); - - const extensions = useMemo(() => { - const codemirrorExtension = getCodemirrorLanguage(language); - return [ - EditorView.editable.of(false), - theme, - lineNumbers(), - lineOffsetExtension(lineOffset), - codemirrorExtension ? codemirrorExtension : [], - StateField.define({ - create(editorState: EditorState) { - const document = editorState.doc; - - const decorations = ranges - .sort((a, b) => { - return a.start.byteOffset - b.start.byteOffset; - }) - .filter(({ start, end }) => { - const startLine = start.lineNumber - lineOffset; - const endLine = end.lineNumber - lineOffset; - - if ( - startLine < 1 || - endLine < 1 || - startLine > document.lines || - endLine > document.lines - ) { - return false; - } - return true; - }) - .map(({ start, end }) => { - const startLine = start.lineNumber - lineOffset; - const endLine = end.lineNumber - lineOffset; - - const from = document.line(startLine).from + start.column - 1; - const to = document.line(endLine).from + end.column - 1; - return markDecoration.range(from, to); - }) - .sort((a, b) => a.from - b.from); - - return Decoration.set(decorations); - }, - update(highlights: DecorationSet, _transaction: Transaction) { - return highlights; - }, - provide: (field) => EditorView.decorations.from(field), - }), - ] - }, [language, lineOffset, ranges, theme]); - - return ( - - ) - -} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx index aaefe1a6..5146c6fe 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx @@ -1,26 +1,34 @@ 'use client'; -import { useMemo } from "react"; -import { CodePreview } from "./codePreview"; +import { useCallback, useMemo } from "react"; import { SearchResultFile, SearchResultChunk } from "@/features/search/types"; import { base64Decode } from "@/lib/utils"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; interface FileMatchProps { match: SearchResultChunk; file: SearchResultFile; - onOpen: () => void; + onOpen: (startLineNumber: number, endLineNumber: number, isCtrlKeyPressed: boolean) => void; } export const FileMatch = ({ match, file, - onOpen, + onOpen: _onOpen, }: FileMatchProps) => { + const content = useMemo(() => { return base64Decode(match.content); }, [match.content]); + const onOpen = useCallback((isCtrlKeyPressed: boolean) => { + const startLineNumber = match.contentStart.lineNumber; + const endLineNumber = content.trimEnd().split('\n').length + startLineNumber - 1; + + _onOpen(startLineNumber, endLineNumber, isCtrlKeyPressed); + }, [content, match.contentStart.lineNumber, _onOpen]); + // If it's just the title, don't show a code preview if (match.matchRanges.length === 0) { return null; @@ -29,21 +37,28 @@ export const FileMatch = ({ return ( { if (e.key !== "Enter") { return; } - onOpen(); + + onOpen(e.metaKey || e.ctrlKey); + }} + onClick={(e) => { + onOpen(e.metaKey || e.ctrlKey); }} - onClick={onOpen} + title="open file: click, open file preview: cmd/ctrl + click" > - + highlightRanges={match.matchRanges} + lineNumbers={true} + lineNumbersOffset={match.contentStart.lineNumber} + renderWhitespace={true} + > + {content} + ); } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index 813fe10a..c179aacc 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -3,16 +3,17 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { Separator } from "@/components/ui/separator"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { FileMatch } from "./fileMatch"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { Button } from "@/components/ui/button"; +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; export const MAX_MATCHES_TO_PREVIEW = 3; interface FileMatchContainerProps { file: SearchResultFile; - onOpenFile: () => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (matchIndex?: number) => void; showAllMatches: boolean; onShowAllMatchesButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -22,18 +23,17 @@ interface FileMatchContainerProps { export const FileMatchContainer = ({ file, - onOpenFile, - onMatchIndexChanged, + onOpenFilePreview, showAllMatches, onShowAllMatchesButtonClicked, isBranchFilteringEnabled, repoInfo, yOffset, }: FileMatchContainerProps) => { - const matchCount = useMemo(() => { return file.chunks.length; }, [file]); + const { navigateToPath } = useBrowseNavigation(); const matches = useMemo(() => { const sortedMatches = file.chunks.sort((a, b) => { @@ -63,14 +63,6 @@ export const FileMatchContainer = ({ return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); - const onOpenMatch = useCallback((index: number) => { - const matchIndex = matches.slice(0, index).reduce((acc, match) => { - return acc + match.matchRanges.length; - }, 0); - onOpenFile(); - onMatchIndexChanged(matchIndex); - }, [matches, onMatchIndexChanged, onOpenFile]); - const branches = useMemo(() => { if (!file.branches) { return []; @@ -91,18 +83,14 @@ export const FileMatchContainer = ({ return repoInfo[file.repositoryId]; }, [repoInfo, file.repositoryId]); - return ( {/* Title */} { - onOpenFile(); - }} > + { + onOpenFilePreview(); + }} + > + Preview + {/* Matches */} @@ -126,8 +123,28 @@ export const FileMatchContainer = ({ { - onOpenMatch(index); + onOpen={(startLineNumber, endLineNumber, isCtrlKeyPressed) => { + if (isCtrlKeyPressed) { + const matchIndex = matches.slice(0, index).reduce((acc, match) => { + return acc + match.matchRanges.length; + }, 0); + onOpenFilePreview(matchIndex); + } else { + navigateToPath({ + repoName: file.repository, + revisionName: file.branches?.[0] ?? 'HEAD', + path: file.fileName.text, + pathType: 'blob', + highlightRange: { + start: { + lineNumber: startLineNumber, + }, + end: { + lineNumber: endLineNumber, + } + } + }); + } }} /> {(index !== matches.length - 1 || isMoreContentButtonVisible) && ( @@ -140,7 +157,7 @@ export const FileMatchContainer = ({ {isMoreContentButtonVisible && ( { if (e.key !== "Enter") { return; @@ -150,7 +167,7 @@ export const FileMatchContainer = ({ onClick={onShowAllMatchesButtonClicked} > {showAllMatches ? : } {showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx index 88757c56..61e41332 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx @@ -2,13 +2,13 @@ import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDebounce, usePrevious } from "@uidotdev/usehooks"; interface SearchResultsPanelProps { fileMatches: SearchResultFile[]; - onOpenFileMatch: (fileMatch: SearchResultFile) => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (fileMatch: SearchResultFile, matchIndex?: number) => void; isLoadMoreButtonVisible: boolean; onLoadMoreButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -19,18 +19,33 @@ const ESTIMATED_LINE_HEIGHT_PX = 20; const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10; const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30; +type ScrollHistoryState = { + scrollOffset?: number; + measurementsCache?: VirtualItem[]; + showAllMatchesStates?: boolean[]; +} + export const SearchResultsPanel = ({ fileMatches, - onOpenFileMatch, - onMatchIndexChanged, + onOpenFilePreview, isLoadMoreButtonVisible, onLoadMoreButtonClicked, isBranchFilteringEnabled, repoInfo, }: SearchResultsPanelProps) => { const parentRef = useRef(null); - const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false)); - const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1); + + // Restore the scroll offset, measurements cache, and other state from the history + // state. This enables us to restore the scroll offset when the user navigates back + // to the page. + // @see: https://github.com/TanStack/virtual/issues/378#issuecomment-2173670081 + const { + scrollOffset: restoreOffset, + measurementsCache: restoreMeasurementsCache, + showAllMatchesStates: restoreShowAllMatchesStates, + } = history.state as ScrollHistoryState; + + const [showAllMatchesStates, setShowAllMatchesStates] = useState(restoreShowAllMatchesStates || Array(fileMatches.length).fill(false)); const virtualizer = useVirtualizer({ count: fileMatches.length, @@ -51,60 +66,55 @@ export const SearchResultsPanel = ({ return estimatedSize; }, - measureElement: (element, _entry, instance) => { - // @note : Stutters were appearing when scrolling upwards. The workaround is - // to use the cached height of the element when scrolling up. - // @see : https://github.com/TanStack/virtual/issues/659 - const isCacheDirty = element.hasAttribute("data-cache-dirty"); - element.removeAttribute("data-cache-dirty"); - const direction = instance.scrollDirection; - if (direction === "forward" || direction === null || isCacheDirty) { - return element.scrollHeight; - } else { - const indexKey = Number(element.getAttribute("data-index")); - // Unfortunately, the cache is a private property, so we need to - // hush the TS compiler. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const cacheMeasurement = instance.itemSizeCache.get(indexKey); - return cacheMeasurement; - } - }, + initialOffset: restoreOffset, + initialMeasurementsCache: restoreMeasurementsCache, enabled: true, overscan: 10, debug: false, }); - const onShowAllMatchesButtonClicked = useCallback((index: number) => { - const states = [...showAllMatchesStates]; - states[index] = !states[index]; - setShowAllMatchesStates(states); - setLastShowAllMatchesButtonClickIndex(index); - }, [showAllMatchesStates]); - - // After the "show N more/less matches" button is clicked, the FileMatchContainer's - // size can change considerably. In cases where N > 3 or 4 cells when collapsing, - // a visual artifact can appear where there is a large gap between the now collapsed - // container and the next container. This is because the container's height was not - // re-calculated. To get arround this, we force a re-measure of the element AFTER - // it was re-rendered (hence the useLayoutEffect). - useLayoutEffect(() => { - if (lastShowAllMatchesButtonClickIndex < 0) { + // When the number of file matches changes, we need to reset our scroll state. + const prevFileMatches = usePrevious(fileMatches); + useEffect(() => { + if (!prevFileMatches) { return; } - const element = virtualizer.elementsCache.get(lastShowAllMatchesButtonClickIndex); - element?.setAttribute('data-cache-dirty', 'true'); - virtualizer.measureElement(element); - - setLastShowAllMatchesButtonClickIndex(-1); - }, [lastShowAllMatchesButtonClickIndex, virtualizer]); + if (prevFileMatches.length !== fileMatches.length) { + setShowAllMatchesStates(Array(fileMatches.length).fill(false)); + virtualizer.scrollToIndex(0); + } + }, [fileMatches.length, prevFileMatches, virtualizer]); - // Reset some state when the file matches change. + // Save the scroll state to the history stack. + const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 100); useEffect(() => { - setShowAllMatchesStates(Array(fileMatches.length).fill(false)); - virtualizer.scrollToIndex(0); - }, [fileMatches, virtualizer]); + history.replaceState( + { + scrollOffset: debouncedScrollOffset ?? undefined, + measurementsCache: virtualizer.measurementsCache, + showAllMatchesStates, + } satisfies ScrollHistoryState, + '', + window.location.href + ); + }, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesStates]); + + const onShowAllMatchesButtonClicked = useCallback((index: number) => { + const states = [...showAllMatchesStates]; + const wasShown = states[index]; + states[index] = !wasShown; + setShowAllMatchesStates(states); + + // When collapsing, scroll to the top of the file match container. This ensures + // that the focused "show fewer matches" button is visible. + if (wasShown) { + virtualizer.scrollToIndex(index, { + align: 'start' + }); + } + }, [showAllMatchesStates, virtualizer]); + return ( { - onOpenFileMatch(file); - }} - onMatchIndexChanged={(matchIndex) => { - onMatchIndexChanged(matchIndex); + onOpenFilePreview={(matchIndex) => { + onOpenFilePreview(file, matchIndex); }} showAllMatches={showAllMatchesStates[virtualRow.index]} onShowAllMatchesButtonClicked={() => { diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx deleted file mode 100644 index f6d3227e..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { EditorState, Extension, StateEffect } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; - -interface CodeMirrorProps { - value?: string; - extensions?: Extension[]; - className?: string; -} - -export interface CodeMirrorRef { - editor: HTMLDivElement | null; - state?: EditorState; - view?: EditorView; -} - -/** - * This component provides a lightweight CodeMirror component that has been optimized to - * render quickly in the search results panel. Why not use react-codemirror? For whatever reason, - * react-codemirror issues many StateEffects when first rendering, causing a stuttery scroll - * experience as new cells load. This component is a workaround for that issue and provides - * a minimal react wrapper around CodeMirror that avoids this issue. - */ -const LightweightCodeMirror = forwardRef(({ - value, - extensions, - className, -}, ref) => { - const editor = useRef(null); - const viewRef = useRef(); - const stateRef = useRef(); - - useImperativeHandle(ref, () => ({ - editor: editor.current, - state: stateRef.current, - view: viewRef.current, - }), []); - - useEffect(() => { - if (!editor.current) { - return; - } - - const state = EditorState.create({ - extensions: [], /* extensions are explicitly left out here */ - doc: value, - }); - stateRef.current = state; - - const view = new EditorView({ - state, - parent: editor.current, - }); - viewRef.current = view; - - return () => { - view.destroy(); - viewRef.current = undefined; - stateRef.current = undefined; - } - }, [value]); - - useEffect(() => { - if (viewRef.current) { - viewRef.current.dispatch({ effects: StateEffect.reconfigure.of(extensions ?? []) }); - } - }, [extensions]); - - return ( - - ) -}); - -LightweightCodeMirror.displayName = "LightweightCodeMirror"; - -export { LightweightCodeMirror }; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index ac793ff8..e307d74c 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { - ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; @@ -15,7 +14,6 @@ import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ImperativePanelHandle } from "react-resizable-panels"; import { search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; @@ -24,8 +22,17 @@ import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { useFilteredMatches } from "./components/filterPanel/useFilterMatches"; +import { Button } from "@/components/ui/button"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { FilterIcon } from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; -const DEFAULT_MATCH_COUNT = 10000; +const DEFAULT_MAX_MATCH_COUNT = 10000; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params @@ -41,18 +48,20 @@ export default function SearchPage() { const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; - const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`); - const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); const { toast } = useToast(); + // Encodes the number of matches to return in the search response. + const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`); + const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount; + const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ - queryKey: ["search", searchQuery, matches], + queryKey: ["search", searchQuery, maxMatchCount], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, - matches, + matches: maxMatchCount, contextLines: 3, whole: false, }, domain)), "client.search"), @@ -63,6 +72,7 @@ const SearchPageInternal = () => { enabled: searchQuery.length > 0, refetchOnWindowFocus: false, retry: false, + staleTime: Infinity, }); useEffect(() => { @@ -122,7 +132,7 @@ const SearchPageInternal = () => { }); }, [captureEvent, searchQuery, searchResponse]); - const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo } = useMemo(() => { + const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo, matchCount } = useMemo(() => { if (!searchResponse) { return { fileMatches: [], @@ -130,6 +140,7 @@ const SearchPageInternal = () => { totalMatchCount: 0, isBranchFilteringEnabled: false, repositoryInfo: {}, + matchCount: 0, }; } @@ -142,32 +153,21 @@ const SearchPageInternal = () => { acc[repo.id] = repo; return acc; }, {} as Record), + matchCount: searchResponse.stats.matchCount, } }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { - return totalMatchCount > matches; - }, [totalMatchCount, matches]); - - const numMatches = useMemo(() => { - // Accumualtes the number of matches across all files - return fileMatches.reduce( - (acc, file) => - acc + file.chunks.reduce( - (acc, chunk) => acc + chunk.matchRanges.length, - 0, - ), - 0, - ); - }, [fileMatches]); + return totalMatchCount > maxMatchCount; + }, [totalMatchCount, maxMatchCount]); const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], - [SearchQueryParams.matches, `${matches * 2}`], + [SearchQueryParams.matches, `${maxMatchCount * 2}`], ) router.push(url); - }, [matches, router, searchQuery, domain]); + }, [maxMatchCount, router, searchQuery, domain]); return ( @@ -193,7 +193,7 @@ const SearchPageInternal = () => { isBranchFilteringEnabled={isBranchFilteringEnabled} repoInfo={repositoryInfo} searchDurationMs={searchDurationMs} - numMatches={numMatches} + numMatches={matchCount} /> )} @@ -219,22 +219,24 @@ const PanelGroup = ({ searchDurationMs, numMatches, }: PanelGroupProps) => { + const [previewedFile, setPreviewedFile] = useState(undefined); + const filteredFileMatches = useFilteredMatches(fileMatches); + const filterPanelRef = useRef(null); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); - const [selectedFile, setSelectedFile] = useState(undefined); - const [filteredFileMatches, setFilteredFileMatches] = useState(fileMatches); - const codePreviewPanelRef = useRef(null); - useEffect(() => { - if (selectedFile) { - codePreviewPanelRef.current?.expand(); + const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false); + + useHotkeys("mod+b", () => { + if (isFilterPanelCollapsed) { + filterPanelRef.current?.expand(); } else { - codePreviewPanelRef.current?.collapse(); + filterPanelRef.current?.collapse(); } - }, [selectedFile]); - - const onFilterChanged = useCallback((matches: SearchResultFile[]) => { - setFilteredFileMatches(matches); - }, []); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Toggle filter panel", + }); return ( {/* ~~ Filter panel ~~ */} setIsFilterPanelCollapsed(true)} + onExpand={() => setIsFilterPanelCollapsed(false)} > - + {isFilterPanelCollapsed && ( + + + + { + filterPanelRef.current?.expand(); + }} + > + + + + + + + Open filter panel + + + + )} + {/* ~~ Search results ~~ */} 0 ? ( { - setSelectedFile(fileMatch); - }} - onMatchIndexChanged={(matchIndex) => { - setSelectedMatchIndex(matchIndex); + onOpenFilePreview={(fileMatch, matchIndex) => { + setSelectedMatchIndex(matchIndex ?? 0); + setPreviewedFile(fileMatch); }} isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} onLoadMoreButtonClicked={onLoadMoreResults} @@ -304,25 +329,27 @@ const PanelGroup = ({ )} - - {/* ~~ Code preview ~~ */} - - setSelectedFile(undefined)} - selectedMatchIndex={selectedMatchIndex} - onSelectedMatchIndexChange={setSelectedMatchIndex} - /> - + {previewedFile && ( + <> + + {/* ~~ Code preview ~~ */} + setPreviewedFile(undefined)} + > + setPreviewedFile(undefined)} + selectedMatchIndex={selectedMatchIndex} + onSelectedMatchIndexChange={setSelectedMatchIndex} + /> + + > + )} ) } diff --git a/packages/web/src/app/components/keyboardShortcutHint.tsx b/packages/web/src/app/components/keyboardShortcutHint.tsx index f93209f1..0bbff3c0 100644 --- a/packages/web/src/app/components/keyboardShortcutHint.tsx +++ b/packages/web/src/app/components/keyboardShortcutHint.tsx @@ -8,7 +8,13 @@ interface KeyboardShortcutHintProps { export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { return ( - + {shortcut} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index feabe357..d43d68df 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -4,78 +4,163 @@ @layer base { :root { - --background: 0 0% 100%; - --background-secondary: 0, 0%, 98%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --background: hsl(0 0% 100%); + --background-secondary: hsl(0, 0%, 98%); + --foreground: hsl(37, 84%, 5%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(222.2 84% 4.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(222.2 84% 4.9%); + --primary: hsl(222.2 47.4% 11.2%); + --primary-foreground: hsl(210 40% 98%); + --secondary: hsl(210 40% 96.1%); + --secondary-foreground: hsl(222.2 47.4% 11.2%); + --muted: hsl(210 40% 96.1%); + --muted-foreground: hsl(215.4 16.3% 46.9%); + --accent: hsl(210 40% 96.1%); + --accent-foreground: hsl(222.2 47.4% 11.2%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(214.3 31.8% 91.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(222.2 84% 4.9%); --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --highlight: 224, 76%, 48%; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --highlight: hsl(224, 76%, 48%); + --sidebar-background: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --editor-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --editor-font-size: 13px; + + --editor-background: var(--background); + --editor-foreground: var(--foreground); + --editor-caret: #3b4252; + --editor-selection: #eceff4; + --editor-selection-match: #e5e9f0; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #2e3440; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: #02255f11; + --editor-match-highlight: hsl(180, 70%, 40%); + + --editor-tag-keyword: #708; + --editor-tag-name: #256; + --editor-tag-function: #00f; + --editor-tag-label: #219; + --editor-tag-constant: #219; + --editor-tag-definition: #00c; + --editor-tag-brace: #219; + --editor-tag-type: #085; + --editor-tag-operator: #708; + --editor-tag-tag: #167; + --editor-tag-bracket-square: #219; + --editor-tag-bracket-angle: #219; + --editor-tag-attribute: #00c; + --editor-tag-string: #a11; + --editor-tag-link: inherit; + --editor-tag-meta: #404740; + --editor-tag-comment: #940; + --editor-tag-emphasis: inherit; + --editor-tag-heading: inherit; + --editor-tag-atom: #219; + --editor-tag-processing: #164; + --editor-tag-separator: #219; + --editor-tag-invalid: #f00; + --editor-tag-quote: #a11; + --editor-tag-annotation-special: #f00; + --editor-tag-number: #219; + --editor-tag-regexp: #e40; + --editor-tag-variable-local: #30a; } .dark { - --background: 222.2 84% 4.9%; - --background-secondary: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --highlight: 217, 91%, 60%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --background: hsl(222.2 84% 4.9%); + --background-secondary: hsl(222.2 84% 4.9%); + --foreground: hsl(210 40% 98%); + --card: hsl(222.2 84% 4.9%); + --card-foreground: hsl(210 40% 98%); + --popover: hsl(222.2 84% 4.9%); + --popover-foreground: hsl(210 40% 98%); + --primary: hsl(210 40% 98%); + --primary-foreground: hsl(222.2 47.4% 11.2%); + --secondary: hsl(217.2 32.6% 17.5%); + --secondary-foreground: hsl(210 40% 98%); + --muted: hsl(217.2 32.6% 17.5%); + --muted-foreground: hsl(215 20.2% 65.1%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(217.2 32.6% 17.5%); + --input: hsl(217.2 32.6% 17.5%); + --ring: hsl(212.7 26.8% 83.9%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --highlight: hsl(217 91% 60%); + --sidebar-background: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --editor-background: var(--background); + --editor-foreground: #abb2bf; + --editor-caret: #528bff; + --editor-selection: #3E4451; + --editor-selection-match: #aafe661a; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #7d8799; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: hsl(219, 14%, 20%); + --editor-match-highlight: hsl(180, 70%, 30%); + + --editor-tag-keyword: #c678dd; + --editor-tag-name: #e06c75; + --editor-tag-function: #61afef; + --editor-tag-label: #61afef; + --editor-tag-constant: #d19a66; + --editor-tag-definition: #abb2bf; + --editor-tag-brace: #56b6c2; + --editor-tag-type: #e5c07b; + --editor-tag-operator: #56b6c2; + --editor-tag-tag: #e06c75; + --editor-tag-bracket-square: #56b6c2; + --editor-tag-bracket-angle: #56b6c2; + --editor-tag-attribute: #e5c07b; + --editor-tag-string: #98c379; + --editor-tag-link: #7d8799; + --editor-tag-meta: #7d8799; + --editor-tag-comment: #7d8799; + --editor-tag-emphasis: #e06c75; + --editor-tag-heading: #e06c75; + --editor-tag-atom: #d19a66; + --editor-tag-processing: #98c379; + --editor-tag-separator: #abb2bf; + --editor-tag-invalid: #ffffff; + --editor-tag-quote: #7d8799; + --editor-tag-annotation-special: #e5c07b; + --editor-tag-number: #e5c07b; + --editor-tag-regexp: #56b6c2; + --editor-tag-variable-local: #61afef; } } @@ -83,6 +168,7 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } @@ -98,13 +184,23 @@ text-align: left; } -.cm-editor .cm-searchMatch { - border: dotted; - background: transparent; +.searchMatch { + background: color-mix(in srgb, var(--editor-match-highlight) 25%, transparent); + border: 1px dashed var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03); } -.cm-editor .cm-searchMatch-selected { - border: solid; +.searchMatch-selected { + background: color-mix(in srgb, var(--editor-match-highlight) 60%, transparent); + border: 1.5px solid var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.06); +} + +.lineHighlight { + background: var(--editor-line-highlight); + border-radius: 2px; } .cm-editor.cm-focused { @@ -123,8 +219,9 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } + body { @apply bg-background text-foreground; } @@ -136,6 +233,22 @@ } .no-scrollbar { - -ms-overflow-style: none; /* IE dan Edge */ - scrollbar-width: none; /* Firefox */ + -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/components/ui/animatedResizableHandle.tsx b/packages/web/src/components/ui/animatedResizableHandle.tsx new file mode 100644 index 00000000..c09635c4 --- /dev/null +++ b/packages/web/src/components/ui/animatedResizableHandle.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ResizableHandle } from "./resizable"; + +export const AnimatedResizableHandle = () => { + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/components/ui/loading-button.tsx b/packages/web/src/components/ui/loading-button.tsx new file mode 100644 index 00000000..0639efd9 --- /dev/null +++ b/packages/web/src/components/ui/loading-button.tsx @@ -0,0 +1,30 @@ +'use client'; + +// @note: this is not a original Shadcn component. + +import { Button, ButtonProps } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import React from "react"; + +export interface LoadingButtonProps extends ButtonProps { + loading?: boolean; +} + +const LoadingButton = React.forwardRef(({ children, loading, ...props }, ref) => { + return ( + + {loading && ( + + )} + {children} + + ) +}); + +LoadingButton.displayName = "LoadingButton"; + +export { LoadingButton }; \ No newline at end of file diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx new file mode 100644 index 00000000..70b825c4 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { Badge } from "@/components/ui/badge"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "@/features/codeNav/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import { Loader2 } from "lucide-react"; +import { useMemo } from "react"; +import { VscSymbolMisc } from "react-icons/vsc"; +import { ReferenceList } from "./referenceList"; + +interface ExploreMenuProps { + selectedSymbolInfo: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } +} + +export const ExploreMenu = ({ + selectedSymbolInfo, +}: ExploreMenuProps) => { + + const domain = useDomain(); + const { + state: { activeExploreMenuTab }, + updateBrowseState, + } = useBrowseState(); + + const { + data: referencesResponse, + isError: isReferencesResponseError, + isPending: isReferencesResponsePending, + isLoading: isReferencesResponseLoading, + } = useQuery({ + queryKey: ["references", selectedSymbolInfo.symbolName, selectedSymbolInfo.repoName, selectedSymbolInfo.revisionName, selectedSymbolInfo.language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolReferences({ + symbolName: selectedSymbolInfo.symbolName, + language: selectedSymbolInfo.language, + revisionName: selectedSymbolInfo.revisionName, + }, domain) + ), + }); + + const { + data: definitionsResponse, + isError: isDefinitionsResponseError, + isPending: isDefinitionsResponsePending, + isLoading: isDefinitionsResponseLoading, + } = useQuery({ + queryKey: ["definitions", selectedSymbolInfo.symbolName, selectedSymbolInfo.repoName, selectedSymbolInfo.revisionName, selectedSymbolInfo.language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolDefinitions({ + symbolName: selectedSymbolInfo.symbolName, + language: selectedSymbolInfo.language, + revisionName: selectedSymbolInfo.revisionName, + }, domain) + ), + }); + + const isPending = isReferencesResponsePending || isDefinitionsResponsePending; + const isLoading = isReferencesResponseLoading || isDefinitionsResponseLoading; + const isError = isDefinitionsResponseError || isReferencesResponseError; + + if (isPending || isLoading) { + return ( + + + Loading... + + ) + } + + if (isError) { + return ( + + Error loading {activeExploreMenuTab} + + ) + } + + const data = activeExploreMenuTab === "references" ? + referencesResponse : + definitionsResponse; + + return ( + + + + + + + Search Based + + + + Symbol references and definitions found using a best-guess search heuristic. + + + + { + updateBrowseState({ activeExploreMenuTab: "references" }); + }} + /> + { + updateBrowseState({ activeExploreMenuTab: "definitions" }); + }} + /> + + + + + + {data.files.length > 0 ? ( + + ) : ( + + + No {activeExploreMenuTab} found + + )} + + + + ) +} + +interface EntryProps { + name: string; + isSelected: boolean; + count?: number; + onClicked: () => void; +} + +const Entry = ({ + name, + isSelected, + count, + onClicked, +}: EntryProps) => { + const countText = useMemo(() => { + if (count === undefined) { + return "?"; + } + + if (count > 999) { + return "999+"; + } + return count.toString(); + }, [count]); + + return ( + onClicked()} + > + {name} + + {countText} + + + ); +} diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx new file mode 100644 index 00000000..8b3acd80 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; +import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; +import { RepositoryInfo, SourceRange } from "@/features/search/types"; +import { base64Decode } from "@/lib/utils"; +import { useMemo } from "react"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface ReferenceListProps { + data: FindRelatedSymbolsResponse; + revisionName: string; +} + +export const ReferenceList = ({ + data, + revisionName, +}: ReferenceListProps) => { + const repoInfoMap = useMemo(() => { + return data.repositoryInfo.reduce((acc, repo) => { + acc[repo.id] = repo; + return acc; + }, {} as Record); + }, [data.repositoryInfo]); + + const { navigateToPath } = useBrowseNavigation(); + const captureEvent = useCaptureEvent(); + + return ( + + {data.files.map((file, index) => { + const repoInfo = repoInfoMap[file.repositoryId]; + + return ( + + + + + + {file.matches + .sort((a, b) => a.range.start.lineNumber - b.range.start.lineNumber) + .map((match, index) => ( + { + captureEvent('wa_explore_menu_reference_clicked', {}); + navigateToPath({ + repoName: file.repository, + revisionName, + path: file.fileName, + pathType: 'blob', + highlightRange: match.range, + }) + }} + /> + ))} + + + ) + })} + + ) +} + + +interface ReferenceListItemProps { + lineContent: string; + range: SourceRange; + language: string; + onClick: () => void; +} + +const ReferenceListItem = ({ + lineContent, + range, + language, + onClick, +}: ReferenceListItemProps) => { + const decodedLineContent = useMemo(() => { + return base64Decode(lineContent); + }, [lineContent]); + + const highlightRanges = useMemo(() => [range], [range]); + + return ( + + + {decodedLineContent} + + + ) +} diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx new file mode 100644 index 00000000..86c0d0f5 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx @@ -0,0 +1,138 @@ +import { Button } from "@/components/ui/button"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { Separator } from "@/components/ui/separator"; +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"; + +interface SymbolHoverPopupProps { + editorRef: ReactCodeMirrorRef; + language: string; + revisionName: string; + onFindReferences: (symbolName: string) => void; + onGotoDefinition: (symbolName: string, symbolDefinitions: SymbolDefinition[]) => void; +} + +export const SymbolHoverPopup: React.FC = ({ + editorRef, + revisionName, + language, + onFindReferences, + onGotoDefinition: _onGotoDefinition, +}) => { + const ref = useRef(null); + const [isSticky, setIsSticky] = useState(false); + + const symbolInfo = useHoveredOverSymbolInfo({ + editorRef, + isSticky, + revisionName, + language, + }); + + // Positions the popup relative to the symbol + useEffect(() => { + if (!symbolInfo) { + return; + } + + const virtualElement: VirtualElement = { + getBoundingClientRect: () => { + return symbolInfo.element.getBoundingClientRect(); + } + } + + if (ref.current) { + computePosition(virtualElement, ref.current, { + placement: 'top', + middleware: [ + offset(2), + flip({ + mainAxis: true, + crossAxis: false, + fallbackPlacements: ['bottom'], + boundary: editorRef.view?.dom, + }), + shift({ + padding: 5, + }) + ] + }).then(({ x, y }) => { + if (ref.current) { + ref.current.style.left = `${x}px`; + ref.current.style.top = `${y}px`; + } + }) + } + }, [symbolInfo, editorRef]); + + const onGotoDefinition = useCallback(() => { + if (!symbolInfo || !symbolInfo.symbolDefinitions) { + return; + } + + _onGotoDefinition(symbolInfo.symbolName, symbolInfo.symbolDefinitions); + }, [symbolInfo, _onGotoDefinition]); + + // @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 || !symbolInfo.symbolDefinitions) { + return; + } + + symbolInfo.element.addEventListener("click", onGotoDefinition); + return () => { + symbolInfo.element.removeEventListener("click", onGotoDefinition); + } + }, [symbolInfo, onGotoDefinition]); + + return symbolInfo ? ( + setIsSticky(true)} + onMouseOut={() => setIsSticky(false)} + > + {symbolInfo.isSymbolDefinitionsLoading ? ( + + + Loading... + + ) : symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 0 ? ( + + ) : ( + No hover info found + )} + + + + { + !symbolInfo.isSymbolDefinitionsLoading && (!symbolInfo.symbolDefinitions || symbolInfo.symbolDefinitions.length === 0) ? + "No definition found" : + `Go to ${symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 1 ? "definitions" : "definition"}` + } + + onFindReferences(symbolInfo.symbolName)} + > + Find references + + + + ) : null; +}; diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx new file mode 100644 index 00000000..92cb69b4 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx @@ -0,0 +1,62 @@ +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; +import { useMemo } from "react"; +import { SourceRange } from "@/features/search/types"; +import { base64Decode } from "@/lib/utils"; + +interface SymbolDefinitionPreviewProps { + symbolDefinition: { + lineContent: string; + language: string; + fileName: string; + repoName: string; + range: SourceRange; + }; +} + +export const SymbolDefinitionPreview = ({ + symbolDefinition, +}: SymbolDefinitionPreviewProps) => { + const { lineContent, language, range } = symbolDefinition; + const highlightRanges = useMemo(() => [range], [range]); + + const decodedLineContent = useMemo(() => { + return base64Decode(lineContent); + }, [lineContent]); + + return ( + + + + + Search Based + + + + Symbol definition found using a best-guess search heuristic. + + + + {decodedLineContent} + + + ) +} \ No newline at end of file diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts new file mode 100644 index 00000000..179a0a54 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts @@ -0,0 +1,78 @@ +import { StateField, Range } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { measureSync } from "@/lib/utils"; + +export const SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE = "data-symbol-hover-target"; + +const decoration = Decoration.mark({ + class: "cm-underline-hover", + attributes: { [SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE]: "true" } +}); + +const NODE_TYPES = [ + // Typescript + Python + "VariableName", + "VariableDefinition", + "TypeDefinition", + "TypeName", + "PropertyName", + "PropertyDefinition", + "JSXIdentifier", + "Identifier", + // C# + "VarName", + "TypeIdentifier", + "PropertyName", + "MethodName", + "Ident", + "ParamName", + "AttrsNamedArg", + // C/C++ + "Identifier", + "NamespaceIdentifier", + "FieldIdentifier", + // Objective-C + "variableName", + "variableName.definition", + // Java + "Definition", + // Rust + "BoundIdentifier", + // Go + "DefName", + "FieldName", + // PHP + "ClassMemberName", + "Name" +] + +export const symbolHoverTargetsExtension = StateField.define({ + create(state) { + // @note: we need to use `ensureSyntaxTree` here (as opposed to `syntaxTree`) + // because we want to parse the entire document, not just the text visible in + // the current viewport. + const { data: tree } = measureSync(() => ensureSyntaxTree(state, state.doc.length), "ensureSyntaxTree"); + const decorations: Range[] = []; + + // @note: useful for debugging + // const getTextAt = (from: number, to: number) => { + // const doc = state.doc; + // return doc.sliceString(from, to); + // } + + tree?.iterate({ + enter: (node) => { + // console.log(node.type.name, getTextAt(node.from, node.to)); + if (NODE_TYPES.includes(node.type.name)) { + decorations.push(decoration.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 diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts new file mode 100644 index 00000000..f21462b1 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts @@ -0,0 +1,139 @@ +import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/actions"; +import { SourceRange } from "@/features/search/types"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE } from "./symbolHoverTargetsExtension"; + +interface UseHoveredOverSymbolInfoProps { + editorRef: ReactCodeMirrorRef; + isSticky: boolean; + revisionName: string; + language: string; +} + +export type SymbolDefinition = { + lineContent: string; + language: string; + fileName: string; + repoName: string; + range: SourceRange; +} + +interface HoveredOverSymbolInfo { + element: HTMLElement; + symbolName: string; + isSymbolDefinitionsLoading: boolean; + symbolDefinitions?: SymbolDefinition[]; +} + +const SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT_MS = 500; +const SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT_MS = 100; + +export const useHoveredOverSymbolInfo = ({ + editorRef, + isSticky, + revisionName, + language, +}: UseHoveredOverSymbolInfoProps): HoveredOverSymbolInfo | undefined => { + const mouseOverTimerRef = useRef(null); + const mouseOutTimerRef = useRef(null); + + const domain = useDomain(); + const [isVisible, setIsVisible] = useState(false); + + const [symbolElement, setSymbolElement] = useState(null); + const symbolName = useMemo(() => { + return (symbolElement && symbolElement.textContent) ?? undefined; + }, [symbolElement]); + + const { data: symbolDefinitions, isLoading: isSymbolDefinitionsLoading } = useQuery({ + queryKey: ["definitions", symbolName, revisionName, language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolDefinitions({ + symbolName: symbolName!, + language, + revisionName, + }, domain) + ), + select: ((data) => { + return data.files.flatMap((file) => { + return file.matches.map((match) => { + return { + lineContent: match.lineContent, + language: file.language, + fileName: file.fileName, + repoName: file.repository, + range: match.range, + } + }) + }) + + }), + enabled: !!symbolName, + staleTime: Infinity, + }) + + const clearTimers = useCallback(() => { + if (mouseOverTimerRef.current) { + clearTimeout(mouseOverTimerRef.current); + } + + if (mouseOutTimerRef.current) { + clearTimeout(mouseOutTimerRef.current); + } + }, []); + + useEffect(() => { + const view = editorRef.view; + if (!view) { + return; + } + + const handleMouseOver = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest(`[${SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE}="true"]`) as HTMLElement; + if (!target) { + return; + } + clearTimers(); + setSymbolElement(target); + + mouseOverTimerRef.current = setTimeout(() => { + setIsVisible(true); + }, SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT_MS); + }; + + const handleMouseOut = () => { + clearTimers(); + + mouseOutTimerRef.current = setTimeout(() => { + setIsVisible(false); + }, SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT_MS); + }; + + view.dom.addEventListener("mouseover", handleMouseOver); + view.dom.addEventListener("mouseout", handleMouseOut); + + return () => { + view.dom.removeEventListener("mouseover", handleMouseOver); + view.dom.removeEventListener("mouseout", handleMouseOut); + }; + }, [editorRef, domain, clearTimers]); + + if (!isVisible && !isSticky) { + return undefined; + } + + if (!symbolElement || !symbolName) { + return undefined; + } + + return { + element: symbolElement, + symbolName, + isSymbolDefinitionsLoading: isSymbolDefinitionsLoading, + symbolDefinitions, + }; +} diff --git a/packages/web/src/features/codeNav/actions.ts b/packages/web/src/features/codeNav/actions.ts new file mode 100644 index 00000000..831dd6c2 --- /dev/null +++ b/packages/web/src/features/codeNav/actions.ts @@ -0,0 +1,118 @@ +'use server'; + +import { sew, withAuth, withOrgMembership } from "@/actions"; +import { searchResponseSchema } from "@/features/search/schemas"; +import { search } from "@/features/search/searchApi"; +import { isServiceError } from "@/lib/utils"; +import { FindRelatedSymbolsResponse } from "./types"; +import { ServiceError } from "@/lib/serviceError"; +import { SearchResponse } from "../search/types"; + +// The maximum number of matches to return from the search API. +const MAX_REFERENCE_COUNT = 1000; + +export const findSearchBasedSymbolReferences = async ( + props: { + symbolName: string, + language: string, + revisionName?: string, + }, + domain: string, +): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; + + const query = `\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)} case:yes`; + + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }, domain); + + if (isServiceError(searchResult)) { + return searchResult; + } + + return parseRelatedSymbolsSearchResponse(searchResult); + }), /* allowSingleTenantUnauthedAccess = */ true) +); + + +export const findSearchBasedSymbolDefinitions = async ( + props: { + symbolName: string, + language: string, + revisionName?: string, + }, + domain: string, +): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; + + const query = `sym:\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)}`; + + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }, domain); + + if (isServiceError(searchResult)) { + return searchResult; + } + + return parseRelatedSymbolsSearchResponse(searchResult); + }), /* allowSingleTenantUnauthedAccess = */ true) +); + +const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { + const parser = searchResponseSchema.transform(async ({ files }) => ({ + stats: { + matchCount: searchResult.stats.matchCount, + }, + files: files.flatMap((file) => { + const chunks = file.chunks; + + return { + fileName: file.fileName.text, + repository: file.repository, + repositoryId: file.repositoryId, + webUrl: file.webUrl, + language: file.language, + matches: chunks.flatMap((chunk) => { + return chunk.matchRanges.map((range) => ({ + lineContent: chunk.content, + range: range, + })) + }) + } + }).filter((file) => file.matches.length > 0), + repositoryInfo: searchResult.repositoryInfo + })); + + return parser.parseAsync(searchResult); +} + +// Expands the language filter to include all variants of the language. +const getExpandedLanguageFilter = (language: string) => { + switch (language) { + case "TypeScript": + case "JavaScript": + case "JSX": + case "TSX": + return `(lang:TypeScript or lang:JavaScript or lang:JSX or lang:TSX)` + default: + return `lang:${language}` + } +} \ No newline at end of file diff --git a/packages/web/src/features/codeNav/schemas.ts b/packages/web/src/features/codeNav/schemas.ts new file mode 100644 index 00000000..03f20721 --- /dev/null +++ b/packages/web/src/features/codeNav/schemas.ts @@ -0,0 +1,20 @@ +import { rangeSchema, repositoryInfoSchema } from "../search/schemas"; +import { z } from "zod"; + +export const findRelatedSymbolsResponseSchema = z.object({ + stats: z.object({ + matchCount: z.number(), + }), + files: z.array(z.object({ + fileName: z.string(), + repository: z.string(), + repositoryId: z.number(), + webUrl: z.string().optional(), + language: z.string(), + matches: z.array(z.object({ + lineContent: z.string(), + range: rangeSchema, + })) + })), + repositoryInfo: z.array(repositoryInfoSchema), +}); \ No newline at end of file diff --git a/packages/web/src/features/codeNav/types.ts b/packages/web/src/features/codeNav/types.ts new file mode 100644 index 00000000..bb9a282b --- /dev/null +++ b/packages/web/src/features/codeNav/types.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { findRelatedSymbolsResponseSchema } from "./schemas"; + +export type FindRelatedSymbolsResponse = z.infer; diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts index e75c7654..6e07c0cb 100644 --- a/packages/web/src/features/entitlements/constants.ts +++ b/packages/web/src/features/entitlements/constants.ts @@ -17,6 +17,7 @@ const entitlements = [ "public-access", "multi-tenancy", "sso", + "code-nav" ] as const; export type Entitlement = (typeof entitlements)[number]; @@ -27,7 +28,7 @@ export const isValidEntitlement = (entitlement: string): entitlement is Entitlem export const entitlementsByPlan: Record = { oss: [], "cloud:team": ["billing", "multi-tenancy", "sso"], - "self-hosted:enterprise": ["search-contexts", "sso"], - "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso"], + "self-hosted:enterprise": ["search-contexts", "sso", "code-nav"], + "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav"], "self-hosted:enterprise-custom": [], } as const; diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts index 415d1dbd..d285aaa9 100644 --- a/packages/web/src/features/search/fileSourceApi.ts +++ b/packages/web/src/features/search/fileSourceApi.ts @@ -1,3 +1,5 @@ +'use server'; + import escapeStringRegexp from "escape-string-regexp"; import { fileNotFound, ServiceError } from "../../lib/serviceError"; import { FileSourceRequest, FileSourceResponse } from "./types"; @@ -41,6 +43,7 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource return { source, language, + webUrl: file.webUrl, } satisfies FileSourceResponse; }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/features/search/schemas.ts b/packages/web/src/features/search/schemas.ts index 9e86d991..1c99d885 100644 --- a/packages/web/src/features/search/schemas.ts +++ b/packages/web/src/features/search/schemas.ts @@ -63,6 +63,9 @@ export const searchResponseSchema = z.object({ regexpsConsidered: z.number(), flushReason: z.number(), }), + stats: z.object({ + matchCount: z.number(), + }), files: z.array(z.object({ fileName: z.object({ // The name of the file @@ -111,4 +114,5 @@ export const fileSourceRequestSchema = z.object({ export const fileSourceResponseSchema = z.object({ source: z.string(), language: z.string(), + webUrl: z.string().optional(), }); \ No newline at end of file diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts index 0d916748..7b5901eb 100644 --- a/packages/web/src/features/search/searchApi.ts +++ b/packages/web/src/features/search/searchApi.ts @@ -6,7 +6,7 @@ import { prisma } from "@/prisma"; import { ErrorCode } from "../../lib/errorCodes"; import { StatusCodes } from "http-status-codes"; import { zoektSearchResponseSchema } from "./zoektSchema"; -import { SearchRequest, SearchResponse, SearchResultRange } from "./types"; +import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { OrgRole, Repo } from "@sourcebot/db"; import * as Sentry from "@sentry/nextjs"; import { sew, withAuth, withOrgMembership } from "@/actions"; @@ -213,6 +213,92 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ } })).forEach(repo => repos.set(repo.name, repo)); + const files = Result.Files?.map((file) => { + const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName); + + const webUrl = (() => { + const template: string | undefined = Result.RepoURLs[file.Repository]; + if (!template) { + return undefined; + } + + // If there are multiple branches pointing to the same revision of this file, it doesn't + // matter which branch we use here, so use the first one. + const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD"; + return getFileWebUrl(template, branch, file.FileName); + })(); + + const identifier = file.RepositoryID ?? file.Repository; + const repo = repos.get(identifier); + + // This should never happen... but if it does, we skip the file. + if (!repo) { + Sentry.captureMessage( + `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, + 'warning' + ); + return undefined; + } + + return { + fileName: { + text: file.FileName, + matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ + start: { + byteOffset: range.Start.ByteOffset, + column: range.Start.Column, + lineNumber: range.Start.LineNumber, + }, + end: { + byteOffset: range.End.ByteOffset, + column: range.End.Column, + lineNumber: range.End.LineNumber, + } + })) : [], + }, + repository: repo.name, + repositoryId: repo.id, + webUrl: webUrl, + language: file.Language, + chunks: file.ChunkMatches + .filter((chunk) => !chunk.FileName) // Filter out filename chunks. + .map((chunk) => { + return { + content: chunk.Content, + matchRanges: chunk.Ranges.map((range) => ({ + start: { + byteOffset: range.Start.ByteOffset, + column: range.Start.Column, + lineNumber: range.Start.LineNumber, + }, + end: { + byteOffset: range.End.ByteOffset, + column: range.End.Column, + lineNumber: range.End.LineNumber, + } + }) satisfies SourceRange), + contentStart: { + byteOffset: chunk.ContentStart.ByteOffset, + column: chunk.ContentStart.Column, + lineNumber: chunk.ContentStart.LineNumber, + }, + symbols: chunk.SymbolInfo?.map((symbol) => { + return { + symbol: symbol.Sym, + kind: symbol.Kind, + parent: symbol.Parent.length > 0 ? { + symbol: symbol.Parent, + kind: symbol.ParentKind, + } : undefined, + } + }) ?? undefined, + } + }), + branches: file.Branches, + content: file.Content, + } + }).filter((file) => file !== undefined) ?? []; + return { zoektStats: { duration: Result.Duration, @@ -236,91 +322,7 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ regexpsConsidered: Result.RegexpsConsidered, flushReason: Result.FlushReason, }, - files: Result.Files?.map((file) => { - const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName); - - const webUrl = (() => { - const template: string | undefined = Result.RepoURLs[file.Repository]; - if (!template) { - return undefined; - } - - // If there are multiple branches pointing to the same revision of this file, it doesn't - // matter which branch we use here, so use the first one. - const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD"; - return getFileWebUrl(template, branch, file.FileName); - })(); - - const identifier = file.RepositoryID ?? file.Repository; - const repo = repos.get(identifier); - - // This should never happen... but if it does, we skip the file. - if (!repo) { - Sentry.captureMessage( - `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, - 'warning' - ); - return undefined; - } - - return { - fileName: { - text: file.FileName, - matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ - start: { - byteOffset: range.Start.ByteOffset, - column: range.Start.Column, - lineNumber: range.Start.LineNumber, - }, - end: { - byteOffset: range.End.ByteOffset, - column: range.End.Column, - lineNumber: range.End.LineNumber, - } - })) : [], - }, - repository: repo.name, - repositoryId: repo.id, - webUrl: webUrl, - language: file.Language, - chunks: file.ChunkMatches - .filter((chunk) => !chunk.FileName) // Filter out filename chunks. - .map((chunk) => { - return { - content: chunk.Content, - matchRanges: chunk.Ranges.map((range) => ({ - start: { - byteOffset: range.Start.ByteOffset, - column: range.Start.Column, - lineNumber: range.Start.LineNumber, - }, - end: { - byteOffset: range.End.ByteOffset, - column: range.End.Column, - lineNumber: range.End.LineNumber, - } - }) satisfies SearchResultRange), - contentStart: { - byteOffset: chunk.ContentStart.ByteOffset, - column: chunk.ContentStart.Column, - lineNumber: chunk.ContentStart.LineNumber, - }, - symbols: chunk.SymbolInfo?.map((symbol) => { - return { - symbol: symbol.Sym, - kind: symbol.Kind, - parent: symbol.Parent.length > 0 ? { - symbol: symbol.Parent, - kind: symbol.ParentKind, - } : undefined, - } - }) ?? undefined, - } - }), - branches: file.Branches, - content: file.Content, - } - }).filter((file) => file !== undefined) ?? [], + files, repositoryInfo: Array.from(repos.values()).map((repo) => ({ id: repo.id, codeHostType: repo.external_codeHostType, @@ -329,6 +331,16 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ webUrl: repo.webUrl ?? undefined, })), isBranchFilteringEnabled: isBranchFilteringEnabled, + stats: { + matchCount: files.reduce( + (acc, file) => + acc + file.chunks.reduce( + (acc, chunk) => acc + chunk.matchRanges.length, + 0, + ), + 0, + ) + } } satisfies SearchResponse; }); diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts index 1e2d331d..5271b94b 100644 --- a/packages/web/src/features/search/types.ts +++ b/packages/web/src/features/search/types.ts @@ -14,7 +14,6 @@ import { z } from "zod"; export type SearchRequest = z.infer; export type SearchResponse = z.infer; -export type SearchResultRange = z.infer; export type SearchResultLocation = z.infer; export type SearchResultFile = SearchResponse["files"][number]; export type SearchResultChunk = SearchResultFile["chunks"][number]; @@ -26,4 +25,5 @@ export type Repository = ListRepositoriesResponse["repos"][number]; export type FileSourceRequest = z.infer; export type FileSourceResponse = z.infer; -export type RepositoryInfo = z.infer; \ No newline at end of file +export type RepositoryInfo = z.infer; +export type SourceRange = z.infer; \ No newline at end of file diff --git a/packages/web/src/hooks/useCodeMirrorHighlighter.ts b/packages/web/src/hooks/useCodeMirrorHighlighter.ts new file mode 100644 index 00000000..0c0b944f --- /dev/null +++ b/packages/web/src/hooks/useCodeMirrorHighlighter.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useMemo } from "react"; +import { tags as t, tagHighlighter } from '@lezer/highlight'; + +export const useCodeMirrorHighlighter = () => { + return useMemo(() => { + return tagHighlighter([ + // Keywords (if, for, class, etc.) + { tag: t.keyword, class: 'text-editor-tag-keyword' }, + + // Names, identifiers, properties + { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName, t.variableName], class: 'text-editor-tag-name' }, + + // Functions and variable definitions + { tag: [t.function(t.variableName), t.definition(t.variableName)], class: 'text-editor-tag-function' }, + { tag: t.local(t.variableName), class: 'text-editor-tag-variable-local' }, + + // Property definitions + { tag: [t.definition(t.name), t.separator, t.definition(t.propertyName)], class: 'text-editor-tag-definition' }, + + // Labels + { tag: [t.labelName], class: 'text-editor-tag-label' }, + + // Constants and standards + { tag: [t.color, t.constant(t.name), t.standard(t.name)], class: 'text-editor-tag-constant' }, + + // Braces and brackets + { tag: [t.brace], class: 'text-editor-tag-brace' }, + { tag: [t.squareBracket], class: 'text-editor-tag-bracket-square' }, + { tag: [t.angleBracket], class: 'text-editor-tag-bracket-angle' }, + + // Types and classes + { tag: [t.typeName, t.namespace], class: 'text-editor-tag-type' }, + { tag: [t.className], class: 'text-editor-tag-tag' }, + + // Numbers and annotations + { tag: [t.number, t.changed, t.modifier, t.self], class: 'text-editor-tag-number' }, + { tag: [t.annotation], class: 'text-editor-tag-annotation-special' }, + + // Operators + { tag: [t.operator, t.operatorKeyword], class: 'text-editor-tag-operator' }, + + // HTML/XML tags and attributes + { tag: [t.tagName], class: 'text-editor-tag-tag' }, + { tag: [t.attributeName], class: 'text-editor-tag-attribute' }, + + // Strings and quotes + { tag: [t.string], class: 'text-editor-tag-string' }, + { tag: [t.quote], class: 'text-editor-tag-quote' }, + { tag: [t.processingInstruction, t.inserted], class: 'text-editor-tag-processing' }, + + // Special string content + { tag: [t.url, t.escape, t.special(t.string)], class: 'text-editor-tag-string' }, + { tag: [t.regexp], class: 'text-editor-tag-constant' }, + + // Links + { tag: t.link, class: 'text-editor-tag-link underline' }, + + // Meta and comments + { tag: [t.meta], class: 'text-editor-tag-meta' }, + { tag: [t.comment], class: 'text-editor-tag-comment italic' }, + + // Text formatting + { tag: t.strong, class: 'text-editor-tag-emphasis font-bold' }, + { tag: t.emphasis, class: 'text-editor-tag-emphasis italic' }, + { tag: t.strikethrough, class: 'text-editor-tag-emphasis line-through' }, + + // Headings + { tag: t.heading, class: 'text-editor-tag-heading font-bold' }, + { tag: t.special(t.heading1), class: 'text-editor-tag-heading font-bold' }, + { tag: t.heading1, class: 'text-editor-tag-heading font-bold' }, + { tag: [t.heading2, t.heading3, t.heading4], class: 'text-editor-tag-heading font-bold' }, + { tag: [t.heading5, t.heading6], class: 'text-editor-tag-heading' }, + + // Atoms and booleans + { tag: [t.atom, t.bool, t.special(t.variableName)], class: 'text-editor-tag-atom' }, + + // Content separator + { tag: [t.contentSeparator], class: 'text-editor-tag-separator' }, + + // Invalid syntax + { tag: t.invalid, class: 'text-editor-tag-invalid' } + ]); + }, []); +} diff --git a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts b/packages/web/src/hooks/useCodeMirrorLanguageExtension.ts similarity index 89% rename from packages/web/src/hooks/useSyntaxHighlightingExtension.ts rename to packages/web/src/hooks/useCodeMirrorLanguageExtension.ts index 053e9b7b..b77ca321 100644 --- a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts +++ b/packages/web/src/hooks/useCodeMirrorLanguageExtension.ts @@ -4,7 +4,7 @@ import { EditorView } from "@codemirror/view"; import { useExtensionWithDependency } from "./useExtensionWithDependency"; import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; -export const useSyntaxHighlightingExtension = (linguistLanguage: string, view: EditorView | undefined) => { +export const useCodeMirrorLanguageExtension = (linguistLanguage: string, view: EditorView | undefined) => { const extension = useExtensionWithDependency( view ?? null, () => { diff --git a/packages/web/src/hooks/useCodeMirrorTheme.ts b/packages/web/src/hooks/useCodeMirrorTheme.ts index e7ee97df..6bbe8f07 100644 --- a/packages/web/src/hooks/useCodeMirrorTheme.ts +++ b/packages/web/src/hooks/useCodeMirrorTheme.ts @@ -1,78 +1,154 @@ 'use client'; -import { useTailwind } from "./useTailwind"; import { useMemo } from "react"; import { useThemeNormalized } from "./useThemeNormalized"; -import createTheme from "@uiw/codemirror-themes"; -import { defaultLightThemeOption } from "@uiw/react-codemirror"; -import { tags as t } from '@lezer/highlight'; +import { + useCodeMirrorHighlighter, +} from "./useCodeMirrorHighlighter"; +import { EditorView } from "@codemirror/view"; import { syntaxHighlighting } from "@codemirror/language"; -import { defaultHighlightStyle } from "@codemirror/language"; - -// From: https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts -const chalky = "#e5c07b", - coral = "#e06c75", - cyan = "#56b6c2", - invalid = "#ffffff", - ivory = "#abb2bf", - stone = "#7d8799", - malibu = "#61afef", - sage = "#98c379", - whiskey = "#d19a66", - violet = "#c678dd", - highlightBackground = "#2c313aaa", - background = "#282c34", - selection = "#3E4451", - cursor = "#528bff"; - +import type { StyleSpec } from 'style-mod'; +import { Extension } from "@codemirror/state"; +import tailwind from "@/tailwind"; export const useCodeMirrorTheme = () => { - const tailwind = useTailwind(); const { theme } = useThemeNormalized(); - - const darkTheme = useMemo(() => { - return createTheme({ - theme: 'dark', - settings: { - background: tailwind.theme.colors.background, - foreground: ivory, - caret: cursor, - selection: selection, - selectionMatch: "#aafe661a", // for matching selections - gutterBackground: background, - gutterForeground: stone, - gutterBorder: 'none', - gutterActiveForeground: ivory, - lineHighlight: highlightBackground, - }, - styles: [ - { tag: t.comment, color: stone }, - { tag: t.keyword, color: violet }, - { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: coral }, - { tag: [t.function(t.variableName), t.labelName], color: malibu }, - { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, - { tag: [t.definition(t.name), t.separator], color: ivory }, - { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: chalky }, - { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: cyan }, - { tag: [t.meta], color: stone }, - { tag: t.strong, fontWeight: 'bold' }, - { tag: t.emphasis, fontStyle: 'italic' }, - { tag: t.strikethrough, textDecoration: 'line-through' }, - { tag: t.link, color: stone, textDecoration: 'underline' }, - { tag: t.heading, fontWeight: 'bold', color: coral }, - { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, - { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, - { tag: t.invalid, color: invalid } - ] - }); - }, [tailwind.theme.colors.background]); + const highlightStyle = useCodeMirrorHighlighter(); const cmTheme = useMemo(() => { - return theme === 'dark' ? darkTheme : [ - defaultLightThemeOption, - syntaxHighlighting(defaultHighlightStyle), + const { + background, + foreground, + caret, + selection, + selectionMatch, + gutterBackground, + gutterForeground, + gutterBorder, + gutterActiveForeground, + lineHighlight, + } = tailwind.theme.colors.editor; + + return [ + createThemeExtension({ + theme: theme === 'dark' ? 'dark' : 'light', + settings: { + background, + foreground, + caret, + selection, + selectionMatch, + gutterBackground, + gutterForeground, + gutterBorder, + gutterActiveForeground, + lineHighlight, + fontFamily: tailwind.theme.fontFamily.editor, + fontSize: tailwind.theme.fontSize.editor, + } + }), + syntaxHighlighting(highlightStyle) ] - }, [theme, darkTheme]); + }, [highlightStyle, theme]); return cmTheme; +} + + +// @see: https://github.com/uiwjs/react-codemirror/blob/e365f7d1f8a0ec2cd88455b7a248f6338c859cc7/themes/theme/src/index.tsx +const createThemeExtension = ({ theme, settings = {} }: CreateThemeOptions): Extension => { + const themeOptions: Record = { + '.cm-gutters': {}, + }; + const baseStyle: StyleSpec = {}; + if (settings.background) { + baseStyle.backgroundColor = settings.background; + } + if (settings.backgroundImage) { + baseStyle.backgroundImage = settings.backgroundImage; + } + if (settings.foreground) { + baseStyle.color = settings.foreground; + } + if (settings.fontSize) { + baseStyle.fontSize = settings.fontSize; + } + if (settings.background || settings.foreground) { + themeOptions['&'] = baseStyle; + } + + if (settings.fontFamily) { + themeOptions['&.cm-editor .cm-scroller'] = { + fontFamily: settings.fontFamily, + }; + } + if (settings.gutterBackground) { + themeOptions['.cm-gutters'].backgroundColor = settings.gutterBackground; + } + if (settings.gutterForeground) { + themeOptions['.cm-gutters'].color = settings.gutterForeground; + } + if (settings.gutterBorder) { + themeOptions['.cm-gutters'].borderRightColor = settings.gutterBorder; + } + + if (settings.caret) { + themeOptions['.cm-content'] = { + caretColor: settings.caret, + }; + themeOptions['.cm-cursor, .cm-dropCursor'] = { + borderLeftColor: settings.caret, + }; + } + + const activeLineGutterStyle: StyleSpec = {}; + if (settings.gutterActiveForeground) { + activeLineGutterStyle.color = settings.gutterActiveForeground; + } + if (settings.lineHighlight) { + themeOptions['.cm-activeLine'] = { + backgroundColor: settings.lineHighlight, + }; + activeLineGutterStyle.backgroundColor = settings.lineHighlight; + } + themeOptions['.cm-activeLineGutter'] = activeLineGutterStyle; + + if (settings.selection) { + themeOptions[ + '&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection' + ] = { + background: settings.selection + ' !important', + }; + } + if (settings.selectionMatch) { + themeOptions['& .cm-selectionMatch'] = { + backgroundColor: settings.selectionMatch, + }; + } + const themeExtension = EditorView.theme(themeOptions, { + dark: theme === 'dark', + }); + + return themeExtension; +}; + +interface CreateThemeOptions { + theme: 'light' | 'dark'; + settings: Settings; +} + +interface Settings { + background?: string; + backgroundImage?: string; + foreground?: string; + caret?: string; + selection?: string; + selectionMatch?: string; + lineHighlight?: string; + gutterBackground?: string; + gutterForeground?: string; + gutterActiveForeground?: string; + gutterBorder?: string; + fontFamily?: string; + fontSize?: StyleSpec['fontSize']; } \ No newline at end of file diff --git a/packages/web/src/hooks/useTailwind.ts b/packages/web/src/hooks/useTailwind.ts deleted file mode 100644 index c6d05eb4..00000000 --- a/packages/web/src/hooks/useTailwind.ts +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { useMemo } from "react"; -import resolveConfig from 'tailwindcss/resolveConfig'; -import tailwindConfig from '../../tailwind.config'; - -export const useTailwind = () => { - const tailwind = useMemo(() => { - return resolveConfig(tailwindConfig); - }, []); - - return tailwind; -} \ No newline at end of file diff --git a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts index 56325af3..3ae9e4a1 100644 --- a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts +++ b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts @@ -1,13 +1,13 @@ import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; -import { SearchResultRange } from "@/features/search/types"; +import { SourceRange } from "@/features/search/types"; const setMatchState = StateEffect.define<{ selectedMatchIndex: number, - ranges: SearchResultRange[], + ranges: SourceRange[], }>(); -const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => { +const convertToCodeMirrorRange = (range: SourceRange, document: Text) => { const { start, end } = range; const from = document.line(start.lineNumber).from + start.column - 1; const to = document.line(end.lineNumber).from + end.column - 1; @@ -46,13 +46,13 @@ const matchHighlighter = StateField.define({ }); const matchMark = Decoration.mark({ - class: "cm-searchMatch" + class: "searchMatch" }); const selectedMatchMark = Decoration.mark({ - class: "cm-searchMatch-selected" + class: "searchMatch-selected" }); -export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResultRange[], view: EditorView) => { +export const highlightRanges = (selectedMatchIndex: number, ranges: SourceRange[], view: EditorView) => { const setState = setMatchState.of({ selectedMatchIndex, ranges, diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 775e60e7..a03669a5 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,12 +1,11 @@ import { NewsItem } from "./types"; -// Sample news data - replace with your actual data source export const newsData: NewsItem[] = [ { unique_id: "code-nav", header: "Code navigation", sub_header: "Built in go-to definition and find references", - url: "https://docs.sourcebot.dev", // TODO: link to code nav docs + url: "https://docs.sourcebot.dev/docs/search/code-navigation" }, { unique_id: "sso", @@ -17,7 +16,7 @@ export const newsData: NewsItem[] = [ { unique_id: "search-contexts", header: "Search contexts", - sub_header: "Group repos into different search contexts to search against", + sub_header: "Filter searches by groups of repos", url: "https://docs.sourcebot.dev/docs/search/search-contexts" } ]; \ No newline at end of file diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 60e4750b..3f601c0d 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -263,5 +263,13 @@ export type PosthogEventMap = { ////////////////////////////////////////////////////////////////// wa_api_key_created: {}, wa_api_key_creation_fail: {}, + ////////////////////////////////////////////////////////////////// + wa_preview_panel_find_references_pressed: {}, + wa_preview_panel_goto_definition_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_browse_find_references_pressed: {}, + wa_browse_goto_definition_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_explore_menu_reference_clicked: {}, } export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index ddaac1c0..0d01f9ff 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -7,6 +7,9 @@ import gerritLogo from "@/public/gerrit.svg"; import bitbucketLogo from "@/public/bitbucket.svg"; import gitLogo from "@/public/git.svg"; import { ServiceError } from "./serviceError"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "./errorCodes"; +import { NextRequest } from "next/server"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -131,7 +134,7 @@ export const getCodeHostInfoForRepo = (repo: { return { type: "generic-git-host", displayName: displayName ?? name, - codeHostName: "Generic Git Host", + codeHostName: "Git Host", repoLink: webUrl, icon: src, iconClassName: className, @@ -235,7 +238,7 @@ export const getDisplayTime = (date: Date) => { } } -export const measureSync = (cb: () => T, measureName: string) => { +export const measureSync = (cb: () => T, measureName: string, outputLog: boolean = true) => { const startMark = `${measureName}.start`; const endMark = `${measureName}.end`; @@ -245,7 +248,9 @@ export const measureSync = (cb: () => T, measureName: string) => { const measure = performance.measure(measureName, startMark, endMark); const durationMs = measure.duration; - console.debug(`[${measureName}] took ${durationMs}ms`); + if (outputLog) { + console.debug(`[${measureName}] took ${durationMs}ms`); + } return { data, @@ -253,7 +258,7 @@ export const measureSync = (cb: () => T, measureName: string) => { } } -export const measure = async (cb: () => Promise, measureName: string) => { +export const measure = async (cb: () => Promise, measureName: string, outputLog: boolean = true) => { const startMark = `${measureName}.start`; const endMark = `${measureName}.end`; @@ -263,7 +268,9 @@ export const measure = async (cb: () => Promise, measureName: string) => { const measure = performance.measure(measureName, startMark, endMark); const durationMs = measure.duration; - console.debug(`[${measureName}] took ${durationMs}ms`); + if (outputLog) { + console.debug(`[${measureName}] took ${durationMs}ms`); + } return { data, @@ -286,4 +293,16 @@ export const unwrapServiceError = async (promise: Promise): } return data; +} + +export const requiredQueryParamGuard = (request: NextRequest, param: string): ServiceError | string => { + const value = request.nextUrl.searchParams.get(param); + if (!value) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MISSING_REQUIRED_QUERY_PARAMETER, + message: `Missing required query param: ${param}`, + }; + } + return value; } \ No newline at end of file diff --git a/packages/web/src/tailwind.ts b/packages/web/src/tailwind.ts new file mode 100644 index 00000000..aafe7770 --- /dev/null +++ b/packages/web/src/tailwind.ts @@ -0,0 +1,5 @@ +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../tailwind.config'; + +const tailwind = resolveConfig(tailwindConfig); +export default tailwind; \ No newline at end of file diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 7b163209..d740177a 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -1,103 +1,153 @@ import type { Config } from "tailwindcss" const config = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + background: 'var(--background)', + backgroundSecondary: 'var(--background-secondary)', + foreground: 'var(--foreground)', + primary: { + DEFAULT: 'var(--primary)', + foreground: 'var(--primary-foreground)' + }, + secondary: { + DEFAULT: 'var(--secondary)', + foreground: 'var(--secondary-foreground)' + }, + destructive: { + DEFAULT: 'var(--destructive)', + foreground: 'var(--destructive-foreground)' + }, + muted: { + DEFAULT: 'var(--muted)', + foreground: 'var(--muted-foreground)' + }, + accent: { + DEFAULT: 'var(--accent)', + foreground: 'var(--accent-foreground)' + }, + popover: { + DEFAULT: 'var(--popover)', + foreground: 'var(--popover-foreground)' + }, + card: { + DEFAULT: 'var(--card)', + foreground: 'var(--card-foreground)' + }, + highlight: 'var(--highlight)', + sidebar: { + DEFAULT: 'var(--sidebar-background)', + foreground: 'var(--sidebar-foreground)', + primary: 'var(--sidebar-primary)', + 'primary-foreground': 'var(--sidebar-primary-foreground)', + accent: 'var(--sidebar-accent)', + 'accent-foreground': 'var(--sidebar-accent-foreground)', + border: 'var(--sidebar-border)', + ring: 'var(--sidebar-ring)' + }, + editor: { + background: 'var(--editor-background)', + foreground: 'var(--editor-foreground)', + caret: 'var(--editor-caret)', + selection: 'var(--editor-selection)', + selectionMatch: 'var(--editor-selection-match)', + gutterBackground: 'var(--editor-gutter-background)', + gutterForeground: 'var(--editor-gutter-foreground)', + gutterBorder: 'var(--editor-gutter-border)', + gutterActiveForeground: 'var(--editor-gutter-active-foreground)', + lineHighlight: 'var(--editor-line-highlight)', + + tag: { + keyword: 'var(--editor-tag-keyword)', + name: 'var(--editor-tag-name)', + function: 'var(--editor-tag-function)', + label: 'var(--editor-tag-label)', + constant: 'var(--editor-tag-constant)', + definition: 'var(--editor-tag-definition)', + brace: 'var(--editor-tag-brace)', + type: 'var(--editor-tag-type)', + operator: 'var(--editor-tag-operator)', + tag: 'var(--editor-tag-tag)', + 'bracket-square': 'var(--editor-tag-bracket-square)', + 'bracket-angle': 'var(--editor-tag-bracket-angle)', + attribute: 'var(--editor-tag-attribute)', + string: 'var(--editor-tag-string)', + link: 'var(--editor-tag-link)', + meta: 'var(--editor-tag-meta)', + comment: 'var(--editor-tag-comment)', + emphasis: 'var(--editor-tag-emphasis)', + heading: 'var(--editor-tag-heading)', + atom: 'var(--editor-tag-atom)', + processing: 'var(--editor-tag-processing)', + separator: 'var(--editor-tag-separator)', + invalid: 'var(--editor-tag-invalid)', + quote: 'var(--editor-tag-quote)', + 'annotation-special': 'var(--editor-tag-annotation-special)', + number: 'var(--editor-tag-number)', + regexp: 'var(--editor-tag-regexp)', + 'variable-local': 'var(--editor-tag-variable-local)', + } + }, + }, + fontSize: { + editor: 'var(--editor-font-size)' + }, + fontFamily: { + editor: 'var(--editor-font-family)' + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'spin-slow': 'spin 1.5s linear infinite' + } + } + }, + plugins: [ + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("tailwindcss-animate"), ], - prefix: "", - theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px' - } - }, - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - backgroundSecondary: 'hsl(var(--background-secondary))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - highlight: 'hsl(var(--highlight))', - sidebar: { - DEFAULT: 'hsl(var(--sidebar-background))', - foreground: 'hsl(var(--sidebar-foreground))', - primary: 'hsl(var(--sidebar-primary))', - 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', - accent: 'hsl(var(--sidebar-accent))', - 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', - border: 'hsl(var(--sidebar-border))', - ring: 'hsl(var(--sidebar-ring))' - } - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - keyframes: { - 'accordion-down': { - from: { - height: '0' - }, - to: { - height: 'var(--radix-accordion-content-height)' - } - }, - 'accordion-up': { - from: { - height: 'var(--radix-accordion-content-height)' - }, - to: { - height: '0' - } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out', - 'spin-slow': 'spin 1.5s linear infinite' - } - } - }, - plugins: [ - require("tailwindcss-animate"), - ], } satisfies Config export default config \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d8654d25..2ae1187d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -340,7 +340,21 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-cpp@npm:^6.0.2": +"@codemirror/lang-angular@npm:^0.1.0": + version: 0.1.4 + resolution: "@codemirror/lang-angular@npm:0.1.4" + dependencies: + "@codemirror/lang-html": "npm:^6.0.0" + "@codemirror/lang-javascript": "npm:^6.1.2" + "@codemirror/language": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.3.3" + checksum: 10c0/9d38350dd5e2defc58b9b18eaf50aa183b9cf8be96270845182f2992b51dbf8af78c567aa736bcd494cbf5aea8400b85b36bfd0131948475f9de7c228b9e3415 + languageName: node + linkType: hard + +"@codemirror/lang-cpp@npm:^6.0.0, @codemirror/lang-cpp@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-cpp@npm:6.0.2" dependencies: @@ -363,7 +377,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-go@npm:^6.0.1": +"@codemirror/lang-go@npm:^6.0.0, @codemirror/lang-go@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-go@npm:6.0.1" dependencies: @@ -393,7 +407,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-java@npm:^6.0.1": +"@codemirror/lang-java@npm:^6.0.0, @codemirror/lang-java@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-java@npm:6.0.1" dependencies: @@ -418,7 +432,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-json@npm:^6.0.1": +"@codemirror/lang-json@npm:^6.0.0, @codemirror/lang-json@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-json@npm:6.0.1" dependencies: @@ -428,7 +442,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-less@npm:^6.0.2": +"@codemirror/lang-less@npm:^6.0.0, @codemirror/lang-less@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-less@npm:6.0.2" dependencies: @@ -441,7 +455,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-liquid@npm:^6.2.2": +"@codemirror/lang-liquid@npm:^6.0.0, @codemirror/lang-liquid@npm:^6.2.2": version: 6.2.3 resolution: "@codemirror/lang-liquid@npm:6.2.3" dependencies: @@ -457,7 +471,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-markdown@npm:^6.2.5": +"@codemirror/lang-markdown@npm:^6.0.0, @codemirror/lang-markdown@npm:^6.2.5": version: 6.3.2 resolution: "@codemirror/lang-markdown@npm:6.3.2" dependencies: @@ -472,7 +486,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-php@npm:^6.0.1": +"@codemirror/lang-php@npm:^6.0.0, @codemirror/lang-php@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-php@npm:6.0.1" dependencies: @@ -485,6 +499,19 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-python@npm:^6.0.0": + version: 6.2.1 + resolution: "@codemirror/lang-python@npm:6.2.1" + dependencies: + "@codemirror/autocomplete": "npm:^6.3.2" + "@codemirror/language": "npm:^6.8.0" + "@codemirror/state": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.1" + "@lezer/python": "npm:^1.1.4" + checksum: 10c0/6e92ac7e5e6e3162cfbbef40be6314278b9b9ff6f65cfc207a75ec95d84a404b9930913240e1a15e3d18c538f6b243f6a0bba4c3e37fa4e318939dfebc51ebd0 + languageName: node + linkType: hard + "@codemirror/lang-python@npm:^6.1.6": version: 6.1.7 resolution: "@codemirror/lang-python@npm:6.1.7" @@ -498,7 +525,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-rust@npm:^6.0.1": +"@codemirror/lang-rust@npm:^6.0.0, @codemirror/lang-rust@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-rust@npm:6.0.1" dependencies: @@ -508,7 +535,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sass@npm:^6.0.2": +"@codemirror/lang-sass@npm:^6.0.0, @codemirror/lang-sass@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-sass@npm:6.0.2" dependencies: @@ -521,7 +548,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sql@npm:^6.7.1": +"@codemirror/lang-sql@npm:^6.0.0, @codemirror/lang-sql@npm:^6.7.1": version: 6.8.0 resolution: "@codemirror/lang-sql@npm:6.8.0" dependencies: @@ -535,7 +562,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-vue@npm:^0.1.3": +"@codemirror/lang-vue@npm:^0.1.1, @codemirror/lang-vue@npm:^0.1.3": version: 0.1.3 resolution: "@codemirror/lang-vue@npm:0.1.3" dependencies: @@ -549,7 +576,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-wast@npm:^6.0.2": +"@codemirror/lang-wast@npm:^6.0.0, @codemirror/lang-wast@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-wast@npm:6.0.2" dependencies: @@ -561,7 +588,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-xml@npm:^6.1.0": +"@codemirror/lang-xml@npm:^6.0.0, @codemirror/lang-xml@npm:^6.1.0": version: 6.1.0 resolution: "@codemirror/lang-xml@npm:6.1.0" dependencies: @@ -575,7 +602,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-yaml@npm:^6.1.1, @codemirror/lang-yaml@npm:^6.1.2": +"@codemirror/lang-yaml@npm:^6.0.0, @codemirror/lang-yaml@npm:^6.1.1, @codemirror/lang-yaml@npm:^6.1.2": version: 6.1.2 resolution: "@codemirror/lang-yaml@npm:6.1.2" dependencies: @@ -590,6 +617,36 @@ __metadata: languageName: node linkType: hard +"@codemirror/language-data@npm:^6.5.1": + version: 6.5.1 + resolution: "@codemirror/language-data@npm:6.5.1" + dependencies: + "@codemirror/lang-angular": "npm:^0.1.0" + "@codemirror/lang-cpp": "npm:^6.0.0" + "@codemirror/lang-css": "npm:^6.0.0" + "@codemirror/lang-go": "npm:^6.0.0" + "@codemirror/lang-html": "npm:^6.0.0" + "@codemirror/lang-java": "npm:^6.0.0" + "@codemirror/lang-javascript": "npm:^6.0.0" + "@codemirror/lang-json": "npm:^6.0.0" + "@codemirror/lang-less": "npm:^6.0.0" + "@codemirror/lang-liquid": "npm:^6.0.0" + "@codemirror/lang-markdown": "npm:^6.0.0" + "@codemirror/lang-php": "npm:^6.0.0" + "@codemirror/lang-python": "npm:^6.0.0" + "@codemirror/lang-rust": "npm:^6.0.0" + "@codemirror/lang-sass": "npm:^6.0.0" + "@codemirror/lang-sql": "npm:^6.0.0" + "@codemirror/lang-vue": "npm:^0.1.1" + "@codemirror/lang-wast": "npm:^6.0.0" + "@codemirror/lang-xml": "npm:^6.0.0" + "@codemirror/lang-yaml": "npm:^6.0.0" + "@codemirror/language": "npm:^6.0.0" + "@codemirror/legacy-modes": "npm:^6.4.0" + checksum: 10c0/5a5dfeaa5c6fba019c7ff3a380ffb11956607f9bc5556537cb0515a367fb6294628fb36b449641d82f56b2236bccae88d0741469183c71cb7bf80ea7861e8fba + languageName: node + linkType: hard + "@codemirror/language@npm:6.x, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.2, @codemirror/language@npm:^6.10.3, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0, @codemirror/language@npm:^6.8.0, @codemirror/language@npm:^6.9.0": version: 6.11.0 resolution: "@codemirror/language@npm:6.11.0" @@ -604,6 +661,15 @@ __metadata: languageName: node linkType: hard +"@codemirror/legacy-modes@npm:^6.4.0": + version: 6.5.1 + resolution: "@codemirror/legacy-modes@npm:6.5.1" + dependencies: + "@codemirror/language": "npm:^6.0.0" + checksum: 10c0/a5fc0c76112f1fe4add414c65876932c24d77126ee4504049fd188abc4e44c5da611beaa46cfe45d5269d6d7b49aefc10c410d457785a39ba3c233f799802cf0 + languageName: node + linkType: hard + "@codemirror/legacy-modes@npm:^6.4.2": version: 6.5.0 resolution: "@codemirror/legacy-modes@npm:6.5.0" @@ -1278,6 +1344,17 @@ __metadata: languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:^4.7.0": + version: 4.7.0 + resolution: "@eslint-community/eslint-utils@npm:4.7.0" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + languageName: node + linkType: hard + "@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.1 resolution: "@eslint-community/regexpp@npm:4.12.1" @@ -1821,7 +1898,7 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.7, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2, @lezer/lr@npm:^1.x": +"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.3.7, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2, @lezer/lr@npm:^1.x": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2" dependencies: @@ -5816,6 +5893,7 @@ __metadata: "@codemirror/lang-xml": "npm:^6.1.0" "@codemirror/lang-yaml": "npm:^6.1.2" "@codemirror/language": "npm:^6.0.0" + "@codemirror/language-data": "npm:^6.5.1" "@codemirror/legacy-modes": "npm:^6.4.2" "@codemirror/search": "npm:^6.5.6" "@codemirror/state": "npm:^6.4.1" @@ -5860,6 +5938,7 @@ __metadata: "@stripe/react-stripe-js": "npm:^3.1.1" "@stripe/stripe-js": "npm:^5.6.0" "@t3-oss/env-nextjs": "npm:^0.12.0" + "@tanstack/eslint-plugin-query": "npm:^5.74.7" "@tanstack/react-query": "npm:^5.53.3" "@tanstack/react-table": "npm:^8.20.5" "@tanstack/react-virtual": "npm:^3.10.8" @@ -5871,6 +5950,7 @@ __metadata: "@types/react-dom": "npm:^18" "@typescript-eslint/eslint-plugin": "npm:^8.3.0" "@typescript-eslint/parser": "npm:^8.3.0" + "@uidotdev/usehooks": "npm:^2.4.1" "@uiw/codemirror-themes": "npm:^4.23.6" "@uiw/react-codemirror": "npm:^4.23.0" "@viz-js/lang-dot": "npm:^1.0.4" @@ -6054,6 +6134,17 @@ __metadata: languageName: node linkType: hard +"@tanstack/eslint-plugin-query@npm:^5.74.7": + version: 5.74.7 + resolution: "@tanstack/eslint-plugin-query@npm:5.74.7" + dependencies: + "@typescript-eslint/utils": "npm:^8.18.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + checksum: 10c0/7f026af15918e0f77e1032c0e53d70fd952d32735b0987a84d0df2b1c6b47ac01773da3812d579c999c398dd677d45400e133a1b3c2979e3f125028743451850 + languageName: node + linkType: hard + "@tanstack/query-core@npm:5.69.0": version: 5.69.0 resolution: "@tanstack/query-core@npm:5.69.0" @@ -6556,6 +6647,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/scope-manager@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/visitor-keys": "npm:8.32.1" + checksum: 10c0/d2cb1f7736388972137d6e510b2beae4bac033fcab274e04de90ebba3ce466c71fe47f1795357e032e4a6c8b2162016b51b58210916c37212242c82d35352e9f + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/type-utils@npm:8.27.0" @@ -6585,6 +6686,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/types@npm:8.32.1" + checksum: 10c0/86f59b29c12e7e8abe45a1659b6fae5e7b0cfaf09ab86dd596ed9d468aa61082bbccd509d25f769b197fbfdf872bbef0b323a2ded6ceaca351f7c679f1ba3bd3 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:7.2.0": version: 7.2.0 resolution: "@typescript-eslint/typescript-estree@npm:7.2.0" @@ -6622,6 +6730,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/visitor-keys": "npm:8.32.1" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10c0/b5ae0d91ef1b46c9f3852741e26b7a14c28bb58ee8a283b9530ac484332ca58a7216b9d22eda23c5449b5fd69c6e4601ef3ebbd68e746816ae78269036c08cda + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/utils@npm:8.27.0" @@ -6637,6 +6763,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:^8.18.1": + version: 8.32.1 + resolution: "@typescript-eslint/utils@npm:8.32.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.32.1" + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/typescript-estree": "npm:8.32.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10c0/a2b90c0417cd3a33c6e22f9cc28c356f251bb8928ef1d25e057feda007d522d281bdc37a9a0d05b70312f00a7b3f350ca06e724867025ea85bba5a4c766732e7 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:7.2.0": version: 7.2.0 resolution: "@typescript-eslint/visitor-keys@npm:7.2.0" @@ -6657,6 +6798,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10c0/9c05053dfd048f681eb96e09ceefa8841a617b8b5950eea05e0844b38fe3510a284eb936324caa899c3ceb4bc23efe56ac01437fab378ac1beeb1c6c00404978 + languageName: node + linkType: hard + +"@uidotdev/usehooks@npm:^2.4.1": + version: 2.4.1 + resolution: "@uidotdev/usehooks@npm:2.4.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/181c43fb324dbe4fef9762c61ab4b8235efa48abedf39a9bfeab65872522c43dae789c4f85b82a1164ed7bb18ae7ff25c3a19e7c4e0eb944937ac7f8109cee9b + languageName: node + linkType: hard + "@uiw/codemirror-extensions-basic-setup@npm:4.23.10": version: 4.23.10 resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10" @@ -15380,7 +15541,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.1": +"ts-api-utils@npm:^2.0.1, ts-api-utils@npm:^2.1.0": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" peerDependencies:
+ Code navigation is not enabled for your plan. +
No symbol selected
- {/* hack since to make the @ symbol look more centered with the text */} - - @ - + @ {`${branchDisplayName}`}
{`${selectedMatchIndex + 1} of ${ranges.length}`}
Loading...
Failed to load file source
{showAllMatches ? : } {showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx index 88757c56..61e41332 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx @@ -2,13 +2,13 @@ import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDebounce, usePrevious } from "@uidotdev/usehooks"; interface SearchResultsPanelProps { fileMatches: SearchResultFile[]; - onOpenFileMatch: (fileMatch: SearchResultFile) => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (fileMatch: SearchResultFile, matchIndex?: number) => void; isLoadMoreButtonVisible: boolean; onLoadMoreButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -19,18 +19,33 @@ const ESTIMATED_LINE_HEIGHT_PX = 20; const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10; const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30; +type ScrollHistoryState = { + scrollOffset?: number; + measurementsCache?: VirtualItem[]; + showAllMatchesStates?: boolean[]; +} + export const SearchResultsPanel = ({ fileMatches, - onOpenFileMatch, - onMatchIndexChanged, + onOpenFilePreview, isLoadMoreButtonVisible, onLoadMoreButtonClicked, isBranchFilteringEnabled, repoInfo, }: SearchResultsPanelProps) => { const parentRef = useRef(null); - const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false)); - const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1); + + // Restore the scroll offset, measurements cache, and other state from the history + // state. This enables us to restore the scroll offset when the user navigates back + // to the page. + // @see: https://github.com/TanStack/virtual/issues/378#issuecomment-2173670081 + const { + scrollOffset: restoreOffset, + measurementsCache: restoreMeasurementsCache, + showAllMatchesStates: restoreShowAllMatchesStates, + } = history.state as ScrollHistoryState; + + const [showAllMatchesStates, setShowAllMatchesStates] = useState(restoreShowAllMatchesStates || Array(fileMatches.length).fill(false)); const virtualizer = useVirtualizer({ count: fileMatches.length, @@ -51,60 +66,55 @@ export const SearchResultsPanel = ({ return estimatedSize; }, - measureElement: (element, _entry, instance) => { - // @note : Stutters were appearing when scrolling upwards. The workaround is - // to use the cached height of the element when scrolling up. - // @see : https://github.com/TanStack/virtual/issues/659 - const isCacheDirty = element.hasAttribute("data-cache-dirty"); - element.removeAttribute("data-cache-dirty"); - const direction = instance.scrollDirection; - if (direction === "forward" || direction === null || isCacheDirty) { - return element.scrollHeight; - } else { - const indexKey = Number(element.getAttribute("data-index")); - // Unfortunately, the cache is a private property, so we need to - // hush the TS compiler. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const cacheMeasurement = instance.itemSizeCache.get(indexKey); - return cacheMeasurement; - } - }, + initialOffset: restoreOffset, + initialMeasurementsCache: restoreMeasurementsCache, enabled: true, overscan: 10, debug: false, }); - const onShowAllMatchesButtonClicked = useCallback((index: number) => { - const states = [...showAllMatchesStates]; - states[index] = !states[index]; - setShowAllMatchesStates(states); - setLastShowAllMatchesButtonClickIndex(index); - }, [showAllMatchesStates]); - - // After the "show N more/less matches" button is clicked, the FileMatchContainer's - // size can change considerably. In cases where N > 3 or 4 cells when collapsing, - // a visual artifact can appear where there is a large gap between the now collapsed - // container and the next container. This is because the container's height was not - // re-calculated. To get arround this, we force a re-measure of the element AFTER - // it was re-rendered (hence the useLayoutEffect). - useLayoutEffect(() => { - if (lastShowAllMatchesButtonClickIndex < 0) { + // When the number of file matches changes, we need to reset our scroll state. + const prevFileMatches = usePrevious(fileMatches); + useEffect(() => { + if (!prevFileMatches) { return; } - const element = virtualizer.elementsCache.get(lastShowAllMatchesButtonClickIndex); - element?.setAttribute('data-cache-dirty', 'true'); - virtualizer.measureElement(element); - - setLastShowAllMatchesButtonClickIndex(-1); - }, [lastShowAllMatchesButtonClickIndex, virtualizer]); + if (prevFileMatches.length !== fileMatches.length) { + setShowAllMatchesStates(Array(fileMatches.length).fill(false)); + virtualizer.scrollToIndex(0); + } + }, [fileMatches.length, prevFileMatches, virtualizer]); - // Reset some state when the file matches change. + // Save the scroll state to the history stack. + const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 100); useEffect(() => { - setShowAllMatchesStates(Array(fileMatches.length).fill(false)); - virtualizer.scrollToIndex(0); - }, [fileMatches, virtualizer]); + history.replaceState( + { + scrollOffset: debouncedScrollOffset ?? undefined, + measurementsCache: virtualizer.measurementsCache, + showAllMatchesStates, + } satisfies ScrollHistoryState, + '', + window.location.href + ); + }, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesStates]); + + const onShowAllMatchesButtonClicked = useCallback((index: number) => { + const states = [...showAllMatchesStates]; + const wasShown = states[index]; + states[index] = !wasShown; + setShowAllMatchesStates(states); + + // When collapsing, scroll to the top of the file match container. This ensures + // that the focused "show fewer matches" button is visible. + if (wasShown) { + virtualizer.scrollToIndex(index, { + align: 'start' + }); + } + }, [showAllMatchesStates, virtualizer]); + return ( { - onOpenFileMatch(file); - }} - onMatchIndexChanged={(matchIndex) => { - onMatchIndexChanged(matchIndex); + onOpenFilePreview={(matchIndex) => { + onOpenFilePreview(file, matchIndex); }} showAllMatches={showAllMatchesStates[virtualRow.index]} onShowAllMatchesButtonClicked={() => { diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx deleted file mode 100644 index f6d3227e..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { EditorState, Extension, StateEffect } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; - -interface CodeMirrorProps { - value?: string; - extensions?: Extension[]; - className?: string; -} - -export interface CodeMirrorRef { - editor: HTMLDivElement | null; - state?: EditorState; - view?: EditorView; -} - -/** - * This component provides a lightweight CodeMirror component that has been optimized to - * render quickly in the search results panel. Why not use react-codemirror? For whatever reason, - * react-codemirror issues many StateEffects when first rendering, causing a stuttery scroll - * experience as new cells load. This component is a workaround for that issue and provides - * a minimal react wrapper around CodeMirror that avoids this issue. - */ -const LightweightCodeMirror = forwardRef(({ - value, - extensions, - className, -}, ref) => { - const editor = useRef(null); - const viewRef = useRef(); - const stateRef = useRef(); - - useImperativeHandle(ref, () => ({ - editor: editor.current, - state: stateRef.current, - view: viewRef.current, - }), []); - - useEffect(() => { - if (!editor.current) { - return; - } - - const state = EditorState.create({ - extensions: [], /* extensions are explicitly left out here */ - doc: value, - }); - stateRef.current = state; - - const view = new EditorView({ - state, - parent: editor.current, - }); - viewRef.current = view; - - return () => { - view.destroy(); - viewRef.current = undefined; - stateRef.current = undefined; - } - }, [value]); - - useEffect(() => { - if (viewRef.current) { - viewRef.current.dispatch({ effects: StateEffect.reconfigure.of(extensions ?? []) }); - } - }, [extensions]); - - return ( - - ) -}); - -LightweightCodeMirror.displayName = "LightweightCodeMirror"; - -export { LightweightCodeMirror }; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index ac793ff8..e307d74c 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { - ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; @@ -15,7 +14,6 @@ import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ImperativePanelHandle } from "react-resizable-panels"; import { search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; @@ -24,8 +22,17 @@ import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { useFilteredMatches } from "./components/filterPanel/useFilterMatches"; +import { Button } from "@/components/ui/button"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { FilterIcon } from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; -const DEFAULT_MATCH_COUNT = 10000; +const DEFAULT_MAX_MATCH_COUNT = 10000; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params @@ -41,18 +48,20 @@ export default function SearchPage() { const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; - const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`); - const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); const { toast } = useToast(); + // Encodes the number of matches to return in the search response. + const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`); + const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount; + const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ - queryKey: ["search", searchQuery, matches], + queryKey: ["search", searchQuery, maxMatchCount], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, - matches, + matches: maxMatchCount, contextLines: 3, whole: false, }, domain)), "client.search"), @@ -63,6 +72,7 @@ const SearchPageInternal = () => { enabled: searchQuery.length > 0, refetchOnWindowFocus: false, retry: false, + staleTime: Infinity, }); useEffect(() => { @@ -122,7 +132,7 @@ const SearchPageInternal = () => { }); }, [captureEvent, searchQuery, searchResponse]); - const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo } = useMemo(() => { + const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo, matchCount } = useMemo(() => { if (!searchResponse) { return { fileMatches: [], @@ -130,6 +140,7 @@ const SearchPageInternal = () => { totalMatchCount: 0, isBranchFilteringEnabled: false, repositoryInfo: {}, + matchCount: 0, }; } @@ -142,32 +153,21 @@ const SearchPageInternal = () => { acc[repo.id] = repo; return acc; }, {} as Record), + matchCount: searchResponse.stats.matchCount, } }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { - return totalMatchCount > matches; - }, [totalMatchCount, matches]); - - const numMatches = useMemo(() => { - // Accumualtes the number of matches across all files - return fileMatches.reduce( - (acc, file) => - acc + file.chunks.reduce( - (acc, chunk) => acc + chunk.matchRanges.length, - 0, - ), - 0, - ); - }, [fileMatches]); + return totalMatchCount > maxMatchCount; + }, [totalMatchCount, maxMatchCount]); const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], - [SearchQueryParams.matches, `${matches * 2}`], + [SearchQueryParams.matches, `${maxMatchCount * 2}`], ) router.push(url); - }, [matches, router, searchQuery, domain]); + }, [maxMatchCount, router, searchQuery, domain]); return ( @@ -193,7 +193,7 @@ const SearchPageInternal = () => { isBranchFilteringEnabled={isBranchFilteringEnabled} repoInfo={repositoryInfo} searchDurationMs={searchDurationMs} - numMatches={numMatches} + numMatches={matchCount} /> )} @@ -219,22 +219,24 @@ const PanelGroup = ({ searchDurationMs, numMatches, }: PanelGroupProps) => { + const [previewedFile, setPreviewedFile] = useState(undefined); + const filteredFileMatches = useFilteredMatches(fileMatches); + const filterPanelRef = useRef(null); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); - const [selectedFile, setSelectedFile] = useState(undefined); - const [filteredFileMatches, setFilteredFileMatches] = useState(fileMatches); - const codePreviewPanelRef = useRef(null); - useEffect(() => { - if (selectedFile) { - codePreviewPanelRef.current?.expand(); + const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false); + + useHotkeys("mod+b", () => { + if (isFilterPanelCollapsed) { + filterPanelRef.current?.expand(); } else { - codePreviewPanelRef.current?.collapse(); + filterPanelRef.current?.collapse(); } - }, [selectedFile]); - - const onFilterChanged = useCallback((matches: SearchResultFile[]) => { - setFilteredFileMatches(matches); - }, []); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Toggle filter panel", + }); return ( {/* ~~ Filter panel ~~ */} setIsFilterPanelCollapsed(true)} + onExpand={() => setIsFilterPanelCollapsed(false)} > - + {isFilterPanelCollapsed && ( + + + + { + filterPanelRef.current?.expand(); + }} + > + + + + + + + Open filter panel + + + + )} + {/* ~~ Search results ~~ */} 0 ? ( { - setSelectedFile(fileMatch); - }} - onMatchIndexChanged={(matchIndex) => { - setSelectedMatchIndex(matchIndex); + onOpenFilePreview={(fileMatch, matchIndex) => { + setSelectedMatchIndex(matchIndex ?? 0); + setPreviewedFile(fileMatch); }} isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} onLoadMoreButtonClicked={onLoadMoreResults} @@ -304,25 +329,27 @@ const PanelGroup = ({ )} - - {/* ~~ Code preview ~~ */} - - setSelectedFile(undefined)} - selectedMatchIndex={selectedMatchIndex} - onSelectedMatchIndexChange={setSelectedMatchIndex} - /> - + {previewedFile && ( + <> + + {/* ~~ Code preview ~~ */} + setPreviewedFile(undefined)} + > + setPreviewedFile(undefined)} + selectedMatchIndex={selectedMatchIndex} + onSelectedMatchIndexChange={setSelectedMatchIndex} + /> + + > + )} ) } diff --git a/packages/web/src/app/components/keyboardShortcutHint.tsx b/packages/web/src/app/components/keyboardShortcutHint.tsx index f93209f1..0bbff3c0 100644 --- a/packages/web/src/app/components/keyboardShortcutHint.tsx +++ b/packages/web/src/app/components/keyboardShortcutHint.tsx @@ -8,7 +8,13 @@ interface KeyboardShortcutHintProps { export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { return ( - + {shortcut} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index feabe357..d43d68df 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -4,78 +4,163 @@ @layer base { :root { - --background: 0 0% 100%; - --background-secondary: 0, 0%, 98%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --background: hsl(0 0% 100%); + --background-secondary: hsl(0, 0%, 98%); + --foreground: hsl(37, 84%, 5%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(222.2 84% 4.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(222.2 84% 4.9%); + --primary: hsl(222.2 47.4% 11.2%); + --primary-foreground: hsl(210 40% 98%); + --secondary: hsl(210 40% 96.1%); + --secondary-foreground: hsl(222.2 47.4% 11.2%); + --muted: hsl(210 40% 96.1%); + --muted-foreground: hsl(215.4 16.3% 46.9%); + --accent: hsl(210 40% 96.1%); + --accent-foreground: hsl(222.2 47.4% 11.2%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(214.3 31.8% 91.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(222.2 84% 4.9%); --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --highlight: 224, 76%, 48%; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --highlight: hsl(224, 76%, 48%); + --sidebar-background: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --editor-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --editor-font-size: 13px; + + --editor-background: var(--background); + --editor-foreground: var(--foreground); + --editor-caret: #3b4252; + --editor-selection: #eceff4; + --editor-selection-match: #e5e9f0; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #2e3440; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: #02255f11; + --editor-match-highlight: hsl(180, 70%, 40%); + + --editor-tag-keyword: #708; + --editor-tag-name: #256; + --editor-tag-function: #00f; + --editor-tag-label: #219; + --editor-tag-constant: #219; + --editor-tag-definition: #00c; + --editor-tag-brace: #219; + --editor-tag-type: #085; + --editor-tag-operator: #708; + --editor-tag-tag: #167; + --editor-tag-bracket-square: #219; + --editor-tag-bracket-angle: #219; + --editor-tag-attribute: #00c; + --editor-tag-string: #a11; + --editor-tag-link: inherit; + --editor-tag-meta: #404740; + --editor-tag-comment: #940; + --editor-tag-emphasis: inherit; + --editor-tag-heading: inherit; + --editor-tag-atom: #219; + --editor-tag-processing: #164; + --editor-tag-separator: #219; + --editor-tag-invalid: #f00; + --editor-tag-quote: #a11; + --editor-tag-annotation-special: #f00; + --editor-tag-number: #219; + --editor-tag-regexp: #e40; + --editor-tag-variable-local: #30a; } .dark { - --background: 222.2 84% 4.9%; - --background-secondary: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --highlight: 217, 91%, 60%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --background: hsl(222.2 84% 4.9%); + --background-secondary: hsl(222.2 84% 4.9%); + --foreground: hsl(210 40% 98%); + --card: hsl(222.2 84% 4.9%); + --card-foreground: hsl(210 40% 98%); + --popover: hsl(222.2 84% 4.9%); + --popover-foreground: hsl(210 40% 98%); + --primary: hsl(210 40% 98%); + --primary-foreground: hsl(222.2 47.4% 11.2%); + --secondary: hsl(217.2 32.6% 17.5%); + --secondary-foreground: hsl(210 40% 98%); + --muted: hsl(217.2 32.6% 17.5%); + --muted-foreground: hsl(215 20.2% 65.1%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(217.2 32.6% 17.5%); + --input: hsl(217.2 32.6% 17.5%); + --ring: hsl(212.7 26.8% 83.9%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --highlight: hsl(217 91% 60%); + --sidebar-background: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --editor-background: var(--background); + --editor-foreground: #abb2bf; + --editor-caret: #528bff; + --editor-selection: #3E4451; + --editor-selection-match: #aafe661a; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #7d8799; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: hsl(219, 14%, 20%); + --editor-match-highlight: hsl(180, 70%, 30%); + + --editor-tag-keyword: #c678dd; + --editor-tag-name: #e06c75; + --editor-tag-function: #61afef; + --editor-tag-label: #61afef; + --editor-tag-constant: #d19a66; + --editor-tag-definition: #abb2bf; + --editor-tag-brace: #56b6c2; + --editor-tag-type: #e5c07b; + --editor-tag-operator: #56b6c2; + --editor-tag-tag: #e06c75; + --editor-tag-bracket-square: #56b6c2; + --editor-tag-bracket-angle: #56b6c2; + --editor-tag-attribute: #e5c07b; + --editor-tag-string: #98c379; + --editor-tag-link: #7d8799; + --editor-tag-meta: #7d8799; + --editor-tag-comment: #7d8799; + --editor-tag-emphasis: #e06c75; + --editor-tag-heading: #e06c75; + --editor-tag-atom: #d19a66; + --editor-tag-processing: #98c379; + --editor-tag-separator: #abb2bf; + --editor-tag-invalid: #ffffff; + --editor-tag-quote: #7d8799; + --editor-tag-annotation-special: #e5c07b; + --editor-tag-number: #e5c07b; + --editor-tag-regexp: #56b6c2; + --editor-tag-variable-local: #61afef; } } @@ -83,6 +168,7 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } @@ -98,13 +184,23 @@ text-align: left; } -.cm-editor .cm-searchMatch { - border: dotted; - background: transparent; +.searchMatch { + background: color-mix(in srgb, var(--editor-match-highlight) 25%, transparent); + border: 1px dashed var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03); } -.cm-editor .cm-searchMatch-selected { - border: solid; +.searchMatch-selected { + background: color-mix(in srgb, var(--editor-match-highlight) 60%, transparent); + border: 1.5px solid var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.06); +} + +.lineHighlight { + background: var(--editor-line-highlight); + border-radius: 2px; } .cm-editor.cm-focused { @@ -123,8 +219,9 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } + body { @apply bg-background text-foreground; } @@ -136,6 +233,22 @@ } .no-scrollbar { - -ms-overflow-style: none; /* IE dan Edge */ - scrollbar-width: none; /* Firefox */ + -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/components/ui/animatedResizableHandle.tsx b/packages/web/src/components/ui/animatedResizableHandle.tsx new file mode 100644 index 00000000..c09635c4 --- /dev/null +++ b/packages/web/src/components/ui/animatedResizableHandle.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ResizableHandle } from "./resizable"; + +export const AnimatedResizableHandle = () => { + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/components/ui/loading-button.tsx b/packages/web/src/components/ui/loading-button.tsx new file mode 100644 index 00000000..0639efd9 --- /dev/null +++ b/packages/web/src/components/ui/loading-button.tsx @@ -0,0 +1,30 @@ +'use client'; + +// @note: this is not a original Shadcn component. + +import { Button, ButtonProps } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import React from "react"; + +export interface LoadingButtonProps extends ButtonProps { + loading?: boolean; +} + +const LoadingButton = React.forwardRef(({ children, loading, ...props }, ref) => { + return ( + + {loading && ( + + )} + {children} + + ) +}); + +LoadingButton.displayName = "LoadingButton"; + +export { LoadingButton }; \ No newline at end of file diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx new file mode 100644 index 00000000..70b825c4 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/index.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { Badge } from "@/components/ui/badge"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "@/features/codeNav/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import { Loader2 } from "lucide-react"; +import { useMemo } from "react"; +import { VscSymbolMisc } from "react-icons/vsc"; +import { ReferenceList } from "./referenceList"; + +interface ExploreMenuProps { + selectedSymbolInfo: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } +} + +export const ExploreMenu = ({ + selectedSymbolInfo, +}: ExploreMenuProps) => { + + const domain = useDomain(); + const { + state: { activeExploreMenuTab }, + updateBrowseState, + } = useBrowseState(); + + const { + data: referencesResponse, + isError: isReferencesResponseError, + isPending: isReferencesResponsePending, + isLoading: isReferencesResponseLoading, + } = useQuery({ + queryKey: ["references", selectedSymbolInfo.symbolName, selectedSymbolInfo.repoName, selectedSymbolInfo.revisionName, selectedSymbolInfo.language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolReferences({ + symbolName: selectedSymbolInfo.symbolName, + language: selectedSymbolInfo.language, + revisionName: selectedSymbolInfo.revisionName, + }, domain) + ), + }); + + const { + data: definitionsResponse, + isError: isDefinitionsResponseError, + isPending: isDefinitionsResponsePending, + isLoading: isDefinitionsResponseLoading, + } = useQuery({ + queryKey: ["definitions", selectedSymbolInfo.symbolName, selectedSymbolInfo.repoName, selectedSymbolInfo.revisionName, selectedSymbolInfo.language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolDefinitions({ + symbolName: selectedSymbolInfo.symbolName, + language: selectedSymbolInfo.language, + revisionName: selectedSymbolInfo.revisionName, + }, domain) + ), + }); + + const isPending = isReferencesResponsePending || isDefinitionsResponsePending; + const isLoading = isReferencesResponseLoading || isDefinitionsResponseLoading; + const isError = isDefinitionsResponseError || isReferencesResponseError; + + if (isPending || isLoading) { + return ( + + + Loading... + + ) + } + + if (isError) { + return ( + + Error loading {activeExploreMenuTab} + + ) + } + + const data = activeExploreMenuTab === "references" ? + referencesResponse : + definitionsResponse; + + return ( + + + + + + + Search Based + + + + Symbol references and definitions found using a best-guess search heuristic. + + + + { + updateBrowseState({ activeExploreMenuTab: "references" }); + }} + /> + { + updateBrowseState({ activeExploreMenuTab: "definitions" }); + }} + /> + + + + + + {data.files.length > 0 ? ( + + ) : ( + + + No {activeExploreMenuTab} found + + )} + + + + ) +} + +interface EntryProps { + name: string; + isSelected: boolean; + count?: number; + onClicked: () => void; +} + +const Entry = ({ + name, + isSelected, + count, + onClicked, +}: EntryProps) => { + const countText = useMemo(() => { + if (count === undefined) { + return "?"; + } + + if (count > 999) { + return "999+"; + } + return count.toString(); + }, [count]); + + return ( + onClicked()} + > + {name} + + {countText} + + + ); +} diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx new file mode 100644 index 00000000..8b3acd80 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; +import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; +import { RepositoryInfo, SourceRange } from "@/features/search/types"; +import { base64Decode } from "@/lib/utils"; +import { useMemo } from "react"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface ReferenceListProps { + data: FindRelatedSymbolsResponse; + revisionName: string; +} + +export const ReferenceList = ({ + data, + revisionName, +}: ReferenceListProps) => { + const repoInfoMap = useMemo(() => { + return data.repositoryInfo.reduce((acc, repo) => { + acc[repo.id] = repo; + return acc; + }, {} as Record); + }, [data.repositoryInfo]); + + const { navigateToPath } = useBrowseNavigation(); + const captureEvent = useCaptureEvent(); + + return ( + + {data.files.map((file, index) => { + const repoInfo = repoInfoMap[file.repositoryId]; + + return ( + + + + + + {file.matches + .sort((a, b) => a.range.start.lineNumber - b.range.start.lineNumber) + .map((match, index) => ( + { + captureEvent('wa_explore_menu_reference_clicked', {}); + navigateToPath({ + repoName: file.repository, + revisionName, + path: file.fileName, + pathType: 'blob', + highlightRange: match.range, + }) + }} + /> + ))} + + + ) + })} + + ) +} + + +interface ReferenceListItemProps { + lineContent: string; + range: SourceRange; + language: string; + onClick: () => void; +} + +const ReferenceListItem = ({ + lineContent, + range, + language, + onClick, +}: ReferenceListItemProps) => { + const decodedLineContent = useMemo(() => { + return base64Decode(lineContent); + }, [lineContent]); + + const highlightRanges = useMemo(() => [range], [range]); + + return ( + + + {decodedLineContent} + + + ) +} diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx new file mode 100644 index 00000000..86c0d0f5 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx @@ -0,0 +1,138 @@ +import { Button } from "@/components/ui/button"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { Separator } from "@/components/ui/separator"; +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"; + +interface SymbolHoverPopupProps { + editorRef: ReactCodeMirrorRef; + language: string; + revisionName: string; + onFindReferences: (symbolName: string) => void; + onGotoDefinition: (symbolName: string, symbolDefinitions: SymbolDefinition[]) => void; +} + +export const SymbolHoverPopup: React.FC = ({ + editorRef, + revisionName, + language, + onFindReferences, + onGotoDefinition: _onGotoDefinition, +}) => { + const ref = useRef(null); + const [isSticky, setIsSticky] = useState(false); + + const symbolInfo = useHoveredOverSymbolInfo({ + editorRef, + isSticky, + revisionName, + language, + }); + + // Positions the popup relative to the symbol + useEffect(() => { + if (!symbolInfo) { + return; + } + + const virtualElement: VirtualElement = { + getBoundingClientRect: () => { + return symbolInfo.element.getBoundingClientRect(); + } + } + + if (ref.current) { + computePosition(virtualElement, ref.current, { + placement: 'top', + middleware: [ + offset(2), + flip({ + mainAxis: true, + crossAxis: false, + fallbackPlacements: ['bottom'], + boundary: editorRef.view?.dom, + }), + shift({ + padding: 5, + }) + ] + }).then(({ x, y }) => { + if (ref.current) { + ref.current.style.left = `${x}px`; + ref.current.style.top = `${y}px`; + } + }) + } + }, [symbolInfo, editorRef]); + + const onGotoDefinition = useCallback(() => { + if (!symbolInfo || !symbolInfo.symbolDefinitions) { + return; + } + + _onGotoDefinition(symbolInfo.symbolName, symbolInfo.symbolDefinitions); + }, [symbolInfo, _onGotoDefinition]); + + // @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 || !symbolInfo.symbolDefinitions) { + return; + } + + symbolInfo.element.addEventListener("click", onGotoDefinition); + return () => { + symbolInfo.element.removeEventListener("click", onGotoDefinition); + } + }, [symbolInfo, onGotoDefinition]); + + return symbolInfo ? ( + setIsSticky(true)} + onMouseOut={() => setIsSticky(false)} + > + {symbolInfo.isSymbolDefinitionsLoading ? ( + + + Loading... + + ) : symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 0 ? ( + + ) : ( + No hover info found + )} + + + + { + !symbolInfo.isSymbolDefinitionsLoading && (!symbolInfo.symbolDefinitions || symbolInfo.symbolDefinitions.length === 0) ? + "No definition found" : + `Go to ${symbolInfo.symbolDefinitions && symbolInfo.symbolDefinitions.length > 1 ? "definitions" : "definition"}` + } + + onFindReferences(symbolInfo.symbolName)} + > + Find references + + + + ) : null; +}; diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx new file mode 100644 index 00000000..92cb69b4 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolDefinitionPreview.tsx @@ -0,0 +1,62 @@ +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; +import { useMemo } from "react"; +import { SourceRange } from "@/features/search/types"; +import { base64Decode } from "@/lib/utils"; + +interface SymbolDefinitionPreviewProps { + symbolDefinition: { + lineContent: string; + language: string; + fileName: string; + repoName: string; + range: SourceRange; + }; +} + +export const SymbolDefinitionPreview = ({ + symbolDefinition, +}: SymbolDefinitionPreviewProps) => { + const { lineContent, language, range } = symbolDefinition; + const highlightRanges = useMemo(() => [range], [range]); + + const decodedLineContent = useMemo(() => { + return base64Decode(lineContent); + }, [lineContent]); + + return ( + + + + + Search Based + + + + Symbol definition found using a best-guess search heuristic. + + + + {decodedLineContent} + + + ) +} \ No newline at end of file diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts new file mode 100644 index 00000000..179a0a54 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts @@ -0,0 +1,78 @@ +import { StateField, Range } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { measureSync } from "@/lib/utils"; + +export const SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE = "data-symbol-hover-target"; + +const decoration = Decoration.mark({ + class: "cm-underline-hover", + attributes: { [SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE]: "true" } +}); + +const NODE_TYPES = [ + // Typescript + Python + "VariableName", + "VariableDefinition", + "TypeDefinition", + "TypeName", + "PropertyName", + "PropertyDefinition", + "JSXIdentifier", + "Identifier", + // C# + "VarName", + "TypeIdentifier", + "PropertyName", + "MethodName", + "Ident", + "ParamName", + "AttrsNamedArg", + // C/C++ + "Identifier", + "NamespaceIdentifier", + "FieldIdentifier", + // Objective-C + "variableName", + "variableName.definition", + // Java + "Definition", + // Rust + "BoundIdentifier", + // Go + "DefName", + "FieldName", + // PHP + "ClassMemberName", + "Name" +] + +export const symbolHoverTargetsExtension = StateField.define({ + create(state) { + // @note: we need to use `ensureSyntaxTree` here (as opposed to `syntaxTree`) + // because we want to parse the entire document, not just the text visible in + // the current viewport. + const { data: tree } = measureSync(() => ensureSyntaxTree(state, state.doc.length), "ensureSyntaxTree"); + const decorations: Range[] = []; + + // @note: useful for debugging + // const getTextAt = (from: number, to: number) => { + // const doc = state.doc; + // return doc.sliceString(from, to); + // } + + tree?.iterate({ + enter: (node) => { + // console.log(node.type.name, getTextAt(node.from, node.to)); + if (NODE_TYPES.includes(node.type.name)) { + decorations.push(decoration.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 diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts new file mode 100644 index 00000000..f21462b1 --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo.ts @@ -0,0 +1,139 @@ +import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/actions"; +import { SourceRange } from "@/features/search/types"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE } from "./symbolHoverTargetsExtension"; + +interface UseHoveredOverSymbolInfoProps { + editorRef: ReactCodeMirrorRef; + isSticky: boolean; + revisionName: string; + language: string; +} + +export type SymbolDefinition = { + lineContent: string; + language: string; + fileName: string; + repoName: string; + range: SourceRange; +} + +interface HoveredOverSymbolInfo { + element: HTMLElement; + symbolName: string; + isSymbolDefinitionsLoading: boolean; + symbolDefinitions?: SymbolDefinition[]; +} + +const SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT_MS = 500; +const SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT_MS = 100; + +export const useHoveredOverSymbolInfo = ({ + editorRef, + isSticky, + revisionName, + language, +}: UseHoveredOverSymbolInfoProps): HoveredOverSymbolInfo | undefined => { + const mouseOverTimerRef = useRef(null); + const mouseOutTimerRef = useRef(null); + + const domain = useDomain(); + const [isVisible, setIsVisible] = useState(false); + + const [symbolElement, setSymbolElement] = useState(null); + const symbolName = useMemo(() => { + return (symbolElement && symbolElement.textContent) ?? undefined; + }, [symbolElement]); + + const { data: symbolDefinitions, isLoading: isSymbolDefinitionsLoading } = useQuery({ + queryKey: ["definitions", symbolName, revisionName, language, domain], + queryFn: () => unwrapServiceError( + findSearchBasedSymbolDefinitions({ + symbolName: symbolName!, + language, + revisionName, + }, domain) + ), + select: ((data) => { + return data.files.flatMap((file) => { + return file.matches.map((match) => { + return { + lineContent: match.lineContent, + language: file.language, + fileName: file.fileName, + repoName: file.repository, + range: match.range, + } + }) + }) + + }), + enabled: !!symbolName, + staleTime: Infinity, + }) + + const clearTimers = useCallback(() => { + if (mouseOverTimerRef.current) { + clearTimeout(mouseOverTimerRef.current); + } + + if (mouseOutTimerRef.current) { + clearTimeout(mouseOutTimerRef.current); + } + }, []); + + useEffect(() => { + const view = editorRef.view; + if (!view) { + return; + } + + const handleMouseOver = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest(`[${SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE}="true"]`) as HTMLElement; + if (!target) { + return; + } + clearTimers(); + setSymbolElement(target); + + mouseOverTimerRef.current = setTimeout(() => { + setIsVisible(true); + }, SYMBOL_HOVER_POPUP_MOUSE_OVER_TIMEOUT_MS); + }; + + const handleMouseOut = () => { + clearTimers(); + + mouseOutTimerRef.current = setTimeout(() => { + setIsVisible(false); + }, SYMBOL_HOVER_POPUP_MOUSE_OUT_TIMEOUT_MS); + }; + + view.dom.addEventListener("mouseover", handleMouseOver); + view.dom.addEventListener("mouseout", handleMouseOut); + + return () => { + view.dom.removeEventListener("mouseover", handleMouseOver); + view.dom.removeEventListener("mouseout", handleMouseOut); + }; + }, [editorRef, domain, clearTimers]); + + if (!isVisible && !isSticky) { + return undefined; + } + + if (!symbolElement || !symbolName) { + return undefined; + } + + return { + element: symbolElement, + symbolName, + isSymbolDefinitionsLoading: isSymbolDefinitionsLoading, + symbolDefinitions, + }; +} diff --git a/packages/web/src/features/codeNav/actions.ts b/packages/web/src/features/codeNav/actions.ts new file mode 100644 index 00000000..831dd6c2 --- /dev/null +++ b/packages/web/src/features/codeNav/actions.ts @@ -0,0 +1,118 @@ +'use server'; + +import { sew, withAuth, withOrgMembership } from "@/actions"; +import { searchResponseSchema } from "@/features/search/schemas"; +import { search } from "@/features/search/searchApi"; +import { isServiceError } from "@/lib/utils"; +import { FindRelatedSymbolsResponse } from "./types"; +import { ServiceError } from "@/lib/serviceError"; +import { SearchResponse } from "../search/types"; + +// The maximum number of matches to return from the search API. +const MAX_REFERENCE_COUNT = 1000; + +export const findSearchBasedSymbolReferences = async ( + props: { + symbolName: string, + language: string, + revisionName?: string, + }, + domain: string, +): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; + + const query = `\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)} case:yes`; + + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }, domain); + + if (isServiceError(searchResult)) { + return searchResult; + } + + return parseRelatedSymbolsSearchResponse(searchResult); + }), /* allowSingleTenantUnauthedAccess = */ true) +); + + +export const findSearchBasedSymbolDefinitions = async ( + props: { + symbolName: string, + language: string, + revisionName?: string, + }, + domain: string, +): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; + + const query = `sym:\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)}`; + + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }, domain); + + if (isServiceError(searchResult)) { + return searchResult; + } + + return parseRelatedSymbolsSearchResponse(searchResult); + }), /* allowSingleTenantUnauthedAccess = */ true) +); + +const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { + const parser = searchResponseSchema.transform(async ({ files }) => ({ + stats: { + matchCount: searchResult.stats.matchCount, + }, + files: files.flatMap((file) => { + const chunks = file.chunks; + + return { + fileName: file.fileName.text, + repository: file.repository, + repositoryId: file.repositoryId, + webUrl: file.webUrl, + language: file.language, + matches: chunks.flatMap((chunk) => { + return chunk.matchRanges.map((range) => ({ + lineContent: chunk.content, + range: range, + })) + }) + } + }).filter((file) => file.matches.length > 0), + repositoryInfo: searchResult.repositoryInfo + })); + + return parser.parseAsync(searchResult); +} + +// Expands the language filter to include all variants of the language. +const getExpandedLanguageFilter = (language: string) => { + switch (language) { + case "TypeScript": + case "JavaScript": + case "JSX": + case "TSX": + return `(lang:TypeScript or lang:JavaScript or lang:JSX or lang:TSX)` + default: + return `lang:${language}` + } +} \ No newline at end of file diff --git a/packages/web/src/features/codeNav/schemas.ts b/packages/web/src/features/codeNav/schemas.ts new file mode 100644 index 00000000..03f20721 --- /dev/null +++ b/packages/web/src/features/codeNav/schemas.ts @@ -0,0 +1,20 @@ +import { rangeSchema, repositoryInfoSchema } from "../search/schemas"; +import { z } from "zod"; + +export const findRelatedSymbolsResponseSchema = z.object({ + stats: z.object({ + matchCount: z.number(), + }), + files: z.array(z.object({ + fileName: z.string(), + repository: z.string(), + repositoryId: z.number(), + webUrl: z.string().optional(), + language: z.string(), + matches: z.array(z.object({ + lineContent: z.string(), + range: rangeSchema, + })) + })), + repositoryInfo: z.array(repositoryInfoSchema), +}); \ No newline at end of file diff --git a/packages/web/src/features/codeNav/types.ts b/packages/web/src/features/codeNav/types.ts new file mode 100644 index 00000000..bb9a282b --- /dev/null +++ b/packages/web/src/features/codeNav/types.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { findRelatedSymbolsResponseSchema } from "./schemas"; + +export type FindRelatedSymbolsResponse = z.infer; diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts index e75c7654..6e07c0cb 100644 --- a/packages/web/src/features/entitlements/constants.ts +++ b/packages/web/src/features/entitlements/constants.ts @@ -17,6 +17,7 @@ const entitlements = [ "public-access", "multi-tenancy", "sso", + "code-nav" ] as const; export type Entitlement = (typeof entitlements)[number]; @@ -27,7 +28,7 @@ export const isValidEntitlement = (entitlement: string): entitlement is Entitlem export const entitlementsByPlan: Record = { oss: [], "cloud:team": ["billing", "multi-tenancy", "sso"], - "self-hosted:enterprise": ["search-contexts", "sso"], - "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso"], + "self-hosted:enterprise": ["search-contexts", "sso", "code-nav"], + "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav"], "self-hosted:enterprise-custom": [], } as const; diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts index 415d1dbd..d285aaa9 100644 --- a/packages/web/src/features/search/fileSourceApi.ts +++ b/packages/web/src/features/search/fileSourceApi.ts @@ -1,3 +1,5 @@ +'use server'; + import escapeStringRegexp from "escape-string-regexp"; import { fileNotFound, ServiceError } from "../../lib/serviceError"; import { FileSourceRequest, FileSourceResponse } from "./types"; @@ -41,6 +43,7 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource return { source, language, + webUrl: file.webUrl, } satisfies FileSourceResponse; }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/features/search/schemas.ts b/packages/web/src/features/search/schemas.ts index 9e86d991..1c99d885 100644 --- a/packages/web/src/features/search/schemas.ts +++ b/packages/web/src/features/search/schemas.ts @@ -63,6 +63,9 @@ export const searchResponseSchema = z.object({ regexpsConsidered: z.number(), flushReason: z.number(), }), + stats: z.object({ + matchCount: z.number(), + }), files: z.array(z.object({ fileName: z.object({ // The name of the file @@ -111,4 +114,5 @@ export const fileSourceRequestSchema = z.object({ export const fileSourceResponseSchema = z.object({ source: z.string(), language: z.string(), + webUrl: z.string().optional(), }); \ No newline at end of file diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts index 0d916748..7b5901eb 100644 --- a/packages/web/src/features/search/searchApi.ts +++ b/packages/web/src/features/search/searchApi.ts @@ -6,7 +6,7 @@ import { prisma } from "@/prisma"; import { ErrorCode } from "../../lib/errorCodes"; import { StatusCodes } from "http-status-codes"; import { zoektSearchResponseSchema } from "./zoektSchema"; -import { SearchRequest, SearchResponse, SearchResultRange } from "./types"; +import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { OrgRole, Repo } from "@sourcebot/db"; import * as Sentry from "@sentry/nextjs"; import { sew, withAuth, withOrgMembership } from "@/actions"; @@ -213,6 +213,92 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ } })).forEach(repo => repos.set(repo.name, repo)); + const files = Result.Files?.map((file) => { + const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName); + + const webUrl = (() => { + const template: string | undefined = Result.RepoURLs[file.Repository]; + if (!template) { + return undefined; + } + + // If there are multiple branches pointing to the same revision of this file, it doesn't + // matter which branch we use here, so use the first one. + const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD"; + return getFileWebUrl(template, branch, file.FileName); + })(); + + const identifier = file.RepositoryID ?? file.Repository; + const repo = repos.get(identifier); + + // This should never happen... but if it does, we skip the file. + if (!repo) { + Sentry.captureMessage( + `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, + 'warning' + ); + return undefined; + } + + return { + fileName: { + text: file.FileName, + matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ + start: { + byteOffset: range.Start.ByteOffset, + column: range.Start.Column, + lineNumber: range.Start.LineNumber, + }, + end: { + byteOffset: range.End.ByteOffset, + column: range.End.Column, + lineNumber: range.End.LineNumber, + } + })) : [], + }, + repository: repo.name, + repositoryId: repo.id, + webUrl: webUrl, + language: file.Language, + chunks: file.ChunkMatches + .filter((chunk) => !chunk.FileName) // Filter out filename chunks. + .map((chunk) => { + return { + content: chunk.Content, + matchRanges: chunk.Ranges.map((range) => ({ + start: { + byteOffset: range.Start.ByteOffset, + column: range.Start.Column, + lineNumber: range.Start.LineNumber, + }, + end: { + byteOffset: range.End.ByteOffset, + column: range.End.Column, + lineNumber: range.End.LineNumber, + } + }) satisfies SourceRange), + contentStart: { + byteOffset: chunk.ContentStart.ByteOffset, + column: chunk.ContentStart.Column, + lineNumber: chunk.ContentStart.LineNumber, + }, + symbols: chunk.SymbolInfo?.map((symbol) => { + return { + symbol: symbol.Sym, + kind: symbol.Kind, + parent: symbol.Parent.length > 0 ? { + symbol: symbol.Parent, + kind: symbol.ParentKind, + } : undefined, + } + }) ?? undefined, + } + }), + branches: file.Branches, + content: file.Content, + } + }).filter((file) => file !== undefined) ?? []; + return { zoektStats: { duration: Result.Duration, @@ -236,91 +322,7 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ regexpsConsidered: Result.RegexpsConsidered, flushReason: Result.FlushReason, }, - files: Result.Files?.map((file) => { - const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName); - - const webUrl = (() => { - const template: string | undefined = Result.RepoURLs[file.Repository]; - if (!template) { - return undefined; - } - - // If there are multiple branches pointing to the same revision of this file, it doesn't - // matter which branch we use here, so use the first one. - const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD"; - return getFileWebUrl(template, branch, file.FileName); - })(); - - const identifier = file.RepositoryID ?? file.Repository; - const repo = repos.get(identifier); - - // This should never happen... but if it does, we skip the file. - if (!repo) { - Sentry.captureMessage( - `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, - 'warning' - ); - return undefined; - } - - return { - fileName: { - text: file.FileName, - matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ - start: { - byteOffset: range.Start.ByteOffset, - column: range.Start.Column, - lineNumber: range.Start.LineNumber, - }, - end: { - byteOffset: range.End.ByteOffset, - column: range.End.Column, - lineNumber: range.End.LineNumber, - } - })) : [], - }, - repository: repo.name, - repositoryId: repo.id, - webUrl: webUrl, - language: file.Language, - chunks: file.ChunkMatches - .filter((chunk) => !chunk.FileName) // Filter out filename chunks. - .map((chunk) => { - return { - content: chunk.Content, - matchRanges: chunk.Ranges.map((range) => ({ - start: { - byteOffset: range.Start.ByteOffset, - column: range.Start.Column, - lineNumber: range.Start.LineNumber, - }, - end: { - byteOffset: range.End.ByteOffset, - column: range.End.Column, - lineNumber: range.End.LineNumber, - } - }) satisfies SearchResultRange), - contentStart: { - byteOffset: chunk.ContentStart.ByteOffset, - column: chunk.ContentStart.Column, - lineNumber: chunk.ContentStart.LineNumber, - }, - symbols: chunk.SymbolInfo?.map((symbol) => { - return { - symbol: symbol.Sym, - kind: symbol.Kind, - parent: symbol.Parent.length > 0 ? { - symbol: symbol.Parent, - kind: symbol.ParentKind, - } : undefined, - } - }) ?? undefined, - } - }), - branches: file.Branches, - content: file.Content, - } - }).filter((file) => file !== undefined) ?? [], + files, repositoryInfo: Array.from(repos.values()).map((repo) => ({ id: repo.id, codeHostType: repo.external_codeHostType, @@ -329,6 +331,16 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ webUrl: repo.webUrl ?? undefined, })), isBranchFilteringEnabled: isBranchFilteringEnabled, + stats: { + matchCount: files.reduce( + (acc, file) => + acc + file.chunks.reduce( + (acc, chunk) => acc + chunk.matchRanges.length, + 0, + ), + 0, + ) + } } satisfies SearchResponse; }); diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts index 1e2d331d..5271b94b 100644 --- a/packages/web/src/features/search/types.ts +++ b/packages/web/src/features/search/types.ts @@ -14,7 +14,6 @@ import { z } from "zod"; export type SearchRequest = z.infer; export type SearchResponse = z.infer; -export type SearchResultRange = z.infer; export type SearchResultLocation = z.infer; export type SearchResultFile = SearchResponse["files"][number]; export type SearchResultChunk = SearchResultFile["chunks"][number]; @@ -26,4 +25,5 @@ export type Repository = ListRepositoriesResponse["repos"][number]; export type FileSourceRequest = z.infer; export type FileSourceResponse = z.infer; -export type RepositoryInfo = z.infer; \ No newline at end of file +export type RepositoryInfo = z.infer; +export type SourceRange = z.infer; \ No newline at end of file diff --git a/packages/web/src/hooks/useCodeMirrorHighlighter.ts b/packages/web/src/hooks/useCodeMirrorHighlighter.ts new file mode 100644 index 00000000..0c0b944f --- /dev/null +++ b/packages/web/src/hooks/useCodeMirrorHighlighter.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useMemo } from "react"; +import { tags as t, tagHighlighter } from '@lezer/highlight'; + +export const useCodeMirrorHighlighter = () => { + return useMemo(() => { + return tagHighlighter([ + // Keywords (if, for, class, etc.) + { tag: t.keyword, class: 'text-editor-tag-keyword' }, + + // Names, identifiers, properties + { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName, t.variableName], class: 'text-editor-tag-name' }, + + // Functions and variable definitions + { tag: [t.function(t.variableName), t.definition(t.variableName)], class: 'text-editor-tag-function' }, + { tag: t.local(t.variableName), class: 'text-editor-tag-variable-local' }, + + // Property definitions + { tag: [t.definition(t.name), t.separator, t.definition(t.propertyName)], class: 'text-editor-tag-definition' }, + + // Labels + { tag: [t.labelName], class: 'text-editor-tag-label' }, + + // Constants and standards + { tag: [t.color, t.constant(t.name), t.standard(t.name)], class: 'text-editor-tag-constant' }, + + // Braces and brackets + { tag: [t.brace], class: 'text-editor-tag-brace' }, + { tag: [t.squareBracket], class: 'text-editor-tag-bracket-square' }, + { tag: [t.angleBracket], class: 'text-editor-tag-bracket-angle' }, + + // Types and classes + { tag: [t.typeName, t.namespace], class: 'text-editor-tag-type' }, + { tag: [t.className], class: 'text-editor-tag-tag' }, + + // Numbers and annotations + { tag: [t.number, t.changed, t.modifier, t.self], class: 'text-editor-tag-number' }, + { tag: [t.annotation], class: 'text-editor-tag-annotation-special' }, + + // Operators + { tag: [t.operator, t.operatorKeyword], class: 'text-editor-tag-operator' }, + + // HTML/XML tags and attributes + { tag: [t.tagName], class: 'text-editor-tag-tag' }, + { tag: [t.attributeName], class: 'text-editor-tag-attribute' }, + + // Strings and quotes + { tag: [t.string], class: 'text-editor-tag-string' }, + { tag: [t.quote], class: 'text-editor-tag-quote' }, + { tag: [t.processingInstruction, t.inserted], class: 'text-editor-tag-processing' }, + + // Special string content + { tag: [t.url, t.escape, t.special(t.string)], class: 'text-editor-tag-string' }, + { tag: [t.regexp], class: 'text-editor-tag-constant' }, + + // Links + { tag: t.link, class: 'text-editor-tag-link underline' }, + + // Meta and comments + { tag: [t.meta], class: 'text-editor-tag-meta' }, + { tag: [t.comment], class: 'text-editor-tag-comment italic' }, + + // Text formatting + { tag: t.strong, class: 'text-editor-tag-emphasis font-bold' }, + { tag: t.emphasis, class: 'text-editor-tag-emphasis italic' }, + { tag: t.strikethrough, class: 'text-editor-tag-emphasis line-through' }, + + // Headings + { tag: t.heading, class: 'text-editor-tag-heading font-bold' }, + { tag: t.special(t.heading1), class: 'text-editor-tag-heading font-bold' }, + { tag: t.heading1, class: 'text-editor-tag-heading font-bold' }, + { tag: [t.heading2, t.heading3, t.heading4], class: 'text-editor-tag-heading font-bold' }, + { tag: [t.heading5, t.heading6], class: 'text-editor-tag-heading' }, + + // Atoms and booleans + { tag: [t.atom, t.bool, t.special(t.variableName)], class: 'text-editor-tag-atom' }, + + // Content separator + { tag: [t.contentSeparator], class: 'text-editor-tag-separator' }, + + // Invalid syntax + { tag: t.invalid, class: 'text-editor-tag-invalid' } + ]); + }, []); +} diff --git a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts b/packages/web/src/hooks/useCodeMirrorLanguageExtension.ts similarity index 89% rename from packages/web/src/hooks/useSyntaxHighlightingExtension.ts rename to packages/web/src/hooks/useCodeMirrorLanguageExtension.ts index 053e9b7b..b77ca321 100644 --- a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts +++ b/packages/web/src/hooks/useCodeMirrorLanguageExtension.ts @@ -4,7 +4,7 @@ import { EditorView } from "@codemirror/view"; import { useExtensionWithDependency } from "./useExtensionWithDependency"; import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; -export const useSyntaxHighlightingExtension = (linguistLanguage: string, view: EditorView | undefined) => { +export const useCodeMirrorLanguageExtension = (linguistLanguage: string, view: EditorView | undefined) => { const extension = useExtensionWithDependency( view ?? null, () => { diff --git a/packages/web/src/hooks/useCodeMirrorTheme.ts b/packages/web/src/hooks/useCodeMirrorTheme.ts index e7ee97df..6bbe8f07 100644 --- a/packages/web/src/hooks/useCodeMirrorTheme.ts +++ b/packages/web/src/hooks/useCodeMirrorTheme.ts @@ -1,78 +1,154 @@ 'use client'; -import { useTailwind } from "./useTailwind"; import { useMemo } from "react"; import { useThemeNormalized } from "./useThemeNormalized"; -import createTheme from "@uiw/codemirror-themes"; -import { defaultLightThemeOption } from "@uiw/react-codemirror"; -import { tags as t } from '@lezer/highlight'; +import { + useCodeMirrorHighlighter, +} from "./useCodeMirrorHighlighter"; +import { EditorView } from "@codemirror/view"; import { syntaxHighlighting } from "@codemirror/language"; -import { defaultHighlightStyle } from "@codemirror/language"; - -// From: https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts -const chalky = "#e5c07b", - coral = "#e06c75", - cyan = "#56b6c2", - invalid = "#ffffff", - ivory = "#abb2bf", - stone = "#7d8799", - malibu = "#61afef", - sage = "#98c379", - whiskey = "#d19a66", - violet = "#c678dd", - highlightBackground = "#2c313aaa", - background = "#282c34", - selection = "#3E4451", - cursor = "#528bff"; - +import type { StyleSpec } from 'style-mod'; +import { Extension } from "@codemirror/state"; +import tailwind from "@/tailwind"; export const useCodeMirrorTheme = () => { - const tailwind = useTailwind(); const { theme } = useThemeNormalized(); - - const darkTheme = useMemo(() => { - return createTheme({ - theme: 'dark', - settings: { - background: tailwind.theme.colors.background, - foreground: ivory, - caret: cursor, - selection: selection, - selectionMatch: "#aafe661a", // for matching selections - gutterBackground: background, - gutterForeground: stone, - gutterBorder: 'none', - gutterActiveForeground: ivory, - lineHighlight: highlightBackground, - }, - styles: [ - { tag: t.comment, color: stone }, - { tag: t.keyword, color: violet }, - { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: coral }, - { tag: [t.function(t.variableName), t.labelName], color: malibu }, - { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, - { tag: [t.definition(t.name), t.separator], color: ivory }, - { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: chalky }, - { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: cyan }, - { tag: [t.meta], color: stone }, - { tag: t.strong, fontWeight: 'bold' }, - { tag: t.emphasis, fontStyle: 'italic' }, - { tag: t.strikethrough, textDecoration: 'line-through' }, - { tag: t.link, color: stone, textDecoration: 'underline' }, - { tag: t.heading, fontWeight: 'bold', color: coral }, - { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, - { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, - { tag: t.invalid, color: invalid } - ] - }); - }, [tailwind.theme.colors.background]); + const highlightStyle = useCodeMirrorHighlighter(); const cmTheme = useMemo(() => { - return theme === 'dark' ? darkTheme : [ - defaultLightThemeOption, - syntaxHighlighting(defaultHighlightStyle), + const { + background, + foreground, + caret, + selection, + selectionMatch, + gutterBackground, + gutterForeground, + gutterBorder, + gutterActiveForeground, + lineHighlight, + } = tailwind.theme.colors.editor; + + return [ + createThemeExtension({ + theme: theme === 'dark' ? 'dark' : 'light', + settings: { + background, + foreground, + caret, + selection, + selectionMatch, + gutterBackground, + gutterForeground, + gutterBorder, + gutterActiveForeground, + lineHighlight, + fontFamily: tailwind.theme.fontFamily.editor, + fontSize: tailwind.theme.fontSize.editor, + } + }), + syntaxHighlighting(highlightStyle) ] - }, [theme, darkTheme]); + }, [highlightStyle, theme]); return cmTheme; +} + + +// @see: https://github.com/uiwjs/react-codemirror/blob/e365f7d1f8a0ec2cd88455b7a248f6338c859cc7/themes/theme/src/index.tsx +const createThemeExtension = ({ theme, settings = {} }: CreateThemeOptions): Extension => { + const themeOptions: Record = { + '.cm-gutters': {}, + }; + const baseStyle: StyleSpec = {}; + if (settings.background) { + baseStyle.backgroundColor = settings.background; + } + if (settings.backgroundImage) { + baseStyle.backgroundImage = settings.backgroundImage; + } + if (settings.foreground) { + baseStyle.color = settings.foreground; + } + if (settings.fontSize) { + baseStyle.fontSize = settings.fontSize; + } + if (settings.background || settings.foreground) { + themeOptions['&'] = baseStyle; + } + + if (settings.fontFamily) { + themeOptions['&.cm-editor .cm-scroller'] = { + fontFamily: settings.fontFamily, + }; + } + if (settings.gutterBackground) { + themeOptions['.cm-gutters'].backgroundColor = settings.gutterBackground; + } + if (settings.gutterForeground) { + themeOptions['.cm-gutters'].color = settings.gutterForeground; + } + if (settings.gutterBorder) { + themeOptions['.cm-gutters'].borderRightColor = settings.gutterBorder; + } + + if (settings.caret) { + themeOptions['.cm-content'] = { + caretColor: settings.caret, + }; + themeOptions['.cm-cursor, .cm-dropCursor'] = { + borderLeftColor: settings.caret, + }; + } + + const activeLineGutterStyle: StyleSpec = {}; + if (settings.gutterActiveForeground) { + activeLineGutterStyle.color = settings.gutterActiveForeground; + } + if (settings.lineHighlight) { + themeOptions['.cm-activeLine'] = { + backgroundColor: settings.lineHighlight, + }; + activeLineGutterStyle.backgroundColor = settings.lineHighlight; + } + themeOptions['.cm-activeLineGutter'] = activeLineGutterStyle; + + if (settings.selection) { + themeOptions[ + '&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection' + ] = { + background: settings.selection + ' !important', + }; + } + if (settings.selectionMatch) { + themeOptions['& .cm-selectionMatch'] = { + backgroundColor: settings.selectionMatch, + }; + } + const themeExtension = EditorView.theme(themeOptions, { + dark: theme === 'dark', + }); + + return themeExtension; +}; + +interface CreateThemeOptions { + theme: 'light' | 'dark'; + settings: Settings; +} + +interface Settings { + background?: string; + backgroundImage?: string; + foreground?: string; + caret?: string; + selection?: string; + selectionMatch?: string; + lineHighlight?: string; + gutterBackground?: string; + gutterForeground?: string; + gutterActiveForeground?: string; + gutterBorder?: string; + fontFamily?: string; + fontSize?: StyleSpec['fontSize']; } \ No newline at end of file diff --git a/packages/web/src/hooks/useTailwind.ts b/packages/web/src/hooks/useTailwind.ts deleted file mode 100644 index c6d05eb4..00000000 --- a/packages/web/src/hooks/useTailwind.ts +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { useMemo } from "react"; -import resolveConfig from 'tailwindcss/resolveConfig'; -import tailwindConfig from '../../tailwind.config'; - -export const useTailwind = () => { - const tailwind = useMemo(() => { - return resolveConfig(tailwindConfig); - }, []); - - return tailwind; -} \ No newline at end of file diff --git a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts index 56325af3..3ae9e4a1 100644 --- a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts +++ b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts @@ -1,13 +1,13 @@ import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; -import { SearchResultRange } from "@/features/search/types"; +import { SourceRange } from "@/features/search/types"; const setMatchState = StateEffect.define<{ selectedMatchIndex: number, - ranges: SearchResultRange[], + ranges: SourceRange[], }>(); -const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => { +const convertToCodeMirrorRange = (range: SourceRange, document: Text) => { const { start, end } = range; const from = document.line(start.lineNumber).from + start.column - 1; const to = document.line(end.lineNumber).from + end.column - 1; @@ -46,13 +46,13 @@ const matchHighlighter = StateField.define({ }); const matchMark = Decoration.mark({ - class: "cm-searchMatch" + class: "searchMatch" }); const selectedMatchMark = Decoration.mark({ - class: "cm-searchMatch-selected" + class: "searchMatch-selected" }); -export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResultRange[], view: EditorView) => { +export const highlightRanges = (selectedMatchIndex: number, ranges: SourceRange[], view: EditorView) => { const setState = setMatchState.of({ selectedMatchIndex, ranges, diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 775e60e7..a03669a5 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,12 +1,11 @@ import { NewsItem } from "./types"; -// Sample news data - replace with your actual data source export const newsData: NewsItem[] = [ { unique_id: "code-nav", header: "Code navigation", sub_header: "Built in go-to definition and find references", - url: "https://docs.sourcebot.dev", // TODO: link to code nav docs + url: "https://docs.sourcebot.dev/docs/search/code-navigation" }, { unique_id: "sso", @@ -17,7 +16,7 @@ export const newsData: NewsItem[] = [ { unique_id: "search-contexts", header: "Search contexts", - sub_header: "Group repos into different search contexts to search against", + sub_header: "Filter searches by groups of repos", url: "https://docs.sourcebot.dev/docs/search/search-contexts" } ]; \ No newline at end of file diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 60e4750b..3f601c0d 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -263,5 +263,13 @@ export type PosthogEventMap = { ////////////////////////////////////////////////////////////////// wa_api_key_created: {}, wa_api_key_creation_fail: {}, + ////////////////////////////////////////////////////////////////// + wa_preview_panel_find_references_pressed: {}, + wa_preview_panel_goto_definition_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_browse_find_references_pressed: {}, + wa_browse_goto_definition_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_explore_menu_reference_clicked: {}, } export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index ddaac1c0..0d01f9ff 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -7,6 +7,9 @@ import gerritLogo from "@/public/gerrit.svg"; import bitbucketLogo from "@/public/bitbucket.svg"; import gitLogo from "@/public/git.svg"; import { ServiceError } from "./serviceError"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "./errorCodes"; +import { NextRequest } from "next/server"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -131,7 +134,7 @@ export const getCodeHostInfoForRepo = (repo: { return { type: "generic-git-host", displayName: displayName ?? name, - codeHostName: "Generic Git Host", + codeHostName: "Git Host", repoLink: webUrl, icon: src, iconClassName: className, @@ -235,7 +238,7 @@ export const getDisplayTime = (date: Date) => { } } -export const measureSync = (cb: () => T, measureName: string) => { +export const measureSync = (cb: () => T, measureName: string, outputLog: boolean = true) => { const startMark = `${measureName}.start`; const endMark = `${measureName}.end`; @@ -245,7 +248,9 @@ export const measureSync = (cb: () => T, measureName: string) => { const measure = performance.measure(measureName, startMark, endMark); const durationMs = measure.duration; - console.debug(`[${measureName}] took ${durationMs}ms`); + if (outputLog) { + console.debug(`[${measureName}] took ${durationMs}ms`); + } return { data, @@ -253,7 +258,7 @@ export const measureSync = (cb: () => T, measureName: string) => { } } -export const measure = async (cb: () => Promise, measureName: string) => { +export const measure = async (cb: () => Promise, measureName: string, outputLog: boolean = true) => { const startMark = `${measureName}.start`; const endMark = `${measureName}.end`; @@ -263,7 +268,9 @@ export const measure = async (cb: () => Promise, measureName: string) => { const measure = performance.measure(measureName, startMark, endMark); const durationMs = measure.duration; - console.debug(`[${measureName}] took ${durationMs}ms`); + if (outputLog) { + console.debug(`[${measureName}] took ${durationMs}ms`); + } return { data, @@ -286,4 +293,16 @@ export const unwrapServiceError = async (promise: Promise): } return data; +} + +export const requiredQueryParamGuard = (request: NextRequest, param: string): ServiceError | string => { + const value = request.nextUrl.searchParams.get(param); + if (!value) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MISSING_REQUIRED_QUERY_PARAMETER, + message: `Missing required query param: ${param}`, + }; + } + return value; } \ No newline at end of file diff --git a/packages/web/src/tailwind.ts b/packages/web/src/tailwind.ts new file mode 100644 index 00000000..aafe7770 --- /dev/null +++ b/packages/web/src/tailwind.ts @@ -0,0 +1,5 @@ +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../tailwind.config'; + +const tailwind = resolveConfig(tailwindConfig); +export default tailwind; \ No newline at end of file diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 7b163209..d740177a 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -1,103 +1,153 @@ import type { Config } from "tailwindcss" const config = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + background: 'var(--background)', + backgroundSecondary: 'var(--background-secondary)', + foreground: 'var(--foreground)', + primary: { + DEFAULT: 'var(--primary)', + foreground: 'var(--primary-foreground)' + }, + secondary: { + DEFAULT: 'var(--secondary)', + foreground: 'var(--secondary-foreground)' + }, + destructive: { + DEFAULT: 'var(--destructive)', + foreground: 'var(--destructive-foreground)' + }, + muted: { + DEFAULT: 'var(--muted)', + foreground: 'var(--muted-foreground)' + }, + accent: { + DEFAULT: 'var(--accent)', + foreground: 'var(--accent-foreground)' + }, + popover: { + DEFAULT: 'var(--popover)', + foreground: 'var(--popover-foreground)' + }, + card: { + DEFAULT: 'var(--card)', + foreground: 'var(--card-foreground)' + }, + highlight: 'var(--highlight)', + sidebar: { + DEFAULT: 'var(--sidebar-background)', + foreground: 'var(--sidebar-foreground)', + primary: 'var(--sidebar-primary)', + 'primary-foreground': 'var(--sidebar-primary-foreground)', + accent: 'var(--sidebar-accent)', + 'accent-foreground': 'var(--sidebar-accent-foreground)', + border: 'var(--sidebar-border)', + ring: 'var(--sidebar-ring)' + }, + editor: { + background: 'var(--editor-background)', + foreground: 'var(--editor-foreground)', + caret: 'var(--editor-caret)', + selection: 'var(--editor-selection)', + selectionMatch: 'var(--editor-selection-match)', + gutterBackground: 'var(--editor-gutter-background)', + gutterForeground: 'var(--editor-gutter-foreground)', + gutterBorder: 'var(--editor-gutter-border)', + gutterActiveForeground: 'var(--editor-gutter-active-foreground)', + lineHighlight: 'var(--editor-line-highlight)', + + tag: { + keyword: 'var(--editor-tag-keyword)', + name: 'var(--editor-tag-name)', + function: 'var(--editor-tag-function)', + label: 'var(--editor-tag-label)', + constant: 'var(--editor-tag-constant)', + definition: 'var(--editor-tag-definition)', + brace: 'var(--editor-tag-brace)', + type: 'var(--editor-tag-type)', + operator: 'var(--editor-tag-operator)', + tag: 'var(--editor-tag-tag)', + 'bracket-square': 'var(--editor-tag-bracket-square)', + 'bracket-angle': 'var(--editor-tag-bracket-angle)', + attribute: 'var(--editor-tag-attribute)', + string: 'var(--editor-tag-string)', + link: 'var(--editor-tag-link)', + meta: 'var(--editor-tag-meta)', + comment: 'var(--editor-tag-comment)', + emphasis: 'var(--editor-tag-emphasis)', + heading: 'var(--editor-tag-heading)', + atom: 'var(--editor-tag-atom)', + processing: 'var(--editor-tag-processing)', + separator: 'var(--editor-tag-separator)', + invalid: 'var(--editor-tag-invalid)', + quote: 'var(--editor-tag-quote)', + 'annotation-special': 'var(--editor-tag-annotation-special)', + number: 'var(--editor-tag-number)', + regexp: 'var(--editor-tag-regexp)', + 'variable-local': 'var(--editor-tag-variable-local)', + } + }, + }, + fontSize: { + editor: 'var(--editor-font-size)' + }, + fontFamily: { + editor: 'var(--editor-font-family)' + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'spin-slow': 'spin 1.5s linear infinite' + } + } + }, + plugins: [ + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("tailwindcss-animate"), ], - prefix: "", - theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px' - } - }, - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - backgroundSecondary: 'hsl(var(--background-secondary))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - highlight: 'hsl(var(--highlight))', - sidebar: { - DEFAULT: 'hsl(var(--sidebar-background))', - foreground: 'hsl(var(--sidebar-foreground))', - primary: 'hsl(var(--sidebar-primary))', - 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', - accent: 'hsl(var(--sidebar-accent))', - 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', - border: 'hsl(var(--sidebar-border))', - ring: 'hsl(var(--sidebar-ring))' - } - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - keyframes: { - 'accordion-down': { - from: { - height: '0' - }, - to: { - height: 'var(--radix-accordion-content-height)' - } - }, - 'accordion-up': { - from: { - height: 'var(--radix-accordion-content-height)' - }, - to: { - height: '0' - } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out', - 'spin-slow': 'spin 1.5s linear infinite' - } - } - }, - plugins: [ - require("tailwindcss-animate"), - ], } satisfies Config export default config \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d8654d25..2ae1187d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -340,7 +340,21 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-cpp@npm:^6.0.2": +"@codemirror/lang-angular@npm:^0.1.0": + version: 0.1.4 + resolution: "@codemirror/lang-angular@npm:0.1.4" + dependencies: + "@codemirror/lang-html": "npm:^6.0.0" + "@codemirror/lang-javascript": "npm:^6.1.2" + "@codemirror/language": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.3.3" + checksum: 10c0/9d38350dd5e2defc58b9b18eaf50aa183b9cf8be96270845182f2992b51dbf8af78c567aa736bcd494cbf5aea8400b85b36bfd0131948475f9de7c228b9e3415 + languageName: node + linkType: hard + +"@codemirror/lang-cpp@npm:^6.0.0, @codemirror/lang-cpp@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-cpp@npm:6.0.2" dependencies: @@ -363,7 +377,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-go@npm:^6.0.1": +"@codemirror/lang-go@npm:^6.0.0, @codemirror/lang-go@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-go@npm:6.0.1" dependencies: @@ -393,7 +407,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-java@npm:^6.0.1": +"@codemirror/lang-java@npm:^6.0.0, @codemirror/lang-java@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-java@npm:6.0.1" dependencies: @@ -418,7 +432,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-json@npm:^6.0.1": +"@codemirror/lang-json@npm:^6.0.0, @codemirror/lang-json@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-json@npm:6.0.1" dependencies: @@ -428,7 +442,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-less@npm:^6.0.2": +"@codemirror/lang-less@npm:^6.0.0, @codemirror/lang-less@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-less@npm:6.0.2" dependencies: @@ -441,7 +455,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-liquid@npm:^6.2.2": +"@codemirror/lang-liquid@npm:^6.0.0, @codemirror/lang-liquid@npm:^6.2.2": version: 6.2.3 resolution: "@codemirror/lang-liquid@npm:6.2.3" dependencies: @@ -457,7 +471,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-markdown@npm:^6.2.5": +"@codemirror/lang-markdown@npm:^6.0.0, @codemirror/lang-markdown@npm:^6.2.5": version: 6.3.2 resolution: "@codemirror/lang-markdown@npm:6.3.2" dependencies: @@ -472,7 +486,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-php@npm:^6.0.1": +"@codemirror/lang-php@npm:^6.0.0, @codemirror/lang-php@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-php@npm:6.0.1" dependencies: @@ -485,6 +499,19 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-python@npm:^6.0.0": + version: 6.2.1 + resolution: "@codemirror/lang-python@npm:6.2.1" + dependencies: + "@codemirror/autocomplete": "npm:^6.3.2" + "@codemirror/language": "npm:^6.8.0" + "@codemirror/state": "npm:^6.0.0" + "@lezer/common": "npm:^1.2.1" + "@lezer/python": "npm:^1.1.4" + checksum: 10c0/6e92ac7e5e6e3162cfbbef40be6314278b9b9ff6f65cfc207a75ec95d84a404b9930913240e1a15e3d18c538f6b243f6a0bba4c3e37fa4e318939dfebc51ebd0 + languageName: node + linkType: hard + "@codemirror/lang-python@npm:^6.1.6": version: 6.1.7 resolution: "@codemirror/lang-python@npm:6.1.7" @@ -498,7 +525,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-rust@npm:^6.0.1": +"@codemirror/lang-rust@npm:^6.0.0, @codemirror/lang-rust@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-rust@npm:6.0.1" dependencies: @@ -508,7 +535,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sass@npm:^6.0.2": +"@codemirror/lang-sass@npm:^6.0.0, @codemirror/lang-sass@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-sass@npm:6.0.2" dependencies: @@ -521,7 +548,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sql@npm:^6.7.1": +"@codemirror/lang-sql@npm:^6.0.0, @codemirror/lang-sql@npm:^6.7.1": version: 6.8.0 resolution: "@codemirror/lang-sql@npm:6.8.0" dependencies: @@ -535,7 +562,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-vue@npm:^0.1.3": +"@codemirror/lang-vue@npm:^0.1.1, @codemirror/lang-vue@npm:^0.1.3": version: 0.1.3 resolution: "@codemirror/lang-vue@npm:0.1.3" dependencies: @@ -549,7 +576,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-wast@npm:^6.0.2": +"@codemirror/lang-wast@npm:^6.0.0, @codemirror/lang-wast@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-wast@npm:6.0.2" dependencies: @@ -561,7 +588,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-xml@npm:^6.1.0": +"@codemirror/lang-xml@npm:^6.0.0, @codemirror/lang-xml@npm:^6.1.0": version: 6.1.0 resolution: "@codemirror/lang-xml@npm:6.1.0" dependencies: @@ -575,7 +602,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-yaml@npm:^6.1.1, @codemirror/lang-yaml@npm:^6.1.2": +"@codemirror/lang-yaml@npm:^6.0.0, @codemirror/lang-yaml@npm:^6.1.1, @codemirror/lang-yaml@npm:^6.1.2": version: 6.1.2 resolution: "@codemirror/lang-yaml@npm:6.1.2" dependencies: @@ -590,6 +617,36 @@ __metadata: languageName: node linkType: hard +"@codemirror/language-data@npm:^6.5.1": + version: 6.5.1 + resolution: "@codemirror/language-data@npm:6.5.1" + dependencies: + "@codemirror/lang-angular": "npm:^0.1.0" + "@codemirror/lang-cpp": "npm:^6.0.0" + "@codemirror/lang-css": "npm:^6.0.0" + "@codemirror/lang-go": "npm:^6.0.0" + "@codemirror/lang-html": "npm:^6.0.0" + "@codemirror/lang-java": "npm:^6.0.0" + "@codemirror/lang-javascript": "npm:^6.0.0" + "@codemirror/lang-json": "npm:^6.0.0" + "@codemirror/lang-less": "npm:^6.0.0" + "@codemirror/lang-liquid": "npm:^6.0.0" + "@codemirror/lang-markdown": "npm:^6.0.0" + "@codemirror/lang-php": "npm:^6.0.0" + "@codemirror/lang-python": "npm:^6.0.0" + "@codemirror/lang-rust": "npm:^6.0.0" + "@codemirror/lang-sass": "npm:^6.0.0" + "@codemirror/lang-sql": "npm:^6.0.0" + "@codemirror/lang-vue": "npm:^0.1.1" + "@codemirror/lang-wast": "npm:^6.0.0" + "@codemirror/lang-xml": "npm:^6.0.0" + "@codemirror/lang-yaml": "npm:^6.0.0" + "@codemirror/language": "npm:^6.0.0" + "@codemirror/legacy-modes": "npm:^6.4.0" + checksum: 10c0/5a5dfeaa5c6fba019c7ff3a380ffb11956607f9bc5556537cb0515a367fb6294628fb36b449641d82f56b2236bccae88d0741469183c71cb7bf80ea7861e8fba + languageName: node + linkType: hard + "@codemirror/language@npm:6.x, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.2, @codemirror/language@npm:^6.10.3, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0, @codemirror/language@npm:^6.8.0, @codemirror/language@npm:^6.9.0": version: 6.11.0 resolution: "@codemirror/language@npm:6.11.0" @@ -604,6 +661,15 @@ __metadata: languageName: node linkType: hard +"@codemirror/legacy-modes@npm:^6.4.0": + version: 6.5.1 + resolution: "@codemirror/legacy-modes@npm:6.5.1" + dependencies: + "@codemirror/language": "npm:^6.0.0" + checksum: 10c0/a5fc0c76112f1fe4add414c65876932c24d77126ee4504049fd188abc4e44c5da611beaa46cfe45d5269d6d7b49aefc10c410d457785a39ba3c233f799802cf0 + languageName: node + linkType: hard + "@codemirror/legacy-modes@npm:^6.4.2": version: 6.5.0 resolution: "@codemirror/legacy-modes@npm:6.5.0" @@ -1278,6 +1344,17 @@ __metadata: languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:^4.7.0": + version: 4.7.0 + resolution: "@eslint-community/eslint-utils@npm:4.7.0" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + languageName: node + linkType: hard + "@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.1 resolution: "@eslint-community/regexpp@npm:4.12.1" @@ -1821,7 +1898,7 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.7, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2, @lezer/lr@npm:^1.x": +"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.3.7, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2, @lezer/lr@npm:^1.x": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2" dependencies: @@ -5816,6 +5893,7 @@ __metadata: "@codemirror/lang-xml": "npm:^6.1.0" "@codemirror/lang-yaml": "npm:^6.1.2" "@codemirror/language": "npm:^6.0.0" + "@codemirror/language-data": "npm:^6.5.1" "@codemirror/legacy-modes": "npm:^6.4.2" "@codemirror/search": "npm:^6.5.6" "@codemirror/state": "npm:^6.4.1" @@ -5860,6 +5938,7 @@ __metadata: "@stripe/react-stripe-js": "npm:^3.1.1" "@stripe/stripe-js": "npm:^5.6.0" "@t3-oss/env-nextjs": "npm:^0.12.0" + "@tanstack/eslint-plugin-query": "npm:^5.74.7" "@tanstack/react-query": "npm:^5.53.3" "@tanstack/react-table": "npm:^8.20.5" "@tanstack/react-virtual": "npm:^3.10.8" @@ -5871,6 +5950,7 @@ __metadata: "@types/react-dom": "npm:^18" "@typescript-eslint/eslint-plugin": "npm:^8.3.0" "@typescript-eslint/parser": "npm:^8.3.0" + "@uidotdev/usehooks": "npm:^2.4.1" "@uiw/codemirror-themes": "npm:^4.23.6" "@uiw/react-codemirror": "npm:^4.23.0" "@viz-js/lang-dot": "npm:^1.0.4" @@ -6054,6 +6134,17 @@ __metadata: languageName: node linkType: hard +"@tanstack/eslint-plugin-query@npm:^5.74.7": + version: 5.74.7 + resolution: "@tanstack/eslint-plugin-query@npm:5.74.7" + dependencies: + "@typescript-eslint/utils": "npm:^8.18.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + checksum: 10c0/7f026af15918e0f77e1032c0e53d70fd952d32735b0987a84d0df2b1c6b47ac01773da3812d579c999c398dd677d45400e133a1b3c2979e3f125028743451850 + languageName: node + linkType: hard + "@tanstack/query-core@npm:5.69.0": version: 5.69.0 resolution: "@tanstack/query-core@npm:5.69.0" @@ -6556,6 +6647,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/scope-manager@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/visitor-keys": "npm:8.32.1" + checksum: 10c0/d2cb1f7736388972137d6e510b2beae4bac033fcab274e04de90ebba3ce466c71fe47f1795357e032e4a6c8b2162016b51b58210916c37212242c82d35352e9f + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/type-utils@npm:8.27.0" @@ -6585,6 +6686,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/types@npm:8.32.1" + checksum: 10c0/86f59b29c12e7e8abe45a1659b6fae5e7b0cfaf09ab86dd596ed9d468aa61082bbccd509d25f769b197fbfdf872bbef0b323a2ded6ceaca351f7c679f1ba3bd3 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:7.2.0": version: 7.2.0 resolution: "@typescript-eslint/typescript-estree@npm:7.2.0" @@ -6622,6 +6730,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/visitor-keys": "npm:8.32.1" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10c0/b5ae0d91ef1b46c9f3852741e26b7a14c28bb58ee8a283b9530ac484332ca58a7216b9d22eda23c5449b5fd69c6e4601ef3ebbd68e746816ae78269036c08cda + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/utils@npm:8.27.0" @@ -6637,6 +6763,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:^8.18.1": + version: 8.32.1 + resolution: "@typescript-eslint/utils@npm:8.32.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.32.1" + "@typescript-eslint/types": "npm:8.32.1" + "@typescript-eslint/typescript-estree": "npm:8.32.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10c0/a2b90c0417cd3a33c6e22f9cc28c356f251bb8928ef1d25e057feda007d522d281bdc37a9a0d05b70312f00a7b3f350ca06e724867025ea85bba5a4c766732e7 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:7.2.0": version: 7.2.0 resolution: "@typescript-eslint/visitor-keys@npm:7.2.0" @@ -6657,6 +6798,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.32.1": + version: 8.32.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.32.1" + dependencies: + "@typescript-eslint/types": "npm:8.32.1" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10c0/9c05053dfd048f681eb96e09ceefa8841a617b8b5950eea05e0844b38fe3510a284eb936324caa899c3ceb4bc23efe56ac01437fab378ac1beeb1c6c00404978 + languageName: node + linkType: hard + +"@uidotdev/usehooks@npm:^2.4.1": + version: 2.4.1 + resolution: "@uidotdev/usehooks@npm:2.4.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/181c43fb324dbe4fef9762c61ab4b8235efa48abedf39a9bfeab65872522c43dae789c4f85b82a1164ed7bb18ae7ff25c3a19e7c4e0eb944937ac7f8109cee9b + languageName: node + linkType: hard + "@uiw/codemirror-extensions-basic-setup@npm:4.23.10": version: 4.23.10 resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10" @@ -15380,7 +15541,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.1": +"ts-api-utils@npm:^2.0.1, ts-api-utils@npm:^2.1.0": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" peerDependencies:
Error loading {activeExploreMenuTab}
No {activeExploreMenuTab} found
{name}
No hover info found