Opencode/daring jackal (#356)
* feat: export inline comment components Expose InlineCommentCard and InlineCommentInput from the comments index Enable importing both components from a single module path * feat: add InlineCommentCard for inline comments with collapse Enable collapsing for long comments with Show more and Show less Provide dropdown menu with edit and delete actions Show file label and line range in the header * feat: add InlineCommentInput component Add InlineCommentInput component for per-line comments Enable Cmd/Ctrl+Enter to save and Escape to cancel Show file label and line range when provided and focus input on mount * feat: render block widgets in CodeMirrorEditor Add blockWidgets prop to configure widgets after lines Render React portals into per-widget containers within the editor Introduce BlockWidget and DOM container mapping to host widgets * feat(PierreDiffViewer): enable inline comment drafting and theming Add inline comment draft management for diff lines Render InlineCommentInput and InlineCommentCard for comments in diff Integrate theme system to support dark and light modes * feat: improve inline comment editing in PlanView Enable auto-scroll for mobile when editing inline comments Cancel edits when clicking outside comment widgets or code gutter Extend save handler to accept explicit range and guard against empty text * feat(pierre-diff-viewer): enable mobile tap-to-extend selection Extend selection on mobile when tapping the same side to include range Show saved annotation cards on all devices * fix(plan): show saved cards on all devices Render saved cards on all devices Ensure saved cards appear for mobile layouts as well * refactor: simplify state usage and remove focus hacks Remove pending focus state and related effects in PlanView and PierreDiffViewer Initialize UI store usage via useUIStore() without destructuring values in PlanView and PierreDiffViewer Align annotation handling to DiffLineAnnotation metadata and include side in lineRange for inline comments
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { RiMoreLine, RiDeleteBinLine, RiEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
interface InlineCommentCardProps {
|
||||
draft: InlineCommentDraft;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineCommentCard({
|
||||
draft,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
}: InlineCommentCardProps) {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
const currentTheme = themeContext?.currentTheme;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Check if content is long enough to warrant collapsing (rough estimate)
|
||||
// In a real app we might measure line height, but length check is a good proxy for now
|
||||
const isLongContent = draft.text.length > 150 || draft.text.split('\n').length > 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border shadow-sm w-full overflow-hidden transition-all duration-200",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
data-comment-card="true"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-1.5">
|
||||
<span className="truncate max-w-[200px]" title={draft.fileLabel}>
|
||||
{draft.fileLabel}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Lines {draft.startLine}-{draft.endLine}</span>
|
||||
{draft.side && <span>({draft.side})</span>}
|
||||
</div>
|
||||
|
||||
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
|
||||
<div className={cn("text-sm whitespace-pre-wrap break-words leading-relaxed", !isOpen && isLongContent && "line-clamp-3")}>
|
||||
{draft.text}
|
||||
</div>
|
||||
|
||||
{isLongContent && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-0 mt-1 text-xs text-muted-foreground hover:text-foreground w-full justify-start"
|
||||
>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<RiArrowUpSLine className="size-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-3 mr-1" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Used for animation purposes if we want to animate height */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 -mr-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiMoreLine className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<RiEditLine className="size-4 mr-2" />
|
||||
Edit comment
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<RiDeleteBinLine className="size-4 mr-2" />
|
||||
Delete comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
|
||||
export interface InlineCommentInputProps {
|
||||
initialText?: string;
|
||||
onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void;
|
||||
onCancel: () => void;
|
||||
fileLabel?: string;
|
||||
lineRange?: { start: number; end: number; side?: 'additions' | 'deletions' };
|
||||
isEditing?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineCommentInput({
|
||||
initialText = '',
|
||||
onSave,
|
||||
onCancel,
|
||||
fileLabel,
|
||||
lineRange,
|
||||
isEditing = false,
|
||||
className,
|
||||
}: InlineCommentInputProps) {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
const currentTheme = themeContext?.currentTheme;
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const [text, setText] = React.useState(initialText);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Stable range snapshot to prevent race with selection clearing
|
||||
const stableRangeRef = useRef(lineRange);
|
||||
useEffect(() => {
|
||||
if (lineRange) {
|
||||
stableRangeRef.current = lineRange;
|
||||
}
|
||||
}, [lineRange]);
|
||||
|
||||
// Focus on mount (desktop only) or when becoming visible
|
||||
useEffect(() => {
|
||||
if (!isMobile && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
// Move cursor to end
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
} else if (isMobile && textareaRef.current) {
|
||||
// Scroll into view on mobile
|
||||
textareaRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (text.trim()) {
|
||||
onSave(text, stableRangeRef.current);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
|
||||
// Stop propagation to prevent parent selection clearing before save
|
||||
e.stopPropagation();
|
||||
if (text.trim()) {
|
||||
onSave(text, stableRangeRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border shadow-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
data-comment-input="true"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3">
|
||||
{(fileLabel || lineRange) && (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
|
||||
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
|
||||
{fileLabel && lineRange && <span>•</span>}
|
||||
{lineRange && <span>Lines {lineRange.start}-{lineRange.end}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment... (Cmd+Enter to save)"
|
||||
className="min-h-[80px] text-sm resize-y"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 mt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
className="h-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
disabled={!text.trim()}
|
||||
className="h-8 min-w-[80px]"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.status?.success,
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
{isEditing ? 'Save' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './InlineCommentCard';
|
||||
export * from './InlineCommentInput';
|
||||
Reference in New Issue
Block a user