Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
282 changes: 282 additions & 0 deletions apps/storybook/stories/DiffViewer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
// 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>
);
},
};

/** 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
76 changes: 76 additions & 0 deletions packages/core/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,82 @@
"defaultMessage": "This {itemName} is already {boundary, select, first {first} last {last} other {}}.",
"description": "Screen-reader-only live announcement when an arrow-key reorder attempt has no effect because the record is already at that end of the list. `{itemName}` is the consumer's singular noun for one record; `{boundary}` is always exactly \"first\" or \"last\"."
},
"@astryx.diffViewer.label": {
"defaultMessage": "Diff",
"description": "Accessible name of a lab DiffViewer's scrollable code area when the file has no path to name it."
},
"@astryx.diffViewer.renamedPath": {
"defaultMessage": "{oldPath} → {newPath}",
"description": "File path shown in a lab DiffViewer header when the file was renamed. `{oldPath}` and `{newPath}` are file paths and must stay untranslated; keep the arrow pointing from the old path to the new one."
},
"@astryx.diffViewer.statusAdded": {
"defaultMessage": "Added",
"description": "Badge in a lab DiffViewer file header marking a file that the change creates."
},
"@astryx.diffViewer.statusDeleted": {
"defaultMessage": "Deleted",
"description": "Badge in a lab DiffViewer file header marking a file that the change removes."
},
"@astryx.diffViewer.statusRenamed": {
"defaultMessage": "Renamed",
"description": "Badge in a lab DiffViewer file header marking a file that the change moves to a new path."
},
"@astryx.diffViewer.addedCount": {
"defaultMessage": "+{count, number}",
"description": "Compact count of added lines shown in a lab DiffViewer file header, e.g. \"+12\". Hidden from screen readers, which hear `@astryx.diffViewer.stats` instead."
},
"@astryx.diffViewer.removedCount": {
"defaultMessage": "−{count, number}",
"description": "Compact count of removed lines shown in a lab DiffViewer file header, e.g. \"−3\" (a minus sign). Hidden from screen readers, which hear `@astryx.diffViewer.stats` instead."
},
"@astryx.diffViewer.stats": {
"defaultMessage": "{added, plural, one {# line added} other {# lines added}}, {removed, plural, one {# line removed} other {# lines removed}}",
"description": "Screen-reader text for the line counts in a lab DiffViewer file header, e.g. \"12 lines added, 3 lines removed\"."
},
"@astryx.diffViewer.added": {
"defaultMessage": "Added",
"description": "Screen-reader text marking one line of a lab DiffViewer as added by the change; sighted readers see a + sign and a tinted row."
},
"@astryx.diffViewer.removed": {
"defaultMessage": "Removed",
"description": "Screen-reader text marking one line of a lab DiffViewer as removed by the change; sighted readers see a - sign and a tinted row."
},
"@astryx.diffViewer.lineNumberButton": {
"defaultMessage": "{side, select, old {Original line {line, number}} other {Changed line {line, number}}}",
"description": "Accessible label of a clickable line number in a lab DiffViewer (callers use it, for example, to start a comment on that line). `side` is `old` for the file before the change and `new` for the file after it; `line` is the line number in that version."
},
"@astryx.diffViewer.columnOldLine": {
"defaultMessage": "Original line number",
"description": "Screen-reader-only header of a lab DiffViewer column holding line numbers in the file before the change. Pairs with `@astryx.diffViewer.lineNumberButton` (\"Original line …\")."
},
"@astryx.diffViewer.columnNewLine": {
"defaultMessage": "Changed line number",
"description": "Screen-reader-only header of a lab DiffViewer column holding line numbers in the file after the change. Pairs with `@astryx.diffViewer.lineNumberButton` (\"Changed line …\")."
},
"@astryx.diffViewer.columnChange": {
"defaultMessage": "Change",
"description": "Screen-reader-only header of a lab DiffViewer column that marks each line as added or removed (noun: the kind of change)."
},
"@astryx.diffViewer.columnCode": {
"defaultMessage": "Code",
"description": "Screen-reader-only header of the source-code column in a lab DiffViewer's single-column (unified) view."
},
"@astryx.diffViewer.columnOldCode": {
"defaultMessage": "Original code",
"description": "Screen-reader-only header of the source-code column for the file before the change, in a lab DiffViewer's side-by-side (split) view."
},
"@astryx.diffViewer.columnNewCode": {
"defaultMessage": "Changed code",
"description": "Screen-reader-only header of the source-code column for the file after the change, in a lab DiffViewer's side-by-side (split) view."
},
"@astryx.diffViewer.binary": {
"defaultMessage": "Binary file not shown",
"description": "Message in place of line changes in a lab DiffViewer when the file is binary and has no text diff."
},
"@astryx.diffViewer.noChanges": {
"defaultMessage": "No content changes",
"description": "Message in a lab DiffViewer when a file changed only its path or mode, so there are no line changes to show."
},
"@astryx.markdownHeadingLinks.copied": {
"defaultMessage": "Link copied",
"description": "Accessible name on the Markdown heading copy button and text announced to a polite live region for ~1.5s after a successful copy. Sentence meaning \"Link copied\"; pairs with `markdownHeadingLinks.copy`."
Expand Down
Loading
Loading