Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
267 changes: 267 additions & 0 deletions apps/storybook/stories/DiffViewer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// 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 start side, new file on the end side. */
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>
),
],
};

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