mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +00:00
Share links (#149)
This commit is contained in:
parent
914008247f
commit
2c1de4d005
19 changed files with 723 additions and 149 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
150
packages/web/src/app/browse/[...path]/codePreview.tsx
Normal file
150
packages/web/src/app/browse/[...path]/codePreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
154
packages/web/src/app/browse/[...path]/page.tsx
Normal file
154
packages/web/src/app/browse/[...path]/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
143
packages/web/src/app/components/editorContextMenu.tsx
Normal file
143
packages/web/src/app/components/editorContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
packages/web/src/app/components/fireHeader.tsx
Normal file
84
packages/web/src/app/components/fireHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
packages/web/src/app/components/pageNotFound.tsx
Normal file
18
packages/web/src/app/components/pageNotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
CodeIcon,
|
||||
Laptop,
|
||||
|
|
|
|||
44
packages/web/src/app/components/topBar.tsx
Normal file
44
packages/web/src/app/components/topBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/app/not-found.tsx
Normal file
7
packages/web/src/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { PageNotFound } from "./components/pageNotFound";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<PageNotFound />
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
26
packages/web/src/hooks/useKeymapExtension.ts
Normal file
26
packages/web/src/hooks/useKeymapExtension.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ export type PosthogEventMap = {
|
|||
regexpsConsidered: number,
|
||||
flushReason: number,
|
||||
fileLanguages: string[]
|
||||
}
|
||||
},
|
||||
share_link_created: {},
|
||||
}
|
||||
|
||||
export type PosthogEvent = keyof PosthogEventMap;
|
||||
|
|
@ -94,6 +94,7 @@ export const fileSourceRequestSchema = z.object({
|
|||
|
||||
export const fileSourceResponseSchema = z.object({
|
||||
source: z.string(),
|
||||
language: z.string(),
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
37
yarn.lock
37
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue