import { DiffEditor, Editor, Monaco } from "@monaco-editor/react"; import React from "react"; import { editor as editor_t } from "monaco-editor"; import { useTranslation } from "react-i18next"; import { LuFileDiff, LuFileMinus, LuFilePlus, LuHistory, LuGitCompareArrows, LuFileCheck, } from "react-icons/lu"; import { IconType } from "react-icons/lib"; import { GitChangeStatus } from "#/api/open-hands.types"; import { I18nKey } from "#/i18n/declaration"; import { getLanguageFromPath } from "#/utils/get-language-from-path"; import { cn } from "#/utils/utils"; import { ChevronDown, ChevronRight } from "lucide-react"; import { useUnifiedGitDiff } from "#/hooks/query/use-unified-git-diff"; import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; import { Typography } from "#/ui/typography"; import { LoadingSpinner } from "./loading-spinner"; import { EditorContainer } from "./editor-container"; import { AccordionPanel } from "./accordion-panel"; type ViewMode = "diff" | "old" | "new"; const VIEW_MODES: { mode: ViewMode; icon: IconType }[] = [ { mode: "old", icon: LuHistory }, { mode: "diff", icon: LuGitCompareArrows }, { mode: "new", icon: LuFileCheck }, ]; const SHARED_EDITOR_OPTIONS: editor_t.IEditorOptions = { renderValidationDecorations: "off", readOnly: true, scrollBeyondLastLine: false, minimap: { enabled: false }, automaticLayout: true, scrollbar: { alwaysConsumeMouseWheel: false }, }; /** Cap opened diff/editor panes so a large file doesn't dominate the drawer. */ export const MAX_DIFF_EDITOR_HEIGHT_PX = 600; function clampDiffEditorHeight(height: number): number { return Math.min(height, MAX_DIFF_EDITOR_HEIGHT_PX); } const STATUS_MAP: Record = { A: LuFilePlus, D: LuFileMinus, M: LuFileDiff, R: "Renamed", U: "Untracked", }; const beforeMount = (monaco: Monaco) => { monaco.editor.defineTheme("custom-diff-theme", { base: "vs-dark", inherit: true, rules: [ { token: "comment", foreground: "6a9955" }, { token: "keyword", foreground: "569cd6" }, { token: "string", foreground: "ce9178" }, { token: "number", foreground: "b5cea8" }, ], colors: { "diffEditor.insertedTextBackground": "#014b01AA", "diffEditor.removedTextBackground": "#750000AA", "diffEditor.insertedLineBackground": "#003f00AA", "diffEditor.removedLineBackground": "#5a0000AA", "diffEditor.border": "var(--oh-border-subtle)", "editorUnnecessaryCode.border": "#00000000", "editorUnnecessaryCode.opacity": "rgba(0, 0, 0, 0.467)", }, }); }; export interface FileDiffViewerProps { path: string; type: GitChangeStatus; /** * When set, show the file's diff as changed by this commit instead of * the working-tree-vs-base diff. Deleted files render their content in * commit mode (both sides come from git objects). */ commit?: string; /** * Controlled accordion open state. When omitted, the row manages its own * expand/collapse (used by unit tests and standalone embeds). */ isExpanded?: boolean; /** Required with `isExpanded` for controlled accordion lists. */ onToggle?: () => void; } export function FileDiffViewer({ path, type, commit, isExpanded: controlledExpanded, onToggle, }: FileDiffViewerProps) { const { t } = useTranslation("openhands"); const [uncontrolledExpanded, setUncontrolledExpanded] = React.useState(false); const isControlled = controlledExpanded !== undefined; const isExpanded = isControlled ? controlledExpanded : uncontrolledExpanded; const isCollapsed = !isExpanded; const handleToggle = () => { if (isControlled) { onToggle?.(); return; } setUncontrolledExpanded((prev) => !prev); }; const [editorHeight, setEditorHeight] = React.useState(0); const [hasMeasuredEditorHeight, setHasMeasuredEditorHeight] = React.useState(false); const [viewMode, setViewMode] = React.useState("diff"); const diffEditorRef = React.useRef(null); const singleEditorRef = React.useRef(null); const isAdded = type === "A" || type === "U"; const isDeleted = type === "D"; const filePath = React.useMemo(() => { if (type === "R") { const parts = path.split(/\s+/).slice(1); return parts[parts.length - 1]; } return path; }, [path, type]); const { data: diff, isLoading, isSuccess, isRefetching, } = useUnifiedGitDiff({ filePath, type, enabled: !isCollapsed, commit, }); const updateEditorHeight = React.useCallback(() => { if (!diffEditorRef.current) return; const originalEditor = diffEditorRef.current.getOriginalEditor(); const modifiedEditor = diffEditorRef.current.getModifiedEditor(); if (originalEditor && modifiedEditor) { setEditorHeight( clampDiffEditorHeight( Math.max( originalEditor.getContentHeight(), modifiedEditor.getContentHeight(), ) + 20, ), ); setHasMeasuredEditorHeight(true); } }, []); const updateSingleEditorHeight = React.useCallback(() => { if (singleEditorRef.current) { setEditorHeight( clampDiffEditorHeight(singleEditorRef.current.getContentHeight() + 20), ); setHasMeasuredEditorHeight(true); } }, []); // Reset measured height when collapsing or switching view modes so the // next paint doesn't flash a stale editor size before Monaco remeasures. React.useEffect(() => { setHasMeasuredEditorHeight(false); setEditorHeight(0); }, [isCollapsed, viewMode]); const handleDiffEditorMount = (editor: editor_t.IStandaloneDiffEditor) => { diffEditorRef.current = editor; updateEditorHeight(); editor.getOriginalEditor().onDidContentSizeChange(updateEditorHeight); editor.getModifiedEditor().onDidContentSizeChange(updateEditorHeight); }; const handleSingleEditorMount = (editor: editor_t.IStandaloneCodeEditor) => { singleEditorRef.current = editor; updateSingleEditorHeight(); editor.onDidContentSizeChange(updateSingleEditorHeight); }; const status = (type === "U" ? STATUS_MAP.A : STATUS_MAP[type]) || "?"; const statusIcon = typeof status === "string" ? ( {status} ) : ( React.createElement(status, { className: "w-4 h-4 shrink-0" }) ); const isFetchingData = isLoading || isRefetching; const language = getLanguageFromPath(filePath); const isMarkdownFile = language === "markdown"; const singleViewContent = viewMode === "old" ? (diff?.original ?? "") : (diff?.modified ?? ""); const showViewModeToggle = !isDeleted; const viewModeControlsVisible = !isCollapsed && showViewModeToggle; const renderEditorShell = (editor: React.ReactNode) => (
{editor}
); const renderContent = () => { if (viewMode === "diff") { return renderEditorShell( , ); } if (isMarkdownFile) { return (
); } return renderEditorShell( , ); }; return (
{isFetchingData ? ( ) : ( statusIcon )} {filePath} {showViewModeToggle && ( e.stopPropagation()} aria-hidden={!viewModeControlsVisible} > {VIEW_MODES.map(({ mode, icon: Icon }) => ( ))} )}
{isDeleted && !commit ? (
{t(I18nKey.DIFF_VIEWER$FILE_DELETED)}
) : ( isSuccess && renderContent() )}
); }