import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, Plus, Trash2 } from "lucide-react-native"; import { Pressable, type PressableStateCallbackType, Text, TextInput, type TextStyle, View, type StyleProp, type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { isWeb } from "@/constants/platform"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; import { useWorkspaceFocusRestoration } from "@/workspace/focus"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; type PressableState = PressableStateCallbackType & { hovered?: boolean }; type WebTextInputRef = TextInput & { getNativeElement?: () => unknown; getNativeRef?: () => unknown; }; function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp { return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered]; } function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): StyleProp { return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered]; } function getWebTextInputElement(input: TextInput | null): HTMLElement | null { if (!isWeb || typeof HTMLElement === "undefined" || !input) { return null; } const webInput = input as WebTextInputRef; const element = webInput.getNativeElement?.() ?? webInput.getNativeRef?.() ?? input; return element instanceof HTMLElement ? element : null; } export const INLINE_REVIEW_COMMENT_HEIGHT = 72; export const INLINE_REVIEW_EDITOR_HEIGHT = 132; const INLINE_REVIEW_GAP = 6; const INLINE_REVIEW_VERTICAL_PADDING = 8; export const SMALL_ACTION_HIT_SLOP = 8; const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const destructiveIconColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); const accentForegroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.accentForeground, }); const ThemedPencil = withUnistyles(Pencil); const ThemedPlus = withUnistyles(Plus); const ThemedTrash2 = withUnistyles(Trash2); export interface InlineReviewEditorState { target: ReviewableDiffTarget; commentId: string | null; body: string; } export interface InlineReviewActions { commentsByTarget: ReadonlyMap; editor: InlineReviewEditorState | null; onStartComment: (target: ReviewableDiffTarget) => void; onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; onCancelEditor: () => void; onSaveEditor: (body: string) => void; onDeleteComment: (id: string) => void; } export function groupInlineReviewCommentsByTarget( comments: readonly ReviewDraftComment[], ): Map { const grouped = new Map(); for (const comment of comments) { const key = buildReviewableDiffTargetKey(comment); grouped.set(key, [...(grouped.get(key) ?? []), comment]); } return grouped; } export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions { const reviewComments = useReviewDraftComments(input.reviewDraftKey); const commentsByTarget = useMemo( () => groupInlineReviewCommentsByTarget(reviewComments), [reviewComments], ); const [editor, setEditor] = useState(null); const addComment = useReviewDraftStore((state) => state.addComment); const updateComment = useReviewDraftStore((state) => state.updateComment); const deleteComment = useReviewDraftStore((state) => state.deleteComment); useEffect(() => { setEditor(null); }, [input.reviewDraftKey]); const handleStartComment = useCallback((target: ReviewableDiffTarget) => { setEditor({ target, commentId: null, body: "" }); }, []); const handleEditComment = useCallback( (target: ReviewableDiffTarget, comment: ReviewDraftComment) => { setEditor({ target, commentId: comment.id, body: comment.body }); }, [], ); const handleCancelEditor = useCallback(() => { setEditor(null); }, []); const handleSaveEditor = useCallback( (body: string) => { const trimmedBody = body.trim(); if (!editor || trimmedBody.length === 0) { return; } if (editor.commentId) { updateComment({ key: input.reviewDraftKey, id: editor.commentId, updates: { body: trimmedBody }, }); } else { addComment({ key: input.reviewDraftKey, comment: { filePath: editor.target.filePath, side: editor.target.side, lineNumber: editor.target.lineNumber, body: trimmedBody, }, }); } setEditor(null); }, [addComment, editor, input.reviewDraftKey, updateComment], ); const handleDeleteComment = useCallback( (id: string) => { deleteComment({ key: input.reviewDraftKey, id }); setEditor((current) => (current?.commentId === id ? null : current)); }, [deleteComment, input.reviewDraftKey], ); return useMemo( () => ({ commentsByTarget, editor, onStartComment: handleStartComment, onEditComment: handleEditComment, onCancelEditor: handleCancelEditor, onSaveEditor: handleSaveEditor, onDeleteComment: handleDeleteComment, }), [ commentsByTarget, editor, handleCancelEditor, handleDeleteComment, handleEditComment, handleSaveEditor, handleStartComment, ], ); } export function isInlineReviewEditorForTarget( editor: InlineReviewEditorState | null, target: ReviewableDiffTarget | null | undefined, ): boolean { return Boolean( editor && target && buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target), ); } export function getInlineReviewThreadState(input: { reviewTarget: ReviewableDiffTarget | null | undefined; reviewActions?: InlineReviewActions; }): { comments: ReviewDraftComment[]; hasEditor: boolean; editingCommentId: string | null; height: number; } | null { const { reviewTarget, reviewActions } = input; if (!reviewTarget || !reviewActions) { return null; } const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? []; const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget) ? reviewActions.editor : null; const hasEditor = editorForTarget !== null; const editingCommentId = editorForTarget?.commentId ?? null; const editingExisting = editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId); const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length; const editorCount = hasEditor ? 1 : 0; const visibleBlockCount = visibleCommentCount + editorCount; if (visibleBlockCount === 0) { return null; } const height = visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT + editorCount * INLINE_REVIEW_EDITOR_HEIGHT + Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP + INLINE_REVIEW_VERTICAL_PADDING * 2; return { comments, hasEditor, editingCommentId, height }; } export function getSplitInlineReviewThreadState(input: { left: ReviewableDiffTarget | null | undefined; right: ReviewableDiffTarget | null | undefined; reviewActions?: InlineReviewActions; }): { left: ReturnType; right: ReturnType; height: number; } | null { const left = getInlineReviewThreadState({ reviewTarget: input.left, reviewActions: input.reviewActions, }); const right = getInlineReviewThreadState({ reviewTarget: input.right, reviewActions: input.reviewActions, }); const height = Math.max(left?.height ?? 0, right?.height ?? 0); if (height === 0) { return null; } return { left, right, height }; } export function InlineReviewGutterCell({ children, reviewTarget, comments, isLineHovered = false, lineHeight, onStartComment, style, actionTestID, }: { children: ReactNode; reviewTarget: ReviewableDiffTarget | null | undefined; comments: readonly ReviewDraftComment[]; isEditorOpen: boolean; isLineHovered?: boolean; lineHeight?: number; onStartComment: (target: ReviewableDiffTarget) => void; style?: StyleProp; actionTestID?: string; }) { const { t } = useTranslation(); const canComment = Boolean(reviewTarget); const hasComments = comments.length > 0; const [isGutterHovered, setIsGutterHovered] = useState(false); const [isPressed, setIsPressed] = useState(false); const [isDismissedAfterPress, setIsDismissedAfterPress] = useState(false); const isInteractionActive = isGutterHovered || isLineHovered || isPressed; const showAction = canComment && isInteractionActive && !isDismissedAfterPress; const handlePress = useCallback(() => { if (reviewTarget) { setIsDismissedAfterPress(true); onStartComment(reviewTarget); } }, [reviewTarget, onStartComment]); const handleHoverIn = useCallback(() => { setIsGutterHovered(true); }, []); const handleHoverOut = useCallback(() => { setIsGutterHovered(false); }, []); const handlePressIn = useCallback(() => { setIsPressed(true); }, []); const handlePressOut = useCallback(() => { setIsPressed(false); }, []); useEffect(() => { if (!isInteractionActive) { setIsDismissedAfterPress(false); } }, [isInteractionActive]); const pressableStyle = useCallback((): StyleProp => style, [style]); const lineHeightStyle = useMemo>( () => lineHeight !== undefined ? inlineUnistylesStyle({ height: lineHeight, minHeight: lineHeight }) : null, [lineHeight], ); const labelStyle = useMemo>( () => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive], [hasComments, lineHeightStyle], ); const innerStyle = useMemo>( () => [styles.gutterInner, lineHeightStyle], [lineHeightStyle], ); const actionIconStyle = useMemo>( () => [ styles.gutterActionIcon, lineHeight !== undefined && inlineUnistylesStyle({ top: Math.floor((lineHeight - 22) / 2) }), ], [lineHeight], ); return ( {children} {showAction ? ( ) : null} ); } export function InlineReviewThread({ reviewTarget, reviewActions, height, viewportWidth, pinToViewport = false, testID, }: { reviewTarget: ReviewableDiffTarget; reviewActions: InlineReviewActions; height: number; viewportWidth?: number; pinToViewport?: boolean; testID?: string; }) { const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? []; const editor = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget) ? reviewActions.editor : null; const editingCommentId = editor?.commentId ?? null; const editingExisting = editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId); const editorElement = editor ? ( ) : null; const containerStyle = useMemo>( () => [ styles.threadContainer, getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }), inlineUnistylesStyle({ minHeight: height }), ], [viewportWidth, pinToViewport, height], ); return ( {comments.map((comment) => { if (comment.id === editingCommentId) { return {editorElement}; } return ( ); })} {editor && !editingExisting ? editorElement : null} ); } function CommentRow({ comment, reviewTarget, onEditComment, onDeleteComment, }: { comment: ReviewDraftComment; reviewTarget: ReviewableDiffTarget; onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; onDeleteComment: (id: string) => void; }) { const { t } = useTranslation(); const handleEdit = useCallback( () => onEditComment(reviewTarget, comment), [onEditComment, reviewTarget, comment], ); const handleDelete = useCallback( () => onDeleteComment(comment.id), [onDeleteComment, comment.id], ); return ( {comment.body} ); } export function getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport, }: { viewportWidth?: number; pinToViewport: boolean; }): StyleProp { const widthStyle = viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null; if (!pinToViewport || !isWeb) { return widthStyle; } const stickyStyle = { position: "sticky", left: 0 } as unknown as ViewStyle; return [stickyStyle, widthStyle]; } export function InlineReviewEditor({ initialBody, onCancel, onSave, testID, }: { initialBody: string; onCancel: () => void; onSave: (body: string) => void; testID?: string; }) { const { t } = useTranslation(); const inputRef = useRef(null); const focus = useWorkspaceFocusRestoration(); const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); const canSave = trimmedBody.length > 0; useEffect(() => { inputRef.current?.focus(); }, []); const handleFocus = useCallback(() => { focus.unfocus(); setIsFocused(true); }, [focus]); const handleBlur = useCallback(() => { setIsFocused(false); focus.restore(); }, [focus]); const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]); useEffect(() => { const element = getWebTextInputElement(inputRef.current); if (!element) { return; } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onCancel(); return; } if (event.key !== "Enter" || event.shiftKey) { return; } if (!event.metaKey && !event.ctrlKey) { return; } event.preventDefault(); event.stopPropagation(); if (!canSave) { return; } handleSave(); }; element.addEventListener("keydown", handleKeyDown); return () => { element.removeEventListener("keydown", handleKeyDown); }; }, [canSave, handleSave, onCancel]); const inputStyle = useMemo>( () => [styles.editorInput, isFocused && styles.editorInputFocused], [isFocused], ); return ( ); } const styles = StyleSheet.create((theme) => ({ gutterInner: { minHeight: theme.lineHeight.diff, alignItems: "stretch", justifyContent: "flex-start", overflow: "visible", }, gutterLabel: { width: "100%", minWidth: 0, height: theme.lineHeight.diff, alignItems: "stretch", justifyContent: "flex-start", position: "relative", overflow: "visible", }, gutterLabelActive: { backgroundColor: theme.colors.surface2, }, gutterActionIcon: { position: "absolute", right: -10, top: Math.floor((theme.lineHeight.diff - 22) / 2), width: 22, height: 22, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.accent, zIndex: 10, elevation: 10, }, placeholderColor: { color: theme.colors.foregroundMuted, }, threadContainer: { flex: 1, minWidth: 0, gap: INLINE_REVIEW_GAP, paddingVertical: INLINE_REVIEW_VERTICAL_PADDING, paddingHorizontal: theme.spacing[3], }, commentBlock: { backgroundColor: theme.colors.surface2, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, commentBody: { flex: 1, minWidth: 0, color: theme.colors.foreground, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.4, }, commentActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexShrink: 0, }, iconButton: { width: 26, height: 26, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.full, ...(isWeb ? { transitionProperty: "background-color", transitionDuration: "120ms", transitionTimingFunction: "ease-in-out", } : {}), }, iconButtonHovered: { backgroundColor: theme.colors.surface3, }, iconButtonDestructiveHovered: { backgroundColor: theme.colors.surface3, }, editorBlock: { minHeight: INLINE_REVIEW_EDITOR_HEIGHT, backgroundColor: theme.colors.surface2, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[3], gap: theme.spacing[3], }, editorInput: { flex: 1, minHeight: 0, color: theme.colors.foreground, backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.md, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.4, textAlignVertical: "top", ...(isWeb ? { outlineWidth: 0, outlineColor: "transparent", } : {}), }, editorInputFocused: { borderColor: theme.colors.accent, }, editorActions: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: theme.spacing[2], }, }));