Skip to content
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
- Fixed line numbers being selectable in Safari in the lightweight code highlighter. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037)
- Fixed GitLab sync deleting repos when the API returns a non-404 error (e.g. 500) during group/user/project fetch. [#1039](https://github.com/sourcebot-dev/sourcebot/pull/1039)
- Fixed React hydration mismatch in `KeyboardShortcutHint` caused by platform detection running at module load time during SSR. [#1041](https://github.com/sourcebot-dev/sourcebot/pull/1041)
- Fixed rendering performance for ask threads, especially when hovering or selecting citations. [#1042](https://github.com/sourcebot-dev/sourcebot/pull/1042)

### Added
- Added optional copy button to the lightweight code highlighter (`isCopyButtonVisible` prop), shown on hover. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { DuplicateChatDialog } from '@/app/[domain]/chat/components/duplicateCha
import { LoginModal } from '@/app/components/loginModal';
import type { IdentityProviderMetadata } from '@/lib/identityProviders';
import { getAskGhLoginWallData } from '../../actions';
import { useParams } from 'next/navigation';
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';

type ChatHistoryState = {
scrollOffset?: number;
Expand Down Expand Up @@ -71,7 +71,6 @@ export const ChatThread = ({
const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: false });
const { toast } = useToast();
const router = useRouter();
const params = useParams<{ domain: string }>();
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
Expand Down Expand Up @@ -341,9 +340,9 @@ export const ChatThread = ({
}

captureEvent('wa_chat_duplicated', { chatId: defaultChatId });
router.push(`/${params.domain}/chat/${result.id}`);
router.push(`/${SINGLE_TENANT_ORG_DOMAIN}/chat/${result.id}`);
return result.id;
}, [defaultChatId, toast, router, params.domain, captureEvent]);
}, [defaultChatId, toast, router, captureEvent]);

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,25 @@ import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/sy
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { cn } from "@/lib/utils";
import { Range } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
import { EditorView } from '@codemirror/view';
import { CodeHostType } from "@sourcebot/db";
import CodeMirror, { ReactCodeMirrorRef, StateField } from '@uiw/react-codemirror';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import isEqual from "fast-deep-equal/react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef, memo, Ref, useCallback, useImperativeHandle, useMemo, useState } from "react";
import { forwardRef, memo, Ref, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { FileReference } from "../../types";
import { createCodeFoldingExtension } from "./codeFoldingExtension";
import { createReferencesHighlightExtension, setHoveredIdEffect, setSelectedIdEffect } from "./referencesHighlightExtension";

const lineDecoration = Decoration.line({
attributes: { class: "cm-range-border-radius chat-lineHighlight" },
});

const selectedLineDecoration = Decoration.line({
attributes: { class: "cm-range-border-radius cm-range-border-shadow chat-lineHighlight-selected" },
});

const hoverLineDecoration = Decoration.line({
attributes: { class: "chat-lineHighlight-hover" },
});

const CODEMIRROR_BASIC_SETUP = {
highlightActiveLine: false,
highlightActiveLineGutter: false,
foldGutter: false,
foldKeymap: false,
} as const;

interface ReferencedFileSourceListItemProps {
id: string;
Expand Down Expand Up @@ -75,47 +70,32 @@ const ReferencedFileSourceListItemComponent = ({
forwardedRef,
() => editorRef as ReactCodeMirrorRef
);

const keymapExtension = useKeymapExtension(editorRef?.view);
const hasCodeNavEntitlement = useHasEntitlement("code-nav");

const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);

const getReferenceAtPos = useCallback((x: number, y: number, view: EditorView): FileReference | undefined => {
const pos = view.posAtCoords({ x, y });
if (pos === null) return undefined;

// Check if position is within the main editor content area
const rect = view.contentDOM.getBoundingClientRect();
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
return undefined;
}

const line = view.state.doc.lineAt(pos);
const lineNumber = line.number;

// Check if this line is part of any highlighted range
const matchingRanges = references.filter(({ range }) =>
range && lineNumber >= range.startLine && lineNumber <= range.endLine
);
const codeFoldingExtension = useMemo(() => {
return createCodeFoldingExtension(references, 3);
}, [references]);

// Sort by the length of the range.
// Shorter ranges are more specific, so we want to prioritize them.
matchingRanges.sort((a, b) => {
const aLength = (a.range!.endLine) - (a.range!.startLine);
const bLength = (b.range!.endLine) - (b.range!.startLine);
return aLength - bLength;
});
const referencesHighlightExtension = useExtensionWithDependency(
editorRef?.view ?? null,
() => createReferencesHighlightExtension(references, onHoveredReferenceChanged, onSelectedReferenceChanged),
[references],
);

if (matchingRanges.length > 0) {
return matchingRanges[0];
useEffect(() => {
if (editorRef?.view) {
editorRef.view.dispatch({ effects: setHoveredIdEffect.of(hoveredReference?.id) });
}
}, [hoveredReference?.id, editorRef?.view]);

return undefined;
}, [references]);

const codeFoldingExtension = useMemo(() => {
return createCodeFoldingExtension(references, 3);
}, [references]);
useEffect(() => {
if (editorRef?.view) {
editorRef.view.dispatch({ effects: setSelectedIdEffect.of(selectedReference?.id) });
}
}, [selectedReference?.id, editorRef?.view]);

const extensions = useMemo(() => {
return [
Expand All @@ -126,88 +106,14 @@ const ReferencedFileSourceListItemComponent = ({
symbolHoverTargetsExtension,
] : []),
codeFoldingExtension,
StateField.define<DecorationSet>({
create(state) {
const decorations: Range<Decoration>[] = [];

for (const { range, id } of references) {
if (!range) {
continue;
}

const isHovered = id === hoveredReference?.id;
const isSelected = id === selectedReference?.id;

for (let line = range.startLine; line <= range.endLine; line++) {
// Skip lines that are outside the document bounds.
if (line > state.doc.lines) {
continue;
}

if (isSelected) {
decorations.push(selectedLineDecoration.range(state.doc.line(line).from));
} else {
decorations.push(lineDecoration.range(state.doc.line(line).from));
if (isHovered) {
decorations.push(hoverLineDecoration.range(state.doc.line(line).from));
}
}

}
}

return Decoration.set(decorations, /* sort = */ true);
},
update(deco, tr) {
return deco.map(tr.changes);
},
provide: (field) => EditorView.decorations.from(field),
}),
EditorView.domEventHandlers({
click: (event, view) => {
const reference = getReferenceAtPos(event.clientX, event.clientY, view);

if (reference) {
onSelectedReferenceChanged(reference.id === selectedReference?.id ? undefined : reference);
return true; // prevent default handling
}
return false;
},
mouseover: (event, view) => {
const reference = getReferenceAtPos(event.clientX, event.clientY, view);
if (!reference) {
return false;
}

if (reference.id === selectedReference?.id || reference.id === hoveredReference?.id) {
return false;
}

onHoveredReferenceChanged(reference);
return true;
},
mouseout: (event, view) => {
const reference = getReferenceAtPos(event.clientX, event.clientY, view);
if (reference) {
return false;
}

onHoveredReferenceChanged(undefined);
return true;
}
})
referencesHighlightExtension,
];
}, [
languageExtension,
keymapExtension,
hasCodeNavEntitlement,
references,
hoveredReference?.id,
selectedReference?.id,
getReferenceAtPos,
onSelectedReferenceChanged,
onHoveredReferenceChanged,
codeFoldingExtension,
referencesHighlightExtension,
]);

const ExpandCollapseIcon = useMemo(() => {
Expand Down Expand Up @@ -253,12 +159,7 @@ const ReferencedFileSourceListItemComponent = ({
extensions={extensions}
readOnly={true}
theme={theme}
basicSetup={{
highlightActiveLine: false,
highlightActiveLineGutter: false,
foldGutter: false,
foldKeymap: false,
}}
basicSetup={CODEMIRROR_BASIC_SETUP}
>
{editorRef && hasCodeNavEntitlement && (
<SymbolHoverPopup
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import { getFileSource } from "@/app/api/(client)/client";
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
import { Skeleton } from "@/components/ui/skeleton";
import { isServiceError, unwrapServiceError } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { memo, useCallback } from "react";
import { FileReference, FileSource, Reference } from "../../types";
import { ReferencedFileSourceListItem } from "./referencedFileSourceListItem";
import isEqual from 'fast-deep-equal/react';

export interface ReferencedFileSourceListItemContainerProps {
fileId: string;
fileSource: FileSource;
references: FileReference[];
hoveredReference?: Reference;
selectedReference?: Reference;
onHoveredReferenceChanged: (reference?: Reference) => void;
onSelectedReferenceChanged: (reference?: Reference) => void;
isExpanded: boolean;
onExpandedChanged: (fileId: string, isExpanded: boolean) => void;
onEditorRef: (fileId: string, ref: ReactCodeMirrorRef | null) => void;
}

const ReferencedFileSourceListItemContainerComponent = ({
fileId,
fileSource,
references,
hoveredReference,
selectedReference,
onHoveredReferenceChanged,
onSelectedReferenceChanged,
isExpanded,
onExpandedChanged,
onEditorRef,
}: ReferencedFileSourceListItemContainerProps) => {
const fileName = fileSource.path.split('/').pop() ?? fileSource.path;

const { data, isLoading, isError, error } = useQuery({
queryKey: ['fileSource', fileSource.path, fileSource.repo, fileSource.revision],
queryFn: () => unwrapServiceError(getFileSource({
path: fileSource.path,
repo: fileSource.repo,
ref: fileSource.revision,
})),
staleTime: Infinity,
});

const handleRef = useCallback((ref: ReactCodeMirrorRef | null) => {
onEditorRef(fileId, ref);
}, [fileId, onEditorRef]);

const handleExpandedChanged = useCallback((isExpanded: boolean) => {
onExpandedChanged(fileId, isExpanded);
}, [fileId, onExpandedChanged]);

if (isLoading) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 p-2">
<VscodeFileIcon fileName={fileName} className="w-4 h-4" />
<span className="text-sm font-medium">{fileName}</span>
</div>
<Skeleton className="h-48 w-full" />
</div>
);
}

if (isError || isServiceError(data) || !data) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 p-2">
<VscodeFileIcon fileName={fileName} className="w-4 h-4" />
<span className="text-sm font-medium">{fileName}</span>
</div>
<div className="p-4 text-sm text-destructive bg-destructive/10 rounded border">
Failed to load file: {isServiceError(data) ? data.message : error?.message ?? 'Unknown error'}
</div>
</div>
);
}

return (
<ReferencedFileSourceListItem
id={fileId}
code={data.source}
language={data.language}
revision={fileSource.revision}
repoName={fileSource.repo}
repoCodeHostType={data.repoCodeHostType}
repoDisplayName={data.repoDisplayName}
repoWebUrl={data.repoExternalWebUrl}
fileName={data.path}
references={references}
ref={handleRef}
onSelectedReferenceChanged={onSelectedReferenceChanged}
onHoveredReferenceChanged={onHoveredReferenceChanged}
selectedReference={selectedReference}
hoveredReference={hoveredReference}
isExpanded={isExpanded}
onExpandedChanged={handleExpandedChanged}
/>
);
};

export const ReferencedFileSourceListItemContainer = memo(ReferencedFileSourceListItemContainerComponent, isEqual);
Loading
Loading