import type { HTMLAttributes } from 'react'; import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import classnames from 'classnames'; import type { UseComponentType } from '@teambit/component'; import type { MaybeLazyLoaded } from '@teambit/component.ui.component-compare.utils.lazy-loading'; import type { TabItem } from '@teambit/component.ui.component-compare.models.component-compare-props'; import type { LaneModel } from '@teambit/lanes.ui.models.lanes-model'; import type { UseLaneDiffStatus } from '@teambit/lanes.ui.compare.lane-compare-hooks.use-lane-diff-status'; import { InlineComponentCompare, CompareToolbar, CompareToolbarActions, CompareSidebar, FileRegistryProvider, useFileRegistry, DiffModeProvider, DepsFilterProvider, CompareDataProvider, RegistryFeeder, } from '@teambit/component.ui.component-compare.component-compare'; import type { CompareViewMode, CompareGroupByOption, CompareSidebarGroup, ComponentComparePair, } from '@teambit/component.ui.component-compare.component-compare'; import { InlineCompareEmpty } from '@teambit/component.ui.component-compare.context'; import { ComponentUrl } from '@teambit/component.modules.component-url'; import { useLaneComponents } from '@teambit/lanes.hooks.use-lane-components'; import { ApiDiffLaneView } from '@teambit/semantics.ui.api-diff-view'; import type { ComponentDiffEntry, ApiDiffInsight, ApiEntry } from '@teambit/semantics.ui.api-diff-view'; import { ChangeType } from '@teambit/lanes.entities.lane-diff'; import type { LaneCompareContextModel } from './lane-compare.context'; import { useLaneCompareContext } from './lane-compare.context'; import { LaneCompareProvider } from './lane-compare.provider'; import { BaseSourceIndicator } from './base-source-indicator'; import { ChangeTypeGroupOrder } from './lane-compare.models'; import { displayChangeType } from './lane-compare.utils'; import styles from './lane-compare.module.scss'; // ── Types ─────────────────────────────────────────────────────────────────── export type LaneCompareProps = { base: LaneModel; compare: LaneModel; host: string; tabs?: MaybeLazyLoaded; customUseComponent?: UseComponentType; customUseLaneDiff?: UseLaneDiffStatus; LaneCompareLoader?: React.ComponentType; ComponentCompareLoader?: React.ComponentType; onStateChanged?: any; groupBy?: 'scope' | 'status'; envIcons?: Map; /** slot-contributed API diff insight renderers (from componentCompareUI.registerApiDiffInsight) */ apiDiffInsights?: ApiDiffInsight[]; } & HTMLAttributes; type ViewMode = 'code' | 'preview' | 'docs' | 'dependencies' | 'tests' | 'config' | 'api'; type GroupBy = 'scope' | 'namespace' | 'status' | 'none'; function scrollInPane(pane: HTMLDivElement, el: Element) { const scrollOnce = () => { const elRect = el.getBoundingClientRect(); const paneRect = pane.getBoundingClientRect(); const drift = elRect.top - paneRect.top; pane.scrollTo({ top: drift + pane.scrollTop, behavior: 'instant' }); return Math.abs(drift); }; scrollOnce(); // settle passes: sections use `content-visibility: auto`, so content above the target may have // been laid out from intrinsic-size estimates on the first measure; scrolling forces real layout, // shifting the target. Re-measure a few frames until it stops moving. let passes = 0; const settle = () => { if (!pane.isConnected || !el.isConnected) return; const drift = scrollOnce(); passes += 1; if (drift > 2 && passes < 4) requestAnimationFrame(settle); }; requestAnimationFrame(settle); } function waitForElementInPane(pane: HTMLDivElement, selector: string, timeoutMs = 5000): Promise { const existing = pane.querySelector(selector); if (existing) return Promise.resolve(existing); return new Promise((resolve) => { const observer = new MutationObserver(() => { const el = pane.querySelector(selector); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(pane, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); resolve(null); }, timeoutMs); }); } function scrollToElement(pane: HTMLDivElement | null, id: string, fileName?: string) { if (!pane) return; if (!fileName) { waitForElementInPane(pane, `[data-component-id="${CSS.escape(id)}"]`).then((el) => { if (el) scrollInPane(pane, el); }); return; } const fileSelector = `[data-file-id="${CSS.escape(id)}:${CSS.escape(fileName)}"]`; const existingFile = pane.querySelector(fileSelector); if (existingFile) { requestAnimationFrame(() => scrollInPane(pane, existingFile)); return; } // File not rendered yet — scroll to component first to trigger lazy loading, then wait for the file const compEl = pane.querySelector(`[data-component-id="${CSS.escape(id)}"]`); if (compEl) { scrollInPane(pane, compEl); } waitForElementInPane(pane, fileSelector).then((el) => { if (el) scrollInPane(pane, el); }); } const ACCENT_COLORS: Record = { [ChangeType.NEW]: 'var(--positive-color, #37b26c)', [ChangeType.SOURCE_CODE]: 'var(--bit-accent-color, #6c5ce7)', [ChangeType.DEPENDENCY]: 'var(--warning-color, #d6a022)', }; /** * Whether a component actually changed between base and compare — any change type other than NONE * (NEW / SOURCE_CODE / DEPENDENCY / ASPECTS / API). Used by the docs and preview views, which have no * per-view diff signal of their own, so they show only modified components rather than every one. */ function isModified(changeType?: ChangeType, changes: ChangeType[] = []): boolean { return changes.some((ch) => ch !== ChangeType.NONE) || (changeType != null && changeType !== ChangeType.NONE); } // ── Blank state ───────────────────────────────────────────────────────────── /** * full-page state for a comparison with no changes at all. rendering the regular compare chrome * (toolbar, tab counts, group-by, sidebar) around an empty pane reads as a broken page, so the * whole body collapses into this single hero instead. `released` distinguishes "the target already * contains everything on this lane" (it moved ahead by absorbing it) from two identical lanes. */ function LaneCompareBlankState({ sourceLane, targetLane, componentCount, released, }: { sourceLane: string; targetLane: string; componentCount: number; released: boolean; }) { const title = released ? `This lane has been released to ${targetLane}` : 'No changes to compare'; let subtitle = 'Both lanes point to the same components.'; if (componentCount > 0) { subtitle = released ? `${targetLane} already includes all ${componentCount} components from ${sourceLane} — there's nothing left to compare.` : `${sourceLane} and ${targetLane} point to the same versions across ${componentCount} components.`; } return (
{title}
{subtitle}
{componentCount > 0 && (
{componentCount} component{componentCount !== 1 ? 's' : ''} · up to date with {targetLane}
)}
); } // ── Main Component ────────────────────────────────────────────────────────── function LaneCompareInline({ base: _base, compare, className, host, tabs: _tabs, customUseComponent: _customUseComponent, customUseLaneDiff: _customUseLaneDiff, LaneCompareLoader: _LaneCompareLoader, ComponentCompareLoader: _ComponentCompareLoader, onStateChanged: _onStateChanged, groupBy: _groupByProp, envIcons, apiDiffInsights, ...rest }: LaneCompareProps) { const { loadingLaneDiff, componentsToDiff, laneComponentDiffByCompId } = useLaneCompareContext() as LaneCompareContextModel; const resolvedTabs = useMemo(() => { if (!_tabs) return []; return typeof _tabs === 'function' ? _tabs() : _tabs; }, [_tabs]); // __bit's useLaneComponents keys on LaneId; hash-based invalidation is not needed here. const { componentDescriptors: laneComponentDescriptors } = useLaneComponents(compare?.id); const compositionsMap = useMemo(() => { const map = new Map(); if (!laneComponentDescriptors) return map; laneComponentDescriptors.forEach((comp) => { const aspect = comp.get('teambit.compositions/compositions'); const compositions = aspect?.data?.compositions; map.set(comp.id.toStringWithoutVersion(), Array.isArray(compositions) && compositions.length > 0); }); return map; // depend on the descriptors themselves (like envIconsMap), not the component count — switching to a // different lane with the same number of components changes the descriptors but not the length, and // keying on length alone would keep the previous lane's composition data (wrong Preview visibility). }, [laneComponentDescriptors]); // Build the env-icon lookup from the descriptors we already loaded. Falls back to the explicit // `envIcons` prop if the parent supplies it (e.g. for testing/storybook). // // `AspectList.get(aspectId)` returns the aspect *data* directly (not a `{ data }` wrapper), // so the icon lives at `.icon`. Some envs also surface the icon nested under the env-descriptor // object, so we check both shapes to be defensive. const envIconsMap = useMemo(() => { const map = new Map(envIcons ?? []); if (!laneComponentDescriptors) return map; laneComponentDescriptors.forEach((comp) => { const envAspect = comp.get('teambit.envs/envs'); const icon = envAspect?.icon || envAspect?.data?.icon || envAspect?.descriptor?.icon || envAspect?.env?.icon || undefined; if (!icon) return; const key = comp.id.toStringWithoutVersion(); if (!map.has(key)) map.set(key, icon); }); return map; }, [laneComponentDescriptors, envIcons]); const [searchParams] = useSearchParams(); const [viewMode, setViewModeState] = useState((searchParams.get('view') as ViewMode) || 'code'); const [groupBy, setGroupByState] = useState((searchParams.get('groupBy') as GroupBy) || 'scope'); const [diffMode, setDiffModeState] = useState<'split' | 'unified'>((searchParams.get('diffMode') as any) || 'split'); // global "show all dependencies" toggle for the deps view (changed-only by default), lifted here so a // single toolbar control drives every deps panel via DepsFilterProvider. const [showAllDeps, setShowAllDeps] = useState(false); const [selectedId, setSelectedIdState] = useState(searchParams.get('componentId') || undefined); const [selectedFile, setSelectedFileState] = useState(searchParams.get('file') || undefined); const [selectedSearchComponents, setSelectedSearchComponents] = useState<{ value: string; payload: string }[]>([]); const diffPaneRef = useRef(null); const fileRegistry = useFileRegistry(); const syncUrl = useCallback((key: string, value: string | undefined) => { const url = new URL(window.location.href); if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); window.history.replaceState(null, '', url.toString()); }, []); const setViewMode = useCallback( (v: ViewMode) => { setViewModeState(v); syncUrl('view', v === 'code' ? undefined : v); // file/export selection is per-view (code files vs API exports share this state) — // carrying it across modes would focus an unrelated item with the same name. setSelectedFileState(undefined); syncUrl('file', undefined); }, [syncUrl] ); const setGroupBy = useCallback( (g: GroupBy) => { setGroupByState(g); syncUrl('groupBy', g === 'scope' ? undefined : g); }, [syncUrl] ); const setDiffMode = useCallback( (d: 'split' | 'unified') => { setDiffModeState(d); syncUrl('diffMode', d === 'split' ? undefined : d); }, [syncUrl] ); const setSelectedId = useCallback( (id: string | undefined) => { setSelectedIdState(id); syncUrl('componentId', id || undefined); }, [syncUrl] ); const setSelectedFile = useCallback( (f: string | undefined) => { setSelectedFileState(f); syncUrl('file', f || undefined); }, [syncUrl] ); // Scroll to selected component on initial page load from URL const initialScrollDone = useRef(false); useEffect(() => { if (initialScrollDone.current || !selectedId || loadingLaneDiff) return; initialScrollDone.current = true; scrollToElement(diffPaneRef.current, selectedId, selectedFile); }, [selectedId, selectedFile, loadingLaneDiff]); // Build component list const allComponents = useMemo( () => (componentsToDiff || []).map(([baseId, compareId]) => { const idStr = compareId?.toStringWithoutVersion() || baseId?.toStringWithoutVersion() || ''; const diff = laneComponentDiffByCompId.get(idStr); const rawChanges: ChangeType[] | null | undefined = (diff as any)?.changes; const changes: ChangeType[] = rawChanges || []; const changeType: ChangeType | undefined = (diff as any)?.changeType; // diff arrived but the server couldn't classify (changes = null, a transient failure). an // UNKNOWN state — the component must stay visible and must never count toward "no changes". const unclassified = Boolean(diff) && rawChanges == null; const baseSource: 'workspace' | 'scope' | undefined = (diff as any)?.baseSource; return { idStr, name: compareId?.fullName || baseId?.fullName || idStr, baseId: baseId?.toString(), compareId: compareId?.toString() || '', baseSource, baseVersion: baseId?.version?.slice(0, 7), compareVersion: compareId?.version?.slice(0, 7), baseUrl: baseId ? ComponentUrl.toUrl(baseId, { includeVersion: true, useLocationOrigin: true }) : undefined, compareUrl: compareId ? ComponentUrl.toUrl(compareId, { includeVersion: true, useLocationOrigin: true }) : undefined, changes, changeType, unclassified, scope: (compareId || baseId)?.scope || '', namespace: (compareId || baseId)?.namespace || '', }; }), [componentsToDiff, laneComponentDiffByCompId] ); // pairs for the bulk compare query — only components that have a base (new components use the useCode path). const comparePairs = useMemo( () => allComponents .filter((c) => !!c.baseId && !!c.compareId) .map((c) => ({ baseId: c.baseId as string, compareId: c.compareId })), [allComponents] ); const componentOptions = useMemo( () => allComponents.map((c) => ({ value: c.name, payload: c.idStr })), [allComponents] ); // Search filter only — deliberately view-mode-independent. The diff pane renders this full set so // that switching view modes never mounts/unmounts panels (which was the 2-4s freeze); per-view // visibility is then driven entirely by CSS via the `data-has-*` attributes below. const searchFilteredComponents = useMemo(() => { if (selectedSearchComponents.length === 0) return allComponents; const selectedIds = new Set(selectedSearchComponents.map((c) => c.payload)); return allComponents.filter((c) => selectedIds.has(c.idStr)); }, [allComponents, selectedSearchComponents]); // Whether a component belongs in the Code view. The lane-diff `changeType` (SOURCE_CODE) is a coarse // hint that can disagree with the real bulk file diff — a component can be flagged as a code change yet // have zero actually-changed files (metadata-only). Once the bulk data has loaded we trust the real // file list (getFiles → [] means "loaded, no code changes" → hide it); until then we fall back to the // coarse hint so components paint without waiting. NEW components render all files via a separate path // (not the bulk registry), so they always qualify. const hasCodeChanges = useCallback( (c: (typeof allComponents)[number]) => { if (c.changeType === ChangeType.NEW || c.changes.some((ch) => ch === ChangeType.NEW)) return true; const files = fileRegistry?.getFiles(c.idStr); // unclassified (server couldn't derive change types): keep visible until the real file list // settles the question — hiding on an unknown would silently drop a possibly-changed component. if (files === undefined) return c.changes.some((ch) => ch === ChangeType.SOURCE_CODE) || c.unclassified; return files.length > 0; }, // getVersion() advances on every registration, so this recomputes as bulk file data streams in. // eslint-disable-next-line react-hooks/exhaustive-deps [fileRegistry, fileRegistry?.getVersion()] ); // Whether a component belongs in the API view — the API analog of `hasCodeChanges`. The API diff view // registers each analyzed component's changed public exports (or an empty list when it has none), so // once that's known `getApiEntries` is the authoritative signal: a non-empty list means real changes. // Until it's known (undefined) we fall back to the coarse hint — a source or dependency change can // move the public API; a new component has no base to diff, so it's excluded — so the sidebar paints // while analysis streams in, then settles to the real answer. const hasApiChanges = useCallback( (c: (typeof allComponents)[number]) => { const entries = fileRegistry?.getApiEntries(c.idStr); // unclassified components stay visible until the API analysis itself settles the question. if (entries === undefined) return c.changes.some((ch) => ch === ChangeType.SOURCE_CODE || ch === ChangeType.DEPENDENCY) || c.unclassified; return entries.length > 0; }, // getVersion() advances on every registration, so this recomputes as API results stream in. // eslint-disable-next-line react-hooks/exhaustive-deps [fileRegistry, fileRegistry?.getVersion()] ); const matchesView = useCallback( (c: (typeof allComponents)[number], mode: ViewMode) => { switch (mode) { case 'code': return hasCodeChanges(c); case 'api': return hasApiChanges(c); case 'preview': // a modified (or unclassified — err toward showing) component that has compositions. return (isModified(c.changeType, c.changes) || c.unclassified) && compositionsMap.get(c.idStr) !== false; case 'docs': // docs have no dedicated diff signal, so show every modified (or unclassified) component. return isModified(c.changeType, c.changes) || c.unclassified; case 'dependencies': return c.changes.some((ch) => ch === ChangeType.DEPENDENCY) || c.unclassified; case 'config': return c.changes.some((ch) => ch === ChangeType.ASPECTS) || c.unclassified; default: // tests show every component. return true; } }, [compositionsMap, hasCodeChanges, hasApiChanges] ); // View-mode-filtered set. Cheap (no heavy mounting) — used for the sidebar, the empty-state check, // and the per-group visible counts. NOT used to mount the diff pane panels. const filteredComponents = useMemo( () => searchFilteredComponents.filter((c) => matchesView(c, viewMode)), [searchFilteredComponents, viewMode, matchesView] ); // Stable `data-has-*` attributes per component, derived only from view-mode-independent data so the // object refs survive view-mode switches and keep `InlineComponentCompare`'s React.memo intact. CSS // (in lane-compare.module.scss, scoped by `[data-view-mode]`) hides panels lacking the active flag. const componentDataAttrs = useMemo(() => { const map = new Map>(); allComponents.forEach((c) => { const attrs: Record = {}; // unclassified counts as modified everywhere: an unknown state must never hide a component. const modified = isModified(c.changeType, c.changes) || c.unclassified; if (hasCodeChanges(c)) attrs['data-has-code'] = ''; // docs and preview only surface modified components (preview additionally needs compositions). if (modified) attrs['data-has-docs'] = ''; if (modified && compositionsMap.get(c.idStr) !== false) attrs['data-has-preview'] = ''; if (c.changes.some((ch) => ch === ChangeType.DEPENDENCY) || c.unclassified) attrs['data-has-deps'] = ''; if (c.changes.some((ch) => ch === ChangeType.ASPECTS) || c.unclassified) attrs['data-has-config'] = ''; map.set(c.idStr, attrs); }); return map; }, [allComponents, compositionsMap, hasCodeChanges]); // Group helper (view-mode-independent grouping: by scope/namespace/status). const groupComponents = useCallback( (comps: typeof allComponents) => { const keyOf = (comp: (typeof allComponents)[number]): string => { if (groupBy === 'status') return comp.changeType || 'unknown'; if (groupBy === 'namespace') return comp.namespace || 'root'; if (groupBy === 'scope') return comp.scope; return 'all'; }; const groups = new Map(); comps.forEach((comp) => { const key = keyOf(comp); if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(comp); }); return [...groups.entries()].sort(([a], [b]) => groupBy === 'status' ? ChangeTypeGroupOrder.indexOf(a as ChangeType) - ChangeTypeGroupOrder.indexOf(b as ChangeType) : a.localeCompare(b) ); }, [groupBy] ); // Sidebar grouping uses the view-filtered set (so the sidebar reflects the active view). const grouped = useMemo(() => groupComponents(filteredComponents), [groupComponents, filteredComponents]); // Pane grouping uses the search-only set — stable across view-mode switches, so panels never remount. const paneGrouped = useMemo( () => groupComponents(searchFilteredComponents), [groupComponents, searchFilteredComponents] ); // idStrs visible in the active view — drives per-group visible counts and empty-group hiding. const visibleIds = useMemo(() => new Set(filteredComponents.map((c) => c.idStr)), [filteredComponents]); // Counts const counts = useMemo(() => { return { code: allComponents.filter((c) => hasCodeChanges(c)).length, // docs and preview show only modified components (they have no per-view diff signal); the count // must match so the tab badge and the auto-view-switch reflect what actually renders. preview: allComponents.filter( (c) => (isModified(c.changeType, c.changes) || c.unclassified) && compositionsMap.get(c.idStr) !== false ).length, docs: allComponents.filter((c) => isModified(c.changeType, c.changes) || c.unclassified).length, dependencies: allComponents.filter((c) => c.changes.some((ch) => ch === ChangeType.DEPENDENCY) || c.unclassified) .length, tests: allComponents.length, config: allComponents.filter((c) => c.changes.some((ch) => ch === ChangeType.ASPECTS) || c.unclassified).length, // API tab count stays the full component set on purpose: a mode with count 0 is hidden by the // toolbar, and the API view must stay reachable even when nothing changed (to show its own // "stable"/"nothing to compare" states) — the sidebar and pane filter to real diffs via // hasApiChanges/hasVisibleDiff, this count only keeps the tab present. api: allComponents.length, }; }, [allComponents, compositionsMap, hasCodeChanges]); const compareViewModes: CompareViewMode[] = useMemo( () => [ { id: 'api', displayName: 'API', icon: 'schema' }, { id: 'code', displayName: 'Code', icon: 'code' }, { id: 'preview', displayName: 'Preview', icon: 'eye' }, { id: 'docs', displayName: 'Docs', icon: 'overview' }, { id: 'dependencies', displayName: 'Dependencies', icon: 'link' }, // { id: 'tests', displayName: 'Tests', icon: 'test' }, { id: 'config', displayName: 'Config', icon: 'configuration' }, ], [] ); const groupByOptions: CompareGroupByOption[] = useMemo(() => { const options: CompareGroupByOption[] = [ { value: 'scope', label: 'Scope' }, { value: 'namespace', label: 'Namespace' }, ]; options.push({ value: 'none', label: 'None' }); return options; }, [viewMode]); const sidebarGroups: CompareSidebarGroup[] = useMemo(() => { const filesFor = (idStr: string) => { if (viewMode === 'code') return fileRegistry?.getFiles(idStr); if (viewMode === 'config') return fileRegistry?.getAspectFiles(idStr); if (viewMode === 'api') return fileRegistry?.getApiEntries(idStr); return undefined; }; return grouped.map(([key, comps]) => ({ key, label: groupBy === 'status' ? displayChangeType(key as ChangeType) : key, items: comps.map((c) => ({ id: c.idStr, name: c.name, envIcon: envIconsMap.get(c.idStr), status: c.changeType, files: filesFor(c.idStr), // only surface a source indicator when the base had to be pulled from the remote scope. sourceIndicator: c.baseSource === 'scope' ? : undefined, })), })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [grouped, groupBy, envIconsMap, fileRegistry?.getVersion(), viewMode]); // every component participates: pairs get diffed (or pre-gated when no API-relevant // change types exist), new components render as "no base to compare" rows. const apiDiffs: ComponentDiffEntry[] = useMemo(() => { const result: ComponentDiffEntry[] = []; laneComponentDiffByCompId.forEach((diff: any) => { result.push({ componentId: diff.componentId, sourceHead: diff.sourceHead, targetHead: diff.targetHead, changes: diff.changes, }); }); return result; }, [laneComponentDiffByCompId]); const registerApiEntries = useCallback( (id: string, entries: ApiEntry[]) => fileRegistry?.registerApiEntries(id, entries), [fileRegistry] ); // Distinguish "the lanes are identical / nothing changed" from "your filters hid everything", // so an empty comparison no longer reads as a blank pane. const emptyState = useMemo(() => { if (allComponents.length === 0) { return { message: 'No changes between these lanes', hint: 'Both lanes point to the same components.' }; } if (selectedSearchComponents.length > 0) { return { message: 'No components match the current filters', hint: 'Clear the search to see all changed components.', }; } const label = compareViewModes.find((v) => v.id === viewMode)?.displayName?.toLowerCase() || viewMode; return { message: `No ${label} changes in this comparison`, hint: 'Try a different view above.' }; }, [allComponents.length, selectedSearchComponents.length, viewMode, compareViewModes]); const isFullPaneView = viewMode === 'api'; useEffect(() => { if (!loadingLaneDiff && counts[viewMode] === 0) { const first = compareViewModes.find((v) => (counts[v.id] ?? 0) > 0); if (first) setViewMode(first.id as ViewMode); } }, [counts, viewMode, loadingLaneDiff]); // No component changed at all (every change list is NONE, or there are no pairs) — the compare has // nothing to show in ANY view, so the whole body renders as a single blank state (below) instead of // empty chrome. NEW/DELETED components count as changes, so their presence keeps the full view. // an unclassified component (the server couldn't derive its change types — transient) blocks the // blank state: "unknown" must never be presented as a verified all-clear. const noChanges = useMemo( () => !loadingLaneDiff && allComponents.every((c) => !c.unclassified && !isModified(c.changeType, c.changes)), [loadingLaneDiff, allComponents] ); // with zero changes, a pair that is not up-to-date means the target moved ahead while already // containing the lane's snaps — i.e. the lane was released/merged into the target. const targetContainsLane = useMemo(() => { if (!noChanges) return false; let ahead = false; laneComponentDiffByCompId.forEach((diff: any) => { if (diff?.upToDate === false && !diff?.unrelated) ahead = true; }); return ahead; }, [noChanges, laneComponentDiffByCompId]); if (loadingLaneDiff) { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } if (noChanges) { return (
); } return (
{/* Toolbar */} setViewMode(v as ViewMode)} groupBy={groupBy} onGroupByChange={(g) => setGroupBy(g as GroupBy)} endActions={ } viewModes={compareViewModes} groupByOptions={groupByOptions} counts={counts} loading={loadingLaneDiff} componentOptions={componentOptions} selectedComponents={selectedSearchComponents} onSelectedComponentsChange={setSelectedSearchComponents} /> {/* Layout */}
{/* Sidebar */} { setSelectedFile(fileName); setSelectedId(selectedId === id && !fileName ? undefined : id || undefined); if (id) { scrollToElement(diffPaneRef.current, id, fileName); } }} loading={loadingLaneDiff} /> {/* Full-pane view (API) — kept mounted to preserve query cache */}
{/* Per-component diff pane */}
{paneGrouped.map(([key, comps]) => { // Count/hide use the active view's visible set; the panels themselves are always // mounted (CSS hides the non-matching ones). An all-hidden group collapses via the // `groupHidden` class — a parent attribute flip that keeps its children mounted. const visibleCount = comps.reduce((n, c) => n + (visibleIds.has(c.idStr) ? 1 : 0), 0); return (
{groupBy !== 'none' && (
{groupBy === 'status' ? displayChangeType(key as ChangeType) : key} {visibleCount}
)} {comps.map((c) => ( ))}
); })} {filteredComponents.length === 0 && (
)}
); } // ── Wrapper ───────────────────────────────────────────────────────────────── const LaneCompareMemoized = React.memo(LaneCompareInline); export const LaneCompare = React.memo(function LaneCompareWrapper({ ...props }: LaneCompareProps) { const laneCompareContext = useLaneCompareContext(); if (laneCompareContext) return ( ); if (!props.base?.id || !props.compare?.id) return null; return ( ); });