mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e1efea8bd4
* feat(review): file comments in diff + unified click-to-highlight comment UX Render file-scoped comments inline in the code review UI and overhaul the comment-card UX for consistency. File comments - Single-file view: a full-text banner below the file path. - All-files view: rendered in the file header (expanded files only) — Pierre's virtualized custom header suppresses the body-prefix slot, and its measured item heights make a taller header safe. Guided-review external annotations can now drop the line-1 anchor hack. Click-to-highlight - Clicking a comment (inline card or sidebar) replays its stored line range as the controlled selection highlight; clicking it again clears it. - Scroll/navigate is sidebar- and findings-list-only (token-based signal) so clicking a comment in the diff never moves the viewport. Unified comment cards - Shared CommentMeta identity row (badges + author + relative time) across the inline, sidebar, and file-banner cards (were three hand-rolled headers). - Shared FileNameChip (basename, full path on hover) replaces the literal "file" badge everywhere. - Force the app sans font on cards (inline was inheriting the diff's monospace), consistent selected ring, spacing, and absolute hover actions so timestamps align across surfaces. Shared helpers: annotationScope (PR-scope / file-scope / line-range projection) and fileName (basename). Plumbs createdAt through DiffAnnotationMetadata so the inline cards can show timestamps. * feat(review): unify comment action row (edit · copy · delete) Shared CommentActions component used by the inline diff card, the file-comment banner, and the sidebar — bottom-aligned, hover-revealed, order left→right edit · copy · delete (delete/close furthest right). - Inline + banner: actions moved from the top (where they overlapped the timestamp) to the bottom, and gained the copy button. - Sidebar: now uses the same component (copy + delete) — same icons, order, and styling. - Read-only (externally-sourced) file comments show copy only. - Extract commentCopyText() so every card's copy produces identical, self-describing text (location prefix + body + reasoning); drops the format that was hand-built in the sidebar and the agent-job panel. - Rename PRCommentsTab's local CommentActions -> PRCommentLinkActions to avoid the name clash with the shared component. - Remove the now-dead .review-comment-action(s) CSS and CommentMeta's trailing slot. * fix(review): address PR review findings - all-files: let an active compose own the line highlight (pendingSelection guard) so clicking/deselecting a comment mid-compose no longer clobbers the toolbar's range — mirrors single-file's `pendingSelection ?? annotationRange`. - clear scrollTargetAnnotation on inline select so a remount (Refresh / base switch) doesn't re-scroll back to a stale sidebar/findings navigate target. - inline diff cards: hide edit/delete for external (source-set) findings (matches the file-comment card) and copy with the file:line location prefix via a precomputed metadata.copyText (commentCopyText), unifying the copy text. - add decorations + createdAt to the file-comment refresh signature so an external PATCH changing only those repaints the all-files header. - wrap finding reasoning text (overflow-wrap/word-break) so long file:line refs no longer bleed past the card edge. * refactor(review): dedupe line-annotation projection + close signature gaps Self-review follow-ups to the PR-feedback fixes: - Extract lineAnnotationMetadata() — the all-files and single-file diff surfaces built the identical 14-field DiffAnnotationMetadata; now one source of truth. - Add reviewProfileLabel + source + createdAt to the LINE-scope refresh signature: the unified CommentMeta now renders those on inline cards too, so an external PATCH changing only them must repaint the item (same gap that was just fixed for the file-comment tuple). - Wrap the agent-job panel's finding text + reasoning (overflow-wrap) so long file:line refs don't bleed past the panel edge, matching the inline card fix. * fix(review): zero inter-file gap + stop file-path descender clipping - all-files: set CodeView layout gap to 0 (Pierre default was 8) so files sit flush; sticky headers already mark file boundaries. Keep default edge padding. - FileHeader: path text leading-none -> leading-normal so the overflow-hidden ellipsis box has room for descenders (g/y/p were clipped by the box bottom). * fix(review): address 2nd-pass review — header re-measure, delete, highlight - all-files file comments: re-measure the CodeView item when a comment expands/collapses/edits (FileCommentCard -> onHeightChange -> refreshItem) so Pierre repositions following items instead of overlapping them. Fixes the dynamic header-height bug; the remaining static sticky-offset drift is bounded by the new banner cap and stays the accepted trade-off. - cap the expanded comment body (max-h + overflow) so a long guided-review note doesn't fill the viewport / inflate the sticky header. - delete is now available on every surface (inline, banner) including external source-set findings; edit stays gated on !source. - restore the line highlight when a compose ends: the all-files pendingSelection effect now re-derives the selected comment's range instead of clearing (mirrors single-file's pendingSelection ?? annotationRange). - add reasoning to the file-comment refresh signature (copyText includes it). - guard handleNavigateToAnnotation with annotationMatchesPrScope so a stale sidebar/finding row can't navigate to an out-of-scope annotation. * refactor(review): share annotation→highlight derivation + smoother re-measure Self-review follow-ups: - Extract lineSelectionForAnnotation() — the compose-end restore and the selection-replay effect built the same annotation→CodeViewLineSelection by hand; share it so they can't diverge. - Re-measure file-comment height via useLayoutEffect (before paint) instead of useEffect, so an expand/collapse lands without a one-frame overlap. * fix(review): ignore navigate to missing annotation + re-seed on scope switch - handleNavigateToAnnotation: return early when the annotation id is gone (deleted) as well as out-of-scope, so a stale row can't leave an orphan selection (ring with nothing to scroll to). Dismissed agent-panel rows are already non-clickable; this is the belt-and-suspenders for any other path. - fileSetKey now includes prUrl/prDiffScope so a pure layer<->full-stack scope switch (same file set) remounts and re-seeds annotations through the current scope filter — the incremental sync bails on an unchanged annotations ref and can't otherwise detect the filter change. * fix(review): allow editing external/source findings inline again Drop the editable = !source gate on inline and file-comment cards. main always showed edit for external findings (it routes through updateExternalAnnotation), so gating it was a regression. Edit, delete, and copy are now all available on every comment surface regardless of source. * refactor(review): drop redundant null-check after navigate guard annotation is guaranteed non-null past the missing/out-of-scope early return.
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import type { CodeAnnotation } from '@plannotator/ui/types';
|
|
import { sanitizeBlockHtml } from '@plannotator/ui/utils/sanitizeHtml';
|
|
import { CommentMeta } from './CommentMeta';
|
|
import { CommentActions } from './CommentActions';
|
|
import { FileNameChip } from './FileNameChip';
|
|
import { commentCopyText } from '../utils/annotationDisplay';
|
|
|
|
interface FileCommentBannerProps {
|
|
/** File-scoped comments for ONE file (already filtered to scope === 'file'). */
|
|
comments: CodeAnnotation[];
|
|
selectedAnnotationId: string | null;
|
|
onSelect: (id: string | null) => void;
|
|
onEdit: (id: string, text: string) => void;
|
|
onDelete: (id: string) => void;
|
|
/** Re-measure hook for the virtualized all-files host (see FileCommentCard). */
|
|
onHeightChange?: () => void;
|
|
}
|
|
|
|
/** First non-empty line of the comment, used as the collapsed one-line preview. */
|
|
function firstLine(text: string): string {
|
|
for (const line of text.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (trimmed) return trimmed.replace(/^#+\s*/, '').replace(/[*_`>]/g, '');
|
|
}
|
|
return text.trim();
|
|
}
|
|
|
|
/**
|
|
* A single file-scoped comment card. Exported so the all-files view can render
|
|
* it directly inside Pierre's annotation slot (the header-prefix slot is
|
|
* suppressed whenever a custom header is present), while the single-file viewer
|
|
* renders a stack of them in {@link FileCommentBanner}.
|
|
*/
|
|
export const FileCommentCard: React.FC<{
|
|
comment: CodeAnnotation;
|
|
isSelected: boolean;
|
|
onSelect: (id: string | null) => void;
|
|
onEdit: (id: string, text: string) => void;
|
|
onDelete: (id: string) => void;
|
|
/** Fired after our rendered height changes (expand/collapse/edit) so the
|
|
* virtualized all-files host can re-measure this item. */
|
|
onHeightChange?: () => void;
|
|
}> = ({ comment, isSelected, onSelect, onEdit, onDelete, onHeightChange }) => {
|
|
// Default expanded (the comment IS the point of a guided review); the toggle
|
|
// lets a reviewer collapse a long note back to one line to reach the hunks.
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [draft, setDraft] = useState(comment.text ?? '');
|
|
|
|
// Tell the owner to re-measure when our height changes — in the all-files view
|
|
// these cards live in Pierre's custom-header portal, whose height isn't auto-
|
|
// observed; without this, expanding/editing overlaps the content below.
|
|
const mounted = useRef(false);
|
|
// Layout effect (before paint) so the re-measure lands without a one-frame
|
|
// overlap when the card grows/shrinks.
|
|
useLayoutEffect(() => {
|
|
if (mounted.current) onHeightChange?.();
|
|
else mounted.current = true;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [collapsed, isEditing]);
|
|
|
|
const html = useMemo(
|
|
() => (comment.text ? sanitizeBlockHtml(comment.text) : ''),
|
|
[comment.text],
|
|
);
|
|
|
|
const saveEdit = () => {
|
|
const trimmed = draft.trim();
|
|
if (trimmed && trimmed !== comment.text) onEdit(comment.id, trimmed);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`review-comment group${isSelected ? ' is-selected' : ''}`}
|
|
data-annotation-id={comment.id}
|
|
onClick={() => onSelect(comment.id)}
|
|
>
|
|
<CommentMeta
|
|
leading={
|
|
<>
|
|
{/* Collapse toggle — always visible (primary affordance for reclaiming
|
|
space from a long comment), unlike the hover-revealed actions. */}
|
|
<button
|
|
className="flex-none -ml-0.5 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
onClick={(e) => { e.stopPropagation(); setCollapsed((c) => !c); }}
|
|
title={collapsed ? 'Expand comment' : 'Collapse comment'}
|
|
>
|
|
<svg className={`w-3.5 h-3.5 transition-transform ${collapsed ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
<FileNameChip path={comment.filePath} />
|
|
</>
|
|
}
|
|
conventionalLabel={comment.conventionalLabel}
|
|
decorations={comment.decorations}
|
|
reviewProfileLabel={comment.reviewProfileLabel}
|
|
source={comment.source}
|
|
author={comment.author}
|
|
createdAt={comment.createdAt}
|
|
/>
|
|
|
|
{isEditing ? (
|
|
<div className="mt-1" onClick={(e) => e.stopPropagation()}>
|
|
<textarea
|
|
autoFocus
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') { e.preventDefault(); setIsEditing(false); }
|
|
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); }
|
|
}}
|
|
className="w-full min-h-[80px] resize-y rounded border border-border bg-background p-2 text-xs leading-relaxed focus:outline-none focus:ring-1 focus:ring-primary/40"
|
|
placeholder="File comment (markdown supported)…"
|
|
/>
|
|
<div className="mt-1 flex items-center justify-end gap-2">
|
|
<button className="text-xs px-2 py-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted" onClick={() => setIsEditing(false)}>
|
|
Cancel
|
|
</button>
|
|
<button className="text-xs px-2 py-1 rounded bg-primary/15 text-primary hover:bg-primary/25" onClick={saveEdit}>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : comment.text ? (
|
|
collapsed ? (
|
|
<div className="review-comment-body truncate text-muted-foreground/80">{firstLine(comment.text)}</div>
|
|
) : (
|
|
<div className="review-comment-body ai-markdown max-h-[220px] overflow-y-auto" dangerouslySetInnerHTML={{ __html: html }} />
|
|
)
|
|
) : null}
|
|
{!isEditing && (
|
|
<CommentActions
|
|
onEdit={() => { setDraft(comment.text ?? ''); setIsEditing(true); setCollapsed(false); }}
|
|
copyText={comment.text ? commentCopyText(comment) : undefined}
|
|
onDelete={() => onDelete(comment.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Renders a file's file-scoped comments directly below the file path, above the
|
|
* diff hunks. Long comments stay fully visible (no truncation) but can be
|
|
* collapsed per-comment. Used in both the single-file viewer (plain React tree)
|
|
* and the all-files view (Pierre's `renderHeaderPrefix` portal slot).
|
|
*/
|
|
export const FileCommentBanner: React.FC<FileCommentBannerProps> = ({
|
|
comments,
|
|
selectedAnnotationId,
|
|
onSelect,
|
|
onEdit,
|
|
onDelete,
|
|
onHeightChange,
|
|
}) => {
|
|
if (comments.length === 0) return null;
|
|
return (
|
|
<div className="file-comment-banner flex flex-col px-4 pt-1 pb-1">
|
|
{comments.map((comment) => (
|
|
<FileCommentCard
|
|
key={comment.id}
|
|
comment={comment}
|
|
isSelected={selectedAnnotationId === comment.id}
|
|
onSelect={onSelect}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onHeightChange={onHeightChange}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|