import { useCallback, useEffect, useMemo, useRef, useImperativeHandle, forwardRef } from "react"; import { multiSelectClassNames, createDiffMultiSelectManager } from ".."; import { useCallbackRef } from "../hooks/useCallbackRef"; import { useUpdateEffect } from "../hooks/useUpdateEffect"; import { DiffModeEnum, DiffView, SplitSide } from "./DiffView"; import type { MultiSelectResult, LineRange, MultiSelectState, DiffFile, DiffMultiSelectManager, MultiSelectOptions, extendDataToPreselectedLines, } from ".."; import type { DiffViewProps } from "./DiffView"; import type { ForwardedRef, ReactNode } from "react"; export interface DiffViewWithMultiSelectProps extends Omit< DiffViewProps, "renderWidgetLine" | "onAddWidgetClick" > { /** * Enable multi-select feature * @default true */ enableMultiSelect?: boolean; /** * Callback when multi-line selection is complete * Use this to open a comment dialog or handle the selection */ onMultiSelectComplete?: (result: MultiSelectResult) => void; /** * Callback when selection changes (during drag) */ onMultiSelectChange?: (range: LineRange | null, state: MultiSelectState) => void; /** * Custom function to scope selection to one hunk * Return the scoped range or null to cancel selection */ scopeMultiSelectToHunk?: (range: LineRange) => LineRange | null; onAddWidgetClick?: (props: { lineNumber: number; fromLineNumber?: number; side: SplitSide }) => void; renderWidgetLine?: (props: { lineNumber: number; fromLineNumber: number; side: SplitSide; diffFile: DiffFile; onClose: () => void; }) => ReactNode; } export interface DiffViewWithMultiSelectRef { getDiffFileInstance: () => DiffFile | null; getSelectionResult: () => MultiSelectResult | null; getSelectionState: () => MultiSelectState; clearSelection: () => void; setPreselectedLines: (lines: { old: number[]; new: number[] }) => void; } type MultiResult = ReturnType; /* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */ const InternalDiffViewWithMultiSelect = ( props: DiffViewWithMultiSelectProps, ref: ForwardedRef ) => { const { enableMultiSelect = true, extendData, onMultiSelectComplete, onMultiSelectChange, scopeMultiSelectToHunk, renderWidgetLine, onAddWidgetClick, diffViewMode = DiffModeEnum.SplitGitHub, ...restProps } = props; const memoSelectChange = useCallbackRef(onMultiSelectChange); const memoSelectComplete = useCallbackRef(onMultiSelectComplete); const memoScopeSelectToHunk = useCallbackRef(scopeMultiSelectToHunk); const containerRef = useRef(null); const diffViewRef = useRef<{ getDiffFileInstance: () => DiffFile | null }>(null); const managerRef = useRef(null); const multiResultRef = useRef(undefined); const isUnifiedMode = !(diffViewMode & DiffModeEnum.Split); const updateMultiResult = useCallback((result?: MultiResult) => { multiResultRef.current = result; managerRef.current?.setPreselectedLines(result || { old: [], new: [] }); }, []); useUpdateEffect(() => { updateMultiResult(undefined); }, [props.diffViewWrap, diffViewMode]); const getDiffFile = useCallback(() => { return diffViewRef.current?.getDiffFileInstance() ?? null; }, []); useEffect(() => { const container = containerRef.current; const diffFile = getDiffFile(); if (!container || !diffFile || !enableMultiSelect) { managerRef.current?.destroy(); managerRef.current = null; return; } const managerOptions: MultiSelectOptions = { enabled: enableMultiSelect, isUnifiedMode, selectedClassName: multiSelectClassNames.selected, onSelectionChange: (range, state) => { if (state.isSelecting) { containerRef.current?.classList.add(multiSelectClassNames.selecting); } else { containerRef.current?.classList.remove(multiSelectClassNames.selecting); } if (state.isSelecting && multiResultRef.current) { updateMultiResult(undefined); } memoSelectChange?.(range, state); }, onSelectionComplete: (result) => { containerRef.current?.classList.remove(multiSelectClassNames.selecting); if (result && result.lines.length > 0) { memoSelectComplete?.(result); const finalResult = { [result.range.side as "old" | "new"]: [result.range.startLineNumber, result.range.endLineNumber], } as MultiResult; updateMultiResult(finalResult); } else { updateMultiResult(undefined); } }, scopeToHunk: memoScopeSelectToHunk, }; if (managerRef.current) { managerRef.current.updateContainer(container); managerRef.current.updateDiffFile(diffFile); managerRef.current.updateOptions(managerOptions); } else { managerRef.current = createDiffMultiSelectManager(container, diffFile, managerOptions); } return () => { managerRef.current?.destroy(); managerRef.current = null; }; }, [ enableMultiSelect, isUnifiedMode, memoScopeSelectToHunk, memoSelectChange, memoSelectComplete, getDiffFile, updateMultiResult, ]); const convertedExtendData = useMemo(() => { if (!extendData) return undefined; const result: { oldFile?: Record; newFile?: Record } = {}; if (extendData.oldFile) { result.oldFile = {}; for (const [key, value] of Object.entries(extendData.oldFile)) { result.oldFile[key] = { data: value.data }; } } if (extendData.newFile) { result.newFile = {}; for (const [key, value] of Object.entries(extendData.newFile)) { result.newFile[key] = { data: value.data }; } } return result; }, [extendData]); const internalRenderWidgetLine = useCallback( ({ lineNumber, side, diffFile, onClose, }: { lineNumber: number; side: SplitSide; diffFile: DiffFile; onClose: () => void; }) => { if (!renderWidgetLine) return null; const sideKey = side === SplitSide.old ? "old" : "new"; const multiResultItem = multiResultRef.current?.[sideKey] as number[]; const fromLineNumber = multiResultItem ? Math.min(...multiResultItem) : lineNumber; const toLineNumber = multiResultItem ? Math.max(...multiResultItem) : lineNumber; return renderWidgetLine({ lineNumber: toLineNumber, fromLineNumber, side, diffFile, onClose, }); }, [renderWidgetLine] ); const getSelectionResult = useCallback(() => { return managerRef.current?.getSelectionResult() ?? null; }, []); const getSelectionState = useCallback(() => { return ( managerRef.current?.getState() ?? { isSelecting: false, startInfo: null, currentRange: null, } ); }, []); const clearSelection = useCallback(() => { managerRef.current?.clearSelection(); }, []); const setPreselectedLines = updateMultiResult; useImperativeHandle( ref, () => ({ getDiffFileInstance: getDiffFile, getSelectionResult, getSelectionState, clearSelection, setPreselectedLines, }), [getDiffFile, getSelectionResult, getSelectionState, clearSelection, setPreselectedLines] ); return (
{ managerRef.current?.clearSelection(); const multiResult = multiResultRef.current; if (multiResult) { const currentSide = SplitSide[side] as unknown as "new" | "old"; const currentMultiResult = multiResult[currentSide] as number[]; const otherSide = currentSide === "new" ? "old" : "new"; const otherMultiResult = multiResult[otherSide] as number[]; if (currentMultiResult?.length) { const max = Math.max(...currentMultiResult); if (max === lineNum) { const finalResult = { [currentSide]: currentMultiResult }; updateMultiResult(finalResult as MultiResult); onAddWidgetClick?.({ lineNumber: max, fromLineNumber: Math.min(...currentMultiResult), side }); return; } } if (isUnifiedMode && otherMultiResult?.length) { const max = Math.max(...otherMultiResult); const diffFile = getDiffFile(); const index = diffFile.getUnifiedLineIndexByLineNumber(lineNum, side); const unifiedItem = diffFile.getUnifiedLine(index); const otherSideLineNum = side === SplitSide.old ? unifiedItem.newLineNumber : unifiedItem.oldLineNumber; if (max === otherSideLineNum) { const finalResult = { [otherSide]: otherMultiResult }; updateMultiResult(finalResult as MultiResult); onAddWidgetClick?.({ lineNumber: max, fromLineNumber: Math.min(...otherMultiResult), side: otherSide === "old" ? SplitSide.old : SplitSide.new, }); return; } } updateMultiResult(undefined); onAddWidgetClick?.({ lineNumber: lineNum, fromLineNumber: lineNum, side }); } else { updateMultiResult(undefined); onAddWidgetClick?.({ lineNumber: lineNum, fromLineNumber: lineNum, side }); } }} renderWidgetLine={renderWidgetLine ? internalRenderWidgetLine : undefined} />
); }; // type helper function // eslint-disable-next-line @typescript-eslint/no-unused-vars function ReactDiffView( _props: DiffViewWithMultiSelectProps & { ref?: ForwardedRef } ) { return <>; } export const DiffViewWithMultiSelect = forwardRef(InternalDiffViewWithMultiSelect) as typeof ReactDiffView;