Files
kandev/apps/web/components/git-diff-view/comment-widget.tsx
T
2026-01-24 23:11:15 +00:00

193 lines
5.7 KiB
TypeScript

'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<HTMLDivElement | null>;
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<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className="p-3 bg-card border-t border-border">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Comment on</span>
{isSingleLine ? (
<span className="font-medium">line {rangeStart}</span>
) : (
<>
<span className="text-muted-foreground">lines</span>
<input
type="number"
min={1}
max={endLine}
value={startLine}
onChange={(e) =>
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"
/>
<span className="text-muted-foreground">to</span>
<span className="font-medium">{endLine}</span>
</>
)}
<Badge variant="outline" className="text-xs">
{side === SplitSide.old ? 'old' : 'new'}
</Badge>
</div>
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a comment..."
className="min-h-[80px] text-sm resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
if (e.key === 'Escape') {
onCancel();
}
}}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!content.trim()}>
Add Comment
</Button>
</div>
</div>
</div>
);
}