From 2c1de4d0051a49e297bde85ecfe7c80d23e683c1 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 7 Jan 2025 10:27:42 -0800 Subject: [PATCH] Share links (#149) --- CHANGELOG.md | 4 + packages/web/package.json | 1 + .../src/app/browse/[...path]/codePreview.tsx | 150 +++++++++++++++++ .../web/src/app/browse/[...path]/page.tsx | 154 ++++++++++++++++++ .../src/app/components/editorContextMenu.tsx | 143 ++++++++++++++++ .../web/src/app/components/fireHeader.tsx | 84 ++++++++++ .../web/src/app/components/pageNotFound.tsx | 18 ++ .../src/app/components/settingsDropdown.tsx | 2 + packages/web/src/app/components/topBar.tsx | 44 +++++ packages/web/src/app/not-found.tsx | 7 + .../codePreviewPanel/codePreview.tsx | 58 ++++--- .../components/codePreviewPanel/index.tsx | 2 + .../searchResultsPanel/fileMatchContainer.tsx | 88 +++------- packages/web/src/app/search/page.tsx | 40 +---- packages/web/src/hooks/useKeymapExtension.ts | 26 +++ packages/web/src/lib/posthogEvents.ts | 3 +- packages/web/src/lib/schemas.ts | 1 + packages/web/src/lib/server/searchService.ts | 10 +- yarn.lock | 37 ++--- 19 files changed, 723 insertions(+), 149 deletions(-) create mode 100644 packages/web/src/app/browse/[...path]/codePreview.tsx create mode 100644 packages/web/src/app/browse/[...path]/page.tsx create mode 100644 packages/web/src/app/components/editorContextMenu.tsx create mode 100644 packages/web/src/app/components/fireHeader.tsx create mode 100644 packages/web/src/app/components/pageNotFound.tsx create mode 100644 packages/web/src/app/components/topBar.tsx create mode 100644 packages/web/src/app/not-found.tsx create mode 100644 packages/web/src/hooks/useKeymapExtension.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 612d448c..e8bc2076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added support for creating share links to snippets of code. ([#149](https://github.com/sourcebot-dev/sourcebot/pull/149)) + ## [2.6.3] - 2024-12-18 ### Added diff --git a/packages/web/package.json b/packages/web/package.json index be0cd38d..4878fc58 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,6 +35,7 @@ "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.33.0", + "@floating-ui/react": "^0.27.2", "@hookform/resolvers": "^3.9.0", "@iconify/react": "^5.1.0", "@iizukak/codemirror-lang-wgsl": "^0.3.0", diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/browse/[...path]/codePreview.tsx new file mode 100644 index 00000000..9a7dbdb4 --- /dev/null +++ b/packages/web/src/app/browse/[...path]/codePreview.tsx @@ -0,0 +1,150 @@ +'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 { useThemeNormalized } from "@/hooks/useThemeNormalized"; +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"; + +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 } = useThemeNormalized(); + + return ( + + { + setIsEditorCreated(true); + }} + value={source} + extensions={extensions} + readOnly={true} + theme={theme === "dark" ? "dark" : "light"} + > + {editorRef.current && editorRef.current.view && currentSelection && ( + + )} + + + ) +} + diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx new file mode 100644 index 00000000..47108622 --- /dev/null +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -0,0 +1,154 @@ +import { FileHeader } from "@/app/components/fireHeader"; +import { TopBar } from "@/app/components/topBar"; +import { Separator } from '@/components/ui/separator'; +import { getFileSource, listRepositories } from '@/lib/server/searchService'; +import { base64Decode, isServiceError } from "@/lib/utils"; +import { CodePreview } from "./codePreview"; +import { PageNotFound } from "@/app/components/pageNotFound"; +import { ErrorCode } from "@/lib/errorCodes"; +import { LuFileX2, LuBookX } from "react-icons/lu"; + +interface BrowsePageProps { + params: { + path: string[]; + }; +} + +export default async function BrowsePage({ + params, +}: BrowsePageProps) { + const rawPath = decodeURIComponent(params.path.join('/')); + const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); + if (sentinalIndex === -1) { + return ; + } + + const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); + const repoName = repoAndRevisionName[0]; + const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; + + const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { + const path = rawPath.substring(sentinalIndex + '/-/'.length); + const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; + switch (pathType) { + case 'tree': + return { + path: path.substring('tree/'.length), + pathType, + }; + case 'blob': + return { + path: path.substring('blob/'.length), + pathType, + }; + } + })(); + + // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata + // given it's name or id. + const reposResponse = await listRepositories(); + if (isServiceError(reposResponse)) { + // @todo : proper error handling + return ( + <> + Error: {reposResponse.message} + + ) + } + const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); + + if (pathType === 'tree') { + // @todo : proper tree handling + return ( + <> + Tree view not supported + + ) + } + + return ( +
+
+ + + {repo && ( + <> +
+ +
+ + + )} +
+ {repo === undefined ? ( +
+
+ + Repository not found +
+
+ ) : ( + + )} +
+ ) +} + +interface CodePreviewWrapper { + path: string, + repoName: string, + revisionName: string, +} + +const CodePreviewWrapper = async ({ + path, + repoName, + revisionName, +}: CodePreviewWrapper) => { + // @todo: this will depend on `pathType`. + const fileSourceResponse = await getFileSource({ + fileName: path, + repository: repoName, + branch: revisionName, + }); + + if (isServiceError(fileSourceResponse)) { + if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { + return ( +
+
+ + File not found +
+
+ ) + } + + // @todo : proper error handling + return ( + <> + Error: {fileSourceResponse.message} + + ) + } + + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/editorContextMenu.tsx b/packages/web/src/app/components/editorContextMenu.tsx new file mode 100644 index 00000000..b8ab0015 --- /dev/null +++ b/packages/web/src/app/components/editorContextMenu.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { createPathWithQueryParams } from "@/lib/utils"; +import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@floating-ui/react"; +import { Link2Icon } from "@radix-ui/react-icons"; +import { EditorView, SelectionRange } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useRef } from "react"; + +interface ContextMenuProps { + view: EditorView; + selection: SelectionRange; + repoName: string; + path: string; + revisionName: string; +} + +export const EditorContextMenu = ({ + view, + selection, + repoName, + path, + revisionName, +}: ContextMenuProps) => { + const ref = useRef(null); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + + useEffect(() => { + if (selection.empty) { + ref.current?.classList.add('hidden'); + } else { + ref.current?.classList.remove('hidden'); + } + }, [selection.empty]); + + + useEffect(() => { + if (selection.empty) { + return; + } + + const { from, to } = selection; + const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + if (!start || !end) { + return; + } + + const selectionElement: VirtualElement = { + getBoundingClientRect: () => { + + const { top, left } = start; + const { bottom, right } = end; + + return { + x: left, + y: top, + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + } + } + } + + if (ref.current) { + computePosition(selectionElement, ref.current, { + middleware: [ + offset(5), + autoPlacement({ + boundary: view.dom, + padding: 5, + allowedPlacements: ['bottom'], + }), + shift({ + padding: 5 + }) + ], + }).then(({ x, y }) => { + if (ref.current) { + ref.current.style.left = `${x}px`; + ref.current.style.top = `${y}px`; + } + }); + } + + }, [selection, view]); + + const onCopyLinkToSelection = useCallback(() => { + const toLineAndColumn = (pos: number) => { + const lineInfo = view.state.doc.lineAt(pos); + return { + line: lineInfo.number, + column: pos - lineInfo.from + 1, + } + } + + const from = toLineAndColumn(selection.from); + const to = toLineAndColumn(selection.to); + + const url = createPathWithQueryParams(`${window.location.origin}/browse/${repoName}@${revisionName}/-/blob/${path}`, + ['highlightRange', `${from?.line}:${from?.column},${to?.line}:${to?.column}`], + ); + + navigator.clipboard.writeText(url); + toast({ + description: "✅ Copied link to selection", + }); + + captureEvent('share_link_created', {}); + + // Reset the selection + view.dispatch( + { + selection: { + anchor: selection.to, + head: selection.to, + } + } + ) + }, [captureEvent, path, repoName, selection.from, selection.to, toast, view, revisionName]); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/fireHeader.tsx b/packages/web/src/app/components/fireHeader.tsx new file mode 100644 index 00000000..702cce58 --- /dev/null +++ b/packages/web/src/app/components/fireHeader.tsx @@ -0,0 +1,84 @@ +import { Repository } from "@/lib/types"; +import { getRepoCodeHostInfo } from "@/lib/utils"; +import { LaptopIcon } from "@radix-ui/react-icons"; +import clsx from "clsx"; +import Image from "next/image"; +import Link from "next/link"; + +interface FileHeaderProps { + repo?: Repository; + fileName: string; + fileNameHighlightRange?: { + from: number; + to: number; + } + branchDisplayName?: string; + branchDisplayTitle?: string; +} + +export const FileHeader = ({ + repo, + fileName, + fileNameHighlightRange, + branchDisplayName, + branchDisplayTitle, +}: FileHeaderProps) => { + + const info = getRepoCodeHostInfo(repo); + + return ( +
+ {info?.icon ? ( + {info.costHostName} + ): ( + + )} + + {info?.displayName} + + {branchDisplayName && ( +

+ {/* hack since to make the @ symbol look more centered with the text */} + + @ + + {`${branchDisplayName}`} +

+ )} + · +
+ + {!fileNameHighlightRange ? + fileName + : ( + <> + {fileName.slice(0, fileNameHighlightRange.from)} + + {fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)} + + {fileName.slice(fileNameHighlightRange.to)} + + )} + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/pageNotFound.tsx b/packages/web/src/app/components/pageNotFound.tsx new file mode 100644 index 00000000..8878f85b --- /dev/null +++ b/packages/web/src/app/components/pageNotFound.tsx @@ -0,0 +1,18 @@ +import { Separator } from "@/components/ui/separator" + +export const PageNotFound = () => { + return ( +
+
+
+

404

+ +

Page not found

+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/settingsDropdown.tsx b/packages/web/src/app/components/settingsDropdown.tsx index 6b1b5986..86b20497 100644 --- a/packages/web/src/app/components/settingsDropdown.tsx +++ b/packages/web/src/app/components/settingsDropdown.tsx @@ -1,3 +1,5 @@ +'use client'; + import { CodeIcon, Laptop, diff --git a/packages/web/src/app/components/topBar.tsx b/packages/web/src/app/components/topBar.tsx new file mode 100644 index 00000000..3ca9bd10 --- /dev/null +++ b/packages/web/src/app/components/topBar.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import Image from "next/image"; +import logoLight from "@/public/sb_logo_light.png"; +import logoDark from "@/public/sb_logo_dark.png"; +import { SearchBar } from "./searchBar"; +import { SettingsDropdown } from "./settingsDropdown"; + +interface TopBarProps { + defaultSearchQuery?: string; +} + +export const TopBar = ({ + defaultSearchQuery +}: TopBarProps) => { + return ( +
+
+ + {"Sourcebot + {"Sourcebot + + +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx new file mode 100644 index 00000000..b777ee1b --- /dev/null +++ b/packages/web/src/app/not-found.tsx @@ -0,0 +1,7 @@ +import { PageNotFound } from "./components/pageNotFound"; + +export default function NotFound() { + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx index e366e585..10de7a71 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx @@ -1,21 +1,19 @@ 'use client'; +import { EditorContextMenu } from "@/app/components/editorContextMenu"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency"; -import { useKeymapType } from "@/hooks/useKeymapType"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; import { SearchResultFileMatch } from "@/lib/types"; -import { defaultKeymap } from "@codemirror/commands"; import { search } from "@codemirror/search"; -import { EditorView, keymap } from "@codemirror/view"; +import { EditorView } from "@codemirror/view"; import { Cross1Icon, FileIcon } from "@radix-ui/react-icons"; import { Scrollbar } from "@radix-ui/react-scroll-area"; -import { vim } from "@replit/codemirror-vim"; -import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror'; import clsx from "clsx"; import { ArrowDown, ArrowUp } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -26,10 +24,12 @@ export interface CodePreviewFile { link?: string; matches: SearchResultFileMatch[]; language: string; + revision: string; } interface CodePreviewProps { file?: CodePreviewFile; + repoName?: string; selectedMatchIndex: number; onSelectedMatchIndexChange: (index: number) => void; onClose: () => void; @@ -37,30 +37,19 @@ interface CodePreviewProps { export const CodePreview = ({ file, + repoName, selectedMatchIndex, onSelectedMatchIndexChange, onClose, }: CodePreviewProps) => { const editorRef = useRef(null); - const [keymapType] = useKeymapType(); const { theme } = useThemeNormalized(); const [gutterWidth, setGutterWidth] = useState(0); - const keymapExtension = useExtensionWithDependency( - editorRef.current?.view ?? null, - () => { - switch (keymapType) { - case "default": - return keymap.of(defaultKeymap); - case "vim": - return vim(); - } - }, - [keymapType] - ); - + const keymapExtension = useKeymapExtension(editorRef.current?.view); const syntaxHighlighting = useSyntaxHighlightingExtension(file?.language ?? '', editorRef.current?.view); + const [currentSelection, setCurrentSelection] = useState(); const extensions = useMemo(() => { return [ @@ -72,12 +61,20 @@ export const CodePreview = ({ search({ top: true, }), - EditorView.updateListener.of(update => { + EditorView.updateListener.of((update) => { const width = update.view.plugin(gutterWidthExtension)?.width; if (width) { setGutterWidth(width); } }), + EditorView.updateListener.of((update) => { + // @note: it's important we reset the selection when + // the document changes... otherwise we will get a floating + // context menu where it shouldn't be. + if (update.selectionSet || update.docChanged) { + setCurrentSelection(update.state.selection.main); + } + }) ]; }, [keymapExtension, syntaxHighlighting]); @@ -178,11 +175,28 @@ export const CodePreview = ({ + > + { + editorRef.current?.view && + file?.filepath && + repoName && + currentSelection && + ( + + ) + } + diff --git a/packages/web/src/app/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/search/components/codePreviewPanel/index.tsx index 30ae2f43..22a0332f 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/search/components/codePreviewPanel/index.tsx @@ -62,6 +62,7 @@ export const CodePreviewPanel = ({ matches: filteredMatches, link: link, language: fileMatch.Language, + revision: branch ?? "HEAD", }; }); }, @@ -71,6 +72,7 @@ export const CodePreviewPanel = ({ return ( { - const repo: Repository | undefined = repoMetadata[file.Repository]; - const info = getRepoCodeHostInfo(repo); - - if (info) { - return { - displayName: info.displayName, - repoLink: info.repoLink, - repoIcon: {info.costHostName} - } - } - - return { - displayName: file.Repository, - repoLink: undefined, - repoIcon: - } - }, [file.Repository, repoMetadata]); - const isMoreContentButtonVisible = useMemo(() => { return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); @@ -104,6 +79,14 @@ export const FileMatchContainer = ({ return file.Branches; }, [file.Branches]); + const branchDisplayName = useMemo(() => { + if (!isBranchFilteringEnabled || branches.length === 0) { + return undefined; + } + + return `${branches[0]}${branches.length > 1 ? ` +${branches.length - 1}` : ''}`; + }, [isBranchFilteringEnabled, branches]); + return (
@@ -114,46 +97,13 @@ export const FileMatchContainer = ({ onOpenFile(); }} > -
- {repoIcon} - { - if (repoLink) { - window.open(repoLink, "_blank"); - } - }} - > - {displayName} - - {isBranchFilteringEnabled && branches.length > 0 && ( - - {`@ ${branches[0]}`} - {branches.length > 1 && ` (+ ${branches.length - 1})`} - - )} - · -
- - {!fileNameRange ? - file.FileName - : ( - <> - {file.FileName.slice(0, fileNameRange.from)} - - {file.FileName.slice(fileNameRange.from, fileNameRange.to)} - - {file.FileName.slice(fileNameRange.to)} - - )} - -
-
+
{/* Matches */} diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx index e8963e82..8e9350e5 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/search/page.tsx @@ -8,23 +8,19 @@ import { import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { useSearchHistory } from "@/hooks/useSearchHistory"; import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; import { createPathWithQueryParams } from "@/lib/utils"; import { SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import logoDark from "../../../public/sb_logo_dark.png"; -import logoLight from "../../../public/sb_logo_light.png"; +import { ImperativePanelHandle } from "react-resizable-panels"; import { getRepos, search } from "../api/(client)/client"; -import { SearchBar } from "../components/searchBar"; -import { SettingsDropdown } from "../components/settingsDropdown"; +import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; -import { ImperativePanelHandle } from "react-resizable-panels"; -import { useSearchHistory } from "@/hooks/useSearchHistory"; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; @@ -178,35 +174,7 @@ export default function SearchPage() {
{/* TopBar */}
-
-
-
{ - router.push("/"); - }} - > - {"Sourcebot - {"Sourcebot -
- -
- -
+ {!isLoading && (
diff --git a/packages/web/src/hooks/useKeymapExtension.ts b/packages/web/src/hooks/useKeymapExtension.ts new file mode 100644 index 00000000..3898f173 --- /dev/null +++ b/packages/web/src/hooks/useKeymapExtension.ts @@ -0,0 +1,26 @@ +'use client'; + +import { EditorView, keymap } from "@uiw/react-codemirror"; +import { useExtensionWithDependency } from "./useExtensionWithDependency"; +import { useKeymapType } from "./useKeymapType"; +import { defaultKeymap } from "@codemirror/commands"; +import { vim } from "@replit/codemirror-vim"; + +export const useKeymapExtension = (view: EditorView | undefined) => { + const [keymapType] = useKeymapType(); + + const extension = useExtensionWithDependency( + view ?? null, + () => { + switch (keymapType) { + case "default": + return keymap.of(defaultKeymap); + case "vim": + return vim(); + } + }, + [keymapType] + ); + + return extension; +} \ No newline at end of file diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index b9c67c2f..114cf106 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -23,7 +23,8 @@ export type PosthogEventMap = { regexpsConsidered: number, flushReason: number, fileLanguages: string[] - } + }, + share_link_created: {}, } export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index e1741644..25526f6b 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -94,6 +94,7 @@ export const fileSourceRequestSchema = z.object({ export const fileSourceResponseSchema = z.object({ source: z.string(), + language: z.string(), }); diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts index a4a9157b..bc37de84 100644 --- a/packages/web/src/lib/server/searchService.ts +++ b/packages/web/src/lib/server/searchService.ts @@ -81,6 +81,9 @@ export const search = async ({ query, maxMatchDisplayCount, whole }: SearchReque } } +// @todo (bkellam) : We should really be using `git show :` to fetch file contents here. +// This will allow us to support permalinks to files at a specific revision that may not be indexed +// by zoekt. export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest): Promise => { const escapedFileName = escapeStringRegexp(fileName); const escapedRepository = escapeStringRegexp(repository); @@ -106,9 +109,12 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource return fileNotFound(fileName, repository); } - const source = files[0].Content ?? ''; + const file = files[0]; + const source = file.Content ?? ''; + const language = file.Language; return { - source + source, + language, } } diff --git a/yarn.lock b/yarn.lock index 38ac6ea8..72b9d77d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,13 +642,22 @@ "@floating-ui/core" "^1.6.0" "@floating-ui/utils" "^0.2.8" -"@floating-ui/react-dom@^2.0.0": +"@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.1.2": version "2.1.2" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== dependencies: "@floating-ui/dom" "^1.0.0" +"@floating-ui/react@^0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.2.tgz#901a04e93061c427d45b69a29c99f641a8b3a7bc" + integrity sha512-k/yP6a9K9QwhLfIu87iUZxCH6XN5z5j/VUHHq0dEnbZYY2Y9jz68E/LXFtK8dkiaYltS2WYohnyKC0VcwVneVg== + dependencies: + "@floating-ui/react-dom" "^2.1.2" + "@floating-ui/utils" "^0.2.8" + tabbable "^6.0.0" + "@floating-ui/utils@^0.2.8": version "0.2.8" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" @@ -5527,16 +5536,8 @@ string-argv@^0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5633,14 +5634,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5718,6 +5712,11 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tabbable@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + tailwind-merge@^2.5.2: version "2.5.3" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.3.tgz#579546e14ddda24462e0303acd8798c50f5511bb"