Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 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
06b36a6
feat(lab): createDiffFile builds a DiffViewer file from two versions …
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
1de5c35
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
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
213b77c
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
b813199
fix(lab): createDiffFile appends its edit script in a loop, and NaN c…
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
f959076
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
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
a3e9f5a
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
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
1ba5e90
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
b5a4121
Merge main into feat/lab-diff-viewer
cixzhang Oct 15, 2026
5fb1e64
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
b558d0a
fix(lab): DiffViewer's hunk area reads left-to-right on right-to-left…
cixzhang Oct 15, 2026
7e28a06
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
d37b688
Merge main into feat/lab-diff-create-file
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,
createDiffFile,
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, while
* the file header and the annotation row follow the page.
*/
export const RightToLeft: Story = {
args: {
file: LONG_LINE_FILE,
viewMode: 'split',
renderLineAnnotation: (ref: DiffViewerLineRef) =>
ref.side === 'new' && ref.lineNumber === 2 ? (
<Text type="body">تحقق من تاريخ انتهاء الخصم قبل الدمج.</Text>
) : 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>
),
};

/** Diff two versions of a text with createDiffFile. */
export const FromTwoTexts: Story = {
args: {
file: createDiffFile({
path: 'config/flags.yaml',
oldValue: [
'flags:',
' checkout_v2: false',
' dark_mode: true',
' search_suggestions: false',
' # owner: payments',
' retries: 3',
].join('\n'),
newValue: [
'flags:',
' checkout_v2: true',
' dark_mode: true',
' search_suggestions: false',
' # owner: payments',
' retries: 5',
' timeout_ms: 2000',
].join('\n'),
}),
},
};

/** 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>
);
},
};

/** 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