Skip to content
Draft
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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Additions Only 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="+3">+3</span>"`;
exports[`Additions Only 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="3 additions, 0 deletions"><span class="inline-flex shrink-0 items-center gap-1 font-mono text-[10px] leading-none" title="3 additions, 0 deletions"><span class="font-semibold text-emerald-400">+3</span></span></span>"`;

exports[`Deletions Only 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="-3">-3</span>"`;
exports[`Deletions Only 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="0 additions, 3 deletions"><span class="inline-flex shrink-0 items-center gap-1 font-mono text-[10px] leading-none" title="0 additions, 3 deletions"><span class="font-semibold text-red-400">-3</span></span></span>"`;

exports[`Mixed 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="+2 -1">+2 -1</span>"`;
exports[`Mixed 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="2 additions, 1 deletions"><span class="inline-flex shrink-0 items-center gap-1 font-mono text-[10px] leading-none" title="2 additions, 1 deletions"><span class="font-semibold text-emerald-400">+2</span><span class="font-semibold text-red-400">-1</span></span></span>"`;

exports[`With Summary Title 1`] = `"<span class="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap border px-2 py-0.5 font-medium focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;&gt;svg]:pointer-events-none [&amp;&gt;svg]:size-3 border-transparent bg-secondary [a&amp;]:hover:bg-secondary/90 max-w-[140px] cursor-pointer truncate rounded-full border-none font-mono text-[10px] text-[#888] tracking-wide transition-all hover:brightness-110" data-slot="badge" title="Add vim and git">Add vim and git</span>"`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { countDiffLineStats, sumDiffLineStats } from "./diff-line-stats";

describe("diff line stats", () => {
it("counts added and removed hunk lines without counting file headers", () => {
const diff = `diff --git a/configuration.nix b/configuration.nix
--- a/configuration.nix
+++ b/configuration.nix
@@ -1,4 +1,6 @@
context
-old
+new
+another
unchanged`;

expect(countDiffLineStats(diff)).toEqual({ added: 2, removed: 1 });
});

it("counts new and removed files", () => {
const newFile = `diff --git a/new.nix b/new.nix
--- /dev/null
+++ b/new.nix
@@ -0,0 +1,3 @@
+{
+ programs.git.enable = true;
+}`;

const removedFile = `diff --git a/old.nix b/old.nix
--- a/old.nix
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- programs.fish.enable = true;
-}`;

expect(countDiffLineStats(newFile)).toEqual({ added: 3, removed: 0 });
expect(countDiffLineStats(removedFile)).toEqual({ added: 0, removed: 3 });
});

it("counts hunk content that resembles diff headers", () => {
const diff = `@@ -1,2 +1,2 @@
---- actual removed content
+++ actual added content`;

expect(countDiffLineStats(diff)).toEqual({ added: 1, removed: 1 });
});

it("sums stats across hunks", () => {
const changes = [
{ diff: "@@ -1 +1 @@\n-old\n+new" },
{ diff: "@@ -5,0 +6,2 @@\n+one\n+two" },
];

expect(sumDiffLineStats(changes)).toEqual({ added: 3, removed: 1 });
});
});
69 changes: 69 additions & 0 deletions apps/native/src/components/widget/summaries/diff-line-stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { cn } from "@/lib/utils";

export type DiffLineStats = {
added: number;
removed: number;
};

export function countDiffLineStats(diff: string): DiffLineStats {
let added = 0;
let removed = 0;
let inHunk = false;

for (const line of diff.split("\n")) {
if (line.startsWith("diff --git ")) {
inHunk = false;
continue;
}

if (line.startsWith("@@")) {
inHunk = true;
continue;
}

if (!inHunk) continue;

if (line.startsWith("+")) added++;
else if (line.startsWith("-")) removed++;
}

return { added, removed };
}

export function sumDiffLineStats(changes: Array<{ diff: string }>): DiffLineStats {
return changes.reduce<DiffLineStats>(
(total, change) => {
const stats = countDiffLineStats(change.diff);
total.added += stats.added;
total.removed += stats.removed;
return total;
},
{ added: 0, removed: 0 },
);
}

interface DiffLineStatsBadgeProps {
stats: DiffLineStats;
className?: string;
}

export function DiffLineStatsBadge({ stats, className }: DiffLineStatsBadgeProps) {
if (stats.added === 0 && stats.removed === 0) return null;

return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 font-mono text-[10px] leading-none",
className,
)}
title={`${stats.added} additions, ${stats.removed} deletions`}
>
{stats.added > 0 && (
<span className="font-semibold text-emerald-400">+{stats.added}</span>
)}
{stats.removed > 0 && (
<span className="font-semibold text-red-400">-{stats.removed}</span>
)}
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HunkPill } from "./hunk-pill";
import { DiffView } from "./diff-view";
import { monaco } from "./monaco-setup";
import { FileView } from "./file-view";
import { DiffLineStatsBadge, sumDiffLineStats } from "./diff-line-stats";

interface FullFileDiffEditorProps {
filename: string;
Expand Down Expand Up @@ -62,6 +63,7 @@ export function FullFileDiffEditor({ filename, changes, contents, isOpen, onOpen
};

const changeType = displayChange.changeType;
const fileStats = sumDiffLineStats(changes);

return (
<CollapsibleDiff
Expand All @@ -70,8 +72,17 @@ export function FullFileDiffEditor({ filename, changes, contents, isOpen, onOpen
onToggle={handleToggle}
headerExtra={
<>
<DiffLineStatsBadge
stats={fileStats}
className="rounded-full bg-black/20 px-1.5 py-0.5"
/>
{changes.map((c, i) => (
<HunkPill key={c.hash} change={c} onClick={() => focusChange(i)} />
<HunkPill
key={c.hash}
change={c}
showCounts={changes.length > 1}
onClick={() => focusChange(i)}
/>
))}
</>
}
Expand Down
42 changes: 15 additions & 27 deletions apps/native/src/components/widget/summaries/hunk-pill.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,39 @@
import { Badge } from "@/components/ui/badge";
import type { ChangeWithRichType } from "@/components/widget/utils";
import { useWidgetStore } from "@/stores/widget-store";

function getDiffBody(diff: string): string[] {
const lines = diff.split("\n");
const hunkStart = lines.findIndex((l) => l.startsWith("@@"));
return hunkStart >= 0 ? lines.slice(hunkStart + 1) : [];
}

function countAddedRemoved(diff: string): { added: number; removed: number } {
const body = getDiffBody(diff);
let added = 0;
let removed = 0;
for (const line of body) {
if (line.startsWith("+") && !line.startsWith("+++")) added++;
else if (line.startsWith("-") && !line.startsWith("---")) removed++;
}
return { added, removed };
}
import { countDiffLineStats, DiffLineStatsBadge } from "./diff-line-stats";

interface HunkPillProps {
change: ChangeWithRichType;
showCounts?: boolean;
onClick: () => void;
}

// Badge shown in a file header for a single change: displays the summary title if available, otherwise +N/-M counts. Clicking scrolls the diff editor to that hunk.
export function HunkPill({ change, onClick }: HunkPillProps) {
// Clicking a hunk pill opens the file and scrolls the diff editor to that hunk.
export function HunkPill({ change, showCounts = true, onClick }: HunkPillProps) {
const changeMap = useWidgetStore((s) => s.changeMap);

let summaryTitle: string | null = null;
if (changeMap) {
for (const group of changeMap.groups) {
const match = group.changes.find((c) => c.hash === change.hash);
if (match) { summaryTitle = match.title; break; }
if (match) {
summaryTitle = match.title;
break;
}
}
if (!summaryTitle) {
const match = changeMap.singles.find((c) => c.hash === change.hash);
if (match) summaryTitle = match.title;
}
}

const { added, removed } = countAddedRemoved(change.diff);
const showCounts = change.changeType === "edited" || change.changeType === "renamed";
const label = summaryTitle
?? (showCounts ? [added && `+${added}`, removed && `-${removed}`].filter(Boolean).join(" ") : null);
const stats = countDiffLineStats(change.diff);
const label = summaryTitle;

if (!label) return null;
if (!label && (!showCounts || (stats.added === 0 && stats.removed === 0))) {
return null;
}

return (
<Badge
Expand All @@ -55,9 +43,9 @@ export function HunkPill({ change, onClick }: HunkPillProps) {
e.stopPropagation();
onClick();
}}
title={label}
title={label ?? `${stats.added} additions, ${stats.removed} deletions`}
>
{label}
{label ?? <DiffLineStatsBadge stats={stats} />}
</Badge>
);
}
Loading