Skip to content

Commit 2c1de4d

Browse files
Share links (#149)
1 parent 9140082 commit 2c1de4d

File tree

19 files changed

+723
-149
lines changed

19 files changed

+723
-149
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added support for creating share links to snippets of code. ([#149](https://github.com/sourcebot-dev/sourcebot/pull/149))
13+
1014
## [2.6.3] - 2024-12-18
1115

1216
### Added

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@codemirror/search": "^6.5.6",
3636
"@codemirror/state": "^6.4.1",
3737
"@codemirror/view": "^6.33.0",
38+
"@floating-ui/react": "^0.27.2",
3839
"@hookform/resolvers": "^3.9.0",
3940
"@iconify/react": "^5.1.0",
4041
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
'use client';
2+
3+
import { ScrollArea } from "@/components/ui/scroll-area";
4+
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
5+
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
6+
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
7+
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
8+
import { search } from "@codemirror/search";
9+
import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror";
10+
import { useEffect, useMemo, useRef, useState } from "react";
11+
import { EditorContextMenu } from "../../components/editorContextMenu";
12+
13+
interface CodePreviewProps {
14+
path: string;
15+
repoName: string;
16+
revisionName: string;
17+
source: string;
18+
language: string;
19+
}
20+
21+
export const CodePreview = ({
22+
source,
23+
language,
24+
path,
25+
repoName,
26+
revisionName,
27+
}: CodePreviewProps) => {
28+
const editorRef = useRef<ReactCodeMirrorRef>(null);
29+
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
30+
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
31+
const keymapExtension = useKeymapExtension(editorRef.current?.view);
32+
const [isEditorCreated, setIsEditorCreated] = useState(false);
33+
34+
const highlightRangeQuery = useNonEmptyQueryParam('highlightRange');
35+
const highlightRange = useMemo(() => {
36+
if (!highlightRangeQuery) {
37+
return;
38+
}
39+
40+
const rangeRegex = /^\d+:\d+,\d+:\d+$/;
41+
if (!rangeRegex.test(highlightRangeQuery)) {
42+
return;
43+
}
44+
45+
const [start, end] = highlightRangeQuery.split(',').map((range) => {
46+
return range.split(':').map((val) => parseInt(val, 10));
47+
});
48+
49+
return {
50+
start: {
51+
line: start[0],
52+
character: start[1],
53+
},
54+
end: {
55+
line: end[0],
56+
character: end[1],
57+
}
58+
}
59+
}, [highlightRangeQuery]);
60+
61+
const extensions = useMemo(() => {
62+
const highlightDecoration = Decoration.mark({
63+
class: "cm-searchMatch-selected",
64+
});
65+
66+
return [
67+
syntaxHighlighting,
68+
EditorView.lineWrapping,
69+
keymapExtension,
70+
search({
71+
top: true,
72+
}),
73+
EditorView.updateListener.of((update: ViewUpdate) => {
74+
if (update.selectionSet) {
75+
setCurrentSelection(update.state.selection.main);
76+
}
77+
}),
78+
StateField.define<DecorationSet>({
79+
create(state) {
80+
if (!highlightRange) {
81+
return Decoration.none;
82+
}
83+
84+
const { start, end } = highlightRange;
85+
const from = state.doc.line(start.line).from + start.character - 1;
86+
const to = state.doc.line(end.line).from + end.character - 1;
87+
88+
return Decoration.set([
89+
highlightDecoration.range(from, to),
90+
]);
91+
},
92+
update(deco, tr) {
93+
return deco.map(tr.changes);
94+
},
95+
provide: (field) => EditorView.decorations.from(field),
96+
}),
97+
];
98+
}, [keymapExtension, syntaxHighlighting, highlightRange]);
99+
100+
useEffect(() => {
101+
if (!highlightRange || !editorRef.current || !editorRef.current.state) {
102+
return;
103+
}
104+
105+
const doc = editorRef.current.state.doc;
106+
const { start, end } = highlightRange;
107+
const from = doc.line(start.line).from + start.character - 1;
108+
const to = doc.line(end.line).from + end.character - 1;
109+
const selection = EditorSelection.range(from, to);
110+
111+
editorRef.current.view?.dispatch({
112+
effects: [
113+
EditorView.scrollIntoView(selection, { y: "center" }),
114+
]
115+
});
116+
// @note: we need to include `isEditorCreated` in the dependency array since
117+
// a race-condition can happen if the `highlightRange` is resolved before the
118+
// editor is created.
119+
// eslint-disable-next-line react-hooks/exhaustive-deps
120+
}, [highlightRange, isEditorCreated]);
121+
122+
const { theme } = useThemeNormalized();
123+
124+
return (
125+
<ScrollArea className="h-full overflow-auto flex-1">
126+
<CodeMirror
127+
className="relative"
128+
ref={editorRef}
129+
onCreateEditor={() => {
130+
setIsEditorCreated(true);
131+
}}
132+
value={source}
133+
extensions={extensions}
134+
readOnly={true}
135+
theme={theme === "dark" ? "dark" : "light"}
136+
>
137+
{editorRef.current && editorRef.current.view && currentSelection && (
138+
<EditorContextMenu
139+
view={editorRef.current.view}
140+
selection={currentSelection}
141+
repoName={repoName}
142+
path={path}
143+
revisionName={revisionName}
144+
/>
145+
)}
146+
</CodeMirror>
147+
</ScrollArea>
148+
)
149+
}
150+
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { FileHeader } from "@/app/components/fireHeader";
2+
import { TopBar } from "@/app/components/topBar";
3+
import { Separator } from '@/components/ui/separator';
4+
import { getFileSource, listRepositories } from '@/lib/server/searchService';
5+
import { base64Decode, isServiceError } from "@/lib/utils";
6+
import { CodePreview } from "./codePreview";
7+
import { PageNotFound } from "@/app/components/pageNotFound";
8+
import { ErrorCode } from "@/lib/errorCodes";
9+
import { LuFileX2, LuBookX } from "react-icons/lu";
10+
11+
interface BrowsePageProps {
12+
params: {
13+
path: string[];
14+
};
15+
}
16+
17+
export default async function BrowsePage({
18+
params,
19+
}: BrowsePageProps) {
20+
const rawPath = decodeURIComponent(params.path.join('/'));
21+
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//);
22+
if (sentinalIndex === -1) {
23+
return <PageNotFound />;
24+
}
25+
26+
const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@');
27+
const repoName = repoAndRevisionName[0];
28+
const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined;
29+
30+
const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => {
31+
const path = rawPath.substring(sentinalIndex + '/-/'.length);
32+
const pathType = path.startsWith('tree/') ? 'tree' : 'blob';
33+
switch (pathType) {
34+
case 'tree':
35+
return {
36+
path: path.substring('tree/'.length),
37+
pathType,
38+
};
39+
case 'blob':
40+
return {
41+
path: path.substring('blob/'.length),
42+
pathType,
43+
};
44+
}
45+
})();
46+
47+
// @todo (bkellam) : We should probably have a endpoint to fetch repository metadata
48+
// given it's name or id.
49+
const reposResponse = await listRepositories();
50+
if (isServiceError(reposResponse)) {
51+
// @todo : proper error handling
52+
return (
53+
<>
54+
Error: {reposResponse.message}
55+
</>
56+
)
57+
}
58+
const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName);
59+
60+
if (pathType === 'tree') {
61+
// @todo : proper tree handling
62+
return (
63+
<>
64+
Tree view not supported
65+
</>
66+
)
67+
}
68+
69+
return (
70+
<div className="flex flex-col h-screen">
71+
<div className='sticky top-0 left-0 right-0 z-10'>
72+
<TopBar
73+
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
74+
/>
75+
<Separator />
76+
{repo && (
77+
<>
78+
<div className="bg-accent py-1 px-2 flex flex-row">
79+
<FileHeader
80+
fileName={path}
81+
repo={repo.Repository}
82+
branchDisplayName={revisionName}
83+
/>
84+
</div>
85+
<Separator />
86+
</>
87+
)}
88+
</div>
89+
{repo === undefined ? (
90+
<div className="flex h-full">
91+
<div className="m-auto flex flex-col items-center gap-2">
92+
<LuBookX className="h-12 w-12 text-secondary-foreground" />
93+
<span className="font-medium text-secondary-foreground">Repository not found</span>
94+
</div>
95+
</div>
96+
) : (
97+
<CodePreviewWrapper
98+
path={path}
99+
repoName={repoName}
100+
revisionName={revisionName ?? 'HEAD'}
101+
/>
102+
)}
103+
</div>
104+
)
105+
}
106+
107+
interface CodePreviewWrapper {
108+
path: string,
109+
repoName: string,
110+
revisionName: string,
111+
}
112+
113+
const CodePreviewWrapper = async ({
114+
path,
115+
repoName,
116+
revisionName,
117+
}: CodePreviewWrapper) => {
118+
// @todo: this will depend on `pathType`.
119+
const fileSourceResponse = await getFileSource({
120+
fileName: path,
121+
repository: repoName,
122+
branch: revisionName,
123+
});
124+
125+
if (isServiceError(fileSourceResponse)) {
126+
if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) {
127+
return (
128+
<div className="flex h-full">
129+
<div className="m-auto flex flex-col items-center gap-2">
130+
<LuFileX2 className="h-12 w-12 text-secondary-foreground" />
131+
<span className="font-medium text-secondary-foreground">File not found</span>
132+
</div>
133+
</div>
134+
)
135+
}
136+
137+
// @todo : proper error handling
138+
return (
139+
<>
140+
Error: {fileSourceResponse.message}
141+
</>
142+
)
143+
}
144+
145+
return (
146+
<CodePreview
147+
source={base64Decode(fileSourceResponse.source)}
148+
language={fileSourceResponse.language}
149+
repoName={repoName}
150+
path={path}
151+
revisionName={revisionName}
152+
/>
153+
)
154+
}

0 commit comments

Comments
 (0)