Skip to content

Commit 27fb5ad

Browse files
feature: File explorer (#336)
1 parent 8dc41a2 commit 27fb5ad

File tree

24 files changed

+1765
-504
lines changed

24 files changed

+1765
-504
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331)
1212
- Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332)
1313
- Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335)
14+
- Added support for a file explorer when browsing files. [#336](https://github.com/sourcebot-dev/sourcebot/pull/336)
1415

1516
## [4.1.1] - 2025-06-03
1617

packages/backend/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
7070
return true;
7171
}
7272

73+
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
74+
// @todo: we should move this to a shared package.
7375
export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => {
7476
// If we are dealing with a local repository, then use that as the path.
7577
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.

packages/web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,16 @@
137137
"react-hotkeys-hook": "^4.5.1",
138138
"react-icons": "^5.3.0",
139139
"react-resizable-panels": "^2.1.1",
140+
"scroll-into-view-if-needed": "^3.1.0",
140141
"server-only": "^0.0.1",
141142
"sharp": "^0.33.5",
143+
"simple-git": "^3.27.0",
142144
"strip-json-comments": "^5.0.1",
143145
"stripe": "^17.6.0",
144146
"tailwind-merge": "^2.5.2",
145147
"tailwindcss-animate": "^1.0.7",
146148
"usehooks-ts": "^3.1.0",
149+
"vscode-icons-js": "^11.6.1",
147150
"zod": "^3.24.3",
148151
"zod-to-json-schema": "^3.24.5"
149152
},
Lines changed: 88 additions & 215 deletions
Original file line numberDiff line numberDiff line change
@@ -1,226 +1,99 @@
11
'use client';
22

3-
import { ResizablePanel } from "@/components/ui/resizable";
4-
import { ScrollArea } from "@/components/ui/scroll-area";
5-
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
6-
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
7-
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
8-
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
9-
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
10-
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
11-
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
12-
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
13-
import { search } from "@codemirror/search";
14-
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
15-
import { useCallback, useEffect, useMemo, useState } from "react";
16-
import { EditorContextMenu } from "../../../components/editorContextMenu";
17-
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
18-
import { useBrowseState } from "../../hooks/useBrowseState";
19-
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
20-
import useCaptureEvent from "@/hooks/useCaptureEvent";
21-
22-
interface CodePreviewPanelProps {
23-
path: string;
24-
repoName: string;
25-
revisionName: string;
26-
source: string;
27-
language: string;
28-
}
29-
30-
export const CodePreviewPanel = ({
31-
source,
32-
language,
33-
path,
34-
repoName,
35-
revisionName,
36-
}: CodePreviewPanelProps) => {
37-
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
38-
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
39-
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
40-
const keymapExtension = useKeymapExtension(editorRef?.view);
41-
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
42-
const { updateBrowseState } = useBrowseState();
43-
const { navigateToPath } = useBrowseNavigation();
44-
const captureEvent = useCaptureEvent();
45-
46-
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
47-
const highlightRange = useMemo((): BrowseHighlightRange | undefined => {
48-
if (!highlightRangeQuery) {
49-
return;
3+
import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils";
4+
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
5+
import { useQuery } from "@tanstack/react-query";
6+
import { getFileSource } from "@/features/search/fileSourceApi";
7+
import { useDomain } from "@/hooks/useDomain";
8+
import { Loader2 } from "lucide-react";
9+
import { Separator } from "@/components/ui/separator";
10+
import { getRepoInfoByName } from "@/actions";
11+
import { cn } from "@/lib/utils";
12+
import Image from "next/image";
13+
import { useMemo } from "react";
14+
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
15+
import { PathHeader } from "@/app/[domain]/components/pathHeader";
16+
17+
export const CodePreviewPanel = () => {
18+
const { path, repoName, revisionName } = useBrowseParams();
19+
const domain = useDomain();
20+
21+
const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({
22+
queryKey: ['fileSource', repoName, revisionName, path, domain],
23+
queryFn: () => unwrapServiceError(getFileSource({
24+
fileName: path,
25+
repository: repoName,
26+
branch: revisionName
27+
}, domain)),
28+
});
29+
30+
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
31+
queryKey: ['repoInfo', repoName, domain],
32+
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
33+
});
34+
35+
const codeHostInfo = useMemo(() => {
36+
if (!repoInfoResponse) {
37+
return undefined;
5038
}
5139

52-
// Highlight ranges can be formatted in two ways:
53-
// 1. start_line,end_line (no column specified)
54-
// 2. start_line:start_column,end_line:end_column (column specified)
55-
const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/;
56-
if (!rangeRegex.test(highlightRangeQuery)) {
57-
return;
58-
}
59-
60-
const [start, end] = highlightRangeQuery.split(',').map((range) => {
61-
if (range.includes(':')) {
62-
return range.split(':').map((val) => parseInt(val, 10));
63-
}
64-
// For line-only format, use column 1 for start and last column for end
65-
const line = parseInt(range, 10);
66-
return [line];
40+
return getCodeHostInfoForRepo({
41+
codeHostType: repoInfoResponse.codeHostType,
42+
name: repoInfoResponse.name,
43+
displayName: repoInfoResponse.displayName,
44+
webUrl: repoInfoResponse.webUrl,
6745
});
46+
}, [repoInfoResponse]);
6847

69-
if (start.length === 1 || end.length === 1) {
70-
return {
71-
start: {
72-
lineNumber: start[0],
73-
},
74-
end: {
75-
lineNumber: end[0],
76-
}
77-
}
78-
} else {
79-
return {
80-
start: {
81-
lineNumber: start[0],
82-
column: start[1],
83-
},
84-
end: {
85-
lineNumber: end[0],
86-
column: end[1],
87-
}
88-
}
89-
}
90-
91-
}, [highlightRangeQuery]);
92-
93-
const extensions = useMemo(() => {
94-
return [
95-
languageExtension,
96-
EditorView.lineWrapping,
97-
keymapExtension,
98-
search({
99-
top: true,
100-
}),
101-
EditorView.updateListener.of((update: ViewUpdate) => {
102-
if (update.selectionSet) {
103-
setCurrentSelection(update.state.selection.main);
104-
}
105-
}),
106-
highlightRange ? rangeHighlightingExtension(highlightRange) : [],
107-
hasCodeNavEntitlement ? symbolHoverTargetsExtension : [],
108-
];
109-
}, [
110-
keymapExtension,
111-
languageExtension,
112-
highlightRange,
113-
hasCodeNavEntitlement,
114-
]);
115-
116-
// Scroll the highlighted range into view.
117-
useEffect(() => {
118-
if (!highlightRange || !editorRef || !editorRef.state) {
119-
return;
120-
}
121-
122-
const doc = editorRef.state.doc;
123-
const { start, end } = highlightRange;
124-
const selection = EditorSelection.range(
125-
doc.line(start.lineNumber).from,
126-
doc.line(end.lineNumber).from,
127-
);
128-
129-
editorRef.view?.dispatch({
130-
effects: [
131-
EditorView.scrollIntoView(selection, { y: "center" }),
132-
]
133-
});
134-
}, [editorRef, highlightRange]);
48+
if (isFileSourcePending || isRepoInfoPending) {
49+
return (
50+
<div className="flex flex-col w-full min-h-full items-center justify-center">
51+
<Loader2 className="w-4 h-4 animate-spin" />
52+
Loading...
53+
</div>
54+
)
55+
}
13556

136-
const onFindReferences = useCallback((symbolName: string) => {
137-
captureEvent('wa_browse_find_references_pressed', {});
138-
139-
updateBrowseState({
140-
selectedSymbolInfo: {
141-
repoName,
142-
symbolName,
143-
revisionName,
144-
language,
145-
},
146-
isBottomPanelCollapsed: false,
147-
activeExploreMenuTab: "references",
148-
})
149-
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);
150-
151-
152-
// If we resolve multiple matches, instead of navigating to the first match, we should
153-
// instead popup the bottom sheet with the list of matches.
154-
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
155-
captureEvent('wa_browse_goto_definition_pressed', {});
156-
157-
if (symbolDefinitions.length === 0) {
158-
return;
159-
}
160-
161-
if (symbolDefinitions.length === 1) {
162-
const symbolDefinition = symbolDefinitions[0];
163-
const { fileName, repoName } = symbolDefinition;
164-
165-
navigateToPath({
166-
repoName,
167-
revisionName,
168-
path: fileName,
169-
pathType: 'blob',
170-
highlightRange: symbolDefinition.range,
171-
})
172-
} else {
173-
updateBrowseState({
174-
selectedSymbolInfo: {
175-
symbolName,
176-
repoName,
177-
revisionName,
178-
language,
179-
},
180-
activeExploreMenuTab: "definitions",
181-
isBottomPanelCollapsed: false,
182-
})
183-
}
184-
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
185-
186-
const theme = useCodeMirrorTheme();
57+
if (isFileSourceError || isRepoInfoError) {
58+
return <div>Error loading file source</div>
59+
}
18760

18861
return (
189-
<ResizablePanel
190-
order={1}
191-
id={"code-preview-panel"}
192-
>
193-
<ScrollArea className="h-full overflow-auto flex-1">
194-
<CodeMirror
195-
className="relative"
196-
ref={setEditorRef}
197-
value={source}
198-
extensions={extensions}
199-
readOnly={true}
200-
theme={theme}
201-
>
202-
{editorRef && editorRef.view && currentSelection && (
203-
<EditorContextMenu
204-
view={editorRef.view}
205-
selection={currentSelection}
206-
repoName={repoName}
207-
path={path}
208-
revisionName={revisionName}
62+
<>
63+
<div className="flex flex-row py-1 px-2 items-center justify-between">
64+
<PathHeader
65+
path={path}
66+
repo={{
67+
name: repoName,
68+
codeHostType: repoInfoResponse.codeHostType,
69+
displayName: repoInfoResponse.displayName,
70+
webUrl: repoInfoResponse.webUrl,
71+
}}
72+
/>
73+
{(fileSourceResponse.webUrl && codeHostInfo) && (
74+
<a
75+
href={fileSourceResponse.webUrl}
76+
target="_blank"
77+
rel="noopener noreferrer"
78+
className="flex flex-row items-center gap-2 px-2 py-0.5 rounded-md flex-shrink-0"
79+
>
80+
<Image
81+
src={codeHostInfo.icon}
82+
alt={codeHostInfo.codeHostName}
83+
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
20984
/>
210-
)}
211-
{editorRef && hasCodeNavEntitlement && (
212-
<SymbolHoverPopup
213-
editorRef={editorRef}
214-
revisionName={revisionName}
215-
language={language}
216-
onFindReferences={onFindReferences}
217-
onGotoDefinition={onGotoDefinition}
218-
/>
219-
)}
220-
</CodeMirror>
221-
222-
</ScrollArea>
223-
</ResizablePanel>
85+
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
86+
</a>
87+
)}
88+
</div>
89+
<Separator />
90+
<PureCodePreviewPanel
91+
source={base64Decode(fileSourceResponse.source)}
92+
language={fileSourceResponse.language}
93+
repoName={repoName}
94+
path={path}
95+
revisionName={revisionName ?? 'HEAD'}
96+
/>
97+
</>
22498
)
225-
}
226-
99+
}

0 commit comments

Comments
 (0)