Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 113 additions & 182 deletions apps/docs/app/(diffs)/playground/PlaygroundClient.tsx

Large diffs are not rendered by default.

126 changes: 119 additions & 7 deletions apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ import {
import { Editor } from '@pierre/diffs/editor';
import { CodeView, useStableCallback } from '@pierre/diffs/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';

import type { PlaygroundAnnotationMetadata } from './constants';
import { CommentForm, ExampleThread } from './PlaygroundComments';
import {
CommentForm,
CommentThread,
ExampleThread,
} from './PlaygroundComments';

const CODE_VIEW_STYLES = { height: '70vh', overflow: 'auto' } as const;

Expand Down Expand Up @@ -45,7 +50,8 @@ interface PlaygroundCodeViewProps {
// items re-diff the edited new side against the original old side.
//
// Annotations ride on item data: a gutter utility gesture appends a comment
// form at its final line, and cancelling the form removes it again.
// form at its final line, submitting persists it as a comment thread, and
// cancelling removes it again.
export function PlaygroundCodeView({
items: initialItems,
options,
Expand All @@ -67,6 +73,46 @@ export function PlaygroundCodeView({
);
}, []);

// Edits remap annotation line numbers (an Enter above a comment shifts it
// down); this writes the remapped set back to the owning item, with the
// version bump every item-data change requires — CodeView drops
// same-version pushes, and its render loop re-applies `item.annotations`
// to the instance on every pass, so a stale item would snap the comment
// back to its pre-edit line. flushSync commits in the same task as the
// editor's shadow-slot rename (the items push is a layout effect), so the
// comment is never projected nowhere between frames. The identity bail
// keeps ordinary typing free: the editor passes the same array reference
// when nothing remapped.
const handleEditChange = useCallback(
(
item: PlaygroundItem,
_file: FileContents,
lineAnnotations?: DiffLineAnnotation<PlaygroundAnnotationMetadata>[]
) => {
if (lineAnnotations == null) {
return;
}
flushSync(() => {
setItems((current) => {
const target = current.find((existing) => existing.id === item.id);
if (target == null || target.annotations === lineAnnotations) {
return current;
}
return current.map((existing) =>
existing.id === item.id
? {
...existing,
annotations: lineAnnotations,
version: (existing.version ?? 0) + 1,
}
: existing
);
});
});
},
[]
);

// Committing a finished edit session is user-space: CodeView only ends the
// session and reports the final contents through this lifecycle. The app
// commits with one combined item write — the new file/fileDiff (fresh
Expand Down Expand Up @@ -193,6 +239,48 @@ export function PlaygroundCodeView({
[]
);

// Submitting persists the form in place: the annotation keeps its position
// and gains the typed body, which flips its rendering to a comment thread.
const submitCommentAtLine = useCallback(
(
itemId: string,
side: AnnotationSide | undefined,
lineNumber: number,
body: string
) => {
setItems((current) =>
current.map((item) => {
if (item.id !== itemId) {
return item;
}
const version = (item.version ?? 0) + 1;
if (item.type === 'file') {
return {
...item,
annotations: (item.annotations ?? []).map((a) =>
a.lineNumber === lineNumber
? { ...a, metadata: { ...a.metadata, body } }
: a
),
version,
};
}
return {
...item,
annotations: (item.annotations ?? []).map((a) =>
a.side === side && a.lineNumber === lineNumber
? { ...a, metadata: { ...a.metadata, body } }
: a
),
version,
};
})
);
setSelectedLines(null);
},
[]
);

// Annotations live on item data, so hiding them is a data change: turning
// the toggle off clears any comments that were added.
useEffect(() => {
Expand All @@ -209,12 +297,15 @@ export function PlaygroundCodeView({
);
}, [showAnnotations]);

// Match the Normal view's precedence: an open comment form pauses the
// gutter utility so the form can't stack.
// Match the Normal view's precedence: an open comment form (neither a
// thread nor a submitted comment) pauses the gutter utility so forms can't
// stack.
const hasOpenCommentForm = items.some(
(item) =>
item.annotations?.some(
(annotation) => annotation.metadata.isThread !== true
(annotation) =>
annotation.metadata.isThread !== true &&
annotation.metadata.body == null
) === true
);
const canSelectLines =
Expand Down Expand Up @@ -243,16 +334,36 @@ export function PlaygroundCodeView({
| DiffLineAnnotation<PlaygroundAnnotationMetadata>,
item: PlaygroundItem
) => {
const side = 'side' in annotation ? annotation.side : undefined;
if (annotation.metadata.isThread === true) {
return <ExampleThread />;
return (
<ExampleThread
onDelete={() =>
removeCommentAtLine(item.id, side, annotation.lineNumber)
}
/>
);
}
if (annotation.metadata.body != null) {
return (
<CommentThread
body={annotation.metadata.body}
onDelete={() =>
removeCommentAtLine(item.id, side, annotation.lineNumber)
}
/>
);
}
return (
<CommentForm
side={'side' in annotation ? annotation.side : undefined}
side={side}
lineNumber={annotation.lineNumber}
onCancel={(side, lineNumber) =>
removeCommentAtLine(item.id, side, lineNumber)
}
onSubmit={(side, lineNumber, body) =>
submitCommentAtLine(item.id, side, lineNumber, body)
}
/>
);
}
Expand Down Expand Up @@ -281,6 +392,7 @@ export function PlaygroundCodeView({
selectedLines={selectedLines}
onSelectedLinesChange={setSelectedLines}
createEditor={createEditor}
onItemEditChange={handleEditChange}
onItemEditComplete={handleEditComplete}
renderHeaderMetadata={renderHeaderMetadata}
renderAnnotation={renderAnnotation}
Expand Down
88 changes: 81 additions & 7 deletions apps/docs/app/(diffs)/playground/PlaygroundComments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';

// Annotation content shared by the playground's view modes: a comment form
// for freshly added gutter comments and a static example thread. `side` is
// undefined for plain file annotations, which have no diff side.
// for freshly added gutter comments, the submitted comment it becomes, and a
// static example thread. `side` is undefined for plain file annotations,
// which have no diff side.
export function CommentForm({
side,
lineNumber,
onCancel,
onSubmit,
}: {
side: AnnotationSide | undefined;
lineNumber: number;
onCancel: (side: AnnotationSide | undefined, lineNumber: number) => void;
onSubmit: (
side: AnnotationSide | undefined,
lineNumber: number,
body: string
) => void;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

Expand All @@ -30,6 +37,16 @@ export function CommentForm({
onCancel(side, lineNumber);
}, [side, lineNumber, onCancel]);

const handleSubmit = useCallback(() => {
const body = textareaRef.current?.value.trim() ?? '';
// An empty submit has no comment to keep; treat it as a cancel.
if (body === '') {
onCancel(side, lineNumber);
return;
}
onSubmit(side, lineNumber, body);
}, [side, lineNumber, onCancel, onSubmit]);

return (
<div
style={{
Expand Down Expand Up @@ -66,10 +83,7 @@ export function CommentForm({
<Button
size="sm"
className="cursor-pointer"
onClick={() => {
console.log('Comment submitted at', side, lineNumber);
handleCancel();
}}
onClick={handleSubmit}
>
Comment
</Button>
Expand All @@ -94,7 +108,59 @@ export function CommentForm({
);
}

export function ExampleThread() {
// The persisted form of a submitted CommentForm: a single-message thread
// showing the typed text. Delete removes the owning annotation.
export function CommentThread({
body,
onDelete,
}: {
body: string;
onDelete: () => void;
}) {
return (
<div
className="max-w-[95%] sm:max-w-[70%]"
style={{
whiteSpace: 'normal',
margin: 10,
fontFamily: 'Geist',
}}
>
<div className="bg-card rounded-lg border p-3 shadow-[0_2px_4px_rgba(0,0,0,0.05)]">
<div className="flex gap-2">
<div className="relative -mt-0.5 flex-shrink-0">
<Avatar className="h-6 w-6">
<AvatarImage src="/avatars/avatar_fat.jpg" alt="You" />
<AvatarFallback>Y</AvatarFallback>
</Avatar>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-foreground font-semibold">You</span>
<span className="text-muted-foreground text-sm">just now</span>
</div>
<p className="text-foreground leading-relaxed whitespace-pre-wrap">
{body}
</p>
</div>
</div>

<div className="mt-2 ml-8 flex items-center gap-4">
<button
onClick={onDelete}
className="text-sm text-red-600 transition-colors hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
</div>
</div>
</div>
);
}

// Delete is optional so the thread can render in read-only contexts; when
// provided it removes the owning annotation.
export function ExampleThread({ onDelete }: { onDelete?: () => void }) {
return (
<div
className="max-w-[95%] sm:max-w-[70%]"
Expand Down Expand Up @@ -151,6 +217,14 @@ export function ExampleThread() {
<button className="text-sm text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
Resolve
</button>
{onDelete != null && (
<button
onClick={onDelete}
className="text-sm text-red-600 transition-colors hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
)}
</div>
</div>
</div>
Expand Down
Loading