diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index f1fe90b2aa..41660f8a99 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -96,7 +96,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -281,6 +281,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.3) + '@git-diff-view/file': + specifier: ^0.0.36 + version: 0.0.36 '@git-diff-view/react': specifier: ^0.0.36 version: 0.0.36(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -404,7 +407,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) happy-dom: specifier: ^20.1.0 version: 20.1.0 @@ -1033,6 +1036,9 @@ packages: '@git-diff-view/core@0.0.36': resolution: {integrity: sha512-A3mN21C/ZdIJz0J9/PSZvLR1dLBu8BG13tVE+w4A9v8dLS5omkoQVjHM9K5GaoJideMhK/11y6V7CMbMXCPQoQ==} + '@git-diff-view/file@0.0.36': + resolution: {integrity: sha512-3I5GMpZzdTwSurzbz8ZT9HL2xo74W5/o/SgWhUpEWjn2Z6pCZrHLAEzWeYyqewv30YM25PuK/JMODT4S5zhvzw==} + '@git-diff-view/lowlight@0.0.36': resolution: {integrity: sha512-8H3fUfnW+jw8EEbhEOr0vq5jXxZfbhNgvf9ruY6GljQdymj8j2R69k83gwMB/GltWEMt5o5624d8MxD21vqsZA==} @@ -6353,6 +6359,14 @@ snapshots: highlight.js: 11.11.1 lowlight: 3.3.0 + '@git-diff-view/file@0.0.36': + dependencies: + '@git-diff-view/core': 0.0.36 + diff: 8.0.3 + fast-diff: 1.3.0 + highlight.js: 11.11.1 + lowlight: 3.3.0 + '@git-diff-view/lowlight@0.0.36': dependencies: '@types/hast': 3.0.4 diff --git a/apps/web/app/demo/git-diff-view/demo-client.tsx b/apps/web/app/demo/git-diff-view/demo-client.tsx new file mode 100644 index 0000000000..c2659324ac --- /dev/null +++ b/apps/web/app/demo/git-diff-view/demo-client.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState } from 'react'; +import { ThemeProvider } from 'next-themes'; +import { TooltipProvider } from '@kandev/ui/tooltip'; +import { Button } from '@kandev/ui/button'; +import { GitDiffViewer } from '@/components/git-diff-view'; + +// Sample diffs for demo +const BUTTON_DIFF_OLD = `import React from 'react'; + +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; +} + +export function Button({ children, onClick, disabled }: ButtonProps) { + return ( + + ); +}`; + +const BUTTON_DIFF_NEW = `import React from 'react'; +import { cn } from '../lib/utils'; + +interface ButtonProps { + children: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + variant?: 'primary' | 'secondary'; + disabled?: boolean; +} + +export function Button({ + children, + onClick, + variant = 'primary', + disabled +}: ButtonProps) { + return ( + + ); +}`; + +const FORMAT_DIFF_OLD = ''; +const FORMAT_DIFF_NEW = `export function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); +}`; + +type DiffExample = 'button' | 'newfile'; + +const DIFFS: Record< + DiffExample, + { label: string; oldContent: string; newContent: string; path: string; description: string } +> = { + button: { + label: 'Button Component', + oldContent: BUTTON_DIFF_OLD, + newContent: BUTTON_DIFF_NEW, + path: 'src/Button.tsx', + description: 'Standard diff with additions and deletions', + }, + newfile: { + label: 'New File', + oldContent: FORMAT_DIFF_OLD, + newContent: FORMAT_DIFF_NEW, + path: 'src/utils/format.ts', + description: 'Shows a newly created file (all additions)', + }, +}; + +function GitDiffViewDemoContent() { + const [selectedDiff, setSelectedDiff] = useState('button'); + const currentDiff = DIFFS[selectedDiff]; + + return ( +
+
+ {/* Header */} +
+

GitDiffViewer Demo

+

+ Diff viewer with inline comments and multi-line selection. +

+
+ + {/* Diff selector */} +
+ {Object.entries(DIFFS).map(([key, { label }]) => ( + + ))} +
+ + {/* Description */} +
+ {currentDiff.description} +
+ + {/* Instructions */} +
+

+ Single line: Click the{' '} + + button on any line to add a + comment. +

+

+ Multi-line: Click and drag from one line to another to select a range, + then add your comment. +

+
+ + {/* Diff viewer */} + + + {/* Features list */} +
+

Features:

+
+
+

Built-in Features

+
    +
  • Syntax highlighting
  • +
  • Split and unified view modes
  • +
  • Light and dark theme support
  • +
  • Add widget (+) buttons on hover
  • +
+
+
+

Comments

+
    +
  • Click + to open inline comment widget
  • +
  • Click and drag to select multiple lines
  • +
  • Visual overlay shows selected range
  • +
  • Comments persisted to localStorage
  • +
+
+
+
+
+
+ ); +} + +export function GitDiffViewDemo() { + return ( + + + + + + ); +} diff --git a/apps/web/app/demo/git-diff-view/page.tsx b/apps/web/app/demo/git-diff-view/page.tsx new file mode 100644 index 0000000000..08be150161 --- /dev/null +++ b/apps/web/app/demo/git-diff-view/page.tsx @@ -0,0 +1,9 @@ +import { GitDiffViewDemo } from './demo-client'; + +export const metadata = { + title: 'GitDiffViewer Demo', +}; + +export default function GitDiffViewDemoPage() { + return ; +} diff --git a/apps/web/components/git-diff-view/comment-indicator.tsx b/apps/web/components/git-diff-view/comment-indicator.tsx new file mode 100644 index 0000000000..c551abbdd7 --- /dev/null +++ b/apps/web/components/git-diff-view/comment-indicator.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { SplitSide } from '@git-diff-view/react'; +import { findRowsInRangeWithLineNumbers, groupByConsecutiveRowIndices } from './dom-utils'; +import type { DiffComment } from './types'; + +interface CommentRangeIndicatorProps { + comment: DiffComment; + wrapperRef: React.RefObject; + viewMode: 'split' | 'unified'; +} + +interface IndicatorRect { + top: number; + height: number; + left: number | string; + width: string; +} + +export function CommentRangeIndicator({ + comment, + wrapperRef, + viewMode, +}: CommentRangeIndicatorProps) { + const [indicators, setIndicators] = useState([]); + + useEffect(() => { + const updatePosition = () => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + const rowsWithNumbers = findRowsInRangeWithLineNumbers(wrapper, viewMode, comment.side, comment.startLine, comment.endLine); + + if (rowsWithNumbers.length === 0) { + setIndicators([]); + return; + } + + const wrapperRect = wrapper.getBoundingClientRect(); + const left = viewMode === 'split' && comment.side === SplitSide.old ? 0 : viewMode === 'split' ? '50%' : 0; + const width = viewMode === 'split' ? '50%' : '100%'; + + // Group rows by consecutive row indices (split view) or all together (unified view) + const groups = viewMode === 'split' + ? groupByConsecutiveRowIndices(rowsWithNumbers) + : [rowsWithNumbers.map(r => r.row)]; + + const newIndicators: IndicatorRect[] = groups.map((group) => { + const firstRect = group[0].getBoundingClientRect(); + const lastRect = group[group.length - 1].getBoundingClientRect(); + + return { + top: firstRect.top - wrapperRect.top + wrapper.scrollTop, + height: lastRect.bottom - firstRect.top, + left, + width, + }; + }); + + setIndicators(newIndicators); + }; + + updatePosition(); + const wrapper = wrapperRef.current; + wrapper?.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + return () => { + wrapper?.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, [comment, wrapperRef, viewMode]); + + if (indicators.length === 0) return null; + + return ( + <> + {indicators.map((indicator, index) => ( +
+ ))} + + ); +} diff --git a/apps/web/components/git-diff-view/comment-renderer.tsx b/apps/web/components/git-diff-view/comment-renderer.tsx new file mode 100644 index 0000000000..a050e39773 --- /dev/null +++ b/apps/web/components/git-diff-view/comment-renderer.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { SplitSide } from '@git-diff-view/react'; +import { Button } from '@kandev/ui/button'; +import { Badge } from '@kandev/ui/badge'; +import { IconTrash } from '@tabler/icons-react'; +import type { DiffComment } from './types'; + +interface CommentRendererProps { + comment: DiffComment; + onDelete: (id: string) => void; +} + +export function CommentRenderer({ comment, onDelete }: CommentRendererProps) { + const isSingleLine = comment.startLine === comment.endLine; + + return ( +
+
+
+
+ {isSingleLine + ? `Line ${comment.startLine}` + : `Lines ${comment.startLine}-${comment.endLine}`} + + {comment.side === SplitSide.old ? 'old' : 'new'} + +
+
{comment.content}
+
+ +
+
+ ); +} diff --git a/apps/web/components/git-diff-view/comment-widget.tsx b/apps/web/components/git-diff-view/comment-widget.tsx new file mode 100644 index 0000000000..b7de90b4d1 --- /dev/null +++ b/apps/web/components/git-diff-view/comment-widget.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { SplitSide, type DiffFile } from '@git-diff-view/react'; +import { Button } from '@kandev/ui/button'; +import { Badge } from '@kandev/ui/badge'; +import { Textarea } from '@kandev/ui/textarea'; +import type { DiffComment } from './types'; + +interface InlineCommentWidgetProps { + lineNumber: number; + side: SplitSide; + startLine: number; + diffFile: DiffFile; + wrapperRef: React.RefObject; + viewMode: 'split' | 'unified'; + filePath: string; + onSave: (comment: DiffComment) => void; + onCancel: () => void; + onStartLineChange: (startLine: number) => void; +} + +export function InlineCommentWidget({ + lineNumber, + side, + startLine, + diffFile, + wrapperRef, + viewMode, + filePath, + onSave, + onCancel, + onStartLineChange, +}: InlineCommentWidgetProps) { + const [content, setContent] = useState(''); + const coverRef = useRef(null); + const textareaRef = useRef(null); + + const endLine = lineNumber; + const rangeStart = Math.min(startLine, endLine); + const rangeEnd = Math.max(startLine, endLine); + const isSingleLine = rangeStart === rangeEnd; + + // Auto-focus textarea + useEffect(() => { + setTimeout(() => textareaRef.current?.focus(), 50); + }, []); + + // Create visual overlay for selected lines (multi-line selection effect) + useEffect(() => { + if (!wrapperRef.current || isSingleLine) return; + + let coverItem = coverRef.current; + if (!coverItem) { + coverItem = document.createElement('div'); + coverRef.current = coverItem; + coverItem.className = 'diff-multiline-selection-overlay'; + } + + const updateCover = () => { + const id = diffFile.getId(); + const wrapper = document.querySelector(`#diff-root${id}`); + if (!wrapper || !coverItem) return; + + const items: HTMLElement[] = []; + + for (let i = rangeStart; i <= rangeEnd; i++) { + const lineIndex = + viewMode === 'split' + ? diffFile.getSplitLineIndexByLineNumber(i, side) + : diffFile.getUnifiedLineIndexByLineNumber(i, side); + + if (lineIndex === undefined) continue; + + const lineDom = wrapper.querySelector(`[data-line="${lineIndex + 1}"]`) as HTMLElement; + if (lineDom) { + items.push(lineDom); + } + } + + if (items.length === 0) { + coverItem.style.display = 'none'; + return; + } + + const firstItem = items[0]; + const lastItem = items[items.length - 1]; + const wrapperEl = wrapperRef.current; + if (!wrapperEl) return; + + const wrapperRect = wrapperEl.getBoundingClientRect(); + const firstRect = firstItem.getBoundingClientRect(); + const lastRect = lastItem.getBoundingClientRect(); + + coverItem.style.display = 'block'; + coverItem.style.top = `${firstRect.top - wrapperRect.top + wrapperEl.scrollTop}px`; + coverItem.style.height = `${lastRect.bottom - firstRect.top}px`; + + if (viewMode === 'split') { + coverItem.style.left = side === SplitSide.old ? '0' : '50%'; + coverItem.style.width = '50%'; + } else { + coverItem.style.left = '0'; + coverItem.style.width = '100%'; + } + + if (!wrapperEl.contains(coverItem)) { + wrapperEl.appendChild(coverItem); + } + }; + + updateCover(); + const unsubscribe = diffFile.subscribe(() => setTimeout(updateCover, 0)); + + return () => { + coverItem?.remove(); + coverRef.current = null; + unsubscribe(); + }; + }, [rangeStart, rangeEnd, side, diffFile, wrapperRef, viewMode, isSingleLine]); + + const handleSubmit = () => { + if (content.trim()) { + const comment: DiffComment = { + id: `${filePath}-${Date.now()}`, + startLine: rangeStart, + endLine: rangeEnd, + side, + content: content.trim(), + createdAt: new Date().toISOString(), + }; + onSave(comment); + } + }; + + return ( +
+
+
+ Comment on + {isSingleLine ? ( + line {rangeStart} + ) : ( + <> + lines + + onStartLineChange(Math.max(1, Math.min(endLine, Number(e.target.value)))) + } + className="w-16 px-2 py-0.5 text-center border border-border rounded text-sm bg-background" + /> + to + {endLine} + + )} + + {side === SplitSide.old ? 'old' : 'new'} + +
+ +