@teambit/component-compare 1.0.1080 → 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.
- package/component-compare-page.module.scss +124 -0
- package/component-compare-page.tsx +509 -0
- package/component-compare.ui.runtime.tsx +98 -16
- package/dist/compare-component-pairs.d.ts +20 -0
- package/dist/compare-component-pairs.js +36 -0
- package/dist/compare-component-pairs.js.map +1 -0
- package/dist/compare-component-pairs.spec.d.ts +1 -0
- package/dist/compare-component-pairs.spec.js +94 -0
- package/dist/compare-component-pairs.spec.js.map +1 -0
- package/dist/component-compare-page.d.ts +16 -0
- package/dist/component-compare-page.js +504 -0
- package/dist/component-compare-page.js.map +1 -0
- package/dist/component-compare-page.module.scss +124 -0
- package/dist/component-compare.graphql.js +34 -0
- package/dist/component-compare.graphql.js.map +1 -1
- package/dist/component-compare.main.runtime.d.ts +64 -4
- package/dist/component-compare.main.runtime.js +187 -6
- package/dist/component-compare.main.runtime.js.map +1 -1
- package/dist/component-compare.ui.runtime.d.ts +43 -3
- package/dist/component-compare.ui.runtime.js +83 -21
- package/dist/component-compare.ui.runtime.js.map +1 -1
- package/dist/diff-cmd.d.ts +3 -3
- package/dist/{preview-1785267101230.js → preview-1785278532531.js} +2 -2
- package/package.json +37 -23
|
@@ -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
|
+
}
|