feat(examples): support visual diff for examples

## Background

Examples are nested in their request's YAML file, so the Git visual diff treated an example change as a raw `examples` field on the request and could not stage it independently.

## Changes

- Collect examples as nested entities matched by their `meta.id`.
- Add an Example diff card with request, response and docs tabs.
- Stage, unstage and discard a single example without touching its siblings.
- Keep examples out of request staging and preserve the export key order.

## Testing

- `vitest run` on the diff engine, entity splice and card tabs tests: 69 passed.
- `eslint` and `tsc --noEmit` on the changed files.
This commit is contained in:
Bingbing committed 2026-11-03 17:20:14 +08:00
1 parent 9f4bde0dd9
commit a77422dfd8
8 files changed
+422 -15

No files matched your search

@@ -7,6 +7,7 @@ import { buildCookieJarTabs } from './cookie-jar-diff-card';
import { computeFieldChanges, type EntityDiff, type VisualDiffEntityType } from './diff-engine';
import type { DiffTabDef } from './diff-tabs';
import { buildEnvironmentTabs } from './environment-diff-card';
import { buildExampleTabs, ExampleDiffCard } from './example-diff-card';
import { buildGenericTabs } from './generic-entity-diff-card';
import { buildGrpcTabs } from './grpc-diff-card';
import { buildMcpTabs } from './mcp-diff-card';
@@ -115,6 +116,82 @@ describe('buildRequestTabs', () => {
});
});
describe('buildExampleTabs', () => {
const before = {
name: '200 OK',
meta: { id: 'exp_1', description: '' },
request: { method: 'GET', url: 'https://a', headers: [], parameters: [], body: {} },
response: { statusCode: 200, statusMessage: 'OK', headers: [], body: '{}' },
};
it('maps the request snapshot and the response to their own tabs, with no raw Request/Response in Settings', () => {
const after = {
...before,
meta: { id: 'exp_1', description: '' },
request: {
...before.request,
url: 'https://b',
headers: [{ name: 'X-New', value: '1' }],
body: { mimeType: 'application/json', text: '{}' },
},
response: { ...before.response, statusCode: 201, body: '{"id":1}' },
};
expect(summary(buildExampleTabs(modified('example', before, after)))).toEqual([
{ id: 'body', status: 'added', count: undefined },
{ id: 'headers', status: 'added', count: 1 },
{ id: 'response', status: 'modified', count: 2 },
]);
});
it('shows the example description as Docs, not in the Settings catch-all', () => {
const after = { ...before, meta: { id: 'exp_1', description: 'Saves the created user' } };
expect(summary(buildExampleTabs(modified('example', before, after)))).toEqual([
{ id: 'docs', status: 'added', count: undefined },
]);
});
it('keeps a method and URL change in the header rather than the Settings catch-all', () => {
const after = { ...before, request: { ...before.request, method: 'POST', url: 'https://b' } };
expect(summary(buildExampleTabs(modified('example', before, after)))).toEqual([]);
});
it('shows request fields the request tabs cannot render in their own catch-all', () => {
const headers = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
];
const withHeaders = { ...before, request: { ...before.request, headers } };
const after = { ...withHeaders, request: { ...withHeaders.request, headers: [...headers].reverse() } };
expect(summary(buildExampleTabs(modified('example', withHeaders, after)))).toEqual([
{ id: 'request-settings', status: 'modified', count: 1 },
]);
});
it('renders the example header with its method and URL', () => {
const after = {
...before,
request: { ...before.request, url: 'https://b' },
response: { ...before.response, body: '{"id":1}' },
};
const html = renderToStaticMarkup(
React.createElement(ExampleDiffCard, {
diff: modified('example', before, after),
staged: false,
pendingAction: null,
isDisabled: false,
}),
);
expect(html).toContain('200 OK');
expect(html).toContain('https://b');
expect(html).toContain('Response');
});
});
describe('Settings catch-all for changes a tab finds nothing to show for', () => {
const request = { name: 'r', url: 'https://a', method: 'GET', meta: { id: 'req_1' } };
const settingsPaths = (tabs: DiffTabDef[]) => {
@@ -31,9 +31,11 @@ describe('ownFields', () => {
it('drops nested entities, which are shown as their own cards', () => {
const folder = { name: 'F', meta: { id: 'fld_1' }, children: [{ name: 'r', meta: { id: 'req_1' } }] };
const env = { name: 'Base', data: { a: 1 }, subEnvironments: [{ name: 'Sub' }] };
const request = { name: 'R', meta: { id: 'req_1' }, examples: [{ name: 'E', meta: { id: 'exp_1' } }] };
expect(ownFields(folder)).toEqual({ name: 'F', meta: { id: 'fld_1' } });
expect(ownFields(env)).toEqual({ name: 'Base', data: { a: 1 } });
expect(ownFields(request)).toEqual({ name: 'R', meta: { id: 'req_1' } });
});
});
@@ -79,6 +81,46 @@ describe('computeVisualDiff - file-level sections', () => {
});
});
describe('computeVisualDiff - examples', () => {
const example = (id: string, body = '{}') => ({
name: id,
meta: { id },
request: { method: 'GET', url: 'https://a', headers: [], body: {} },
response: { statusCode: 200, statusMessage: '', headers: [], body },
});
const request = (examples: unknown[]) => ({
name: 'r',
url: 'https://a',
method: 'GET',
meta: { id: 'req_1' },
examples,
});
it('reports an example change as its own entity, not a request change', () => {
const entities = computeVisualDiff(
file([request([example('exp_1', 'old')])]),
file([request([example('exp_1', 'new')])]),
).entities;
expect(entities.map(e => [e.id, e.type, e.status])).toEqual([['exp_1', 'example', 'modified']]);
expect(entities[0].fieldChanges.map(change => change.path)).toEqual(['response']);
});
it('reports added and removed examples inside their request', () => {
const added = computeVisualDiff(
file([request([example('exp_1')])]),
file([request([example('exp_1'), example('exp_2')])]),
).entities;
expect(added.map(e => [e.id, e.type, e.status])).toEqual([['exp_2', 'example', 'added']]);
const removed = computeVisualDiff(
file([request([example('exp_1'), example('exp_2')])]),
file([request([example('exp_1')])]),
).entities;
expect(removed.map(e => [e.id, e.type, e.status])).toEqual([['exp_2', 'example', 'removed']]);
});
});
describe('diffByKey', () => {
const userAgent = { name: 'User-Agent', value: 'insomnia/12.1.0', disabled: false };
const customUserAgent = { name: 'User-Agent', value: '123', disabled: true };
@@ -13,6 +13,7 @@ import { parse as parseYaml } from 'yaml';
* (bullet list of raw field changes). Status of dedicated visual cards:
*
* - [x] Request (HTTP) -> RequestDiffCard
* - [x] Example -> ExampleDiffCard
* - [x] Environment -> EnvironmentDiffCard
* - [x] Request Group (folder) -> RequestGroupDiffCard
* - [x] WebSocket Request -> WebSocketDiffCard
@@ -36,6 +37,7 @@ export type VisualDiffEntityType =
| 'websocket_request'
| 'socketio_request'
| 'request_group'
| 'example'
| 'environment'
| 'mock_route'
| 'mcp_request'
@@ -132,7 +134,7 @@ function cleanMeta(meta: any) {
}
// Keys that are structural (handled by entity matching itself) rather than displayable fields
const STRUCTURAL_KEYS = new Set(['children', 'subEnvironments']);
const STRUCTURAL_KEYS = new Set(['children', 'subEnvironments', 'examples']);
// An entity node minus its nested entities (eg. a folder's `children`), which get their own cards.
export function ownFields(node: any): any {
@@ -365,6 +367,14 @@ function collectEntities(file: any): Map<string, CollectedEntity> {
if (id) {
map.set(id, { type: classifyCollectionNode(node), name: node.name || 'Untitled', node });
}
// Examples live inside their request's file, not in a file of their own, so they are
// diffed as nested entities matched by id.
(node.examples ?? []).forEach((example: { meta?: { id?: string }; name?: string }) => {
const exampleId = example?.meta?.id;
if (exampleId) {
map.set(exampleId, { type: 'example', name: example.name || 'Example', node: example });
}
});
if (Array.isArray(node.children)) {
node.children.forEach(addCollectionNode);
}
@@ -11,6 +11,7 @@ import { CookieJarDiffCard } from './cookie-jar-diff-card';
import { computeVisualDiff, getBaseEnvironmentId } from './diff-engine';
import { applyEntityChange, hasOnlyWorkspaceIdentity } from './entity-splice';
import { EnvironmentDiffCard } from './environment-diff-card';
import { ExampleDiffCard } from './example-diff-card';
import { GenericEntityDiffCard } from './generic-entity-diff-card';
import { GrpcDiffCard } from './grpc-diff-card';
import { McpDiffCard } from './mcp-diff-card';
@@ -262,6 +263,9 @@ export const EntityDiffList: FC<Props> = ({
case 'request': {
return <RequestDiffCard key={diff.id} diff={diff} {...actionProps} />;
}
case 'example': {
return <ExampleDiffCard key={diff.id} diff={diff} {...actionProps} />;
}
case 'request_group': {
return <RequestGroupDiffCard key={diff.id} diff={diff} {...actionProps} />;
}
@@ -118,6 +118,114 @@ describe('applyEntityChange - collection tree', () => {
});
});
describe('applyEntityChange - examples', () => {
const example = (id: string, body = '{}') => ({
name: id,
meta: { id },
request: { method: 'GET', url: 'https://a' },
response: { statusCode: 200, statusMessage: '', headers: [], body },
});
const requestWithExamples = (examples: unknown[]) => ({ ...req('req_1'), examples });
const examplesOf = (file: any) =>
(file.collection[0].examples ?? []).map((item: any) => [item.meta.id, item.response.body]);
it('grafts one example without touching its siblings', () => {
const head = collectionFile(BASE_ENV, [
requestWithExamples([example('exp_1', 'old'), example('exp_2', 'two')]),
]);
const workdir = collectionFile(BASE_ENV, [
requestWithExamples([example('exp_1', 'new'), example('exp_2', 'two'), example('exp_3', 'three')]),
]);
const result = parse(applyEntityChange(head, workdir, 'exp_1'));
expect(examplesOf(result)).toEqual([
['exp_1', 'new'],
['exp_2', 'two'],
]);
});
it('adds and removes a single example', () => {
const one = collectionFile(BASE_ENV, [requestWithExamples([example('exp_1')])]);
const two = collectionFile(BASE_ENV, [requestWithExamples([example('exp_1'), example('exp_2')])]);
expect(examplesOf(parse(applyEntityChange(one, two, 'exp_2')))).toEqual([
['exp_1', '{}'],
['exp_2', '{}'],
]);
const result = parse(applyEntityChange(two, one, 'exp_2'));
expect(examplesOf(result)).toEqual([['exp_1', '{}']]);
});
it('drops the examples key when the last example is removed, as the export does', () => {
const withExample = collectionFile(BASE_ENV, [requestWithExamples([example('exp_1')])]);
const without = collectionFile(BASE_ENV, [requestWithExamples([])]);
const result = parse(applyEntityChange(withExample, without, 'exp_1'));
expect(result.collection[0]).not.toHaveProperty('examples');
});
it("doesn't stage example changes along with the request's own fields", () => {
const head = collectionFile(BASE_ENV, [
{ ...requestWithExamples([example('exp_1', 'old')]), url: 'https://old' },
]);
const workdir = collectionFile(BASE_ENV, [
{ ...requestWithExamples([example('exp_1', 'new')]), url: 'https://new' },
]);
const result = parse(applyEntityChange(head, workdir, 'req_1'));
expect(result.collection[0].url).toBe('https://new');
expect(examplesOf(result)).toEqual([['exp_1', 'old']]);
});
it('keeps examples where the export writes them when staging the request', () => {
const exportedRequest = (url: string) => ({
url,
name: 'r',
meta: { id: 'req_1' },
method: 'GET',
body: {},
parameters: [],
headers: [],
authentication: {},
pathParameters: [],
examples: [example('exp_1')],
});
const head = collectionFile(BASE_ENV, [exportedRequest('https://old')]);
const workdir = collectionFile(BASE_ENV, [exportedRequest('https://new')]);
const result = parse(applyEntityChange(head, workdir, 'req_1'));
expect(Object.keys(result.collection[0])).toEqual([
'url',
'name',
'meta',
'method',
'body',
'parameters',
'headers',
'authentication',
'pathParameters',
'examples',
]);
});
it('carries a newly added request along when staging one of its examples', () => {
const base = collectionFile(BASE_ENV, []);
const source = collectionFile(BASE_ENV, [
requestWithExamples([example('exp_1', 'one'), example('exp_2', 'two')]),
]);
const result = parse(applyEntityChange(base, source, 'exp_1'));
expect(result.collection[0].meta.id).toBe('req_1');
expect(examplesOf(result)).toEqual([['exp_1', 'one']]);
});
});
describe('applyEntityChange - base environment', () => {
it('drops a field that is absent from the source (eg. unstaging an added `data` block)', () => {
const head = collectionFile({ name: 'Base Environment', meta: { id: ENV_ID } });
@@ -89,6 +89,7 @@ function safeParse(text: string): any {
interface EntityLocation {
section:
| 'collection'
| 'example'
| 'environments-base'
| 'sub-environment'
| 'routes'
@@ -97,6 +98,8 @@ interface EntityLocation {
| 'file-section';
// ids of ancestor folders, root to the entity's direct parent (collection section only)
folderPath: string[];
// id of the request owning the example (example section only)
parentRequestId?: string;
// top-level keys making up the entity (file-section only)
keys?: readonly string[];
}
@@ -108,6 +111,9 @@ function findInCollection(nodes: any[], entityId: string, path: string[]): Entit
if (node?.meta?.id === entityId) {
return { section: 'collection', folderPath: path };
}
if ((node?.examples ?? []).some((example: { meta?: { id?: string } }) => example?.meta?.id === entityId)) {
return { section: 'example', folderPath: path, parentRequestId: node.meta?.id };
}
if (Array.isArray(node?.children)) {
const found = findInCollection(node.children, entityId, [...path, node.meta?.id]);
if (found) {
@@ -255,30 +261,50 @@ function descendToContainer(baseFile: any, sourceFile: any, folderPath: string[]
return { container, sourceArray };
}
// A folder's own fields with `children` swapped for the given ones — placed after `meta`, where the
// app writes them, and left out when empty, as the app's export does.
function withChildren(folder: any, children: any[] | undefined): any {
const hasChildren = Array.isArray(children) && children.length > 0;
// An entity's own fields with one nested collection (`children` for folders, `examples` for
// requests) swapped for the given one. An existing key keeps its position; a missing one goes
// where the app writes it (after `meta` for folders, last for requests) and is left out when
// empty, as the app's export does.
function withNested(
node: any,
key: 'children' | 'examples',
nested: any[] | undefined,
insertAfterMeta: boolean,
): any {
const hasNested = Array.isArray(nested) && nested.length > 0;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(folder)) {
if (key === 'children') {
for (const [field, value] of Object.entries(node)) {
if (field === key) {
if (hasNested) {
result[key] = nested;
}
continue;
}
result[key] = value;
if (key === 'meta' && hasChildren) {
result.children = children;
result[field] = value;
if (insertAfterMeta && field === 'meta' && hasNested && !(key in result)) {
result[key] = nested;
}
}
if (hasChildren && !('children' in result)) {
result.children = children;
if (hasNested && !(key in result)) {
result[key] = nested;
}
return result;
}
// Sets a request's examples, dropping the key when empty as the app's export does.
function setExamples(request: any, examples: any[]) {
if (examples.length > 0) {
request.examples = examples;
} else {
delete request.examples;
}
}
// Grafts a collection-tree entity (request or folder) from source into base,
// wherever source has it — moving it if it sits elsewhere in base, and removing
// it if source no longer has it. A folder only carries its own fields: its
// `children` are separate entities, so base's children are kept as-is.
// it if source no longer has it. A folder only carries its own fields, and a
// request only its own: their `children`/`examples` are separate entities, so
// base's are kept as-is.
function applyCollectionChange(baseFile: any, sourceFile: any, entityId: string) {
if (!Array.isArray(baseFile.collection)) {
baseFile.collection = [];
@@ -301,7 +327,12 @@ function applyCollectionChange(baseFile: any, sourceFile: any, entityId: string)
const sourceNode = sourceArray[sourceIndex];
// Recognised by id, not by a `children` array: the app omits `children` for an empty folder,
// and treating such a folder as a leaf would replace base's folder along with its contents.
const node = entityId.startsWith('fld_') ? withChildren(sourceNode, existing?.node?.children) : sourceNode;
// A request keeps base's examples for the same reason: examples are their own cards.
const node = entityId.startsWith('fld_')
? withNested(sourceNode, 'children', existing?.node?.children, true)
: entityId.startsWith('req')
? withNested(sourceNode, 'examples', existing?.node?.examples, false)
: sourceNode;
// Staying in the same container keeps its original position to avoid reorder noise.
const insertAt = existing?.container === container ? existingIndex : Math.min(sourceIndex, container.length);
@@ -317,6 +348,41 @@ function applyChange(baseFile: any, sourceFile: any, entityId: string, location:
applyCollectionChange(baseFile, sourceFile, entityId);
return;
}
case 'example': {
const parentRequestId = location.parentRequestId;
if (!parentRequestId) {
return;
}
const sourceRequest = findNodeWithContainer(sourceFile.collection, parentRequestId)?.node;
const baseRequest = findNodeWithContainer(baseFile.collection, parentRequestId)?.node;
if (!sourceRequest) {
if (baseRequest) {
setExamples(baseRequest, spliceById(baseRequest.examples ?? [], [], entityId));
}
return;
}
if (!baseRequest) {
// The request is new too: carry it along, with only the example being staged, so the
// example has somewhere to live. Its other examples keep their own cards.
const sourceExample = (sourceRequest.examples ?? []).find((example: any) => example?.meta?.id === entityId);
const sourceLocation = findInCollection(sourceFile.collection, parentRequestId, []);
if (!sourceExample || !sourceLocation) {
return;
}
const { container, sourceArray } = descendToContainer(baseFile, sourceFile, sourceLocation.folderPath);
const sourceIndex = sourceArray.findIndex((node: any) => node?.meta?.id === parentRequestId);
container.splice(sourceIndex === -1 ? container.length : Math.min(sourceIndex, container.length), 0, {
...sourceRequest,
examples: [sourceExample],
});
return;
}
setExamples(baseRequest, spliceById(baseRequest.examples ?? [], sourceRequest.examples ?? [], entityId));
return;
}
case 'environments-base': {
// The base environment is the container of the sub-environments, so
// removing it removes them too.
@@ -0,0 +1,99 @@
import type { FC } from 'react';
import { formatMethodName, getRequestBadgeClassName } from '../../tags/method-tag';
import { computeFieldChanges, diffByKey, type EntityDiff, sideStatus, valuesEqual } from './diff-engine';
import { type DiffTabDef, docsTab, KeyValueDiffRows, withSettingsTab } from './diff-tabs';
import { buildRequestTabs } from './request-diff-card';
import { type EntityCardActionProps, FieldDiffRow } from './shared';
import { buildHeaderLines, EntityHeader, TabbedDiffCard } from './tabbed-diff-card';
// Fields this card shows outside its Settings catch-all. `request` is covered by the header
// (method and URL) and the request tabs; without this it would fall through to the raw Settings
// catch-all when only method or URL changed.
const CARD_FIELD_PATHS = new Set(['name', 'request']);
// The example editor reuses the request editor for its request snapshot, so its tabs
// (params, body, auth, headers, scripts) are reused here too. They all belong to the
// example's `request` snapshot as far as the example-level catch-all is concerned.
function requestTabs(diff: EntityDiff): DiffTabDef[] {
const before = diff.before?.request;
const after = diff.after?.request;
return buildRequestTabs({
...diff,
before,
after,
fieldChanges: computeFieldChanges(before, after),
}).map(tab => ({
...tab,
// The request card's own catch-all (eg. headers that were only reordered) stays a tab of
// its own, so an example-level Settings tab still means "outside the request snapshot".
id: tab.id === 'settings' ? 'request-settings' : tab.id,
label: tab.id === 'settings' ? 'Request settings' : tab.label,
paths: ['request'],
}));
}
// The response part of an example is a saved snapshot, not a live response, so it has no
// timeline: status, headers and body are all there is to show.
function responseTab(diff: EntityDiff): DiffTabDef | null {
const before = diff.before?.response;
const after = diff.after?.response;
const statusChanges = computeFieldChanges(
{ statusCode: before?.statusCode, statusMessage: before?.statusMessage },
{ statusCode: after?.statusCode, statusMessage: after?.statusMessage },
);
const headerRows = diffByKey(before?.headers, after?.headers, 'name');
const bodyChanged = !valuesEqual(before?.body, after?.body);
if (statusChanges.length === 0 && headerRows.length === 0 && !bodyChanged) {
return null;
}
return {
id: 'response',
label: 'Response',
status: sideStatus(Boolean(before), Boolean(after)),
count: statusChanges.length + headerRows.length + (bodyChanged ? 1 : 0),
paths: ['response'],
content: () => (
<div className="flex flex-col gap-3">
{statusChanges.map(change => (
<FieldDiffRow key={change.path} label={change.label} before={change.before} after={change.after} />
))}
<KeyValueDiffRows title="Headers" rows={headerRows} />
{bodyChanged && <FieldDiffRow label="Content" before={before?.body} after={after?.body} />}
</div>
),
};
}
export function buildExampleTabs(diff: EntityDiff): DiffTabDef[] {
return withSettingsTab(diff, CARD_FIELD_PATHS, [...requestTabs(diff), responseTab(diff), docsTab(diff)]);
}
export const ExampleDiffCard: FC<{ diff: EntityDiff } & EntityCardActionProps> = ({ diff, ...actionProps }) => {
const request = (diff.after ?? diff.before)?.request;
const currentMethod = request?.method || 'GET';
// Header changes inside the request snapshot don't surface as top-level field changes,
// so add them for the old-line preview of the method and URL.
const header = buildHeaderLines(
{ ...diff, fieldChanges: [...diff.fieldChanges, ...computeFieldChanges(diff.before?.request, diff.after?.request)] },
{
badgeOf: node => {
const label = formatMethodName(node?.request?.method || currentMethod);
return { label, className: getRequestBadgeClassName(label) };
},
detailOf: node => node?.request?.url || '(no url)',
watchedPaths: ['method', 'url'],
},
);
return (
<TabbedDiffCard
diff={diff}
header={<EntityHeader {...header} />}
tabs={buildExampleTabs(diff)}
{...actionProps}
/>
);
};
@@ -9,6 +9,7 @@ import { InlineDiffEditor } from './inline-diff-editor';
const ENTITY_TYPE_LABELS: Record<VisualDiffEntityType, string> = {
request: 'Request',
example: 'Example',
grpc_request: 'gRPC Request',
websocket_request: 'WebSocket Request',
socketio_request: 'Socket.IO Request',