Share links (#149)

This commit is contained in:
Brendan Kellam 2025-01-07 10:27:42 -08:00 committed by GitHub
parent 914008247f
commit 2c1de4d005
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 723 additions and 149 deletions

View file

@ -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

View file

@ -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",

View file

@ -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<ReactCodeMirrorRef>(null);
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
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<DecorationSet>({
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 (
<ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror
className="relative"
ref={editorRef}
onCreateEditor={() => {
setIsEditorCreated(true);
}}
value={source}
extensions={extensions}
readOnly={true}
theme={theme === "dark" ? "dark" : "light"}
>
{editorRef.current && editorRef.current.view && currentSelection && (
<EditorContextMenu
view={editorRef.current.view}
selection={currentSelection}
repoName={repoName}
path={path}
revisionName={revisionName}
/>
)}
</CodeMirror>
</ScrollArea>
)
}

View file

@ -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 <PageNotFound />;
}
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 (
<div className="flex flex-col h-screen">
<div className='sticky top-0 left-0 right-0 z-10'>
<TopBar
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
/>
<Separator />
{repo && (
<>
<div className="bg-accent py-1 px-2 flex flex-row">
<FileHeader
fileName={path}
repo={repo.Repository}
branchDisplayName={revisionName}
/>
</div>
<Separator />
</>
)}
</div>
{repo === undefined ? (
<div className="flex h-full">
<div className="m-auto flex flex-col items-center gap-2">
<LuBookX className="h-12 w-12 text-secondary-foreground" />
<span className="font-medium text-secondary-foreground">Repository not found</span>
</div>
</div>
) : (
<CodePreviewWrapper
path={path}
repoName={repoName}
revisionName={revisionName ?? 'HEAD'}
/>
)}
</div>
)
}
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 (
<div className="flex h-full">
<div className="m-auto flex flex-col items-center gap-2">
<LuFileX2 className="h-12 w-12 text-secondary-foreground" />
<span className="font-medium text-secondary-foreground">File not found</span>
</div>
</div>
)
}
// @todo : proper error handling
return (
<>
Error: {fileSourceResponse.message}
</>
)
}
return (
<CodePreview
source={base64Decode(fileSourceResponse.source)}
language={fileSourceResponse.language}
repoName={repoName}
path={path}
revisionName={revisionName}
/>
)
}

View file

@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className="absolute z-10 flex flex-col gap-2 bg-background border border-gray-300 dark:border-gray-700 rounded-md shadow-lg p-2"
>
<Button
variant="ghost"
size="sm"
onClick={onCopyLinkToSelection}
>
<Link2Icon className="h-4 w-4 mr-1" />
Share selection
</Button>
</div>
)
}

View file

@ -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 (
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
{info?.icon ? (
<Image
src={info.icon}
alt={info.costHostName}
className={`w-4 h-4 ${info.iconClassName}`}
/>
): (
<LaptopIcon className="w-4 h-4" />
)}
<Link
className={clsx("font-medium", {
"cursor-pointer hover:underline": info?.repoLink,
})}
href={info?.repoLink ?? ""}
>
{info?.displayName}
</Link>
{branchDisplayName && (
<p
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-0.5"
title={branchDisplayTitle}
>
{/* hack since to make the @ symbol look more centered with the text */}
<span
style={{
fontSize: "0.60rem",
lineHeight: "1rem",
marginBottom: "0.1rem",
}}
>
@
</span>
{`${branchDisplayName}`}
</p>
)}
<span>·</span>
<div className="flex-1 flex items-center overflow-hidden">
<span className="inline-block w-full truncate-start font-mono text-sm">
{!fileNameHighlightRange ?
fileName
: (
<>
{fileName.slice(0, fileNameHighlightRange.from)}
<span className="bg-yellow-200 dark:bg-blue-700">
{fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)}
</span>
{fileName.slice(fileNameHighlightRange.to)}
</>
)}
</span>
</div>
</div>
)
}

View file

@ -0,0 +1,18 @@
import { Separator } from "@/components/ui/separator"
export const PageNotFound = () => {
return (
<div className="flex h-screen">
<div className="m-auto">
<div className="flex flex-row items-center gap-2">
<h1 className="text-xl">404</h1>
<Separator
orientation="vertical"
className="h-5"
/>
<p className="text-sm">Page not found</p>
</div>
</div>
</div>
)
}

View file

@ -1,3 +1,5 @@
'use client';
import {
CodeIcon,
Laptop,

View file

@ -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 (
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
<div className="grow flex flex-row gap-4 items-center">
<Link
href="/"
className="shrink-0 cursor-pointer"
>
<Image
src={logoDark}
className="h-4 w-auto hidden dark:block"
alt={"Sourcebot logo"}
/>
<Image
src={logoLight}
className="h-4 w-auto block dark:hidden"
alt={"Sourcebot logo"}
/>
</Link>
<SearchBar
size="sm"
defaultQuery={defaultSearchQuery}
className="w-full"
/>
</div>
<SettingsDropdown
menuButtonClassName="w-8 h-8"
/>
</div>
)
}

View file

@ -0,0 +1,7 @@
import { PageNotFound } from "./components/pageNotFound";
export default function NotFound() {
return (
<PageNotFound />
)
}

View file

@ -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<ReactCodeMirrorRef>(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<SelectionRange>();
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 = ({
<ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror
ref={editorRef}
className="relative"
readOnly={true}
value={file?.content}
theme={theme === "dark" ? "dark" : "light"}
extensions={extensions}
/>
>
{
editorRef.current?.view &&
file?.filepath &&
repoName &&
currentSelection &&
(
<EditorContextMenu
view={editorRef.current.view}
path={file?.filepath}
repoName={repoName}
selection={currentSelection}
revisionName={file.revision}
/>
)
}
</CodeMirror>
<Scrollbar orientation="vertical" />
<Scrollbar orientation="horizontal" />
</ScrollArea>

View file

@ -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 (
<CodePreview
file={file}
repoName={fileMatch?.Repository}
onClose={onClose}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={onSelectedMatchIndexChange}

View file

@ -1,12 +1,10 @@
'use client';
import { getRepoCodeHostInfo } from "@/lib/utils";
import { useCallback, useMemo } from "react";
import Image from "next/image";
import { DoubleArrowDownIcon, DoubleArrowUpIcon, LaptopIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import { FileHeader } from "@/app/components/fireHeader";
import { Separator } from "@/components/ui/separator";
import { Repository, SearchResultFile } from "@/lib/types";
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
import { useCallback, useMemo } from "react";
import { FileMatch } from "./fileMatch";
export const MAX_MATCHES_TO_PREVIEW = 3;
@ -58,32 +56,9 @@ export const FileMatchContainer = ({
}
}
return null;
return undefined;
}, [matches]);
const { repoIcon, displayName, repoLink } = useMemo(() => {
const repo: Repository | undefined = repoMetadata[file.Repository];
const info = getRepoCodeHostInfo(repo);
if (info) {
return {
displayName: info.displayName,
repoLink: info.repoLink,
repoIcon: <Image
src={info.icon}
alt={info.costHostName}
className={`w-4 h-4 ${info.iconClassName}`}
/>
}
}
return {
displayName: file.Repository,
repoLink: undefined,
repoIcon: <LaptopIcon className="w-4 h-4" />
}
}, [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 (
<div>
@ -114,46 +97,13 @@ export const FileMatchContainer = ({
onOpenFile();
}}
>
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
{repoIcon}
<span
className={clsx("font-medium", {
"cursor-pointer hover:underline": repoLink,
})}
onClick={() => {
if (repoLink) {
window.open(repoLink, "_blank");
}
}}
>
{displayName}
</span>
{isBranchFilteringEnabled && branches.length > 0 && (
<span
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-0.5"
title={branches.join(", ")}
>
{`@ ${branches[0]}`}
{branches.length > 1 && ` (+ ${branches.length - 1})`}
</span>
)}
<span>·</span>
<div className="flex-1 flex items-center overflow-hidden">
<span className="inline-block w-full truncate-start font-mono text-sm">
{!fileNameRange ?
file.FileName
: (
<>
{file.FileName.slice(0, fileNameRange.from)}
<span className="bg-yellow-200 dark:bg-blue-700">
{file.FileName.slice(fileNameRange.from, fileNameRange.to)}
</span>
{file.FileName.slice(fileNameRange.to)}
</>
)}
</span>
</div>
</div>
<FileHeader
repo={repoMetadata[file.Repository]}
fileName={file.FileName}
fileNameHighlightRange={fileNameRange}
branchDisplayName={branchDisplayName}
branchDisplayTitle={branches.join(", ")}
/>
</div>
{/* Matches */}

View file

@ -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() {
<div className="flex flex-col h-screen overflow-clip">
{/* TopBar */}
<div className="sticky top-0 left-0 right-0 z-10">
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4">
<div className="grow flex flex-row gap-4 items-center">
<div
className="shrink-0 cursor-pointer"
onClick={() => {
router.push("/");
}}
>
<Image
src={logoDark}
className="h-4 w-auto hidden dark:block"
alt={"Sourcebot logo"}
/>
<Image
src={logoLight}
className="h-4 w-auto block dark:hidden"
alt={"Sourcebot logo"}
/>
</div>
<SearchBar
size="sm"
defaultQuery={searchQuery}
className="w-full"
/>
</div>
<SettingsDropdown
menuButtonClassName="w-8 h-8"
/>
</div>
<TopBar defaultSearchQuery={searchQuery} />
<Separator />
{!isLoading && (
<div className="bg-accent py-1 px-2 flex flex-row items-center gap-4">

View file

@ -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;
}

View file

@ -23,7 +23,8 @@ export type PosthogEventMap = {
regexpsConsidered: number,
flushReason: number,
fileLanguages: string[]
}
},
share_link_created: {},
}
export type PosthogEvent = keyof PosthogEventMap;

View file

@ -94,6 +94,7 @@ export const fileSourceRequestSchema = z.object({
export const fileSourceResponseSchema = z.object({
source: z.string(),
language: z.string(),
});

View file

@ -81,6 +81,9 @@ export const search = async ({ query, maxMatchDisplayCount, whole }: SearchReque
}
}
// @todo (bkellam) : We should really be using `git show <hash>:<path>` 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<FileSourceResponse | ServiceError> => {
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,
}
}

View file

@ -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"