Skip to content

Share links #149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jan 7, 2025
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/app/browse/[...path]/codePreview.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 154 additions & 0 deletions packages/web/src/app/browse/[...path]/page.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>
)
}
Loading
Loading