From a77422dfd8c0ce41265de577329300fbedf4d086 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Tue, 3 Nov 2026 15:29:48 +0800 Subject: [PATCH] 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. --- .../modals/visual-diff/card-tabs.test.ts | 77 +++++++++++++ .../modals/visual-diff/diff-engine.test.ts | 42 +++++++ .../modals/visual-diff/diff-engine.ts | 12 +- .../modals/visual-diff/entity-diff-list.tsx | 4 + .../modals/visual-diff/entity-splice.test.ts | 108 ++++++++++++++++++ .../modals/visual-diff/entity-splice.ts | 94 ++++++++++++--- .../modals/visual-diff/example-diff-card.tsx | 99 ++++++++++++++++ .../components/modals/visual-diff/shared.tsx | 1 + 8 files changed, 422 insertions(+), 15 deletions(-) create mode 100644 packages/insomnia/src/ui/components/modals/visual-diff/example-diff-card.tsx diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/card-tabs.test.ts b/packages/insomnia/src/ui/components/modals/visual-diff/card-tabs.test.ts index 7400265e18..83ce568a2f 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/card-tabs.test.ts +++ b/packages/insomnia/src/ui/components/modals/visual-diff/card-tabs.test.ts @@ -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[]) => { diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts index f9c1500495..f3733223d7 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts +++ b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts @@ -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 }; diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts index 294250bc6a..c199ed34cf 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts +++ b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts @@ -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 { 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); } diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx b/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx index 20c8d73dd5..1ffcd9c178 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx +++ b/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx @@ -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 = ({ case 'request': { return ; } + case 'example': { + return ; + } case 'request_group': { return ; } diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.test.ts b/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.test.ts index 5a2d5d187f..20a1acfd06 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.test.ts +++ b/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.test.ts @@ -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 } }); diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.ts b/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.ts index 80b397bca0..2bb40db0f0 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.ts +++ b/packages/insomnia/src/ui/components/modals/visual-diff/entity-splice.ts @@ -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 = {}; - 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. diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/example-diff-card.tsx b/packages/insomnia/src/ui/components/modals/visual-diff/example-diff-card.tsx new file mode 100644 index 0000000000..799b000557 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/visual-diff/example-diff-card.tsx @@ -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: () => ( +
+ {statusChanges.map(change => ( + + ))} + + {bodyChanged && } +
+ ), + }; +} + +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 ( + } + tabs={buildExampleTabs(diff)} + {...actionProps} + /> + ); +}; diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/shared.tsx b/packages/insomnia/src/ui/components/modals/visual-diff/shared.tsx index b9903a9426..0370dfa9cc 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/shared.tsx +++ b/packages/insomnia/src/ui/components/modals/visual-diff/shared.tsx @@ -9,6 +9,7 @@ import { InlineDiffEditor } from './inline-diff-editor'; const ENTITY_TYPE_LABELS: Record = { request: 'Request', + example: 'Example', grpc_request: 'gRPC Request', websocket_request: 'WebSocket Request', socketio_request: 'Socket.IO Request',