Files
vibe-kanban/frontend/src/components/DiffCard.tsx
Louis Knight-Webb bd96b7c18b Review comments should contain line of code (vibe-kanban) (#731)
* **Changes**
- Capture the relevant code line in each draft/comment so it’s stored alongside metadata and rendered in the Markdown output, with backtick-safe formatting for inline/triple code blocks (`frontend/src/contexts/ReviewProvider.tsx:4-107`).
- Pull the plain line text from the diff when a comment widget opens and stash it on the draft before saving (`frontend/src/components/DiffCard.tsx:140-155`).
- Pass the captured line through when persisting a new review comment (`frontend/src/components/diff/CommentWidgetLine.tsx:28-34`).

- Tests: `pnpm run check`

Consider spot-checking the review markdown in the UI to confirm the new code line snippet displays as expected.

* Line capture now trims trailing newline characters so inline code renders on a single line (`frontend/src/components/DiffCard.tsx:140-154`). `pnpm run check` still passes. Let me know if you spot any other formatting quirks.

* Cleanup script changes for task attempt 93f0100f-256d-4177-839d-53cb700d2a3e

* Pulled the diff-line lookup into a reusable helper so `DiffCard` now grabs and normalizes the raw line content before drafting comments; this keeps the widget payload lean and avoids scattering newline-trimming logic (`frontend/src/components/DiffCard.tsx:1-161`). Added a `stripLineEnding` utility so future callers have a single place to remove CR/LF sequences without touching other whitespace (`frontend/src/utils/string.ts:24-29`).

Tests: `pnpm run check` (fails — TypeScript can’t resolve types for `markdown-to-jsx` in `frontend/src/components/ui/markdown-renderer.tsx`; worth checking that dependency’s types or a module declaration is in place before re-running).
2025-09-15 23:33:44 +01:00

303 lines
8.8 KiB
TypeScript

import { Diff } from 'shared/types';
import { DiffModeEnum, DiffView, SplitSide } from '@git-diff-view/react';
import { generateDiffFile, type DiffFile } from '@git-diff-view/file';
import { useMemo } from 'react';
import { useUserSystem } from '@/components/config-provider';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import { getActualTheme } from '@/utils/theme';
import { stripLineEnding } from '@/utils/string';
import { Button } from '@/components/ui/button';
import {
ChevronRight,
ChevronUp,
Trash2,
ArrowLeftRight,
FilePlus2,
PencilLine,
Copy,
Key,
ExternalLink,
MessageSquare,
} from 'lucide-react';
import '@/styles/diff-style-overrides.css';
import { attemptsApi } from '@/lib/api';
import type { TaskAttempt } from 'shared/types';
import { useReview, type ReviewDraft } from '@/contexts/ReviewProvider';
import { CommentWidgetLine } from '@/components/diff/CommentWidgetLine';
import { ReviewCommentRenderer } from '@/components/diff/ReviewCommentRenderer';
import { useDiffViewMode } from '@/stores/useDiffViewStore';
type Props = {
diff: Diff;
expanded: boolean;
onToggle: () => void;
selectedAttempt: TaskAttempt | null;
};
function labelAndIcon(diff: Diff) {
const c = diff.change;
if (c === 'deleted') return { label: 'Deleted', Icon: Trash2 };
if (c === 'renamed') return { label: 'Renamed', Icon: ArrowLeftRight };
if (c === 'added')
return { label: undefined as string | undefined, Icon: FilePlus2 };
if (c === 'copied') return { label: 'Copied', Icon: Copy };
if (c === 'permissionChange')
return { label: 'Permission Changed', Icon: Key };
return { label: undefined as string | undefined, Icon: PencilLine };
}
function readPlainLine(
diffFile: DiffFile | null,
lineNumber: number,
side: SplitSide
) {
if (!diffFile) return undefined;
try {
const rawLine =
side === SplitSide.old
? diffFile.getOldPlainLine(lineNumber)
: diffFile.getNewPlainLine(lineNumber);
if (rawLine?.value === undefined) return undefined;
return stripLineEnding(rawLine.value);
} catch (error) {
console.error('Failed to read line content for review comment', error);
return undefined;
}
}
export default function DiffCard({
diff,
expanded,
onToggle,
selectedAttempt,
}: Props) {
const { config } = useUserSystem();
const theme = getActualTheme(config?.theme);
const { comments, drafts, setDraft } = useReview();
const globalMode = useDiffViewMode();
const oldName = diff.oldPath || undefined;
const newName = diff.newPath || oldName || 'unknown';
const oldLang =
getHighLightLanguageFromPath(oldName || newName || '') || 'plaintext';
const newLang =
getHighLightLanguageFromPath(newName || oldName || '') || 'plaintext';
const { label, Icon } = labelAndIcon(diff);
// Build a diff from raw contents so the viewer can expand beyond hunks
const oldContentSafe = diff.oldContent || '';
const newContentSafe = diff.newContent || '';
const isContentEqual = oldContentSafe === newContentSafe;
const diffFile = useMemo(() => {
if (isContentEqual) return null;
try {
const oldFileName = oldName || newName || 'unknown';
const newFileName = newName || oldName || 'unknown';
const file = generateDiffFile(
oldFileName,
oldContentSafe,
newFileName,
newContentSafe,
oldLang,
newLang
);
file.initRaw();
return file;
} catch (e) {
console.error('Failed to build diff for view', e);
return null;
}
}, [
isContentEqual,
oldName,
newName,
oldLang,
newLang,
oldContentSafe,
newContentSafe,
]);
const add = diffFile?.additionLength ?? 0;
const del = diffFile?.deletionLength ?? 0;
// Review functionality
const filePath = newName || oldName || 'unknown';
const commentsForFile = useMemo(
() => comments.filter((c) => c.filePath === filePath),
[comments, filePath]
);
// Transform comments to git-diff-view extendData format
const extendData = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const oldFileData: Record<string, { data: any }> = {};
const newFileData: Record<string, { data: any }> = {};
commentsForFile.forEach((comment) => {
const lineKey = String(comment.lineNumber);
if (comment.side === SplitSide.old) {
oldFileData[lineKey] = { data: comment };
} else {
newFileData[lineKey] = { data: comment };
}
});
return {
oldFile: oldFileData,
newFile: newFileData,
};
}, [commentsForFile]);
const handleAddWidgetClick = (lineNumber: number, side: SplitSide) => {
const widgetKey = `${filePath}-${side}-${lineNumber}`;
const codeLine = readPlainLine(diffFile, lineNumber, side);
const draft: ReviewDraft = {
filePath,
side,
lineNumber,
text: '',
...(codeLine !== undefined ? { codeLine } : {}),
};
setDraft(widgetKey, draft);
};
const renderWidgetLine = (props: any) => {
const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`;
const draft = drafts[widgetKey];
if (!draft) return null;
return (
<CommentWidgetLine
draft={draft}
widgetKey={widgetKey}
onSave={props.onClose}
onCancel={props.onClose}
/>
);
};
const renderExtendLine = (lineData: any) => {
return <ReviewCommentRenderer comment={lineData.data} />;
};
// Title row
const title = (
<p
className="text-xs font-mono overflow-x-auto flex-1"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
>
<Icon className="h-3 w-3 inline mr-2" aria-hidden />
{label && <span className="mr-2">{label}</span>}
{diff.change === 'renamed' && oldName ? (
<span className="inline-flex items-center gap-2">
<span>{oldName}</span>
<span aria-hidden></span>
<span>{newName}</span>
</span>
) : (
<span>{newName}</span>
)}
<span className="ml-3" style={{ color: 'hsl(var(--console-success))' }}>
+{add}
</span>
<span className="ml-2" style={{ color: 'hsl(var(--console-error))' }}>
-{del}
</span>
{commentsForFile.length > 0 && (
<span className="ml-3 inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded">
<MessageSquare className="h-3 w-3" />
{commentsForFile.length}
</span>
)}
</p>
);
const handleOpenInIDE = async () => {
if (!selectedAttempt?.id) return;
try {
const openPath = newName || oldName;
await attemptsApi.openEditor(
selectedAttempt.id,
undefined,
openPath || undefined
);
} catch (err) {
console.error('Failed to open file in IDE:', err);
}
};
const expandable = true;
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
{expandable && (
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
{title}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleOpenInIDE();
}}
className="h-6 w-6 p-0 ml-2"
title="Open in IDE"
disabled={diff.change === 'deleted'}
>
<ExternalLink className="h-3 w-3" aria-hidden />
</Button>
</div>
{expanded && diffFile && (
<div>
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={
globalMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified
}
diffViewFontSize={12}
diffViewAddWidget
onAddWidgetClick={handleAddWidgetClick}
renderWidgetLine={renderWidgetLine}
extendData={extendData}
renderExtendLine={renderExtendLine}
/>
</div>
)}
{expanded && !diffFile && (
<div
className="px-4 pb-4 text-xs font-mono"
style={{ color: 'hsl(var(--muted-foreground) / 0.9)' }}
>
{isContentEqual
? diff.change === 'renamed'
? 'File renamed with no content changes.'
: diff.change === 'permissionChange'
? 'File permission changed.'
: 'No content changes to display.'
: 'Failed to render diff for this file.'}
</div>
)}
</div>
);
}