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
248 changes: 248 additions & 0 deletions apps/dashboard/src/components/compare/compare-diff-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { FileIcon, GitCommitIcon } from "@diffkit/icons";
import { cn } from "@diffkit/ui/lib/utils";
import type { DiffLineAnnotation } from "@pierre/diffs/react";
import { Link } from "@tanstack/react-router";
import { useEffect, useMemo, useRef, useState } from "react";
import { ReviewFileDiffBlock } from "#/components/pulls/review/review-file-diff-block";
import type {
PendingComment,
ReviewAnnotation,
} from "#/components/pulls/review/review-types";
import { encodeFileId } from "#/components/pulls/review/review-utils";
import { formatRelativeTime } from "#/lib/format-relative-time";
import type {
PullCommit,
PullFile,
PullReviewComment,
} from "#/lib/github.types";

const EMPTY_ANNOTATIONS: DiffLineAnnotation<PullReviewComment>[] = [];
const EMPTY_PENDING: PendingComment[] = [];
const EMPTY_REPLIES: ReadonlyMap<number, PullReviewComment[]> = new Map();
const EMPTY_THREAD_INFO: ReadonlyMap<
number,
{ threadId: string; isResolved: boolean }
> = new Map();

const INITIAL_VISIBLE_COUNT = 30;
const LOAD_MORE_CHUNK = 12;

const noop = () => {};
const noopRange = () => {};
const noopAnnotation = (
_a: DiffLineAnnotation<ReviewAnnotation> | PendingComment,
) => {};
const noopEdit = (_a: PendingComment, _b: string) => {};

export function CompareDiffView({
commits,
files,
owner,
repo,
}: {
commits: PullCommit[];
files: PullFile[];
owner: string;
repo: string;
}) {
const [diffStyle, setDiffStyle] = useState<"unified" | "split">("split");
const [visibleCount, setVisibleCount] = useState(() =>
Math.min(files.length, INITIAL_VISIBLE_COUNT),
);
const loadMoreRef = useRef<HTMLDivElement>(null);

useEffect(() => {
setVisibleCount((prev) =>
Math.min(
files.length,
Math.max(files.length === 0 ? 0 : INITIAL_VISIBLE_COUNT, prev),
),
);
}, [files.length]);
Comment on lines +54 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset visibleCount when the comparison changes, not just when the file count changes.

This effect only watches files.length, so moving to another compare result with the same count keeps the previous visibleCount. That skips the intended 30-file initial cap and can dump the full diff immediately on the next compare view.

♻️ Suggested change
-	useEffect(() => {
-		setVisibleCount((prev) =>
-			Math.min(
-				files.length,
-				Math.max(files.length === 0 ? 0 : INITIAL_VISIBLE_COUNT, prev),
-			),
-		);
-	}, [files.length]);
+	useEffect(() => {
+		setVisibleCount(Math.min(files.length, INITIAL_VISIBLE_COUNT));
+	}, [files]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
setVisibleCount((prev) =>
Math.min(
files.length,
Math.max(files.length === 0 ? 0 : INITIAL_VISIBLE_COUNT, prev),
),
);
}, [files.length]);
useEffect(() => {
setVisibleCount(Math.min(files.length, INITIAL_VISIBLE_COUNT));
}, [files]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/compare/compare-diff-view.tsx` around lines 54
- 61, The effect currently depends only on files.length and keeps the previous
visibleCount (via setVisibleCount(prev => ...)), so when the comparison swaps to
a different results array with the same length it preserves the old
visibleCount; update the useEffect (the useEffect + setVisibleCount call) to
depend on the files array identity (use files instead of files.length) and reset
visibleCount to Math.min(files.length, files.length === 0 ? 0 :
INITIAL_VISIBLE_COUNT) (i.e. drop the prev-based logic) so a new comparison
always starts at the INITIAL_VISIBLE_COUNT cap; reference: useEffect,
setVisibleCount, files, INITIAL_VISIBLE_COUNT.


useEffect(() => {
if (visibleCount >= files.length) return;
const sentinel = loadMoreRef.current;
if (!sentinel) return;

const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return;
setVisibleCount((prev) =>
Math.min(files.length, prev + LOAD_MORE_CHUNK),
);
},
{ rootMargin: "3000px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [files.length, visibleCount]);

const visibleFiles = useMemo(
() => files.slice(0, visibleCount),
[files, visibleCount],
);

const totals = useMemo(() => {
let additions = 0;
let deletions = 0;
for (const f of files) {
additions += f.additions;
deletions += f.deletions;
}
return { additions, deletions };
}, [files]);

return (
<div className="flex flex-col gap-16">
<section className="flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold">
<GitCommitIcon size={15} strokeWidth={2} />
Commits
<span className="text-xs font-normal text-muted-foreground">
({commits.length})
</span>
</h2>
{commits.length === 0 ? (
<p className="rounded-lg border bg-surface-0 px-4 py-3 text-sm text-muted-foreground">
No new commits.
</p>
) : (
<ol className="flex flex-col divide-y rounded-lg border bg-surface-0">
{commits.map((commit) => {
const firstLine = commit.message.split("\n")[0];
return (
<li
key={commit.sha}
className="flex items-center gap-3 px-4 py-2.5 text-sm"
>
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border bg-surface-1">
<GitCommitIcon
size={12}
strokeWidth={2}
className="text-muted-foreground"
/>
</div>
{commit.author ? (
<img
src={commit.author.avatarUrl}
alt={commit.author.login}
className="size-5 shrink-0 rounded-full border border-border"
/>
) : (
<div className="size-5 shrink-0 rounded-full bg-surface-2" />
)}
<Link
to="/$owner/$repo/commit/$sha"
params={{ owner, repo, sha: commit.sha }}
className="min-w-0 flex-1 truncate text-left text-foreground transition-colors hover:text-foreground hover:underline"
>
{firstLine}
</Link>
<code className="shrink-0 rounded bg-surface-1 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{commit.sha.slice(0, 7)}
</code>
{commit.createdAt ? (
<span className="shrink-0 text-xs text-muted-foreground">
{formatRelativeTime(commit.createdAt)}
</span>
) : null}
</li>
);
})}
</ol>
)}
</section>

<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold">
<FileIcon size={15} strokeWidth={2} />
Files changed
<span className="text-xs font-normal text-muted-foreground">
({files.length})
</span>
{files.length > 0 ? (
<span className="ml-2 flex items-center gap-1.5 text-xs">
<span className="tabular-nums font-medium text-green-500">
+{totals.additions}
</span>
<span className="tabular-nums font-medium text-red-500">
-{totals.deletions}
</span>
</span>
) : null}
</h2>
{files.length > 0 ? (
<div className="flex items-center rounded-md border bg-surface-1">
<button
type="button"
className={cn(
"rounded-l-md px-2.5 py-1 text-xs font-medium transition-colors",
diffStyle === "unified"
? "bg-surface-0 text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setDiffStyle("unified")}
>
Unified
</button>
<button
type="button"
className={cn(
"rounded-r-md px-2.5 py-1 text-xs font-medium transition-colors",
diffStyle === "split"
? "bg-surface-0 text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setDiffStyle("split")}
>
Split
</button>
</div>
) : null}
</div>
{files.length === 0 ? (
<p className="rounded-lg border bg-surface-0 px-4 py-3 text-sm text-muted-foreground">
No files changed.
</p>
) : (
<div className="flex flex-col gap-4">
{visibleFiles.map((file) => (
<ReviewFileDiffBlock
key={file.filename}
id={encodeFileId(file.filename)}
file={file}
diffStyle={diffStyle}
isNearViewport
readOnly
annotations={EMPTY_ANNOTATIONS}
repliesByCommentId={EMPTY_REPLIES}
owner={owner}
repo={repo}
pullNumber={0}
pendingComments={EMPTY_PENDING}
activeCommentForm={null}
selectedLines={null}
onGutterClick={noopRange}
onCancelComment={noop}
onAddComment={noopAnnotation}
onEditComment={noopEdit}
threadInfoByCommentId={EMPTY_THREAD_INFO}
/>
))}

{visibleCount < files.length ? (
<>
<div ref={loadMoreRef} className="h-8" />
<div className="rounded-lg border bg-surface-0 px-3 py-2 text-xs text-muted-foreground">
Showing {visibleCount} of {files.length} files
</div>
</>
) : null}
</div>
)}
</section>
</div>
);
}
142 changes: 142 additions & 0 deletions apps/dashboard/src/components/compare/compare-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
ChevronDownIcon,
GitPullRequestDraftIcon,
GitPullRequestIcon,
} from "@diffkit/icons";
import { Button } from "@diffkit/ui/components/button";
import {
MarkdownEditor,
type MentionConfig,
} from "@diffkit/ui/components/markdown-editor";
import { Spinner } from "@diffkit/ui/components/spinner";
import { cn } from "@diffkit/ui/lib/utils";
import { Link } from "@tanstack/react-router";
import { type Dispatch, type SetStateAction, useRef, useState } from "react";

export function CompareForm({
title,
body,
onTitleChange,
onBodyChange,
onSubmit,
submitting,
error,
canSubmit,
mentionConfig,
owner,
repo,
}: {
title: string;
body: string;
onTitleChange: (v: string) => void;
onBodyChange: Dispatch<SetStateAction<string>>;
onSubmit: (draft: boolean) => void;
submitting: boolean;
error: string | null;
canSubmit: boolean;
mentionConfig?: MentionConfig;
owner: string;
repo: string;
}) {
const [draftMode, setDraftMode] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const label = draftMode ? "Create draft pull request" : "Create pull request";

const handleExecute = () => {
if (submitting) return;
if (!title.trim()) {
titleRef.current?.focus();
return;
}
if (!canSubmit) return;
onSubmit(draftMode);
};

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label
htmlFor="pr-title"
className="text-sm font-medium text-foreground"
>
Title <span className="text-destructive">*</span>
</label>
<input
id="pr-title"
ref={titleRef}
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Pull request title"
// biome-ignore lint/a11y/noAutofocus: intentional — this is a dedicated PR-creation page
autoFocus
className="flex h-9 w-full rounded-md border bg-surface-1 px-3 py-1 text-sm outline-none transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>

<div className="compact-md-editor flex flex-col gap-2 [&_[class*='min-h-\[640px\]']]:!min-h-[20rem]">
<span className="text-sm font-medium text-foreground">Description</span>
<MarkdownEditor
value={body}
onChange={onBodyChange}
placeholder="Describe the changes…"
mentions={mentionConfig}
/>
</div>

{error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
) : null}

<div className="flex items-center justify-end gap-3">
<Button variant="ghost" asChild>
<Link to="/$owner/$repo" params={{ owner, repo }}>
Cancel
</Link>
</Button>

<div className="inline-flex items-stretch overflow-hidden rounded-md shadow-xs">
<button
type="button"
disabled={submitting}
onClick={handleExecute}
className={cn(
"flex h-8 items-center gap-1.5 px-3 text-xs font-medium transition-[background-color,opacity] disabled:pointer-events-none disabled:opacity-50",
draftMode
? "bg-surface-2 text-foreground hover:bg-surface-2/80"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
Comment on lines +100 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Expose disabled state on the primary create action.

At Line 100, the button stays interactive while submitting or !canSubmit; handleExecute blocks, but the control should reflect disabled semantics in the UI/accessibility tree.

🔧 Suggested fix
 					<button
 						type="button"
 						onClick={handleExecute}
+						disabled={submitting || !canSubmit}
 						className={cn(
-							"flex h-8 items-center gap-1.5 px-3 text-xs font-medium transition-[background-color,opacity]",
+							"flex h-8 items-center gap-1.5 px-3 text-xs font-medium transition-[background-color,opacity] disabled:pointer-events-none disabled:opacity-50",
 							draftMode
 								? "bg-surface-2 text-foreground hover:bg-surface-2/80"
 								: "bg-primary text-primary-foreground hover:bg-primary/90",
 						)}
 					>

Also applies to: 110-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/compare/compare-form.tsx` around lines 100 -
108, The primary create button currently remains interactive when submitting or
when !canSubmit; update the button that uses handleExecute and draftMode to
reflect disabled semantics by adding a disabled={submitting || !canSubmit} prop
and aria-disabled={submitting || !canSubmit}, remove or guard the onClick (call
handleExecute only when not disabled) and adjust the className logic to include
a disabled style branch (e.g., "opacity-50 pointer-events-none" or your design
token) so appearance and accessibility match state; apply the same changes to
the second button block referenced (lines ~110-118) that uses similar variables.

>
{submitting ? (
<Spinner size={13} />
) : draftMode ? (
<GitPullRequestDraftIcon size={13} strokeWidth={2} />
) : (
<GitPullRequestIcon size={13} strokeWidth={2} />
)}
{label}
</button>
<button
type="button"
aria-label={
draftMode
? "Switch to create pull request"
: "Switch to create draft"
}
disabled={submitting}
onClick={() => setDraftMode((v) => !v)}
className={cn(
"flex h-8 w-7 items-center justify-center border-l transition-[background-color,opacity] disabled:pointer-events-none disabled:opacity-50",
draftMode
? "border-border bg-surface-2 text-foreground hover:bg-surface-2/80"
: "border-black/40 bg-primary text-primary-foreground hover:bg-primary/90 dark:border-white/20",
)}
>
<ChevronDownIcon size={13} strokeWidth={2} />
</button>
</div>
</div>
</div>
);
}
Loading
Loading