Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ee53b79
feat(lab): parseUnifiedDiff reads a patch into the DiffViewer file model
latxh Oct 15, 2026
939219d
feat(lab): DiffViewer — unified/split file diff for code review
latxh Oct 15, 2026
c12a36a
Merge main into feat/lab-diff-parse
cixzhang Oct 15, 2026
9186983
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
6de4c2d
fix(lab): parseUnifiedDiff marks a file renamed only from git rename …
cixzhang Oct 15, 2026
844c4df
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
ece4b0e
fix(lab): DiffViewer draws no annotation row when renderLineAnnotatio…
cixzhang Oct 15, 2026
2b96e01
fix(lab): parseUnifiedDiff reads a headerless diff -r binary line as …
cixzhang Oct 15, 2026
8cae7e3
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
c6e8350
fix(lab): parseUnifiedDiff checks a line's shape before the binary-li…
cixzhang Oct 15, 2026
2e675ec
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
111b8fc
fix(lab): parseUnifiedDiff reads binary lines with plain string matching
cixzhang Oct 15, 2026
3f5135b
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
b5a4121
Merge main into feat/lab-diff-viewer
cixzhang Oct 15, 2026
b558d0a
fix(lab): DiffViewer's hunk area reads left-to-right on right-to-left…
cixzhang Oct 15, 2026
9416ce6
fix(lab): DiffViewer annotation content opens in view on right-to-lef…
cixzhang Oct 15, 2026
c597d65
fix(lab): DiffViewer caps a split view's new-side annotation to the p…
cixzhang Oct 15, 2026
58be66d
fix(lab): DiffViewer leaves a narrow new-side slice to scrolling inst…
cixzhang Oct 15, 2026
87e42cf
fix(lab): DiffViewer re-measures the new-side annotation cap when the…
cixzhang Oct 15, 2026
e8af7d9
refactor(lab): DiffViewer keeps annotation placement simple: rows scr…
cixzhang Oct 15, 2026
39d64b2
fix(lab): DiffViewer keeps a long comment from widening the table
cixzhang Oct 15, 2026
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
7 changes: 7 additions & 0 deletions .changeset/diff-viewer-catalog-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] Adds the `@astryx.diffViewer.*` keys to the `en` catalog so the lab `DiffViewer` reads its file-status badges, line counts, line-number button labels, per-line "Added"/"Removed" labels, and empty-state messages from the translation runtime. No existing string changes; locales without a translation fall back to English.

@latxh
302 changes: 302 additions & 0 deletions apps/storybook/stories/DiffViewer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import type {Meta, StoryObj} from '@storybook/react';
import {useState} from 'react';
import {
DiffViewer,
parseUnifiedDiff,
type DiffViewerLineRef,
} from '@astryxdesign/lab';
import {Button} from '@astryxdesign/core/Button';
import {Card} from '@astryxdesign/core/Card';
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
import {HStack, StackItem, VStack} from '@astryxdesign/core/Stack';
import {Text} from '@astryxdesign/core/Text';

const PATCH = `diff --git a/src/cart/total.ts b/src/cart/total.ts
index 4c1a2b7..9e0f3d1 100644
--- a/src/cart/total.ts
+++ b/src/cart/total.ts
@@ -1,16 +1,18 @@
import type {CartItem} from './types';
+import {roundCurrency} from '../money';

/**
- * Sum the price of every item in the cart.
+ * Sum the price of every item in the cart, applying the discount once
+ * after the subtotal so rounding errors do not compound per item.
*/
-export function cartTotal(items: CartItem[], discount = 0) {
- let total = 0;
- for (const item of items) {
- total += item.price * item.quantity * (1 - discount);
- }
- return total;
+export function cartTotal(items: CartItem[], discount = 0): number {
+ const subtotal = items.reduce(
+ (sum, item) => sum + item.price * item.quantity,
+ 0,
+ );
+ return roundCurrency(subtotal * (1 - discount));
}

export function isEmpty(items: CartItem[]) {
return items.length === 0;
}
@@ -40,7 +42,7 @@ export function formatTotal(total: number, currency: string) {
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
- maximumFractionDigits: 3,
+ maximumFractionDigits: 2,
});
return formatter.format(total);
}
diff --git a/src/money.ts b/src/money.ts
new file mode 100644
index 0000000..b3f1c2e
--- /dev/null
+++ b/src/money.ts
@@ -0,0 +1,4 @@
+/** Round to whole cents. */
+export function roundCurrency(value: number): number {
+ return Math.round(value * 100) / 100;
+}
diff --git a/src/cart/legacy.ts b/src/cart/legacyTotals.ts
similarity index 100%
rename from src/cart/legacy.ts
rename to src/cart/legacyTotals.ts
diff --git a/public/receipt.png b/public/receipt.png
index 1f2e3d4..5a6b7c8 100644
Binary files a/public/receipt.png and b/public/receipt.png differ
`;

const FILES = parseUnifiedDiff(PATCH);
const [TOTAL_FILE] = FILES;

const meta: Meta<typeof DiffViewer> = {
title: 'Lab/DiffViewer',
component: DiffViewer,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
args: {
file: TOTAL_FILE,
},
decorators: [
Story => (
<div style={{maxWidth: 1080, padding: 16}}>
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof DiffViewer>;

/** One file, unified view (the default). */
export const Default: Story = {};

/** Old file on the left, new file on the right. */
export const Split: Story = {
args: {viewMode: 'split'},
};

/** Long lines wrap instead of scrolling. */
export const Wrapped: Story = {
args: {viewMode: 'split', isWrapped: true},
decorators: [
Story => (
<div style={{maxWidth: 720}}>
<Story />
</div>
),
],
};

const LONG_LINE_FILE =
parseUnifiedDiff(`diff --git a/src/cart/discounts.ts b/src/cart/discounts.ts
--- a/src/cart/discounts.ts
+++ b/src/cart/discounts.ts
@@ -1,4 +1,4 @@
import type {CartItem} from './types';
-export const discountFor = (item: CartItem, rules: ReadonlyArray<{sku: string; percent: number}>) => rules.find(rule => rule.sku === item.sku)?.percent ?? 0;
+export const discountFor = (item: CartItem, rules: ReadonlyArray<{sku: string; percent: number; expiresAt?: number}>) => rules.find(rule => rule.sku === item.sku && (rule.expiresAt == null || rule.expiresAt > Date.now()))?.percent ?? 0;

export const NO_DISCOUNT = 0;
`)[0];

/**
* A right-to-left page with lines wider than the viewer. The hunk area stays
* left-to-right like a code editor, so every line opens at its start. The file
* header and comment text follow the page. Comment rows belong to the code
* layout: each comment reads from its side's right edge and, like the code
* beside it, may need a horizontal scroll to bring into view.
*/
export const RightToLeft: Story = {
args: {
file: LONG_LINE_FILE,
viewMode: 'split',
renderLineAnnotation: (ref: DiffViewerLineRef) => {
if (ref.side === 'old' && ref.lineNumber === 2) {
return <Text type="body">كان الخصم لا يتحقق من تاريخ الانتهاء.</Text>;
}
if (ref.side === 'new' && ref.lineNumber === 2) {
return <Text type="body">تحقق من تاريخ انتهاء الخصم قبل الدمج.</Text>;
}
return null;
},
},
decorators: [
Story => (
<div dir="rtl" style={{maxWidth: 960}}>
<Story />
</div>
),
],
};

/** A whole patch: one DiffViewer per file, incl. added, renamed, and binary files. */
export const MultiFilePatch: Story = {
render: args => (
<VStack gap={3}>
{FILES.map(file => (
<DiffViewer key={file.newPath ?? file.oldPath} {...args} file={file} />
))}
</VStack>
),
};

/** File header actions: a "Viewed" checkbox that also folds the file away. */
export const ViewedToggle: Story = {
render: args => {
const [isViewed, setIsViewed] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<DiffViewer
{...args}
isCollapsed={isCollapsed}
onCollapsedChange={setIsCollapsed}
headerEnd={
<CheckboxInput
label="Viewed"
value={isViewed}
onChange={checked => {
setIsViewed(checked);
setIsCollapsed(checked);
}}
/>
}
/>
);
},
};

interface Comment {
id: number;
side: DiffViewerLineRef['side'];
lineNumber: number;
}

/**
* Review comments: click a line number to open a comment under that line.
* Anchors (side + lineNumber) stay put when switching view modes.
*/
export const ReviewComments: Story = {
render: args => {
const [viewMode, setViewMode] = useState<'unified' | 'split'>('unified');
const [comments, setComments] = useState<Comment[]>([
{id: 1, side: 'new', lineNumber: 13},
]);
return (
<VStack gap={3}>
<HStack gap={2} vAlign="center">
<StackItem size="fill">
<Text type="supporting" color="secondary">
Click a line number to add a comment.
</Text>
</StackItem>
<Button
label={viewMode === 'unified' ? 'Split view' : 'Unified view'}
variant="secondary"
onClick={() =>
setViewMode(mode => (mode === 'unified' ? 'split' : 'unified'))
}
/>
</HStack>
<DiffViewer
{...args}
viewMode={viewMode}
onLineNumberClick={ref =>
setComments(current => [
...current,
{id: Date.now(), side: ref.side, lineNumber: ref.lineNumber},
])
}
renderLineAnnotation={ref => {
const here = comments.filter(
comment =>
comment.side === ref.side &&
comment.lineNumber === ref.lineNumber,
);
if (here.length === 0) {
return null;
}
return (
<VStack gap={2}>
{here.map(comment => (
<Card key={comment.id}>
<HStack gap={2} vAlign="center">
<StackItem size="fill">
<Text>
{`Comment on ${comment.side} line ${comment.lineNumber}`}
</Text>
</StackItem>
<Button
label="Resolve"
variant="ghost"
size="sm"
onClick={() =>
setComments(current =>
current.filter(c => c.id !== comment.id),
)
}
/>
</HStack>
</Card>
))}
</VStack>
);
}}
/>
</VStack>
);
},
};

/**
* A multi-sentence comment wraps within the width the code sets; it never
* widens the table.
*/
export const LongComment: Story = {
args: {
renderLineAnnotation: (ref: DiffViewerLineRef) =>
ref.side === 'new' && ref.lineNumber === 13 ? (
<Text type="body">
Rounding once after the subtotal fixes the drift we saw on large
carts, but it changes totals for carts that relied on per-item
rounding. Before this lands, check the receipt snapshots, confirm the
discount still applies to gift cards, and add a test with a 33%
discount on three items so the rounding rule is pinned. If any partner
integrations read the per-item totals, they need a heads-up as well.
</Text>
) : null,
},
};

/** Without line numbers, capped height. */
export const Compact: Story = {
args: {hasLineNumbers: false, maxHeight: 240},
};
10 changes: 10 additions & 0 deletions internal/shadcn-registry/routes.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,11 @@
"path": "components/lab/code-editor",
"aliases": []
},
{
"name": "component-lab-diff-viewer",
"path": "components/lab/diff-viewer",
"aliases": []
},
{
"name": "component-lab-drawer",
"path": "components/lab/drawer",
Expand Down Expand Up @@ -4255,6 +4260,11 @@
"path": "showcases/dialog/default",
"aliases": []
},
{
"name": "showcase-diff-viewer-unified-diff-from-a-patch",
"path": "showcases/diff-viewer/unified-diff-from-a-patch",
"aliases": []
},
{
"name": "showcase-divider-variants",
"path": "showcases/divider/variants",
Expand Down
Loading
Loading