@teambit/component-compare 1.0.1079 → 1.0.1081

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ .page {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ height: 100%;
6
+ min-height: 0;
7
+ background: var(--surface-base-color, #fff);
8
+ }
9
+
10
+ .versionPickerRow {
11
+ flex: 0 0 auto;
12
+ padding: 8px 16px;
13
+ border-bottom: 1px solid var(--border-color, #e6e8eb);
14
+ }
15
+
16
+ .blankState {
17
+ flex: 1 1 auto;
18
+ min-height: 0;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ }
23
+
24
+ @keyframes compareSkeletonShimmer {
25
+ 0% {
26
+ background-position: 200% 0;
27
+ }
28
+ 100% {
29
+ background-position: -200% 0;
30
+ }
31
+ }
32
+
33
+ %compareSkeletonShape {
34
+ border-radius: 6px;
35
+ background: linear-gradient(
36
+ 90deg,
37
+ var(--surface-neutral-color, #f0f2f5) 25%,
38
+ var(--surface-hover-color, #e8ecf0) 37%,
39
+ var(--surface-neutral-color, #f0f2f5) 63%
40
+ );
41
+ background-size: 400% 100%;
42
+ animation: compareSkeletonShimmer 1.4s ease infinite;
43
+ }
44
+
45
+ .toolbarSkeleton {
46
+ flex: 0 0 auto;
47
+ display: flex;
48
+ gap: 8px;
49
+ padding: 10px 16px;
50
+ border-bottom: 1px solid var(--border-color, #e6e8eb);
51
+ }
52
+
53
+ .skelPill {
54
+ @extend %compareSkeletonShape;
55
+ width: 92px;
56
+ height: 28px;
57
+ }
58
+
59
+ .bodySkeleton {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 10px;
63
+ padding: 16px 20px;
64
+ }
65
+
66
+ .skelHeader {
67
+ @extend %compareSkeletonShape;
68
+ width: 40%;
69
+ height: 16px;
70
+ margin-bottom: 8px;
71
+ }
72
+
73
+ .skelLine {
74
+ @extend %compareSkeletonShape;
75
+ height: 12px;
76
+ }
77
+
78
+ .diffPane {
79
+ flex: 1 1 auto;
80
+ min-height: 0;
81
+ // shrink below content width so a wide diff scrolls inside its own file body instead of forcing
82
+ // the whole page horizontally wider.
83
+ min-width: 0;
84
+ overflow: auto;
85
+ // No padding/gap on the scroll container itself: the sticky component header (top:
86
+ // `--group-header-height`) and diff-file headers (top: `--group-header-height` +
87
+ // `--component-header-height`) are measured from the scrollport edge, so any padding here
88
+ // offsets them and they stop stacking flush. Inner cards/headers carry their own padding
89
+ // (mirrors lane-compare's padding-less `.diffPane`).
90
+ display: flex;
91
+ flex-direction: column;
92
+
93
+ // Tab visibility driven by `data-view-mode` on the pane. All `DeferredTab` siblings are mounted
94
+ // simultaneously so their internal query state survives mode switches; CSS hides the non-active
95
+ // tabs. Mirror of lane-compare's panel selectors so the same inline-* tabs work in both pages.
96
+ &[data-view-mode='code'] [data-tab-id]:not([data-tab-id='inline-code']) {
97
+ display: none;
98
+ }
99
+ &[data-view-mode='preview'] [data-tab-id]:not([data-tab-id='inline-preview']) {
100
+ display: none;
101
+ }
102
+ &[data-view-mode='dependencies'] [data-tab-id]:not([data-tab-id='inline-deps']) {
103
+ display: none;
104
+ }
105
+ &[data-view-mode='tests'] [data-tab-id]:not([data-tab-id='inline-tests']) {
106
+ display: none;
107
+ }
108
+ &[data-view-mode='config'] [data-tab-id]:not([data-tab-id='inline-config']) {
109
+ display: none;
110
+ }
111
+ &[data-view-mode='docs'] [data-tab-id]:not([data-tab-id='inline-docs']) {
112
+ display: none;
113
+ }
114
+ // The API view is not an inline `[data-tab-id]` tab — it's rendered as a direct child of the
115
+ // inline compare (only while active). Hide every inline tab so only the API element shows.
116
+ &[data-view-mode='api'] [data-tab-id] {
117
+ display: none;
118
+ }
119
+
120
+ // reveal the header note a view registered for the active view (e.g. the deps change tally).
121
+ &[data-view-mode='dependencies'] [data-header-extra-view='dependencies'] {
122
+ display: inline-flex;
123
+ }
124
+ }
@@ -0,0 +1,509 @@
1
+ import type { HTMLAttributes } from 'react';
2
+ import React, { useContext, useEffect, useMemo, useState } from 'react';
3
+ import classnames from 'classnames';
4
+ import { useSearchParams } from 'react-router-dom';
5
+ import { ComponentContext, ComponentDescriptorContext, useComponent } from '@teambit/component';
6
+ import type { TabItem } from '@teambit/component.ui.component-compare.models.component-compare-props';
7
+ import {
8
+ ComponentCompareContext,
9
+ InlineCompareEmpty,
10
+ useComponentCompare,
11
+ } from '@teambit/component.ui.component-compare.context';
12
+ import { ComponentCompareVersionPicker } from '@teambit/component.ui.component-compare.version-picker';
13
+ import { useCompareQueryParam } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url';
14
+ import { sortByDateDsc } from '@teambit/component.ui.component-compare.utils.sort-logs';
15
+ import { groupByVersion } from '@teambit/component.ui.component-compare.utils.group-by-version';
16
+ import type { LegacyComponentLog } from '@teambit/legacy-component-log';
17
+ import {
18
+ CompareDataProvider,
19
+ CompareToolbar,
20
+ CompareToolbarActions,
21
+ DiffModeProvider,
22
+ DepsFilterProvider,
23
+ FileRegistryProvider,
24
+ InlineComponentCompare,
25
+ RegistryFeeder,
26
+ useCompareData,
27
+ } from '@teambit/component.ui.component-compare.component-compare';
28
+ import type { CompareViewMode, ComponentComparePair } from '@teambit/component.ui.component-compare.component-compare';
29
+ import { computeDepsDiff } from '@teambit/dependencies.ui.deps-diff-table';
30
+ import { useApiDiff } from '@teambit/semantics.ui.api-diff-view';
31
+
32
+ import styles from './component-compare-page.module.scss';
33
+
34
+ export type ComponentComparePageProps = {
35
+ host: string;
36
+ tabs: TabItem[] | (() => TabItem[]);
37
+ /**
38
+ * Lazily resolves the API compare element (contributed by the api-reference aspect). Resolved at
39
+ * render — not construction — so it doesn't depend on UI-provider registration order. When it
40
+ * yields an element, the page exposes an `api` view mode; the element is only mounted while that
41
+ * view is active, so its diff query fires on-demand.
42
+ */
43
+ getApiTab?: () => React.ReactNode;
44
+ className?: string;
45
+ } & HTMLAttributes<HTMLDivElement>;
46
+
47
+ type ViewMode = 'code' | 'preview' | 'docs' | 'dependencies' | 'tests' | 'config' | 'api';
48
+ type DiffMode = 'split' | 'unified';
49
+
50
+ // The single-component compare offers the same view modes as lane-compare. Which ones actually
51
+ // appear is driven by real per-view content counts (see `CompareView`) — mirroring how the cloud
52
+ // changes view derives available views from each component's change types, so a user never lands
53
+ // on a view that has nothing to show. `api` is only listed when the api-reference aspect supplies
54
+ // an API tab (see `getApiTab`); like lane-compare it's always available then (the view renders its
55
+ // own "nothing to compare" state), since we only fetch the diff once the view is opened.
56
+ // Order mirrors lane-compare exactly (API first), so the two compare surfaces read identically.
57
+ const COMPARE_VIEW_MODES: CompareViewMode[] = [
58
+ { id: 'api', displayName: 'API', icon: 'schema' },
59
+ { id: 'code', displayName: 'Code', icon: 'code' },
60
+ { id: 'preview', displayName: 'Preview', icon: 'eye' },
61
+ { id: 'docs', displayName: 'Docs', icon: 'overview' },
62
+ { id: 'dependencies', displayName: 'Dependencies', icon: 'link' },
63
+ { id: 'tests', displayName: 'Tests', icon: 'test' },
64
+ { id: 'config', displayName: 'Configuration', icon: 'configuration' },
65
+ ];
66
+
67
+ function findPrevVersionFromCurrent(currentVersion?: string) {
68
+ return (log: LegacyComponentLog, index: number, logs: LegacyComponentLog[]) => {
69
+ if (!currentVersion) return index === 1;
70
+ const prevIndex = logs.findIndex((l) => (l.tag || l.hash) === currentVersion) + 1;
71
+ return index === prevIndex;
72
+ };
73
+ }
74
+
75
+ export function ComponentComparePage({
76
+ host: hostFromProps,
77
+ tabs,
78
+ getApiTab,
79
+ className,
80
+ ...rest
81
+ }: ComponentComparePageProps) {
82
+ // `component` from ComponentContext is authoritative for the compare side — mirrors legacy
83
+ // `ComponentCompare` which never loaded a separate compareModel when there was no override.
84
+ // Loading a separate copy via useComponent risks an id string that differs from the live
85
+ // workspace component (e.g. no version), which breaks the server's local-changes detection
86
+ // (`computeCompare`: `comparingWithLocalChanges = workspace && baseId === compareId`).
87
+ const component = useContext(ComponentContext);
88
+ const componentDescriptor = useContext(ComponentDescriptorContext);
89
+
90
+ const resolvedTabs = useMemo(() => (typeof tabs === 'function' ? tabs() : tabs), [tabs]);
91
+ const apiTab = useMemo(() => getApiTab?.(), [getApiTab]);
92
+
93
+ const baseVersion = useCompareQueryParam('baseVersion');
94
+ // The compare side follows the component page's main version dropdown, which sets `?version`.
95
+ // When it's set the user is viewing a specific published version, so we compare against THAT
96
+ // version (clean); when absent we default to the live workspace. Read the exact `version` key —
97
+ // NOT `search.includes('version')`, which the old code used and which also matched `baseVersion`,
98
+ // wrongly flipping this whenever a base was picked.
99
+ const compareVersionParam = useCompareQueryParam('version');
100
+ const isWorkspace = hostFromProps === 'teambit.workspace/workspace';
101
+
102
+ const allVersionInfo = useMemo(
103
+ () => component.logs?.slice().sort(sortByDateDsc) || [],
104
+ [component.id.toString(), component.logs?.length]
105
+ );
106
+ const isNew = allVersionInfo.length === 0;
107
+ // "workspace" (live on-disk files, incl. uncommitted) only when NO specific version is selected.
108
+ // With `?version` set, the compare side is that clean version — the server then diffs it as a real
109
+ // version instead of the live workspace (`computeCompare`'s `compareIsLiveWorkspace`).
110
+ const compareHasLocalChanges = isWorkspace && !isNew && !compareVersionParam;
111
+
112
+ // Pick the base version: explicit URL param > previous published version > current.
113
+ // The default base is the *previous* published version so the workspace compare shows the changes
114
+ // since that version (committed + uncommitted). Defaulting to the current checked-out version made
115
+ // base === compare, so the diff was empty and the view collapsed to its always-on sections.
116
+ const lastVersionInfo = useMemo(
117
+ () => allVersionInfo.find(findPrevVersionFromCurrent(component.id.version)),
118
+ [allVersionInfo, component.id.version]
119
+ );
120
+
121
+ const baseId = useMemo(
122
+ () =>
123
+ (baseVersion && component.id.changeVersion(baseVersion)) ||
124
+ (lastVersionInfo && component.id.changeVersion(lastVersionInfo.tag || lastVersionInfo.hash)) ||
125
+ component.id,
126
+ [baseVersion, lastVersionInfo?.tag, lastVersionInfo?.hash, component.id.toString()]
127
+ );
128
+
129
+ const {
130
+ component: base,
131
+ componentDescriptor: baseComponentDescriptor,
132
+ loading: baseLoading,
133
+ error: baseError,
134
+ } = useComponent('teambit.scope/scope', baseId?.toString(), { skip: !baseId || isNew });
135
+
136
+ // The base model is fetched asynchronously. Until it resolves we must report `loading: true` —
137
+ // otherwise consumers (the toolbar counts, the dependency diff, etc.) observe `base === undefined`
138
+ // with `loading === false` and wrongly treat the component as new (no base + has compare),
139
+ // deriving bogus "everything changed" data before the comparison is actually stable. We treat the
140
+ // base as loading until the model is present (not just until Apollo's flag flips), which also
141
+ // covers the skip→fetch transition where the flag is briefly false with no model yet.
142
+ const baseModelLoading = !isNew && !!baseId && !baseError && (!!baseLoading || !base);
143
+
144
+ // The compare side is always the checked-out component. In workspace mode the server recognises
145
+ // this as the live workspace and diffs the base snap against the on-disk files (so uncommitted
146
+ // changes are included) — see `computeCompare`'s `compareIsLiveWorkspace`. The base then differs
147
+ // from the compare (previous version vs. current), which is what surfaces the real change set.
148
+ const baseIdStr = baseId?.toString();
149
+ const compareIdStr = component.id.toString();
150
+
151
+ // When the resolved base and compare point at the exact same version (and we are not diffing
152
+ // live workspace changes against a snap), there is nothing to diff. This is common: a
153
+ // single-version component, or the user picking the same version on both sides. Without this
154
+ // the whole pane just blanks out, so we show an explicit "identical versions" state instead.
155
+ const isSameVersion = !isNew && !compareHasLocalChanges && !!baseIdStr && baseIdStr === compareIdStr;
156
+
157
+ const logsByVersion = useMemo(
158
+ () => (component.logs || []).slice().reduce(groupByVersion, new Map<string, LegacyComponentLog>()),
159
+ [component.id.toString(), component.logs?.length]
160
+ );
161
+
162
+ // Build a minimal ComponentCompareContext for the version picker (it only reads
163
+ // `compare.model.logs`, `compare.model.version`, and `base.model.version`). Data for the
164
+ // diff itself flows through `CompareDataProvider` → `InlineContextProvider` inside
165
+ // `InlineComponentCompare`, which sets its own inner context with the file/aspect/test maps.
166
+ const componentCompareModel = useMemo(
167
+ () => ({
168
+ compare: {
169
+ model: component,
170
+ descriptor: componentDescriptor,
171
+ hasLocalChanges: compareHasLocalChanges,
172
+ },
173
+ base: base && {
174
+ model: base,
175
+ descriptor: baseComponentDescriptor,
176
+ },
177
+ // `!component.logs` ⇒ the compare component's version history hasn't loaded yet, so `isNew`
178
+ // (derived from it) is not yet known. Stay in loading until it resolves — otherwise we briefly
179
+ // treat the component as new (no base) and render the wrong toolbar before the base/diff load.
180
+ loading: !component.logs || baseModelLoading,
181
+ logsByVersion,
182
+ isFullScreen: true,
183
+ hidden: false,
184
+ }),
185
+ [
186
+ component,
187
+ base?.id.toString(),
188
+ componentDescriptor,
189
+ baseComponentDescriptor,
190
+ compareHasLocalChanges,
191
+ logsByVersion,
192
+ baseModelLoading,
193
+ ]
194
+ );
195
+
196
+ const pair: ComponentComparePair | null = useMemo(() => {
197
+ if (isNew || isSameVersion || !baseIdStr || !compareIdStr) return null;
198
+ return { baseId: baseIdStr, compareId: compareIdStr };
199
+ }, [isNew, isSameVersion, baseIdStr, compareIdStr]);
200
+ const pairs = useMemo(() => (pair ? [pair] : []), [pair]);
201
+
202
+ // Map the registered inline tabs to view-mode ids so `CompareView` only counts/shows modes that
203
+ // are actually registered (e.g. there is no `docs` inline tab today). The `api` view is not an
204
+ // inline tab — it's supplied separately by `getApiTab` — so append it here when available.
205
+ const registeredModeIds = useMemo(() => {
206
+ const ids = resolvedTabs.map((t) => inlineTabIdToViewMode(t.id)).filter(Boolean) as ViewMode[];
207
+ if (apiTab) ids.push('api');
208
+ return ids;
209
+ }, [resolvedTabs, apiTab]);
210
+
211
+ const baseVersionShort = baseId?.version?.slice(0, 7);
212
+ const compareVersionShort = compareHasLocalChanges ? 'workspace' : (component.id.version || '').slice(0, 7);
213
+
214
+ return (
215
+ <ComponentCompareContext.Provider value={componentCompareModel as any}>
216
+ <CompareDataProvider pairs={pairs}>
217
+ <FileRegistryProvider>
218
+ <RegistryFeeder pairs={pairs} />
219
+ <div className={classnames(styles.page, className)} {...rest}>
220
+ <div className={styles.versionPickerRow}>
221
+ <ComponentCompareVersionPicker
222
+ componentId={component.id.toStringWithoutVersion()}
223
+ baseVersion={baseId?.version}
224
+ compareVersion={component.id.version}
225
+ compareHasLocalChanges={compareHasLocalChanges}
226
+ host={hostFromProps}
227
+ />
228
+ </div>
229
+
230
+ {isSameVersion ? (
231
+ <div className={styles.blankState}>
232
+ <InlineCompareEmpty
233
+ message="No changes to compare"
234
+ baseVersion={baseVersionShort}
235
+ compareVersion={compareVersionShort}
236
+ hint="Both sides point to the same version. Pick a different base above to compare."
237
+ />
238
+ </div>
239
+ ) : (
240
+ <CompareView
241
+ isNew={isNew}
242
+ name={component.id.fullName}
243
+ baseId={isNew ? undefined : baseIdStr}
244
+ compareId={compareIdStr || component.id.toString()}
245
+ baseVersionShort={baseVersionShort}
246
+ compareVersionShort={compareVersionShort}
247
+ resolvedTabs={resolvedTabs}
248
+ registeredModeIds={registeredModeIds}
249
+ apiTab={apiTab}
250
+ host={hostFromProps}
251
+ />
252
+ )}
253
+ </div>
254
+ </FileRegistryProvider>
255
+ </CompareDataProvider>
256
+ </ComponentCompareContext.Provider>
257
+ );
258
+ }
259
+
260
+ type CompareViewProps = {
261
+ isNew: boolean;
262
+ name: string;
263
+ baseId?: string;
264
+ compareId: string;
265
+ baseVersionShort?: string;
266
+ compareVersionShort?: string;
267
+ resolvedTabs: TabItem[];
268
+ registeredModeIds: ViewMode[];
269
+ /** API compare element, rendered only while the `api` view is active (on-demand fetch). */
270
+ apiTab?: React.ReactNode;
271
+ host: string;
272
+ };
273
+
274
+ /**
275
+ * Renders the toolbar + diff pane. Lives inside `CompareDataProvider` so it can read the bulk
276
+ * compare result and derive real per-view counts: a view mode only appears when it has content
277
+ * (code files changed, compositions exist, aspects changed, …), so the user never clicks into an
278
+ * empty view. Mirrors the cloud changes view, which gates each view on the component's change types.
279
+ */
280
+ function CompareView({
281
+ isNew,
282
+ name,
283
+ baseId,
284
+ compareId,
285
+ baseVersionShort,
286
+ compareVersionShort,
287
+ resolvedTabs,
288
+ registeredModeIds,
289
+ apiTab,
290
+ host,
291
+ }: CompareViewProps) {
292
+ const [searchParams] = useSearchParams();
293
+ const [viewMode, setViewModeState] = useState<ViewMode>((searchParams.get('view') as ViewMode) || 'code');
294
+ const [diffMode, setDiffModeState] = useState<DiffMode>((searchParams.get('diffMode') as DiffMode) || 'split');
295
+ const [showAllDeps, setShowAllDeps] = useState(false);
296
+
297
+ const syncUrl = React.useCallback((key: string, value: string | undefined) => {
298
+ const url = new URL(window.location.href);
299
+ if (value) url.searchParams.set(key, value);
300
+ else url.searchParams.delete(key);
301
+ window.history.replaceState(null, '', url.toString());
302
+ }, []);
303
+
304
+ const setViewMode = React.useCallback(
305
+ (v: ViewMode) => {
306
+ setViewModeState(v);
307
+ syncUrl('view', v === 'code' ? undefined : v);
308
+ },
309
+ [syncUrl]
310
+ );
311
+ const setDiffMode = React.useCallback(
312
+ (d: DiffMode) => {
313
+ setDiffModeState(d);
314
+ syncUrl('diffMode', d === 'split' ? undefined : d);
315
+ },
316
+ [syncUrl]
317
+ );
318
+
319
+ const componentCompare = useComponentCompare();
320
+ const compareData = useCompareData();
321
+ const data = compareData?.compareDataFor(compareId);
322
+ const dataLoading = compareData?.loading ?? false;
323
+
324
+ // The API view mode appears when there's something meaningful to show. Unlike the bulk
325
+ // code/aspect/test data, the API diff is a separate query — fetch it here (cheap: one pair) so the
326
+ // count is real. The API tab element self-fetches the same query when opened, so Apollo serves it
327
+ // from cache (no double round-trip). Skipped entirely when there's no API tab registered.
328
+ const { result: apiDiffResult, loading: apiDiffLoading } = useApiDiff(baseId, compareId, { skip: !apiTab });
329
+ // A per-version extraction gap (a side built before API extraction, or a failed extraction) is a
330
+ // "couldn't compute" state worth surfacing — NOT the same as "no changes". Distinct from a
331
+ // component-wide DISABLED/NO_EXTRACTOR (a component that simply has no API), which stays hidden so
332
+ // the tab isn't noise on non-API components.
333
+ const apiExtractionGap = (side?: { available: boolean; reason?: string }) =>
334
+ !!side && !side.available && (side.reason === 'NOT_BUILT' || side.reason === 'FAILED');
335
+ const apiTabHasContent =
336
+ !!apiDiffResult &&
337
+ (apiDiffResult.hasChanges ||
338
+ (apiDiffResult.unresolvedExports?.length ?? 0) > 0 ||
339
+ apiExtractionGap(apiDiffResult.base) ||
340
+ apiExtractionGap(apiDiffResult.compare));
341
+
342
+ // Single stability gate. `componentCompare.loading` is true while the base/compare *models* are
343
+ // still being fetched; `dataLoading` is true while the bulk code/aspect/test diff is in flight;
344
+ // `apiDiffLoading` while the API diff (which gates the API tab) resolves. We must not derive any
345
+ // per-view counts until ALL are settled — otherwise a half-loaded state (base model not yet
346
+ // present) reads as "no base → new → everything changed", or the API tab flickers in late.
347
+ const loading = (componentCompare?.loading ?? false) || dataLoading || (!!apiTab && apiDiffLoading);
348
+
349
+ // All per-view derivation happens here, and ONLY in the stable branch. While anything is loading we
350
+ // render a skeleton (below) instead of the toolbar, so we compute nothing — no counting, and most
351
+ // importantly no dependency diff against half-loaded or absent data.
352
+ const counts = useMemo(() => {
353
+ const acc: Record<string, number> = {};
354
+ if (loading) return acc;
355
+
356
+ // Dependency changes come from the dependency-resolver aspect on each descriptor (the same source
357
+ // InlineDepsCompare diffs) — there is no precomputed deps diff in the bulk gql result.
358
+ const getDeps = (descriptor: any): any[] => {
359
+ const aspect = descriptor?.get?.('teambit.dependencies/dependency-resolver');
360
+ return aspect?.data?.dependencies || aspect?.dependencies || [];
361
+ };
362
+ const depChanges = computeDepsDiff(
363
+ isNew ? [] : getDeps(componentCompare?.base?.descriptor),
364
+ getDeps(componentCompare?.compare?.descriptor)
365
+ ).filter((e) => e.status !== 'unchanged').length;
366
+
367
+ // Compositions live on the component model (not the bulk diff). Show preview if either side has any.
368
+ const compositionsCount = Math.max(
369
+ componentCompare?.compare?.model?.compositions?.length || 0,
370
+ componentCompare?.base?.model?.compositions?.length || 0
371
+ );
372
+
373
+ const changedCode = data ? (data.code || []).filter((f) => f.status && f.status !== 'UNCHANGED').length : 0;
374
+ const changedAspects = data ? (data.aspects || []).length : 0;
375
+ const changedTests = data ? (data.tests || []).filter((f) => f.status && f.status !== 'UNCHANGED').length : 0;
376
+
377
+ for (const id of registeredModeIds) {
378
+ switch (id) {
379
+ case 'code':
380
+ acc.code = isNew ? 1 : changedCode;
381
+ break;
382
+ case 'config':
383
+ acc.config = isNew ? 1 : changedAspects;
384
+ break;
385
+ case 'tests':
386
+ acc.tests = isNew ? 0 : changedTests;
387
+ break;
388
+ case 'preview':
389
+ acc.preview = compositionsCount;
390
+ break;
391
+ case 'dependencies':
392
+ acc.dependencies = depChanges;
393
+ break;
394
+ case 'api':
395
+ // Show when there are real changes, unresolved exports, OR a per-version schema gap
396
+ // (so "schema unavailable for this version" is surfaced, not silently hidden). Stays
397
+ // hidden for a plain computed no-op and for non-API components.
398
+ acc.api = apiTabHasContent ? 1 : 0;
399
+ break;
400
+ default:
401
+ acc[id] = 1;
402
+ }
403
+ }
404
+ return acc;
405
+ }, [loading, registeredModeIds, isNew, data, componentCompare, apiTabHasContent]);
406
+
407
+ // If the active view has no content, fall back to the first view that does.
408
+ useEffect(() => {
409
+ if (loading) return;
410
+ if ((counts[viewMode] ?? 0) === 0) {
411
+ const first = COMPARE_VIEW_MODES.find((v) => (counts[v.id] ?? 0) > 0);
412
+ if (first) setViewMode(first.id as ViewMode);
413
+ }
414
+ // eslint-disable-next-line react-hooks/exhaustive-deps
415
+ }, [counts, viewMode, loading]);
416
+
417
+ // Render a skeleton until base, compare and the bulk diff are all loaded. We must not render the
418
+ // toolbar early: its visible view modes are derived from the counts, so a half-loaded state would
419
+ // show the wrong set (e.g. 5 modes) and then visibly collapse (to 2) once data arrives.
420
+ if (loading) {
421
+ return <CompareViewSkeleton />;
422
+ }
423
+
424
+ return (
425
+ <DiffModeProvider mode={diffMode}>
426
+ <DepsFilterProvider showAll={showAllDeps}>
427
+ <CompareToolbar
428
+ viewMode={viewMode}
429
+ onViewModeChange={(v) => setViewMode(v as ViewMode)}
430
+ endActions={
431
+ <CompareToolbarActions
432
+ viewMode={viewMode}
433
+ diffMode={diffMode}
434
+ onDiffModeChange={setDiffMode}
435
+ depsShowAll={showAllDeps}
436
+ onDepsShowAllChange={setShowAllDeps}
437
+ />
438
+ }
439
+ viewModes={COMPARE_VIEW_MODES}
440
+ counts={counts}
441
+ showCounts={false}
442
+ loading={loading}
443
+ />
444
+
445
+ <div className={styles.diffPane} data-view-mode={viewMode}>
446
+ <InlineComponentCompare
447
+ name={name}
448
+ baseId={baseId}
449
+ compareId={compareId}
450
+ baseVersion={baseVersionShort}
451
+ compareVersion={compareVersionShort}
452
+ allTabs={resolvedTabs}
453
+ host={host}
454
+ // Hand the inner context our already-resolved pair: base = the scope's published snap,
455
+ // compare = the live workspace component (with local changes). Without this the inner
456
+ // context re-fetches by id and, in local-changes mode (compareId === baseId), would load
457
+ // the same snap for both sides — making the deps/config/preview tabs show base vs base.
458
+ baseOverride={componentCompare?.base as { model?: any; descriptor?: any } | undefined}
459
+ compareOverride={componentCompare?.compare as { model?: any; descriptor?: any } | undefined}
460
+ >
461
+ {/* The API view isn't an inline tab — render it inside the inline context (so it sees the
462
+ resolved base/compare pair) only while it's the active view, so its diff query fires
463
+ on-demand. CSS hides the inline `[data-tab-id]` panels when `data-view-mode='api'`. */}
464
+ {viewMode === 'api' ? apiTab : null}
465
+ </InlineComponentCompare>
466
+ </div>
467
+ </DepsFilterProvider>
468
+ </DiffModeProvider>
469
+ );
470
+ }
471
+
472
+ function CompareViewSkeleton() {
473
+ return (
474
+ <>
475
+ <div className={styles.toolbarSkeleton}>
476
+ {Array.from({ length: 4 }).map((_, i) => (
477
+ <span key={i} className={styles.skelPill} />
478
+ ))}
479
+ </div>
480
+ <div className={styles.diffPane}>
481
+ <div className={styles.bodySkeleton}>
482
+ <div className={styles.skelHeader} />
483
+ {Array.from({ length: 8 }).map((_, i) => (
484
+ <div key={i} className={styles.skelLine} style={{ width: `${45 + ((i * 7) % 45)}%` }} />
485
+ ))}
486
+ </div>
487
+ </div>
488
+ </>
489
+ );
490
+ }
491
+
492
+ function inlineTabIdToViewMode(id?: string): ViewMode | undefined {
493
+ switch (id) {
494
+ case 'inline-code':
495
+ return 'code';
496
+ case 'inline-preview':
497
+ return 'preview';
498
+ case 'inline-docs':
499
+ return 'docs';
500
+ case 'inline-deps':
501
+ return 'dependencies';
502
+ case 'inline-tests':
503
+ return 'tests';
504
+ case 'inline-config':
505
+ return 'config';
506
+ default:
507
+ return undefined;
508
+ }
509
+ }