Skip to content

feature: File explorer #336

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 18 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331)
- Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332)
- Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335)
- Added support for a file explorer when browsing files. [#336](https://github.com/sourcebot-dev/sourcebot/pull/336)

## [4.1.1] - 2025-06-03

Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
return true;
}

// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
// @todo: we should move this to a shared package.
export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => {
// If we are dealing with a local repository, then use that as the path.
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
Expand Down
3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,16 @@
"react-hotkeys-hook": "^4.5.1",
"react-icons": "^5.3.0",
"react-resizable-panels": "^2.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
"simple-git": "^3.27.0",
"strip-json-comments": "^5.0.1",
"stripe": "^17.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"vscode-icons-js": "^11.6.1",
"zod": "^3.24.3",
"zod-to-json-schema": "^3.24.5"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,226 +1,99 @@
'use client';

import { ResizablePanel } from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { search } from "@codemirror/search";
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
import { useCallback, useEffect, useMemo, useState } from "react";
import { EditorContextMenu } from "../../../components/editorContextMenu";
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { useBrowseState } from "../../hooks/useBrowseState";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent";

interface CodePreviewPanelProps {
path: string;
repoName: string;
revisionName: string;
source: string;
language: string;
}

export const CodePreviewPanel = ({
source,
language,
path,
repoName,
revisionName,
}: CodePreviewPanelProps) => {
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
const keymapExtension = useKeymapExtension(editorRef?.view);
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const { updateBrowseState } = useBrowseState();
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();

const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
const highlightRange = useMemo((): BrowseHighlightRange | undefined => {
if (!highlightRangeQuery) {
return;
import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { useQuery } from "@tanstack/react-query";
import { getFileSource } from "@/features/search/fileSourceApi";
import { useDomain } from "@/hooks/useDomain";
import { Loader2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useMemo } from "react";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
import { PathHeader } from "@/app/[domain]/components/pathHeader";

export const CodePreviewPanel = () => {
const { path, repoName, revisionName } = useBrowseParams();
const domain = useDomain();

const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({
queryKey: ['fileSource', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(getFileSource({
fileName: path,
repository: repoName,
branch: revisionName
}, domain)),
});

const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
queryKey: ['repoInfo', repoName, domain],
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
});

const codeHostInfo = useMemo(() => {
if (!repoInfoResponse) {
return undefined;
}

// Highlight ranges can be formatted in two ways:
// 1. start_line,end_line (no column specified)
// 2. start_line:start_column,end_line:end_column (column specified)
const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/;
if (!rangeRegex.test(highlightRangeQuery)) {
return;
}

const [start, end] = highlightRangeQuery.split(',').map((range) => {
if (range.includes(':')) {
return range.split(':').map((val) => parseInt(val, 10));
}
// For line-only format, use column 1 for start and last column for end
const line = parseInt(range, 10);
return [line];
return getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType,
name: repoInfoResponse.name,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
});
}, [repoInfoResponse]);

if (start.length === 1 || end.length === 1) {
return {
start: {
lineNumber: start[0],
},
end: {
lineNumber: end[0],
}
}
} else {
return {
start: {
lineNumber: start[0],
column: start[1],
},
end: {
lineNumber: end[0],
column: end[1],
}
}
}

}, [highlightRangeQuery]);

const extensions = useMemo(() => {
return [
languageExtension,
EditorView.lineWrapping,
keymapExtension,
search({
top: true,
}),
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.selectionSet) {
setCurrentSelection(update.state.selection.main);
}
}),
highlightRange ? rangeHighlightingExtension(highlightRange) : [],
hasCodeNavEntitlement ? symbolHoverTargetsExtension : [],
];
}, [
keymapExtension,
languageExtension,
highlightRange,
hasCodeNavEntitlement,
]);

// Scroll the highlighted range into view.
useEffect(() => {
if (!highlightRange || !editorRef || !editorRef.state) {
return;
}

const doc = editorRef.state.doc;
const { start, end } = highlightRange;
const selection = EditorSelection.range(
doc.line(start.lineNumber).from,
doc.line(end.lineNumber).from,
);

editorRef.view?.dispatch({
effects: [
EditorView.scrollIntoView(selection, { y: "center" }),
]
});
}, [editorRef, highlightRange]);
if (isFileSourcePending || isRepoInfoPending) {
return (
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
)
}

const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_browse_find_references_pressed', {});

updateBrowseState({
selectedSymbolInfo: {
repoName,
symbolName,
revisionName,
language,
},
isBottomPanelCollapsed: false,
activeExploreMenuTab: "references",
})
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);


// If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_browse_goto_definition_pressed', {});

if (symbolDefinitions.length === 0) {
return;
}

if (symbolDefinitions.length === 1) {
const symbolDefinition = symbolDefinitions[0];
const { fileName, repoName } = symbolDefinition;

navigateToPath({
repoName,
revisionName,
path: fileName,
pathType: 'blob',
highlightRange: symbolDefinition.range,
})
} else {
updateBrowseState({
selectedSymbolInfo: {
symbolName,
repoName,
revisionName,
language,
},
activeExploreMenuTab: "definitions",
isBottomPanelCollapsed: false,
})
}
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);

const theme = useCodeMirrorTheme();
if (isFileSourceError || isRepoInfoError) {
return <div>Error loading file source</div>
}

return (
<ResizablePanel
order={1}
id={"code-preview-panel"}
>
<ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror
className="relative"
ref={setEditorRef}
value={source}
extensions={extensions}
readOnly={true}
theme={theme}
>
{editorRef && editorRef.view && currentSelection && (
<EditorContextMenu
view={editorRef.view}
selection={currentSelection}
repoName={repoName}
path={path}
revisionName={revisionName}
<>
<div className="flex flex-row py-1 px-2 items-center justify-between">
<PathHeader
path={path}
repo={{
name: repoName,
codeHostType: repoInfoResponse.codeHostType,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
}}
/>
{(fileSourceResponse.webUrl && codeHostInfo) && (
<a
href={fileSourceResponse.webUrl}
target="_blank"
rel="noopener noreferrer"
className="flex flex-row items-center gap-2 px-2 py-0.5 rounded-md flex-shrink-0"
>
<Image
src={codeHostInfo.icon}
alt={codeHostInfo.codeHostName}
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
/>
)}
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
editorRef={editorRef}
revisionName={revisionName}
language={language}
onFindReferences={onFindReferences}
onGotoDefinition={onGotoDefinition}
/>
)}
</CodeMirror>

</ScrollArea>
</ResizablePanel>
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
</a>
)}
</div>
<Separator />
<PureCodePreviewPanel
source={base64Decode(fileSourceResponse.source)}
language={fileSourceResponse.language}
repoName={repoName}
path={path}
revisionName={revisionName ?? 'HEAD'}
/>
</>
)
}

}
Loading