# useDiffReview

> DiffReview without its markup: the review’s state, keyboard and result, for your own design system.

Source: https://agent-ui-kit-demo.vercel.app/docs/components/use-diff-review

## Installation

From npm:

```bash
npm i signoff-ui ai
```

```tsx
import { useDiffReview, reviewToolOutput } from 'signoff-ui';
import 'signoff-ui/styles.css'; // once per app, or @import 'signoff-ui/tailwind.css' with Tailwind v4
```

Or as source, with the shadcn CLI:

```bash
npx shadcn@latest add @signoff-ui/use-diff-review
```

```tsx
import { useDiffReview } from '@/components/signoff-ui/use-diff-review';
```

## Usage

```tsx
function MyReview({ files, onSubmit }: { files: FileChange[]; onSubmit: (r: DiffReviewResult) => void }) {
  const review = useDiffReview({ files, onSubmit });
  return (
    <section aria-label="Review" {...review.getRootProps()}>
      {review.items.map((item, i) => (
        <div key={item.id} {...review.getItemProps(i)}>
          <MyHunk hunk={item.hunk} />
          <button {...review.getDecisionProps(i, 'accepted')}>Keep</button>
          <button {...review.getDecisionProps(i, 'rejected')}>Drop</button>
        </div>
      ))}
      {review.draft && <textarea aria-label="Comment" {...review.getDraftProps()} />}
      <button {...review.getSubmitProps()}>Apply</button>
      <p role="status">{review.announcement}</p>
    </section>
  );
}
```

## API reference

### useDiffReview

The review without its markup: parsing (in a worker for large files), decisions, comments,
viewed files, unchanged context, the keyboard and the result, for teams that render their own.
`DiffReview` is this hook and its styled markup. Spread `getRootProps()` on the element that
contains the review, `getItemProps(i)` on each item's element, and render `announcement` in a
polite live region; the keys are DiffReview's.

```ts
useDiffReview({ files: changes, decisions: decisionsProp, defaultDecisions, onDecisionsChange, comments: commentsProp, defaultComments, onCommentsChange, viewed: viewedProp, defaultViewed, onViewedChange, onSubmit, readOnly, context, maxEditLength, diffWorker, autoAdvance, collapseViewed, labels }: UseDiffReviewOptions): UseDiffReviewResult
```

Parameter, `UseDiffReviewOptions`:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `files` (required) | `readonly FileChange[]` |  |  |
| `decisions` | `Readonly<Record<string, HunkDecision>>` |  | Controlled decisions keyed by item id: a hunk's `${path}:${index}`, or `${path}:file` for a file with no hunks (binary, renamed or empty). A repeated path gets `${path}#2`, `#3` and so on, skipping a suffix another file's path already has. |
| `defaultDecisions` | `Readonly<Record<string, HunkDecision>>` |  |  |
| `onDecisionsChange` | `(decisions: Record<string, HunkDecision>) => void` |  |  |
| `comments` | `readonly DiffReviewComment[]` |  | Controlled comments. |
| `defaultComments` | `readonly DiffReviewComment[]` |  |  |
| `onCommentsChange` | `(comments: DiffReviewComment[]) => void` |  |  |
| `viewed` | `Readonly<Record<string, boolean>>` |  | Controlled "Viewed" marks, by file id (the path, or `path#2`… for a repeated path). |
| `defaultViewed` | `Readonly<Record<string, boolean>>` |  |  |
| `onViewedChange` | `(viewed: Record<string, boolean>) => void` |  |  |
| `onSubmit` | `(result: DiffReviewResult, review: { toPatch: () => string; }) => void \| PromiseLike<void>` |  | Called with the reviewed result, and a `toPatch` that gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changed `files`, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change. |
| `readOnly` | `boolean` |  | Decide nothing, comment on nothing: only read. |
| `context` | `number` |  | Lines of context around each change. Default 3. |
| `maxEditLength` | `number` |  | Past this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000. |
| `diffWorker` | `DiffWorkerFactory \| false` |  | Where files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own `Worker` running `signoff-ui`'s diff worker, or `false` to diff them on the main thread after the first paint. Without a working worker the latter is the fallback. |
| `autoAdvance` | `boolean` |  | After accepting or rejecting with the keyboard, move to the next undecided item. Default `true`. |
| `collapseViewed` | `boolean` |  | Fold a file away once it is marked viewed. Default `true`. |
| `labels` | `SignoffLabelsInput` |  | Words to use instead of the English defaults, for what the hook announces and names: see `SignoffLabelsProvider`. |

Returns `UseDiffReviewResult`:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `files` (required) | `DiffReviewFileState[]` |  |  |
| `items` (required) | `ReviewItem[]` |  | Every item to decide, in file order; `index` in the getters refers to this list. |
| `activeIndex` (required) | `number` |  | The item in the tab order, focused by J and K. |
| `counts` (required) | `{ accepted: number; rejected: number; pending: number; total: number; }` |  |  |
| `comparing` (required) | `boolean` |  | Some file is still being diffed in the background; applying waits for it. |
| `decisions` (required) | `Readonly<Record<string, HunkDecision>>` |  |  |
| `comments` (required) | `readonly DiffReviewComment[]` |  |  |
| `viewed` (required) | `Readonly<Record<string, boolean>>` |  |  |
| `selection` (required) | `DiffReviewSelection` |  |  |
| `draft` (required) | `DiffReviewDraft` |  |  |
| `shown` (required) | `Readonly<Record<string, ShownContext>>` |  | Unchanged lines shown around hunks, by `${fileId}:${gap.before}`. |
| `announcement` (required) | `string` |  | What to announce: render it in a polite live region. |
| `readOnly` (required) | `boolean` |  |  |
| `isMac` (required) | `boolean` |  |  |
| `decide` (required) | `(id: string, decision: HunkDecision, options?: { advance?: boolean; }) => void` |  |  |
| `decideFile` (required) | `(fileId: string, decision: Exclude<HunkDecision, 'pending'>) => void` |  |  |
| `decideAll` (required) | `(decision: Exclude<HunkDecision, 'pending'>) => void` |  |  |
| `setViewed` (required) | `(fileId: string, viewed: boolean) => void` |  |  |
| `setCollapsed` (required) | `(fileId: string, collapsed: boolean) => void` |  |  |
| `focusItem` (required) | `(index: number) => void` |  | Focus an item by index (clamped), as J and K do. |
| `activate` (required) | `(index: number) => void` |  | Make an item the one in the tab order, as focusing it does. Stable across renders. |
| `registerItem` (required) | `(id: string, el: HTMLElement \| null) => void` |  | Record an item's element, for focus. Stable across renders. |
| `focusFile` (required) | `(fileId: string) => void` |  | Unfold a file and focus its first item, as the file navigator does. |
| `selectLines` (required) | `(itemId: string, from: number, to?: number) => void` |  | Select lines `from…to` of a hunk item; `to` defaults to `from`. |
| `clearSelection` (required) | `() => void` |  |  |
| `startComment` (required) | `(options?: { itemId?: string; commentId?: string; }) => void` |  | Start a comment: on the selected lines, else on the item (a hunk or a whole file), or edit one. |
| `setDraftText` (required) | `(text: string) => void` |  |  |
| `registerDraft` (required) | `(el: HTMLTextAreaElement \| null) => void` |  | The comment editor's element, for focus. Stable across renders. |
| `saveDraft` (required) | `() => void` |  |  |
| `cancelDraft` (required) | `() => void` |  |  |
| `removeComment` (required) | `(id: string) => void` |  |  |
| `showContext` (required) | `(fileId: string, gap: ContextGap, from: 'start' \| 'end' \| 'all') => void` |  | Show more unchanged lines of a file's gap: from its start, its end, or all of it. |
| `submit` (required) | `() => void` |  | Send the review to `onSubmit`, once per review state. |
| `result` (required) | `() => DiffReviewResult` |  | The review as `onSubmit` receives it, for the current state. |
| `toPatch` (required) | `() => string` |  | The accepted changes as a unified diff. |
| `getRootProps` (required) | `() => { onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; }` |  |  |
| `getItemProps` (required) | `(index: number) => { ref: (el: HTMLElement \| null) => void; role: 'group'; tabIndex: 0 \| -1; 'aria-label': string; 'data-decision': HunkDecision; onFocus: () => void; }` |  | A focusable item: a roving tabindex, its accessible name and its decision. |
| `getDecisionProps` (required) | `(index: number, decision: Exclude<HunkDecision, 'pending'>) => { type: 'button'; 'aria-pressed': boolean; onClick: () => void; }` |  | Accept or reject one item; pressing it again resets it. |
| `getFileDecisionProps` (required) | `(fileId: string, decision: Exclude<HunkDecision, 'pending'>) => { type: 'button'; onClick: () => void; 'aria-keyshortcuts': string; }` |  |  |
| `getViewedProps` (required) | `(fileId: string) => { ref: (el: HTMLInputElement \| null) => void; type: 'checkbox'; checked: boolean; onChange: (event: ChangeEvent<HTMLInputElement>) => void; 'aria-keyshortcuts': 'V'; }` |  |  |
| `getLineNumberProps` (required) | `(index: number, line: number) => { onClick: (event: MouseEvent) => void; }` |  | Click a line number to select it; Shift-click extends the selection in the same hunk. |
| `getDraftProps` (required) | `() => { ref: (el: HTMLTextAreaElement \| null) => void; value: string; onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void; }` |  |  |
| `getSubmitProps` (required) | `() => { type: 'button'; onClick: () => void; 'aria-disabled': true \| undefined; 'aria-keyshortcuts': string; }` |  |  |
| `getNavigatorItemProps` (required) | `(fileId: string) => { type: 'button'; tabIndex: 0 \| -1; ref: (el: HTMLButtonElement \| null) => void; 'aria-current': true \| undefined; onClick: () => void; onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void; }` |  | One button per file in a navigator list, with a roving tabindex: ↑ and ↓ move, Enter goes. |

### DiffReviewFileState

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` |  | The file's id: its path, or `path#2`… for a path that repeats. |
| `path` (required) | `string` |  |  |
| `change` (required) | `FileChange` |  |  |
| `parsed` (required) | `ParsedFileDiff` |  | `undefined` while the file is diffed in the background ("Comparing…"). |
| `items` (required) | `ReviewItem[]` |  | What it asks to decide: its hunks, or the file as a whole. |
| `decision` (required) | `FileDecision` ('accepted' \| 'rejected' \| 'partial' \| 'pending' \| 'unchanged') |  |  |
| `decided` (required) | `number` |  | How many of its items are decided. |
| `viewed` (required) | `boolean` |  |  |
| `collapsed` (required) | `boolean` |  | Folded away: its items are skipped by J and K. |
| `gaps` (required) | `ContextGap[]` |  | Unchanged lines around its hunks that can be shown, when both contents are known. |

### DiffReviewSelection

Lines selected in a hunk, by index into `hunk.lines`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `item` (required) | `ReviewItem` |  |  |
| `hunk` (required) | `DiffHunk` |  |  |
| `anchor` (required) | `number` |  | Where the selection started, and where it ends (the line moved with the keyboard). |
| `head` (required) | `number` |  |  |
| `from` (required) | `number` |  | The first and last selected index. |
| `to` (required) | `number` |  |  |
| `range` (required) | `LineRange` |  |  |

### DiffReviewDraft

A comment being written: a new one, or an edit of `commentId`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fileId` (required) | `string` |  |  |
| `target` (required) | `DiffReviewComment['target']` |  |  |
| `text` (required) | `string` |  |  |
| `commentId` | `string` |  |  |
| `hunkId` | `string` |  |  |
| `range` | `LineRange` |  |  |

### ReviewItem

One thing to decide: a hunk, or a file with no hunks as a whole (binary, a rename, an empty file).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` |  | The decision key: the hunk's id, or `${fileId}:file`. |
| `file` (required) | `ParsedFileDiff` |  |  |
| `hunk` (required) | `DiffHunk` |  | `undefined` for a whole file. |

## Accessibility

- The hook has no markup of its own. Its getters bring what `DiffReview` relies on: a roving tabindex and a name for each item (`getItemProps`), `aria-pressed` on decision buttons, `aria-keyshortcuts`, `aria-disabled` on Apply while files are being compared, and a roving tabindex for a file list (`getNavigatorItemProps`).
- Every key `DiffReview` has works once `getRootProps()` is on the element around the review and `getItemProps(i)` on each item. Render `announcement` in a polite live region: it says what each decision, selection, comment and viewed mark did.
- Give the comment editor a label: `getDraftProps()` gives it focus, its value and its keys (⌘ or Ctrl + Enter saves, Escape cancels).
