Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 10 additions & 8 deletions apps/docs/app/(diffs)/docs/CodeView/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ regardless of scale, so its data model does not depend on traditional
immutability or deep equality checks, which can quickly become expensive.

- Every item needs a stable unique `id`. That id is how `scrollTo`, line
selection, `getItem`, `updateItem`, and reconciliation find the correct
records.
selection, `getItem`, `removeItem`, `updateItem`, and reconciliation find the
correct records.
- Items are either `{ type: 'file', file }` or `{ type: 'diff', fileDiff }`.
- If you keep the same item id but change its content or annotations, you must
increment the `version` so `CodeView` can make an efficient targeted updates
Expand Down Expand Up @@ -187,14 +187,14 @@ do not switch between them without remounting with a new `key`.
| Mode | Use | Item prop | Item updates |
| ---------- | -------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- |
| Controlled | React state owns the complete item list | `items` | Publish a new `items` array. Append-only changes are optimized; other changes reconcile the list. |
| Imperative | The viewer instance owns the item list after mount | optional `initialItems` | Use the ref APIs: `addItems`, `getItem`, and `updateItem`. |
| Imperative | The viewer instance owns the item list after mount | optional `initialItems` | Use the ref APIs: `addItems`, `getItem`, `removeItem`, and `updateItem`. |

Use controlled mode when item data already lives naturally in React state and
the list is small enough that mutating arrays or items is cheap. Use imperative
mode for very large or streaming surfaces where routing every item update
through React would be expensive. In imperative mode, omit `items`, optionally
seed the viewer with `initialItems`, and use the `CodeViewHandle` to add new
items or update existing ones.
items, remove items, or update existing ones.

### Editing Item Annotations

Expand Down Expand Up @@ -229,20 +229,22 @@ remapping rules, stable metadata IDs, and annotation-content lifetime guidance.
- In React, pass `initialItems` instead of `items` for imperative item
ownership. `initialItems` seeds the viewer once; later item changes should go
through the ref.
- In React, `addItems` and `updateItem` require imperative item ownership and
throw if the viewer is controlled with `items`.
- In React, `addItems`, `removeItem`, and `updateItem` require imperative item
ownership and throw if the viewer is controlled with `items`.
- In React, use `selectedLines` and `onSelectedLinesChange` when selection needs
to live in component state.
- In React, use the ref for `scrollTo`, `setSelectedLines`, `getSelectedLines`,
`clearSelectedLines`, `getItem`, `updateItem`, `addItems`, and `getInstance`.
`clearSelectedLines`, `getItem`, `updateItem`, `addItems`, `removeItem`, and
`getInstance`.
- `renderCustomHeader`, `renderHeaderPrefix`, `renderHeaderFilenameSuffix`,
`renderHeaderMetadata`, `renderAnnotation`, and `renderGutterUtility` receive
the whole `CodeViewItem`, which makes it easy to branch on `item.type`.
- In Vanilla JS, `CodeView` owns a scrollable root that you set up once and
update over time.
- In Vanilla JS, call `setup(root)` once with the scrollable container.
- In Vanilla JS, use `setItems`, `addItem`, or `addItems` to populate the
viewer, and `getItem` / `updateItem` for item-level imperative changes.
viewer, and `getItem`, `removeItem`, or `updateItem` for item-level imperative
changes.
- Shared callbacks receive the normal file/diff payload plus a `context`
argument containing the current viewer item and instance.
- `onPostRender` receives `(node, instance, phase, context)`. Its `unmount`
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ rendered `File` or `FileDiff` with `edit()`.
(`onMergeConflictResolve` / `onMergeConflictAction`).

The `CodeView` tab above is the quick-start version. For the deeper guide on
`setup`, `setItems`, `addItems`, `getItem`, `updateItem`, selection, and
`scrollTo`, see [CodeView](#codeview).
`setup`, `setItems`, `addItems`, `getItem`, `removeItem`, `updateItem`,
selection, and `scrollTo`, see [CodeView](#codeview).

### Props

Expand Down Expand Up @@ -63,8 +63,8 @@ default; set `disableErrorHandling: true` when you want errors to rethrow.
`CodeView` forwards many of those same options to each rendered item, while
adding CodeView-specific controls like `layout`, `itemMetrics`, `stickyHeaders`,
`pointerEventsOnScroll`, and `smoothScrollSettings`. Its class instance also
exposes item-level methods such as `addItems`, `getItem`, and `updateItem`. See
[CodeView](#codeview) for the dedicated guide.
exposes item-level methods such as `addItems`, `getItem`, `removeItem`, and
`updateItem`. See [CodeView](#codeview) for the dedicated guide.

Header customization and collapsing behavior:

Expand Down
84 changes: 52 additions & 32 deletions packages/diffs/src/components/CodeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1503,8 +1503,8 @@ export class CodeView<LAnnotation = undefined> {
this.markItemLayoutDirty(item);
this.scrollDirty = true;
this.render();
this.syncSelection();
this.syncItemEditors();
this.syncSelection();
return true;
}

Expand Down Expand Up @@ -1554,37 +1554,36 @@ export class CodeView<LAnnotation = undefined> {

public addItems(inputs: readonly CodeViewItem<LAnnotation>[]): void {
this.appendItemsInternal(inputs);
this.syncSelection();
this.syncItemEditors();
this.syncSelection();
}

public setItems(items: readonly CodeViewItem<LAnnotation>[]): void {
if (items.length === 0) {
// An empty controlled list removes every item, so end active edit
// sessions the way reconcile removals do: publish each session's final
// contents (from its last change) through onItemEditComplete. Direct
// reset()/cleanUp() calls stay silent — those are teardowns, not item
// data updates.
const completions: CodeViewItemEditChange<LAnnotation>[] = [];
for (const record of this.itemEditors.values()) {
const { lastChange } = record.state;
if (lastChange != null) {
completions.push(lastChange);
}
}
this.reset();
// Fired after reset so a handler that calls back into setItems/addItems
// runs against clean state (mirrors syncItemEditors' post-loop firing).
for (const { item, file, lineAnnotations } of completions) {
this.options.onItemEditComplete?.(item, file, lineAnnotations);
public removeItem(itemId: string): boolean {
const item = this.idToItem.get(itemId);
if (item == null) {
console.error(`CodeView.removeItem: unknown item id "${itemId}"`);
return false;
}

const nextItems: CodeViewItem<LAnnotation>[] = [];
for (const current of this.items) {
if (current !== item) {
nextItems.push(current.item);
}
} else if (this.items.length === 0) {
}
this.setItems(nextItems);
Comment thread
amadeus marked this conversation as resolved.
Comment thread
amadeus marked this conversation as resolved.
return true;
}

public setItems(items: readonly CodeViewItem<LAnnotation>[]): void {
let removedItemsById: Readonly<CodeViewItemMap<LAnnotation>> | undefined;
if (this.items.length === 0) {
this.appendItemsInternal(items);
} else if (!this.tryAppendItems(items)) {
this.reconcileItems(items);
removedItemsById = this.reconcileItems(items);
}
this.syncItemEditors(removedItemsById);
this.syncSelection();
this.syncItemEditors();
}

/**
Expand Down Expand Up @@ -2004,6 +2003,7 @@ export class CodeView<LAnnotation = undefined> {
const item = this.idToItem.get(this.selectedLines.id);
if (item == null) {
this.selectedLines = null;
this.options.onSelectedLinesChange?.(null);
return;
}

Expand Down Expand Up @@ -2085,15 +2085,18 @@ export class CodeView<LAnnotation = undefined> {
* attachItemEditor, so this only reconciles editors CodeView is already
* holding.
*/
private syncItemEditors(): void {
private syncItemEditors(
removedItems?: Readonly<CodeViewItemMap<LAnnotation>>
): void {
if (this.itemEditors.size === 0) {
return;
}

const completions: CodeViewItemEditChange<LAnnotation>[] = [];
for (const [id, record] of this.itemEditors) {
const item = this.idToItem.get(id);
if (item != null && this.isItemInEditMode(item)) {
const removedItem = removedItems?.get(id);
if (removedItem == null && item != null && this.isItemInEditMode(item)) {
continue;
}
// cleanUp is idempotent, so editors already detached by their released
Expand All @@ -2106,10 +2109,15 @@ export class CodeView<LAnnotation = undefined> {
// so finish the session here (idempotent: the dirty marker clears on
// the first run). A live item goes through its instance, which also
// preserves expansion state and invalidates layout; removed items fall
// back to a plain metadata recompute from the last change's snapshot.
const itemSnapshot = item?.item ?? record.state.lastChange?.item;
// back to the snapshot captured with the editor's last change.
const { lastChange } = record.state;
const itemSnapshot =
removedItem == null
? (item?.item ?? lastChange?.item)
: (lastChange?.item ?? removedItem.item);
if (itemSnapshot?.type === 'diff') {
if (
removedItem == null &&
item != null &&
item.type === 'diff' &&
item.instance.completeEditSession()
Expand All @@ -2119,13 +2127,14 @@ export class CodeView<LAnnotation = undefined> {
}
finishEditSessionForDiff(itemSnapshot.fileDiff);
}
const { lastChange } = record.state;
if (lastChange != null) {
// Prefer the current item record (it carries the update that ended
// the session, e.g. edit: false); the snapshot from the last change
// covers sessions ended by removing the item.
completions.push(
item == null ? lastChange : { ...lastChange, item: item.item }
removedItem != null || item == null
? lastChange
: { ...lastChange, item: item.item }
);
}
}
Expand Down Expand Up @@ -2481,7 +2490,9 @@ export class CodeView<LAnnotation = undefined> {
* records, rebuilds the lookup maps, and marks layout dirty whenever order,
* membership, or versioned item data changes.
*/
private reconcileItems(items: readonly CodeViewItem<LAnnotation>[]): void {
private reconcileItems(
items: readonly CodeViewItem<LAnnotation>[]
): Readonly<CodeViewItemMap<LAnnotation>> | undefined {
const { items: previousItems, idToItem: previousById } = this;
const removedItems = new Set(previousItems);
const nextItems: CodeViewContextItem<LAnnotation>[] = [];
Expand All @@ -2493,6 +2504,7 @@ export class CodeView<LAnnotation = undefined> {
VirtualizedFileDiff<LAnnotation> | VirtualizedFile<LAnnotation>,
CodeViewContextItem<LAnnotation>
> = new Map();
const removedItemsById: CodeViewItemMap<LAnnotation> = new Map();
let firstDirtyIndex: number | undefined;

for (let index = 0; index < items.length; index++) {
Expand Down Expand Up @@ -2532,7 +2544,7 @@ export class CodeView<LAnnotation = undefined> {

if (firstDirtyIndex == null) {
if (removedItems.size === 0) {
return;
return undefined;
}
firstDirtyIndex = Math.max(nextItems.length - 1, 0);
}
Expand All @@ -2544,6 +2556,7 @@ export class CodeView<LAnnotation = undefined> {
if (removedItem == null || !removedItems.has(removedItem)) {
continue;
}
removedItemsById.set(removedItem.item.id, removedItem);
this.releaseRenderedItem(removedItem);
const dirtyIndex = Math.max(nextItems.length - 1, 0);
firstDirtyIndex = Math.min(firstDirtyIndex ?? dirtyIndex, dirtyIndex);
Expand All @@ -2562,6 +2575,7 @@ export class CodeView<LAnnotation = undefined> {
this.markLayoutDirtyFromIndex(firstDirtyIndex);
this.scrollDirty = true;
this.render();
return removedItemsById.size > 0 ? removedItemsById : undefined;
}

/**
Expand Down Expand Up @@ -3492,6 +3506,12 @@ export class CodeView<LAnnotation = undefined> {
private updateStickyPositioning(): void {
const stickyBounds = this.getStickyBounds();
if (stickyBounds == null) {
// No rendered slice means no sticky scaffold: clear the spacer so an
// emptied viewer sheds the offset height captured from the last
// rendered layout instead of keeping phantom space in the container.
if (this.renderState.firstIndex === -1) {
this.stickyOffset.style.height = '';
}
return;
}
const { stickyTop, stickyBottom } = stickyBounds;
Expand Down
14 changes: 14 additions & 0 deletions packages/diffs/src/react/CodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export type CodeViewProps<LAnnotation = undefined> =
export interface CodeViewHandle<LAnnotation> {
addItems(items: readonly CodeViewItem<LAnnotation>[]): void;
getItem(id: string): CodeViewItem<LAnnotation> | undefined;
removeItem(id: string): boolean;
updateItem(item: CodeViewItem<LAnnotation>): boolean;
updateItemId(oldId: string, newId: string): boolean;
scrollTo(target: CodeViewScrollTarget): void;
Expand Down Expand Up @@ -514,6 +515,19 @@ function CodeViewInner<LAnnotation = undefined>(
return instance.getItem(id);
}
},
removeItem(id) {
const { controlled, instance } = cachedDataRef.current;
assertUncontrolledCodeViewAction(controlled, 'removeItem');
if (instance == null) {
console.error(
'CodeView.removeItem: no valid instance to remove item from',
id
);
return false;
}

return instance.removeItem(id);
},
updateItem(item) {
const { controlled, instance } = cachedDataRef.current;
assertUncontrolledCodeViewAction(controlled, 'updateItem');
Expand Down
1 change: 1 addition & 0 deletions packages/diffs/src/utils/areManagedSnapshotsEqual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function areRenderedItemsEqual<LAnnotation>(
previousItem.id !== nextItem.id ||
previousItem.type !== nextItem.type ||
previousItem.element !== nextItem.element ||
previousItem.instance !== nextItem.instance ||
previousItem.version !== nextItem.version
) {
return false;
Expand Down
Loading