diff --git a/.bitmap b/.bitmap index d3b670580fe4..3cf5c986e17a 100644 --- a/.bitmap +++ b/.bitmap @@ -1710,6 +1710,14 @@ "mainFile": "index.ts", "rootDir": "scopes/ui-foundation/ui" }, + "ui/api-diff-view": { + "name": "ui/api-diff-view", + "scope": "", + "version": "", + "defaultScope": "teambit.semantics", + "mainFile": "index.ts", + "rootDir": "components/ui/api-diff-view" + }, "ui/aspect-box": { "name": "ui/aspect-box", "scope": "teambit.harmony", @@ -1759,6 +1767,41 @@ "mainFile": "index.ts", "rootDir": "components/ui/code-view" }, + "ui/compare/lane-compare": { + "name": "ui/compare/lane-compare", + "scope": "teambit.lanes", + "version": "0.0.206", + "mainFile": "index.ts", + "rootDir": "components/ui/compare/lane-compare", + "config": { + "teambit.dependencies/dependency-resolver": { + "policy": { + "dependencies": { + "@teambit/legacy": "-" + }, + "devDependencies": { + "@types/mocha": "9.1.0", + "@teambit/legacy": "-" + }, + "peerDependencies": {} + } + } + } + }, + "ui/compare/lane-compare-hooks/use-lane-diff-status": { + "name": "ui/compare/lane-compare-hooks/use-lane-diff-status", + "scope": "teambit.lanes", + "version": "0.0.144", + "mainFile": "index.ts", + "rootDir": "components/ui/compare/lane-compare-hooks/use-lane-diff-status" + }, + "ui/compare/lane-compare-page": { + "name": "ui/compare/lane-compare-page", + "scope": "teambit.lanes", + "version": "0.0.174", + "mainFile": "index.ts", + "rootDir": "components/ui/compare/lane-compare-page" + }, "ui/component-compare/changelog": { "name": "ui/component-compare/changelog", "scope": "teambit.component", @@ -1836,6 +1879,13 @@ "mainFile": "index.ts", "rootDir": "components/ui/component-filters/show-main-filter" }, + "ui/component-meta": { + "name": "ui/component-meta", + "scope": "teambit.component", + "version": "0.0.368", + "mainFile": "index.ts", + "rootDir": "components/ui/component-meta" + }, "ui/component-preview": { "name": "ui/component-preview", "scope": "teambit.preview", @@ -1843,6 +1893,14 @@ "mainFile": "index.ts", "rootDir": "scopes/preview/ui/component-preview" }, + "ui/components-overview": { + "name": "ui/components-overview", + "scope": "", + "version": "", + "defaultScope": "teambit.explorer", + "mainFile": "index.ts", + "rootDir": "components/ui/components-overview" + }, "ui/composition-compare": { "name": "ui/composition-compare", "scope": "teambit.compositions", @@ -1871,6 +1929,28 @@ "mainFile": "index.ts", "rootDir": "scopes/cloud/ui/current-user" }, + "ui/deps-diff-table": { + "name": "ui/deps-diff-table", + "scope": "", + "version": "", + "defaultScope": "teambit.dependencies", + "mainFile": "index.ts", + "rootDir": "components/ui/deps-diff-table" + }, + "ui/diff-viewer": { + "name": "ui/diff-viewer", + "scope": "", + "version": "", + "defaultScope": "teambit.code", + "mainFile": "index.ts", + "rootDir": "components/ui/diff-viewer", + "config": { + "teambit.react/react-env@1.3.5": {}, + "teambit.envs/envs": { + "env": "teambit.react/react-env" + } + } + }, "ui/docs-app": { "name": "ui/docs-app", "scope": "teambit.react", @@ -1941,6 +2021,61 @@ "mainFile": "index.ts", "rootDir": "components/ui/hooks/use-core-aspects" }, + "ui/hooks/use-data-query": { + "name": "ui/hooks/use-data-query", + "scope": "teambit.ui-foundation", + "version": "0.0.506", + "mainFile": "index.ts", + "rootDir": "components/ui/hooks/use-data-query" + }, + "ui/inline-code-compare": { + "name": "ui/inline-code-compare", + "scope": "", + "version": "", + "defaultScope": "teambit.code", + "mainFile": "index.ts", + "rootDir": "components/ui/inline-code-compare" + }, + "ui/inline-config-compare": { + "name": "ui/inline-config-compare", + "scope": "", + "version": "", + "defaultScope": "teambit.review", + "mainFile": "index.ts", + "rootDir": "components/ui/inline-config-compare" + }, + "ui/inline-deps-compare": { + "name": "ui/inline-deps-compare", + "scope": "", + "version": "", + "defaultScope": "teambit.review", + "mainFile": "index.ts", + "rootDir": "components/ui/inline-deps-compare" + }, + "ui/inline-diff-viewer": { + "name": "ui/inline-diff-viewer", + "scope": "", + "version": "", + "defaultScope": "teambit.code", + "mainFile": "index.ts", + "rootDir": "components/ui/inline-diff-viewer" + }, + "ui/inline-preview-compare": { + "name": "ui/inline-preview-compare", + "scope": "", + "version": "", + "defaultScope": "teambit.preview", + "mainFile": "index.ts", + "rootDir": "components/ui/inline-preview-compare" + }, + "ui/inline-tests-compare": { + "name": "ui/inline-tests-compare", + "scope": "", + "version": "", + "defaultScope": "teambit.review", + "mainFile": "index.ts", + "rootDir": "components/ui/inline-tests-compare" + }, "ui/inputs/lane-selector": { "name": "ui/inputs/lane-selector", "scope": "teambit.lanes", @@ -1997,6 +2132,14 @@ "mainFile": "index.ts", "rootDir": "components/ui/preserve-workspace-mode" }, + "ui/preview-compare": { + "name": "ui/preview-compare", + "scope": "", + "version": "", + "defaultScope": "teambit.preview", + "mainFile": "index.ts", + "rootDir": "components/ui/preview-compare" + }, "ui/preview-placeholder": { "name": "ui/preview-placeholder", "scope": "teambit.preview", diff --git a/.gitignore b/.gitignore index 3e5d08726c35..25c68dc027b3 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,11 @@ package-lock.json # Release notes temp files (for review only) releases-docs/temp-files/ +# Local-only planning / spec / analysis docs — not shipped with the repo. +# Lives on individual contributors' machines for session context; not part of the project. +# Anchored to repo root so it doesn't also match `scopes/docs/docs` (the docs aspect). +/docs/ + + +# superpowers brainstorm artifacts +.superpowers/ diff --git a/components/entities/semantic-schema-diff/api-diff-change.ts b/components/entities/semantic-schema-diff/api-diff-change.ts index c92b4e06625e..f447b2472660 100644 --- a/components/entities/semantic-schema-diff/api-diff-change.ts +++ b/components/entities/semantic-schema-diff/api-diff-change.ts @@ -11,6 +11,27 @@ export enum APIDiffStatus { MODIFIED = 'MODIFIED', } +/** + * why a schema could not be obtained for one side of the diff. + * NOT_BUILT — the version was built before API extraction existed (no schema.json artifact). + * NO_EXTRACTOR — the component's env does not provide a schema extractor. + * DISABLED — schema extraction is disabled by config. + * FAILED — extraction or artifact retrieval threw. + */ +export type SchemaUnavailableReason = 'NOT_BUILT' | 'NO_EXTRACTOR' | 'DISABLED' | 'FAILED'; + +export type SchemaAvailability = { + available: boolean; + reason?: SchemaUnavailableReason; +}; + +/** + * whether the diff was actually computed, or skipped because schema data is + * missing for one or both sides. when not COMPUTED, change lists are empty — + * a missing schema must never be diffed as if it were an empty API. + */ +export type APIDiffComputeStatus = 'COMPUTED' | 'BASE_UNAVAILABLE' | 'COMPARE_UNAVAILABLE' | 'UNAVAILABLE'; + export type APIDiffChange = { status: APIDiffStatus; visibility: 'public' | 'internal'; @@ -26,11 +47,23 @@ export type APIDiffChange = { }; export type APIDiffResult = { + status: APIDiffComputeStatus; + base: SchemaAvailability; + compare: SchemaAvailability; hasChanges: boolean; + /** consumer-facing impact — derived from public changes only */ impact: ImpactLevel; + /** severity of internal (non-exported) changes — never affects `impact` */ + internalImpact: ImpactLevel; publicChanges: APIDiffChange[]; internalChanges: APIDiffChange[]; changes: APIDiffChange[]; + /** + * exports the extractor couldn't resolve on one or both sides (an `UnImplementedSchema` placeholder). + * these are NOT diffed as add/remove/modify — extraction gaps, surfaced separately as "couldn't + * analyze" so a real change list stays meaningful. + */ + unresolvedExports: string[]; added: number; removed: number; modified: number; diff --git a/components/entities/semantic-schema-diff/api-diff.ts b/components/entities/semantic-schema-diff/api-diff.ts index 7059174a39ab..59bc5e4d6224 100644 --- a/components/entities/semantic-schema-diff/api-diff.ts +++ b/components/entities/semantic-schema-diff/api-diff.ts @@ -1,17 +1,44 @@ -import type { APISchema } from '@teambit/semantics.entities.semantic-schema'; +import type { APISchema, SchemaNode } from '@teambit/semantics.entities.semantic-schema'; import { deepEqualNoLocation as deepEqual } from '@teambit/semantics.entities.semantic-schema'; -import type { APIDiffResult, APIDiffChange } from './api-diff-change'; +import type { APIDiffResult, APIDiffChange, SchemaAvailability, APIDiffComputeStatus } from './api-diff-change'; import { APIDiffStatus } from './api-diff-change'; import type { ImpactLevel } from './impact-rule'; import type { ImpactAssessor, AssessedChange } from './impact-assessor'; import { worstImpact } from './impact-assessor'; -import { buildExportMap, buildInternalMap, getSchemaTypeName, getDisplayName, toComparableObject } from './utils'; +import { + buildExportMap, + buildInternalMap, + getSchemaTypeName, + getDisplayName, + getExportName, + unwrapExport, + toComparableObject, +} from './utils'; + +/** + * Names of exports the extractor couldn't resolve on a side — it produced an `UnImplementedSchema` + * placeholder (no signature, no structure) rather than a real schema node. These are extraction gaps, + * not API surface, and must never be diffed as added/removed/modified. We collect them so the diff can + * (a) suppress the phantom add/remove they'd otherwise cause and (b) surface them as a distinct + * "couldn't analyze" state instead of a meaningful change. + */ +function collectUnresolvedNames(exports: SchemaNode[]): Set { + const names = new Set(); + for (const exp of exports) { + if (getSchemaTypeName(unwrapExport(exp)) === 'UnImplementedSchema') { + const name = getExportName(exp); + if (name) names.add(name); + } + } + return names; +} function diffExports( baseExports: ReturnType, compareExports: ReturnType, visibility: 'public' | 'internal', - assessor: ImpactAssessor + assessor: ImpactAssessor, + unresolved?: { base: Set; compare: Set; out: Set } ): APIDiffChange[] { const allNames = new Set([...baseExports.keys(), ...compareExports.keys()]); const changes: APIDiffChange[] = []; @@ -21,6 +48,12 @@ function diffExports( const compareEntry = compareExports.get(name); if (!baseEntry && compareEntry) { + // Present in compare, absent from base — but if base had it as an unresolved placeholder it + // existed there too; this is an extraction gap, not a new export. Surface it, don't add it. + if (unresolved?.base.has(name)) { + unresolved.out.add(name); + continue; + } const addedImpact = assessor.assessFact({ changeKind: 'export-added', description: `export '${name}' added`, @@ -37,6 +70,12 @@ function diffExports( compareNode: compareEntry.unwrapped.toObject(), }); } else if (baseEntry && !compareEntry) { + // Absent from compare — but if compare has it as an unresolved placeholder it's still there, + // just un-analyzable. Not a removal; surface as "couldn't analyze" instead of a phantom remove. + if (unresolved?.compare.has(name)) { + unresolved.out.add(name); + continue; + } const removedImpact = assessor.assessFact({ changeKind: 'export-removed', description: `export '${name}' removed`, @@ -57,12 +96,21 @@ function diffExports( const compareComparable = toComparableObject(compareEntry.unwrapped); if (!deepEqual(baseComparable, compareComparable)) { - const facts = baseEntry.unwrapped.diff?.(compareEntry.unwrapped) ?? []; + // Drop facts whose rendered `from`/`to` are identical. The deep compare catches non-semantic + // differences (resolved-type internals, member ordering, build artifacts) that don't change + // the visible API — they'd otherwise surface as "X changed" rows with no actual diff, the bulk + // of the noise after an env/build change. A real change always renders a different from/to. + const facts = (baseEntry.unwrapped.diff?.(compareEntry.unwrapped) ?? []).filter( + (f) => !(f.from != null && f.to != null && f.from === f.to) + ); const assessed: AssessedChange[] = assessor.assess(facts); const baseSig = baseEntry.unwrapped.signature; const compareSig = compareEntry.unwrapped.signature; - const sigsDiffer = baseSig !== compareSig; + // require both signatures: a null on one side is missing extraction data, not a real change. + // without this guard a real signature vs. `undefined` reads as "differ", so an export with no + // actual change is emitted as MODIFIED (empty block) instead of being skipped below. + const sigsDiffer = Boolean(baseSig && compareSig && baseSig !== compareSig); // Skip if no semantic changes detected — the structural difference is // just metadata (locations, ordering) that doesn't affect the API. @@ -108,6 +156,24 @@ function dedupeInternals( return deduped; } +/** + * Drop exports that are bare re-references (a `TypeRefSchema`) to a symbol already exported under its + * own name in the same module — e.g. `export default Foo` where `Foo` is also a named export. Such an + * alias adds no independent API surface, and extractors represent it inconsistently across builds + * (an unresolved default one build, a `Foo (default)` type-ref alias the next). Left in, that + * inconsistency surfaces as a phantom added/removed export on an otherwise unrelated (e.g. docs-only) + * change. Mirrors `dedupeInternals` — remove the redundant entry so the diff sees only real surface. + */ +function dedupeReexportAliases(exports: ReturnType): ReturnType { + for (const [key, entry] of exports) { + const target = (entry.unwrapped as any)?.name; + if (getSchemaTypeName(entry.unwrapped) === 'TypeRefSchema' && target && target !== key && exports.has(target)) { + exports.delete(key); + } + } + return exports; +} + /** * Detect symbols that moved between public and internal across versions. * Returns the visibility change entries and mutates the maps to remove the @@ -191,9 +257,64 @@ function detectVisibilityChanges( return changes; } -export function computeAPIDiff(base: APISchema, compare: APISchema, assessor: ImpactAssessor): APIDiffResult { - const baseExports = buildExportMap(base.module.exports); - const compareExports = buildExportMap(compare.module.exports); +export type APIDiffAvailability = { + base: SchemaAvailability; + compare: SchemaAvailability; +}; + +const AVAILABLE: SchemaAvailability = { available: true }; + +function emptyResult(status: APIDiffComputeStatus, availability: APIDiffAvailability): APIDiffResult { + return { + status, + base: availability.base, + compare: availability.compare, + hasChanges: false, + impact: 'PATCH', + internalImpact: 'PATCH', + publicChanges: [], + internalChanges: [], + changes: [], + unresolvedExports: [], + added: 0, + removed: 0, + modified: 0, + breaking: 0, + nonBreaking: 0, + patch: 0, + }; +} + +export function computeAPIDiff( + base: APISchema, + compare: APISchema, + assessor: ImpactAssessor, + availability: APIDiffAvailability = { base: AVAILABLE, compare: AVAILABLE } +): APIDiffResult { + // A missing schema must never be diffed as if it were an empty API — one empty side + // would report the entire surface as added/removed, and two empty sides would be + // indistinguishable from "no changes". Short-circuit with an explicit status instead. + if (!availability.base.available || !availability.compare.available) { + const status: APIDiffComputeStatus = + !availability.base.available && !availability.compare.available + ? 'UNAVAILABLE' + : !availability.base.available + ? 'BASE_UNAVAILABLE' + : 'COMPARE_UNAVAILABLE'; + return emptyResult(status, availability); + } + + const baseExports = dedupeReexportAliases(buildExportMap(base.module.exports)); + const compareExports = dedupeReexportAliases(buildExportMap(compare.module.exports)); + + // Exports the extractor couldn't resolve on either side. Tracked so their absence on the other side + // isn't mistaken for an add/remove, and so they can be surfaced as "couldn't analyze" (a `TypeRef` + // that resolves to a present export is already handled by dedupeReexportAliases above). + const unresolved = { + base: collectUnresolvedNames(base.module.exports), + compare: collectUnresolvedNames(compare.module.exports), + out: new Set(), + }; const baseInternals = dedupeInternals(buildInternalMap(base.internals || []), baseExports); const compareInternals = dedupeInternals(buildInternalMap(compare.internals || []), compareExports); @@ -207,9 +328,21 @@ export function computeAPIDiff(base: APISchema, compare: APISchema, assessor: Im assessor ); - const publicChanges = [...visibilityChanges, ...diffExports(baseExports, compareExports, 'public', assessor)]; + const publicChanges = [ + ...visibilityChanges, + ...diffExports(baseExports, compareExports, 'public', assessor, unresolved), + ]; const internalChanges = diffExports(baseInternals, compareInternals, 'internal', assessor); + // Names unresolved on BOTH sides that never surfaced as a resolved export — the export exists but is + // opaque to extraction in both versions. (Names resolved on at least one side are not "unresolved".) + for (const name of unresolved.base) { + if (unresolved.compare.has(name) && !baseExports.has(name) && !compareExports.has(name)) { + unresolved.out.add(name); + } + } + const unresolvedExports = [...unresolved.out]; + const allChanges = [...publicChanges, ...internalChanges]; const counts = { added: 0, removed: 0, modified: 0, breaking: 0, nonBreaking: 0, patch: 0 }; @@ -222,14 +355,22 @@ export function computeAPIDiff(base: APISchema, compare: APISchema, assessor: Im else if (c.impact === 'PATCH') counts.patch++; } - const impact: ImpactLevel = allChanges.length > 0 ? worstImpact(allChanges) : 'PATCH'; + // Consumer-facing impact comes from public changes only — an internal-only refactor + // must not stamp the component MAJOR. Internal severity is reported separately. + const impact: ImpactLevel = publicChanges.length > 0 ? worstImpact(publicChanges) : 'PATCH'; + const internalImpact: ImpactLevel = internalChanges.length > 0 ? worstImpact(internalChanges) : 'PATCH'; return { + status: 'COMPUTED', + base: availability.base, + compare: availability.compare, hasChanges: allChanges.length > 0, impact, + internalImpact, publicChanges, internalChanges, changes: allChanges, + unresolvedExports, ...counts, }; } diff --git a/components/entities/semantic-schema-diff/index.ts b/components/entities/semantic-schema-diff/index.ts index 0520ba8be12c..d4d14df6ef8d 100644 --- a/components/entities/semantic-schema-diff/index.ts +++ b/components/entities/semantic-schema-diff/index.ts @@ -1,6 +1,13 @@ export { computeAPIDiff } from './api-diff'; +export type { APIDiffAvailability } from './api-diff'; export { APIDiffStatus } from './api-diff-change'; -export type { APIDiffResult, APIDiffChange } from './api-diff-change'; +export type { + APIDiffResult, + APIDiffChange, + SchemaAvailability, + SchemaUnavailableReason, + APIDiffComputeStatus, +} from './api-diff-change'; export type { ImpactLevel, ImpactRule } from './impact-rule'; export { ImpactAssessor, worstImpact } from './impact-assessor'; export type { AssessedChange } from './impact-assessor'; diff --git a/components/entities/semantic-schema-diff/utils.ts b/components/entities/semantic-schema-diff/utils.ts index a2abdffa7ac7..4ca70b8b22de 100644 --- a/components/entities/semantic-schema-diff/utils.ts +++ b/components/entities/semantic-schema-diff/utils.ts @@ -37,9 +37,11 @@ export function buildExportMap( for (const exp of exports) { const name = getExportName(exp); const unwrapped = unwrapExport(exp); - if (name) { - map.set(name, { name, node: exp, unwrapped }); - } + // Skip exports the extractor couldn't resolve — an UnImplementedSchema carries no signature and + // is not a real API surface (the display path filters it too). Keeping it here also lets it mask + // a real export sharing the same name (last-write-wins on the map key), hiding actual changes. + if (!name || getSchemaTypeName(unwrapped) === 'UnImplementedSchema') continue; + map.set(name, { name, node: exp, unwrapped }); } return map; } diff --git a/components/entities/semantic-schema/schema-diff-members.ts b/components/entities/semantic-schema/schema-diff-members.ts index 6fd2c28b879d..f57b0db112d0 100644 --- a/components/entities/semantic-schema/schema-diff-members.ts +++ b/components/entities/semantic-schema/schema-diff-members.ts @@ -22,7 +22,9 @@ function classifyMemberChange(base: Record, compare: Record n.name === name); const cNode = ((compareNode as any).members as SchemaNode[])?.find((n: any) => n.name === name); if (bNode && cNode) { + // carry the member signature so member-level facts (notably doc-only changes) can show the + // declaration as context in the UI — the doc sits on top of the signature it documents. + const signature = compareMember.signature || baseMember.signature; for (const d of bNode.diff(cNode)) { - facts.push({ ...d, description: `${kind} '${name}': ${d.description}` }); + facts.push({ ...d, description: `${kind} '${name}': ${d.description}`, signature }); } continue; } @@ -103,28 +108,38 @@ function diffModifiedMembers( function diffStructural(baseObj: Record, compareObj: Record): SchemaChangeFact[] { const facts: SchemaChangeFact[] = []; + // NOTE on the `from !== to` guards below: `deepEqualNoLocation` compares the raw nodes, which can + // carry non-semantic differences between two builds (resolved-type internals, doc, ordering of + // equivalent representations). Those make the deep compare report a change even when the rendered + // type the consumer actually sees is identical — e.g. `extends React.Component` on both + // sides getting flagged MAJOR. So a structural change is only real once its human-comparable + // string representation differs; otherwise it's a false positive and we drop it. if (!deepEqualNoLocation(baseObj.typeParams, compareObj.typeParams)) { const from = (baseObj.typeParams || []).join(', '); const to = (compareObj.typeParams || []).join(', '); - facts.push({ - changeKind: 'type-parameters-changed', - description: `type parameters changed: <${from || 'none'}> → <${to || 'none'}>`, - context: { from, to }, - from, - to, - }); + if (from !== to) { + facts.push({ + changeKind: 'type-parameters-changed', + description: `type parameters changed: <${from || 'none'}> → <${to || 'none'}>`, + context: { from, to }, + from, + to, + }); + } } if (!deepEqualNoLocation(baseObj.extendsNodes, compareObj.extendsNodes)) { const from = (baseObj.extendsNodes || []).map((n: any) => n.name || typeStr(n)).join(', ') || 'none'; const to = (compareObj.extendsNodes || []).map((n: any) => n.name || typeStr(n)).join(', ') || 'none'; - facts.push({ - changeKind: 'extends-changed', - description: `extends changed: ${from} → ${to}`, - context: { from, to }, - from, - to, - }); + if (from !== to) { + facts.push({ + changeKind: 'extends-changed', + description: `extends changed: ${from} → ${to}`, + context: { from, to }, + from, + to, + }); + } } if ( @@ -133,13 +148,15 @@ function diffStructural(baseObj: Record, compareObj: Record n.name || typeStr(n)).join(', ') || 'none'; const to = (compareObj.implementNodes || []).map((n: any) => n.name || typeStr(n)).join(', ') || 'none'; - facts.push({ - changeKind: 'implements-changed', - description: `implements changed: ${from} → ${to}`, - context: { from, to }, - from, - to, - }); + if (from !== to) { + facts.push({ + changeKind: 'implements-changed', + description: `implements changed: ${from} → ${to}`, + context: { from, to }, + from, + to, + }); + } } return facts; diff --git a/components/entities/semantic-schema/schema-diff.ts b/components/entities/semantic-schema/schema-diff.ts index f7ce0d83954d..d3994d65496c 100644 --- a/components/entities/semantic-schema/schema-diff.ts +++ b/components/entities/semantic-schema/schema-diff.ts @@ -5,6 +5,11 @@ export type SchemaChangeFact = { context: Record; from?: string; to?: string; + /** + * the owning member's signature, carried on member-level facts (e.g. a doc-only change) so the UI + * can show the declaration as context — "what member is this doc/change on" — next to the diff. + */ + signature?: string; }; const SCHEMA_DISPLAY_NAMES: Record = { @@ -122,6 +127,23 @@ export function deepEqualNoLocation(a: any, b: any): boolean { return false; } +/** + * the full human-readable doc text: the description plus every JSDoc tag (`@see …`, `@deprecated …`, + * etc.) on its own line. The diff must show the whole comment — surfacing only `doc.comment` drops + * the tags, so a "documentation removed" reads as a trimmed fragment of what was actually there. + */ +function fullDocText(doc: Record | undefined): string | undefined { + if (!doc) return undefined; + const parts: string[] = []; + if (doc.comment) parts.push(doc.comment); + for (const tag of doc.tags || []) { + const tagName = tag.tagName || tag.name; + if (!tagName) continue; + parts.push(`@${tagName}${tag.comment ? ` ${tag.comment}` : ''}`); + } + return parts.join('\n') || doc.raw || undefined; +} + export function diffDoc( baseDoc: Record | undefined, compareDoc: Record | undefined @@ -134,7 +156,7 @@ export function diffDoc( changeKind: 'documentation-added', description: 'documentation added', context: {}, - to: compareDoc.comment || '(doc added)', + to: fullDocText(compareDoc) || '(doc added)', }, ]; } @@ -144,7 +166,7 @@ export function diffDoc( changeKind: 'documentation-removed', description: 'documentation removed', context: {}, - from: baseDoc.comment || '(doc removed)', + from: fullDocText(baseDoc) || '(doc removed)', }, ]; } @@ -161,8 +183,8 @@ export function diffDoc( changeKind: 'documentation-changed', description: `documentation ${changes.join(' and ')} changed`, context: {}, - from: baseDoc?.comment, - to: compareDoc?.comment, + from: fullDocText(baseDoc), + to: fullDocText(compareDoc), }, ]; } diff --git a/components/entities/semantic-schema/schema-node.ts b/components/entities/semantic-schema/schema-node.ts index 3078a1081478..f0568bcbc626 100644 --- a/components/entities/semantic-schema/schema-node.ts +++ b/components/entities/semantic-schema/schema-node.ts @@ -106,8 +106,11 @@ export abstract class SchemaNode implements ISchemaNode { const compareNoDoc = { ...compareObj, doc: undefined, location: undefined }; if (deepEqualNoLocation(baseNoDoc, compareNoDoc)) return facts; - // Signature changed - if (baseObj.signature !== compareObj.signature) { + // Signature changed — only when BOTH sides actually have a signature. A null/empty signature on + // one side means the extractor produced no signature there (e.g. an incompletely-built compare + // snapshot), not that the API changed; comparing a real signature against `undefined` would + // otherwise report a bogus breaking "signature changed" (a major source of false breaking flags). + if (baseObj.signature && compareObj.signature && baseObj.signature !== compareObj.signature) { facts.push({ changeKind: 'signature-changed', description: 'signature changed', diff --git a/components/ui/api-diff-view/api-diff-data-context.tsx b/components/ui/api-diff-view/api-diff-data-context.tsx new file mode 100644 index 000000000000..6dcb02a74223 --- /dev/null +++ b/components/ui/api-diff-view/api-diff-data-context.tsx @@ -0,0 +1,177 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { gql, useQuery } from '@apollo/client'; +import type { APIDiffResult } from './api-diff-model'; +import { API_DIFF_RESULT_FIELDS } from './api-diff-model'; + +/** number of component pairs requested per bulk api-diff page */ +export const API_DIFF_PAGE_SIZE = 25; + +export type ApiDiffPair = { + baseId: string; + compareId: string; +}; + +type ApiDiffsQueryResult = { + getHost: { id: string; apiDiffs: Array } | null; +}; + +export const QUERY_API_DIFFS = gql` + query ApiDiffs($pairs: [ComponentComparePair!]!, $offset: Int, $limit: Int, $host: String) { + getHost(id: $host) { + id + apiDiffs(pairs: $pairs, offset: $offset, limit: $limit) { + ${API_DIFF_RESULT_FIELDS} + } + } + } +`; + +export type ApiDiffDataContextModel = { + /** + * look up bulk api-diff data for a component pair by its `compareId`. + * `null` = the pair's diff could not be computed, OR it was requested but paging finished/stopped + * early before reaching it (so it will never resolve); `undefined` = the pair is not in this + * provider's list, or its page has not loaded yet (check `loading` to disambiguate). + */ + getApiDiff: (compareId: string) => APIDiffResult | null | undefined; + /** true while pages are still loading; settles to false once every page loads or paging stops early */ + loading: boolean; + /** number of pairs whose data has loaded so far */ + loadedCount: number; +}; + +const ApiDiffDataContext = createContext(undefined); + +export function useApiDiffData(): ApiDiffDataContextModel | undefined { + return useContext(ApiDiffDataContext); +} + +/** + * fires the bulk `apiDiffs` query for the whole `pairs` list and exposes the results via context — + * the batched replacement for one `apiDiff` query per component. loads sequentially in pages of + * API_DIFF_PAGE_SIZE (page 1 first, then `fetchMore` for each subsequent page) so the API tab makes + * ceil(pairs.length / API_DIFF_PAGE_SIZE) requests instead of N. + * + * gated by `active`: when the API view is not the active tab the query is skipped entirely, so the + * mounted-but-hidden pane never touches the network until the user opens it. results stay cached + * afterwards (both in Apollo and in the server-side `getAPIDiff` memo). `pairs` is stabilized + * internally by content, so callers do not have to memoize it. + */ +export function ApiDiffDataProvider({ + pairs, + active, + host, + children, +}: { + pairs: ApiDiffPair[]; + active?: boolean; + /** GraphQL host id, e.g. "teambit.scope/scope" */ + host?: string; + children: ReactNode; +}) { + // stabilize by content: a new array reference with the same pairs must not restart the query. + const pairsKey = JSON.stringify(pairs); + // eslint-disable-next-line react-hooks/exhaustive-deps + const stablePairs = useMemo(() => pairs, [pairsKey]); + + // only fetch when the API view is active — otherwise the mounted-but-hidden pane fires the bulk query on load. + const skip = !active || stablePairs.length === 0; + + const { data, loading, fetchMore } = useQuery(QUERY_API_DIFFS, { + variables: { pairs: stablePairs, offset: 0, limit: API_DIFF_PAGE_SIZE, host }, + skip, + notifyOnNetworkStatusChange: true, + }); + + const results: Array = data?.getHost?.apiDiffs ?? []; + const allLoaded = skip || results.length >= stablePairs.length; + + // flips true once paging terminates early — a page returns empty/short, a fetch fails, or the host is + // null — so the loop stops AND `loading` can settle even when fewer results than pairs came back. + // reactive (not a ref) so the derived `loading` flag updates. reset on pairs change. + const [terminated, setTerminated] = useState(false); + useEffect(() => { + setTerminated(false); + }, [pairsKey]); + + // the pairs set this provider is currently paging — a fetchMore started for an earlier set can still + // resolve/reject after `pairs` changed, so its callbacks check this before terminating the new loop. + const pairsKeyRef = useRef(pairsKey); + useEffect(() => { + pairsKeyRef.current = pairsKey; + }, [pairsKey]); + + // every page loaded, or paging stopped early: no further requests will be made. + const done = allLoaded || terminated; + + // sequential background paging: whenever a page settles and pairs remain, request the next page. + useEffect(() => { + if (skip || loading || done) return; + // guard the async terminations to this page's pairs set: an in-flight fetchMore from a previous + // pairsKey must not terminate the paging loop of a newer one. + const activeKey = pairsKey; + const stillActive = () => pairsKeyRef.current === activeKey; + fetchMore({ + variables: { pairs: stablePairs, offset: results.length, limit: API_DIFF_PAGE_SIZE, host }, + updateQuery: (prev: any, { fetchMoreResult }: any) => { + const prevHost = prev.getHost; + if (!fetchMoreResult || !prevHost) { + // nothing came back, or the accumulated result has no host (getHost: null) to page against → + // can't fetch further; terminate so the effect stops re-issuing fetchMore and loading settles. + if (stillActive()) setTerminated(true); + return prev; + } + // a fetchMore issued for a previous pairs set can resolve after `pairs` changed; discard its + // page rather than merging it into the current session's results (which would corrupt the new + // diff set) or terminating the new loop. + if (!stillActive()) return prev; + const newItems = fetchMoreResult.getHost?.apiDiffs ?? []; + if (newItems.length === 0) setTerminated(true); + return { + getHost: { + ...prevHost, + apiDiffs: [...prevHost.apiDiffs, ...newItems], + }, + }; + }, + }).catch(() => { + // a failed page stops the sequence; pages already loaded stay usable. + if (stillActive()) setTerminated(true); + }); + }, [skip, loading, done, results.length, stablePairs, fetchMore, host, pairsKey]); + + const dataByCompareId = useMemo(() => { + // results are returned aligned to the requested slice and concatenated in order, so position + // `i` corresponds to `stablePairs[i]` (APIDiffResult carries no id to key on directly). + const map = new Map(); + results.forEach((res, i) => { + const key = stablePairs[i]?.compareId; + if (key) map.set(key, res ?? null); + }); + return map; + }, [results, stablePairs]); + + // every compareId this provider was asked to fetch — lets getApiDiff tell "requested but unresolved" + // (paging stopped early) apart from "not in this provider's list" once paging is done. + const requestedCompareIds = useMemo(() => new Set(stablePairs.map((p) => p.compareId)), [stablePairs]); + + const value = useMemo( + () => ({ + getApiDiff: (compareId: string) => { + const found = dataByCompareId.get(compareId); + if (found !== undefined) return found; + // paging finished (fully, or stopped early on a short page / fetch error / null host) but this + // requested pair never produced an entry → surface it as failed (null) rather than "still + // loading" (undefined), so consumers keyed on `getApiDiff(...) === undefined` don't spin forever. + if (done && requestedCompareIds.has(compareId)) return null; + return undefined; + }, + loading: !done, + loadedCount: results.length, + }), + [dataByCompareId, done, requestedCompareIds, results.length] + ); + + return {children}; +} diff --git a/components/ui/api-diff-view/api-diff-insights.tsx b/components/ui/api-diff-view/api-diff-insights.tsx new file mode 100644 index 000000000000..2391c010cc85 --- /dev/null +++ b/components/ui/api-diff-view/api-diff-insights.tsx @@ -0,0 +1,36 @@ +import React, { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; +import type { APIDiffChange, APIDiffResult } from './api-diff-model'; + +export type ApiDiffInsightContext = { + /** component id without version */ + componentId: string; + baseId?: string; + compareId?: string; + result: APIDiffResult; +}; + +/** + * a renderer contributed by an external aspect (via + * `componentCompareUI.registerApiDiffInsight`) that augments a single API change + * with extra intelligence — migration hints, affected dependents, codemods, etc. + */ +export type ApiDiffInsight = { + id: string; + /** when omitted, the insight renders for every change */ + matches?: (change: APIDiffChange, ctx: ApiDiffInsightContext) => boolean; + render: (change: APIDiffChange, ctx: ApiDiffInsightContext) => ReactNode; +}; + +/** module-level constant so the no-insights context value keeps a stable identity across renders */ +const EMPTY_INSIGHTS: ApiDiffInsight[] = []; + +const InsightsContext = createContext(EMPTY_INSIGHTS); + +export function ApiDiffInsightProvider({ insights, children }: { insights?: ApiDiffInsight[]; children: ReactNode }) { + return {children}; +} + +export function useApiDiffInsights(): ApiDiffInsight[] { + return useContext(InsightsContext); +} diff --git a/components/ui/api-diff-view/api-diff-model.ts b/components/ui/api-diff-view/api-diff-model.ts new file mode 100644 index 000000000000..9e472c28458f --- /dev/null +++ b/components/ui/api-diff-view/api-diff-model.ts @@ -0,0 +1,193 @@ +import { gql } from '@apollo/client'; +import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; +import type { + ImpactLevel, + APIDiffComputeStatus, + SchemaUnavailableReason, +} from '@teambit/semantics.entities.semantic-schema-diff'; + +export type { ImpactLevel, APIDiffComputeStatus, SchemaUnavailableReason }; + +/** + * UI-facing shapes of the API diff GraphQL payload. These mirror the server entity + * (`@teambit/semantics.entities.semantic-schema-diff`) but only carry the fields the + * query below selects. All compare UIs must import these from here — do not redeclare. + */ +export type APIDiffDetail = { + changeKind: string; + description: string; + impact: ImpactLevel; + from?: string; + to?: string; + /** owning member's signature — shown as context next to member-level (e.g. doc-only) changes. */ + signature?: string; +}; + +export type APIDiffChange = { + status: 'ADDED' | 'REMOVED' | 'MODIFIED'; + visibility: 'public' | 'internal'; + exportName: string; + schemaType: string; + schemaTypeRaw: string; + impact: ImpactLevel; + baseSignature?: string; + compareSignature?: string; + changes?: APIDiffDetail[]; +}; + +export type SchemaSideAvailability = { + available: boolean; + reason?: SchemaUnavailableReason; +}; + +export type APIDiffResult = { + status: APIDiffComputeStatus; + base: SchemaSideAvailability; + compare: SchemaSideAvailability; + hasChanges: boolean; + impact: ImpactLevel; + internalImpact: ImpactLevel; + publicChanges: APIDiffChange[]; + internalChanges: APIDiffChange[]; + /** exports the extractor couldn't analyze — surfaced distinctly, never as a change. */ + unresolvedExports?: string[]; + added: number; + removed: number; + modified: number; + breaking: number; + nonBreaking: number; + patch: number; +}; + +const API_DIFF_CHANGE_FIELDS = ` + status + visibility + exportName + schemaType + schemaTypeRaw + impact + baseSignature + compareSignature + changes { + changeKind + description + impact + from + to + signature + } +`; + +/** + * GraphQL selection set for a single `APIDiffResult`. Shared by the per-component `apiDiff` + * query (below) and the bulk `apiDiffs` query (`api-diff-data-context`) so the two never drift. + */ +export const API_DIFF_RESULT_FIELDS = ` + status + base { + available + reason + } + compare { + available + reason + } + hasChanges + impact + internalImpact + unresolvedExports + added + removed + modified + breaking + nonBreaking + patch + publicChanges { + ${API_DIFF_CHANGE_FIELDS} + } + internalChanges { + ${API_DIFF_CHANGE_FIELDS} + } +`; + +export const API_DIFF_QUERY = gql` + query ApiDiff($baseId: String!, $compareId: String!, $host: String) { + getHost(id: $host) { + id + apiDiff(baseId: $baseId, compareId: $compareId) { + ${API_DIFF_RESULT_FIELDS} + } + } + } +`; + +export type UseApiDiffOptions = { + host?: string; + skip?: boolean; +}; + +export type UseApiDiffResult = { + /** undefined while loading, null when the server could not compute the diff at all */ + result: APIDiffResult | null | undefined; + loading?: boolean; + error?: string; +}; + +export function useApiDiff(baseId?: string, compareId?: string, options?: UseApiDiffOptions): UseApiDiffResult { + const skip = options?.skip || !baseId || !compareId || baseId === compareId; + const { data, loading, error } = useDataQuery<{ getHost: { apiDiff: APIDiffResult | null } }>(API_DIFF_QUERY, { + variables: { baseId: baseId || '', compareId: compareId || '', host: options?.host }, + skip, + }); + + return { + result: data ? (data.getHost?.apiDiff ?? null) : undefined, + loading, + error: error?.message, + }; +} + +/** semver-flavored display label for an impact level */ +export function impactLabel(impact: ImpactLevel | string): string { + switch (impact) { + case 'BREAKING': + return 'MAJOR'; + case 'NON_BREAKING': + return 'MINOR'; + default: + return 'PATCH'; + } +} + +const REASON_TEXT: Record = { + NOT_BUILT: 'was built before API extraction, so no API snapshot exists', + NO_EXTRACTOR: "env doesn't provide a schema extractor", + DISABLED: 'has schema extraction disabled', + FAILED: 'API data could not be loaded', +}; + +/** + * human-readable explanation of why the diff could not be computed. + * returns undefined when the diff was computed. + */ +export function unavailableText( + result: APIDiffResult, + baseVersion?: string, + compareVersion?: string +): string | undefined { + const ver = (v?: string) => (v ? ` ${v.slice(0, 7)}` : ''); + switch (result.status) { + case 'BASE_UNAVAILABLE': + return `base version${ver(baseVersion)} ${REASON_TEXT[result.base.reason || 'FAILED']}`; + case 'COMPARE_UNAVAILABLE': + return `compare version${ver(compareVersion)} ${REASON_TEXT[result.compare.reason || 'FAILED']}`; + case 'UNAVAILABLE': { + const baseReason = REASON_TEXT[result.base.reason || 'FAILED']; + const compareReason = REASON_TEXT[result.compare.reason || 'FAILED']; + if (baseReason === compareReason) return `neither version has API data (${baseReason})`; + return `neither version has API data (base ${baseReason}; compare ${compareReason})`; + } + default: + return undefined; + } +} diff --git a/components/ui/api-diff-view/api-diff-view.composition.tsx b/components/ui/api-diff-view/api-diff-view.composition.tsx new file mode 100644 index 000000000000..007ea74af708 --- /dev/null +++ b/components/ui/api-diff-view/api-diff-view.composition.tsx @@ -0,0 +1,313 @@ +import React from 'react'; +import type { APIDiffResult, APIDiffChange } from './api-diff-model'; +import { ComponentApiDiffSection, ApiDiffSlimRow } from './component-api-diff-section'; +import { ApiDiffInsightProvider } from './api-diff-insights'; + +const heroChange: APIDiffChange = { + status: 'MODIFIED', + visibility: 'public', + exportName: 'Hero', + schemaType: 'React Component', + schemaTypeRaw: 'ReactSchema', + impact: 'BREAKING', + baseSignature: 'Hero(props: { title: string }): ReactNode', + compareSignature: 'Hero(props: { title: ReactNode, onCta: () => void }): ReactNode', + changes: [ + { + changeKind: 'parameter-added', + description: "required prop 'onCta' added — existing usages won't compile", + impact: 'BREAKING', + }, + { + changeKind: 'parameter-type-changed', + description: "prop 'title' type changed", + impact: 'BREAKING', + from: 'string', + to: 'ReactNode', + }, + ], +}; + +const addedChange: APIDiffChange = { + status: 'ADDED', + visibility: 'public', + exportName: 'useHeroAnalytics', + schemaType: 'Function', + schemaTypeRaw: 'FunctionLikeSchema', + impact: 'NON_BREAKING', + compareSignature: 'useHeroAnalytics(heroId: string): HeroAnalytics', +}; + +const internalChange: APIDiffChange = { + status: 'MODIFIED', + visibility: 'internal', + exportName: 'normalizeHeroProps', + schemaType: 'Function', + schemaTypeRaw: 'FunctionLikeSchema', + impact: 'BREAKING', + baseSignature: 'normalizeHeroProps(props: HeroProps): HeroProps', + compareSignature: 'normalizeHeroProps(props: HeroProps, strict: boolean): HeroProps', + changes: [{ changeKind: 'parameter-added', description: "required parameter 'strict' added", impact: 'BREAKING' }], +}; + +// mirrors a real env-class diff: a breaking removal + base-class change, a batch of minor optional +// additions (properties then methods), and a patch doc removal — exercises the severity clusters, +// collapse-by-kind, and the highlighted add/remove signatures. +const classChange: APIDiffChange = { + status: 'MODIFIED', + visibility: 'public', + exportName: 'AttReact', + schemaType: 'Class', + schemaTypeRaw: 'ClassSchema', + impact: 'BREAKING', + changes: [ + { + changeKind: 'member-removed', + description: "Variable 'eslintConfigPath' removed — consumers using it will break", + impact: 'BREAKING', + from: '(property) AttReact.eslintConfigPath: string', + }, + { + changeKind: 'extends-changed', + description: 'extends changed: extends ReactEnv → extends ReactRspackEnv', + impact: 'BREAKING', + from: 'extends ReactEnv', + to: 'extends ReactRspackEnv', + }, + { + changeKind: 'member-added', + description: "Variable 'oxlintConfigPath' added (optional): (property) AttReact.oxlintConfigPath: string", + impact: 'NON_BREAKING', + to: '(property) AttReact.oxlintConfigPath: string', + }, + { + changeKind: 'member-added', + description: "Variable 'envCurrentPath' added (optional): (property) AttReact.envCurrentPath: string", + impact: 'NON_BREAKING', + to: '(property) AttReact.envCurrentPath: string', + }, + { + changeKind: 'member-added', + description: "Function 'getOxlintNodeOptions' added: (method) AttReact.getOxlintNodeOptions(): OxlintOptions", + impact: 'NON_BREAKING', + to: '(method) AttReact.getOxlintNodeOptions(): { configPath: string; tsconfigPath: string; typeAware: boolean }', + }, + { + changeKind: 'member-added', + description: "Function 'workspaceConfig' added: (method) AttReact.workspaceConfig(): ConfigWriterList", + impact: 'NON_BREAKING', + to: '(method) AttReact.workspaceConfig(): ConfigWriterList', + }, + { + changeKind: 'documentation-removed', + description: "Function 'build': documentation removed", + impact: 'PATCH', + from: 'a set of processes to be performed before a component is snapped, during its build phase\n@see https://bit.dev/docs/react-env/build-pipelines', + signature: '(method) AttReact.build(): Pipeline', + }, + ], +}; + +function makeResult(overrides: Partial): APIDiffResult { + return { + status: 'COMPUTED', + base: { available: true }, + compare: { available: true }, + hasChanges: true, + impact: 'BREAKING', + internalImpact: 'PATCH', + publicChanges: [], + internalChanges: [], + added: 0, + removed: 0, + modified: 0, + breaking: 0, + nonBreaking: 0, + patch: 0, + ...overrides, + }; +} + +export const SectionWithBreakingChanges = () => ( +
+ +
+); + +export const SectionUnresolvedExports = () => ( +
+ +
+); + +export const SectionOnlyUnresolved = () => ( +
+ +
+); + +export const SectionSeverityClusters = () => ( +
+ +
+); + +export const SectionWithInsights = () => ( + change.impact === 'BREAKING', + render: (change) => ( + ✦ Migration hint for {change.exportName}: wrap existing callers with an adapter. + ), + }, + ]} + > +
+ +
+
+); + +export const SectionLoading = () => ( +
+ +
+); + +export const SectionUnavailable = () => ( +
+ +
+); + +// NEITHER side has an API — the calm blank state (distinct from the amber one-side "Schema unavailable"). +export const SectionNoApiAvailable = () => ( +
+ +
+); + +export const SectionError = () => ( +
+ +
+); + +export const SectionInternalOnly = () => ( +
+ +
+); + +export const SectionNoChanges = () => ( +
+ +
+); + +export const SlimRows = () => ( +
+ + + + +
+); diff --git a/components/ui/api-diff-view/api-diff-view.module.scss b/components/ui/api-diff-view/api-diff-view.module.scss new file mode 100644 index 000000000000..898d2d25a8be --- /dev/null +++ b/components/ui/api-diff-view/api-diff-view.module.scss @@ -0,0 +1,924 @@ +$code-font: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +$border-color: var(--border-medium-color, #e8ecf0); +$surface-color: var(--surface-color, #fff); +$accent-color: var(--bit-accent-color, #6c5ce7); +$text-primary: var(--on-surface-color, #1a1a2e); +// note: deliberately darker than the theme's "muted" token — API diff text is content to be read, +// not decoration, so it must clear WCAG AA (~4.5:1) on the surface, which #a0aec0-style mutes don't. +$text-secondary: var(--on-surface-medium-color, #5a626c); +$text-muted: var(--on-background-medium-color, #79808a); +$focus-color: var(--focus-color, #0969da); +$danger: var(--on-surface-negative-bold, #cf222e); +$warning: var(--warning-color, #bb8009); +$success: var(--success-color, #1a7f37); + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.fullView { + padding: 24px; + max-width: 1080px; +} + +// ── summary bar ────────────────────────────────────────────────────────────── + +.summaryBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + padding: 16px 22px; + border-radius: 12px; + background: $surface-color; + border: 1px solid $border-color; +} + +.summaryTitle { + display: flex; + align-items: center; + gap: 12px; +} + +.summaryHeading { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.01em; + color: $text-primary; +} + +.summaryLoading { + font-size: 13px; + color: $accent-color; + animation: pulse 1.5s ease-in-out infinite; +} + +.summaryProgress { + width: 160px; + height: 8px; + border-radius: 4px; + background: var(--surface-neutral-color, #eaeef2); + overflow: hidden; + flex-shrink: 0; +} + +.summaryProgressFill { + height: 100%; + border-radius: 4px; + background: $focus-color; + transition: width 0.3s ease; +} + +.summaryStats { + display: flex; + align-items: center; + gap: 18px; +} + +.summaryStat { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: $text-secondary; +} + +.summaryStatMuted { + font-size: 13px; + color: $text-muted; +} + +.summaryDot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +// ── per-component section ──────────────────────────────────────────────────── + +.section { + margin-bottom: 18px; + border-radius: 12px; + border: 1px solid $border-color; + background: $surface-color; + overflow: hidden; +} + +.sectionHeader { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: var(--surface-neutral-color, #f6f8fa); + border-bottom: 1px solid $border-color; +} + +.sectionName { + font-size: 15px; + font-weight: 600; + color: $text-primary; + font-family: $code-font; +} + +.sectionVersions { + font-size: 13px; + color: $text-secondary; + font-family: $code-font; +} + +.sectionStats { + display: flex; + gap: 14px; + margin-left: auto; +} + +.sectionLoading { + font-size: 13px; + color: $text-secondary; + animation: pulse 1.5s ease-in-out infinite; +} + +.sectionSkeleton { + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px; +} + +.skeletonBar { + height: 12px; + border-radius: 6px; + background: linear-gradient( + 90deg, + var(--surface-neutral-color, #eaeef2) 25%, + var(--surface-color, #f6f8fa) 50%, + var(--surface-neutral-color, #eaeef2) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} + +.statAdded { + font-size: 12.5px; + font-weight: 600; + color: $success; +} + +.statRemoved { + font-size: 12.5px; + font-weight: 600; + color: $danger; +} + +.statModified { + font-size: 12.5px; + font-weight: 600; + color: $warning; +} + +.statBreaking { + font-size: 12.5px; + font-weight: 700; + color: $danger; + background: color-mix(in srgb, $danger 10%, transparent); + padding: 2px 8px; + border-radius: 5px; +} + +// ── change block ───────────────────────────────────────────────────────────── + +.changeBlock { + margin: 12px 16px; + border: 1px solid $border-color; + border-radius: 10px; + overflow: hidden; + scroll-margin-top: 12px; +} + +.changeBlockFocused { + box-shadow: 0 0 0 2px color-mix(in srgb, $focus-color 45%, transparent); +} + +.changeBlockDimmed { + opacity: 0.78; +} + +.changeBlockHeader { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding: 12px 14px; +} + +.changeBlockNote { + margin-left: auto; + font-size: 12.5px; + color: $text-secondary; +} + +.statusIndicator { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1.5px solid; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; + font-family: $code-font; +} + +.exportName { + font-size: 15px; + font-weight: 600; + color: $text-primary; + font-family: $code-font; +} + +.schemaType { + font-size: 12.5px; + color: $text-secondary; + background: rgba(0, 0, 0, 0.04); + padding: 2px 8px; + border-radius: 5px; +} + +// ── signatures + cause diffs (shared git-style diff block) ──────────────────── + +.signatures { + padding: 0 14px 12px; +} + +// ── member-cause clusters (severity groups + collapse-by-kind) ──────────────── +// A modified export's member changes are clustered by severity (breaking → minor → patch); within a +// tier, additions/removals collapse by kind into signature snippets, modifications keep the token +// diff, and doc-only changes render as notes. + +.causeClusters { + display: flex; + flex-direction: column; +} + +.severityGroup { + padding: 2px 14px 14px; + + & + & { + border-top: 1px solid color-mix(in srgb, $border-color 55%, transparent); + } +} + +.severityHeader { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 2px 10px; +} + +.severityLabel { + line-height: 1; + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.severityCount { + font-size: 12px; + font-weight: 600; + padding: 1px 9px; + border-radius: 9px; + font-variant-numeric: tabular-nums; +} + +// only the breaking tier carries a faint tint — it's the one a reader must not miss. minor/patch +// stay on the plain surface so the block doesn't turn into a stack of colored bands. +.severity_BREAKING { + background: color-mix(in srgb, $danger 3.5%, transparent); + + .severityHeader { + color: $danger; + } + .severityCount { + background: color-mix(in srgb, $danger 12%, transparent); + color: $danger; + } +} + +.severity_NON_BREAKING { + .severityHeader { + color: $warning; + } + .severityCount { + background: color-mix(in srgb, $warning 14%, transparent); + color: $warning; + } +} + +.severity_PATCH { + .severityHeader { + color: $success; + } + .severityCount { + background: color-mix(in srgb, $success 13%, transparent); + color: $success; + } +} + +.memberGroup { + & + & { + margin-top: 12px; + } + + // the label already provides the gap above the snippet; drop the diff block's own top margin. + .diffBlock { + margin-top: 0; + } +} + +.memberLabel { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 6px; +} + +.memberName { + font-family: $code-font; + font-size: 13px; + font-weight: 600; + color: $text-primary; +} + +.memberKind { + font-size: 12px; + color: $text-secondary; +} + +.memberNote { + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 0; + + & + & { + border-top: 1px solid color-mix(in srgb, $border-color 45%, transparent); + } +} + +// added/removed signatures sit on a NEUTRAL surface so the syntax colors (tuned for a light code +// background) always clear contrast — the add/remove signal is the colored left edge + gutter, never +// colored text on a green/red fill (which fails WCAG for yellow-on-green, red-on-red, …). +.sigBlock { + border: 1px solid $border-color; + border-left-width: 3px; + border-radius: 8px; + overflow: hidden; + background: var(--surface-neutral-color, #f6f8fa); +} + +.sigBlockAdd { + border-left-color: $success; + + .diffGutter { + color: $success; + } +} + +.sigBlockRemove { + border-left-color: $danger; + + .diffGutter { + color: $danger; + } +} + +.sigLine { + display: grid; + grid-template-columns: 26px 1fr; + align-items: start; + font-family: $code-font; + font-size: 13.5px; + line-height: 1.7; + color: $text-primary; + padding: 7px 14px 7px 0; + + & + & { + border-top: 1px solid color-mix(in srgb, $border-color 55%, transparent); + } +} + +// documentation (JSDoc/comment) changes show the actual prose that was removed/added. Prose, not a +// signature — dark text on a faint tint (dark-on-tint clears contrast), italic to read as doc. +.docBlock { + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; +} + +.docLine { + display: grid; + grid-template-columns: 26px 1fr; + align-items: start; + padding: 8px 14px 8px 0; + + & + & { + border-top: 1px solid color-mix(in srgb, $border-color 55%, transparent); + } +} + +.docRemoved { + background: color-mix(in srgb, $danger 7%, transparent); + + .diffGutter { + color: $danger; + } +} + +.docAdded { + background: color-mix(in srgb, $success 8%, transparent); + + .diffGutter { + color: $success; + } +} + +.docText { + font-size: 13px; + line-height: 1.6; + color: $text-primary; + font-style: italic; + white-space: pre-wrap; + word-break: break-word; +} + +// the declaration the doc belongs to — an unchanged context line beneath the doc diff. Neutral +// surface, monospace + syntax highlighting, a "·" gutter so it reads as context, not an edit. +.docContext { + background: var(--surface-neutral-color, #f6f8fa); + font-family: $code-font; + font-size: 13px; + line-height: 1.65; + color: $text-primary; + + .diffGutter { + color: $text-muted; + } +} + +.causeDescription { + font-size: 14px; + line-height: 1.55; + color: $text-primary; + flex: 1; +} + +.impactDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + position: relative; + top: -1px; +} + +// the reviewable surface: a removed line over an added line, full width, monospaced, with an +// aligned gutter marker. dark text on a tint reads far better than colored-text-on-white, and +// `pre-wrap` + `break-word` keep long type strings legible instead of truncating mid-token. +.diffBlock { + display: flex; + flex-direction: column; + margin-top: 8px; + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; +} + +.diffLine { + display: grid; + grid-template-columns: 26px 1fr; + align-items: start; + font-family: $code-font; + font-size: 13.5px; + line-height: 1.7; + color: $text-primary; + padding: 7px 14px 7px 0; + + & + & { + border-top: 1px solid color-mix(in srgb, $border-color 60%, transparent); + } +} + +.diffGutter { + text-align: center; + font-weight: 700; + user-select: none; + opacity: 0.95; +} + +.diffCode { + white-space: pre-wrap; + word-break: break-word; +} + +// token-level highlight: the exact tokens that differ get a stronger tint + weight, so a one-char +// type change reads instantly. ``'s default yellow is overridden here. +.diffTok { + background: transparent; + color: inherit; + font-weight: 700; + border-radius: 3px; + padding: 0 1px; +} + +.diffLineRemoved { + background: color-mix(in srgb, $danger 8%, transparent); + + .diffGutter { + color: $danger; + } + + .diffTok { + background: color-mix(in srgb, $danger 24%, transparent); + color: $danger; + } +} + +.diffLineAdded { + background: color-mix(in srgb, $success 9%, transparent); + + .diffGutter { + color: $success; + } + + .diffTok { + background: color-mix(in srgb, $success 22%, transparent); + color: var(--success-color, #136a2e); + } +} + +// a no-op signature shown for context (internal/transitive change, public signature unchanged): +// neutral surface, a "·" gutter, and a plain-language note so it reads as context, not a diff. +.diffLineContext { + grid-template-columns: 26px 1fr auto; + background: var(--surface-neutral-color, #f6f8fa); + color: $text-secondary; + + .diffGutter { + color: $text-muted; + } +} + +.diffContextNote { + align-self: center; + padding-left: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 11.5px; + font-style: italic; + color: $text-muted; + white-space: nowrap; +} + +// ── change category groups (breaking → minor → patch) ───────────────────────── + +.changeGroup { + & + & { + border-top: 1px solid color-mix(in srgb, $border-color 55%, transparent); + } +} + +.changeGroupHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 18px 2px; + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-secondary; +} + +.changeGroupDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: $text-muted; + + &[data-impact='BREAKING'] { + background: $danger; + } + + &[data-impact='NON_BREAKING'] { + background: $warning; + } + + &[data-impact='PATCH'] { + background: $success; + } +} + +.changeGroupCount { + font-size: 11px; + font-weight: 600; + color: $text-secondary; + background: rgba(0, 0, 0, 0.05); + padding: 1px 8px; + border-radius: 9px; +} + +// ── blank state: no API to compare (neither side exposes one) ───────────────── +// Calm, centered, neutral — NOT the amber warning treatment. There's nothing to act on here. +.apiBlankState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 12px; + padding: 56px 24px; + min-height: 220px; +} + +.apiBlankIcon { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-neutral-color, #f6f8fa); + color: $text-muted; + border: 1px solid $border-color; +} + +.apiBlankTitle { + font-size: 15.5px; + font-weight: 600; + color: $text-primary; +} + +.apiBlankDetail { + font-size: 13px; + line-height: 1.55; + color: $text-secondary; + max-width: 44ch; +} + +// ── unresolved exports (extraction gaps — distinct from real changes) ───────── +// A neutral, informational note (not the add/remove/modify vocabulary): the extractor couldn't read +// these exports on one side, so their presence/absence is not a meaningful diff. +.unresolvedNote { + display: flex; + align-items: baseline; + gap: 10px; + margin: 12px 16px; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, $warning 35%, $border-color); + border-radius: 8px; + background: color-mix(in srgb, $warning 7%, transparent); +} + +.unresolvedIcon { + color: $warning; + font-size: 13px; + flex-shrink: 0; +} + +.unresolvedText { + font-size: 12.5px; + line-height: 1.55; + color: $text-secondary; + + code { + font-family: $code-font; + } +} + +// ── insights (slot-contributed) ────────────────────────────────────────────── + +.insights { + margin: 0 14px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.insight { + padding: 10px 14px; + border: 1px dashed $border-color; + border-radius: 8px; + background: var(--surface-neutral-color, #fafbfc); + font-size: 13px; + line-height: 1.5; + color: $text-secondary; +} + +// ── internal section ───────────────────────────────────────────────────────── + +.internalSection { + border-top: 1px solid $border-color; + padding-bottom: 6px; +} + +.internalToggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 18px; + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; + font-size: 13.5px; + font-weight: 600; + color: $text-secondary; + text-align: left; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } +} + +.internalCount { + font-size: 12px; + font-weight: 600; + color: $text-secondary; + background: rgba(0, 0, 0, 0.05); + padding: 1px 8px; + border-radius: 9px; +} + +// ── slim component-level rows ──────────────────────────────────────────────── + +.slimRowWrapper { + border-radius: 10px; + border: 1px solid $border-color; + background: $surface-color; + margin-bottom: 10px; + overflow: hidden; + scroll-margin-top: 12px; +} + +.slimRow { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 18px; + color: $text-secondary; + font-size: 13.5px; +} + +.slimRowButton { + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; + text-align: left; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } +} + +.slimName { + font-family: $code-font; + font-size: 14px; + font-weight: 500; + color: $text-primary; +} + +.slimChip { + font-size: 11.5px; + font-weight: 600; + padding: 2px 9px; + border-radius: 11px; + border: 1px solid $border-color; + white-space: nowrap; +} + +.slimChipOk { + color: $text-secondary; +} + +.slimChipWarn { + color: $warning; + border-color: color-mix(in srgb, $warning 35%, transparent); + background: color-mix(in srgb, $warning 8%, transparent); +} + +.slimChipError { + color: $danger; + border-color: color-mix(in srgb, $danger 35%, transparent); + background: color-mix(in srgb, $danger 7%, transparent); +} + +.slimDetail { + font-size: 13px; + color: $text-secondary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slimExpand { + margin-left: auto; + font-size: 16px; + color: $text-muted; + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.slimExpandOpen { + transform: rotate(90deg); +} + +.slimExpanded { + border-top: 1px solid $border-color; + padding: 6px 0; +} + +// ── impact badges ──────────────────────────────────────────────────────────── + +.impactBadge { + font-size: 11px; + font-weight: 700; + padding: 3px 9px; + border-radius: 6px; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.impactBadgeSmall { + font-size: 10.5px; + font-weight: 700; + padding: 2px 7px; + border-radius: 5px; + letter-spacing: 0.04em; + white-space: nowrap; + flex-shrink: 0; +} + +// ── whole-pane states ──────────────────────────────────────────────────────── + +.emptyState { + text-align: center; + padding: 56px 20px; +} + +.emptyIcon { + width: 56px; + height: 56px; + border-radius: 50%; + background: color-mix(in srgb, $success 8%, transparent); + color: $success; + font-size: 26px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; +} + +.emptyIconNeutral { + background: var(--surface-neutral-color, #f6f8fa); + color: $text-muted; + border: 1px solid $border-color; +} + +.emptyTitle { + font-size: 17px; + font-weight: 600; + color: $text-primary; + margin-bottom: 6px; +} + +.emptySubtitle { + font-size: 14px; + line-height: 1.55; + color: $text-secondary; + max-width: 520px; + margin: 0 auto; +} diff --git a/components/ui/api-diff-view/api-diff-view.tsx b/components/ui/api-diff-view/api-diff-view.tsx new file mode 100644 index 000000000000..ccef343e020d --- /dev/null +++ b/components/ui/api-diff-view/api-diff-view.tsx @@ -0,0 +1,391 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import type { ComponentID } from '@teambit/component-id'; +import type { APIDiffResult } from './api-diff-model'; +import { ApiDiffDataProvider, useApiDiffData } from './api-diff-data-context'; +import type { ApiDiffPair } from './api-diff-data-context'; +import { ApiDiffInsightProvider } from './api-diff-insights'; +import type { ApiDiffInsight } from './api-diff-insights'; +import { ComponentApiDiffSection, ApiDiffSlimRow } from './component-api-diff-section'; +import styles from './api-diff-view.module.scss'; + +export type ComponentDiffEntry = { + componentId: ComponentID; + /** head of the compare side (the lane under review) */ + sourceHead?: string; + /** head of the base side — absent for components that are new on the lane */ + targetHead?: string; + /** lane-diff change types for this component (e.g. SOURCE_CODE, DEPENDENCY) — used to + * skip the API diff query for components whose API cannot have changed */ + changes?: string[]; +}; + +export type ApiEntry = { name: string; status: string }; + +export type ApiDiffLaneViewProps = { + diffs: ComponentDiffEntry[]; + /** GraphQL host id, e.g. "teambit.scope/scope" */ + host?: string; + /** component currently selected in the sidebar */ + selectedId?: string; + /** export name currently selected in the sidebar */ + selectedExport?: string; + /** slot-contributed insight renderers (see `registerApiDiffInsight`) */ + insights?: ApiDiffInsight[]; + /** + * called when a component's changed public exports are known — the host view + * feeds these into its sidebar tree (kept as a callback so this component stays + * registry-agnostic and avoids a dependency cycle with component-compare). + */ + onApiEntries?: (componentId: string, entries: ApiEntry[]) => void; + /** + * whether the API view is the active view. The pane is kept mounted (for scroll anchors + query + * cache) even when hidden, so without this every component would fire its API-diff query on initial + * load — N expensive schema-extraction round trips that clog the network before the user ever opens + * the API tab. Queries fire only once this is true; results stay cached afterwards. + */ + active?: boolean; +}; + +/** change types that can affect a component's API surface. config/docs-only changes can't. */ +const API_RELEVANT_CHANGES = new Set(['SOURCE_CODE', 'NEW', 'DEPENDENCY']); + +type EntryKind = 'query' | 'gated' | 'incomparable'; + +function classifyEntry(entry: ComponentDiffEntry): EntryKind { + // missing targetHead: new on the lane OR unrelated histories; missing sourceHead: not on + // this lane. either way there's no base/compare pair to diff. + if (!entry.sourceHead || !entry.targetHead) return 'incomparable'; + // same snap on both sides — identical API by definition, and useApiDiff would skip anyway. + if (entry.sourceHead === entry.targetHead) return 'gated'; + // gate only on explicit evidence: an empty changes array is treated as unknown, not as + // "verified no changes". + if (entry.changes && entry.changes.length > 0 && !entry.changes.some((c) => API_RELEVANT_CHANGES.has(c))) { + return 'gated'; + } + return 'query'; +} + +/** copy for components without a comparable pair — new components are the common case */ +function incomparableCopy(entry: ComponentDiffEntry): { chip: string; detail: string } { + if (entry.changes?.includes('NEW')) return { chip: 'new component', detail: 'no base version to compare' }; + return { chip: 'not comparable', detail: 'no common base version between these lanes' }; +} + +const REGISTRY_STATUS: Record = { ADDED: 'NEW', REMOVED: 'DELETED', MODIFIED: 'MODIFIED' }; + +function ComponentApiDiffContainer({ + entry, + selectedExport, + onLoaded, + onApiEntries, + active, +}: { + entry: ComponentDiffEntry; + selectedExport?: string; + onLoaded: (id: string, result: APIDiffResult | null) => void; + onApiEntries?: (componentId: string, entries: ApiEntry[]) => void; + active?: boolean; +}) { + const baseId = entry.targetHead ? entry.componentId.changeVersion(entry.targetHead)?.toString() : undefined; + const compareId = entry.sourceHead ? entry.componentId.changeVersion(entry.sourceHead)?.toString() : undefined; + const componentIdStr = entry.componentId.toStringWithoutVersion(); + + // read this pair's diff from the batched bulk query rather than firing a per-component query. + // `undefined` = still loading (its page hasn't returned); `null` = couldn't be computed. + const apiData = useApiDiffData(); + const providerLoading = apiData?.loading ?? true; + const result = compareId ? apiData?.getApiDiff(compareId) : undefined; + const loading = result === undefined && providerLoading; + const error = undefined; + + React.useEffect(() => { + // don't mark a pair "loaded" while its page is still in flight, or the totals/progress would + // count it as failed before the bulk query reaches it. the `active` guard keeps a hidden pane + // (whose bulk query is skipped) from reporting everything as loaded/failed prematurely. + if (active && !loading) onLoaded(componentIdStr, result ?? null); + }, [active, loading, result, componentIdStr, onLoaded]); + + // feed changed public exports to the host's sidebar tree (nested under the component, + // exactly like files in code mode). the sidebar's scroll-sync then targets the + // `data-file-id=":"` anchors rendered by ApiChangeBlock. + React.useEffect(() => { + if (!onApiEntries || !result || result.status !== 'COMPUTED') return; + onApiEntries( + componentIdStr, + result.publicChanges.map((c) => ({ name: c.exportName, status: REGISTRY_STATUS[c.status] || 'MODIFIED' })) + ); + }, [result, componentIdStr, onApiEntries]); + + return ( + + ); +} + +/** + * full-pane API diff for a set of component pairs (lane compare's API view). + * always renders every component (sections for changed ones, slim rows for the rest); + * selection from the sidebar scrolls — it never filters. + */ +export function ApiDiffLaneView({ + diffs, + host, + selectedId, + selectedExport, + insights, + onApiEntries, + active, +}: ApiDiffLaneViewProps) { + const entries = useMemo(() => diffs.map((d) => ({ entry: d, kind: classifyEntry(d) })), [diffs]); + const queried = useMemo(() => entries.filter((e) => e.kind === 'query'), [entries]); + + // one bulk-query pair per component that needs a real diff (base = target head, compare = source head). + // 'query' kind guarantees both heads exist, so these ids are always defined. + const queryPairs = useMemo( + () => + queried + .map(({ entry }) => ({ + baseId: entry.componentId.changeVersion(entry.targetHead)?.toString() ?? '', + compareId: entry.componentId.changeVersion(entry.sourceHead)?.toString() ?? '', + })) + .filter((p) => p.baseId && p.compareId), + [queried] + ); + + const [results, setResults] = useState>(new Map()); + const onDiffLoaded = useCallback((componentId: string, result: APIDiffResult | null) => { + setResults((prev) => { + if (prev.has(componentId) && prev.get(componentId) === result) return prev; + const next = new Map(prev); + next.set(componentId, result); + return next; + }); + }, []); + + // aggregate only over the CURRENT queried set — the results map is append-only, so if + // `diffs` changes while mounted, entries from a previous comparison must not leak into + // the totals or the progress count. + const totals = useMemo(() => { + let added = 0; + let removed = 0; + let modified = 0; + let breaking = 0; + let internal = 0; + let withPublicChanges = 0; + let withAnyChanges = 0; + let unavailable = 0; + let failed = 0; + let loaded = 0; + queried.forEach(({ entry }) => { + const idStr = entry.componentId.toStringWithoutVersion(); + if (!results.has(idStr)) return; + loaded += 1; + const r = results.get(idStr); + if (!r) { + failed += 1; + return; + } + if (r.status !== 'COMPUTED') { + unavailable += 1; + return; + } + const pub = r.publicChanges || []; + added += pub.filter((c) => c.status === 'ADDED').length; + removed += pub.filter((c) => c.status === 'REMOVED').length; + modified += pub.filter((c) => c.status === 'MODIFIED').length; + breaking += pub.filter((c) => c.impact === 'BREAKING').length; + internal += (r.internalChanges || []).length; + if (pub.length > 0) withPublicChanges += 1; + if (r.hasChanges) withAnyChanges += 1; + }); + return { + added, + removed, + modified, + breaking, + internal, + withPublicChanges, + withAnyChanges, + unavailable, + failed, + loaded, + }; + }, [results, queried]); + + const loadedCount = totals.loaded; + const allLoaded = loadedCount >= queried.length; + + if (entries.length === 0) { + return ( +
+ +
+ ); + } + + const allIncomparable = entries.every((e) => e.kind === 'incomparable'); + if (allIncomparable) { + return ( +
+ +
+ ); + } + + // gated entries were analyzed cheaply (no relevant changes → no query needed). failed + // queries are NOT analyzed — they must not inflate the stable hero's claim. + const gatedCount = entries.filter((e) => e.kind === 'gated').length; + const analyzedCount = loadedCount - totals.unavailable - totals.failed + gatedCount; + const stable = allLoaded && totals.withAnyChanges === 0 && totals.failed === 0 && analyzedCount > 0; + + return ( + + +
+
+
+ API Surface Changes + {!allLoaded && ( + + Analyzing {loadedCount} of {queried.length}… + + )} +
+ {!allLoaded && queried.length > 0 && ( +
+
+
+ )} + {allLoaded && totals.withAnyChanges > 0 && ( +
+ {totals.breaking > 0 && ( + + + {totals.breaking} breaking + + )} + + + {totals.added} added + + + + {totals.removed} removed + + + + {totals.modified} modified + + {totals.internal > 0 && {totals.internal} internal} +
+ )} +
+ + {stable && ( + + )} + + {/* every component always renders (sections or slim rows) — even in the stable + state — so the sidebar's data-component-id scroll anchors always exist. */} + {entries.map(({ entry, kind }) => { + const idStr = entry.componentId.toStringWithoutVersion(); + if (kind === 'incomparable') { + const copy = incomparableCopy(entry); + return ( + + ); + } + if (kind === 'gated') { + const sameVersion = entry.sourceHead === entry.targetHead; + return ( + + ); + } + return ( + + ); + })} +
+ + + ); +} + +function EmptyState({ + icon, + title, + subtitle, + tone, +}: { + icon: string; + title: string; + subtitle: string; + tone: 'success' | 'neutral'; +}) { + return ( +
+
{icon}
+
{title}
+
{subtitle}
+
+ ); +} + +/** @deprecated use ApiDiffLaneView */ +export const ApiDiffFullView = ApiDiffLaneView; +export type ApiDiffFullViewProps = ApiDiffLaneViewProps; diff --git a/components/ui/api-diff-view/component-api-diff-section.tsx b/components/ui/api-diff-view/component-api-diff-section.tsx new file mode 100644 index 000000000000..64eb26583127 --- /dev/null +++ b/components/ui/api-diff-view/component-api-diff-section.tsx @@ -0,0 +1,821 @@ +import React, { useState } from 'react'; +import type { ReactNode } from 'react'; +import { useHighlightedLines, resolveTokenColor } from '@teambit/code.ui.diff-viewer'; +import type { APIDiffChange, APIDiffDetail, APIDiffResult, ImpactLevel } from './api-diff-model'; +import { impactLabel, unavailableText } from './api-diff-model'; +import { useApiDiffInsights } from './api-diff-insights'; +import type { ApiDiffInsightContext } from './api-diff-insights'; +import styles from './api-diff-view.module.scss'; + +/** + * public changes are grouped + ordered by impact so the most consequential ones are read first and + * the list is scannable by category (breaking → minor → patch) rather than an arbitrary mix. + */ +const CHANGE_GROUPS: { key: string; label: string; match: (c: APIDiffChange) => boolean }[] = [ + { key: 'BREAKING', label: 'Breaking', match: (c) => c.impact === 'BREAKING' }, + { key: 'NON_BREAKING', label: 'Minor', match: (c) => c.impact === 'NON_BREAKING' }, + { key: 'PATCH', label: 'Patch', match: (c) => c.impact !== 'BREAKING' && c.impact !== 'NON_BREAKING' }, +]; + +export function ImpactBadge({ impact, small }: { impact: ImpactLevel | string; small?: boolean }) { + const colors: Record = { + BREAKING: { bg: 'rgba(207, 34, 46, 0.1)', fg: 'var(--on-surface-negative-bold, #cf222e)' }, + NON_BREAKING: { bg: 'rgba(210, 153, 34, 0.1)', fg: 'var(--warning-color, #d6a022)' }, + PATCH: { bg: 'rgba(26, 127, 55, 0.08)', fg: 'var(--success-color, #1a7f37)' }, + }; + const c = colors[impact] || colors.PATCH; + + return ( + + {impactLabel(impact)} + + ); +} + +export function StatusIndicator({ status }: { status: string }) { + const config: Record = { + ADDED: { label: '+', color: 'var(--success-color, #1a7f37)' }, + REMOVED: { label: '−', color: 'var(--on-surface-negative-bold, #cf222e)' }, + MODIFIED: { label: '~', color: 'var(--warning-color, #d6a022)' }, + }; + const c = config[status] || config.MODIFIED; + + return ( + + {c.label} + + ); +} + +function ImpactDot({ impact }: { impact: string }) { + const colors: Record = { + BREAKING: 'var(--on-surface-negative-bold, #cf222e)', + NON_BREAKING: 'var(--warning-color, #d6a022)', + PATCH: 'var(--success-color, #1a7f37)', + }; + return ; +} + +type Tok = { text: string; changed: boolean }; + +/** split into identifier / whitespace / punctuation tokens so the diff aligns on real boundaries. */ +function tokenize(s: string): string[] { + return s.match(/[A-Za-z0-9_$]+|\s+|[^A-Za-z0-9_$\s]/g) || []; +} + +/** + * token-level (word) diff of two signature strings via an LCS walk — marks exactly which tokens + * were removed on the `from` side and added on the `to` side, so a one-character type change is + * visible instead of two identical-looking lines. + */ +function tokenDiff(from: string, to: string): { fromParts: Tok[]; toParts: Tok[] } { + const a = tokenize(from); + const b = tokenize(to); + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0)); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const fromParts: Tok[] = []; + const toParts: Tok[] = []; + let i = 0; + let j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { + fromParts.push({ text: a[i], changed: false }); + toParts.push({ text: b[j], changed: false }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + fromParts.push({ text: a[i], changed: true }); + i++; + } else { + toParts.push({ text: b[j], changed: true }); + j++; + } + } + while (i < m) fromParts.push({ text: a[i++], changed: true }); + while (j < n) toParts.push({ text: b[j++], changed: true }); + return { fromParts, toParts }; +} + +function DiffCode({ parts }: { parts: Tok[] }) { + return ( + + {parts.map((p, i) => + // only emphasize meaningful (non-whitespace) changed tokens — highlighting spaces is noise. + p.changed && p.text.trim() ? ( + + {p.text} + + ) : ( + {p.text} + ) + )} + + ); +} + +/** + * a before/after signature shown as a git-style stacked diff with token-level highlighting: the + * removed line over the added line, full-width monospace, aligned gutter, and only the tokens that + * actually differ emphasized. + * + * When both sides are present and identical, the public signature didn't move (the change is + * internal/transitive). Rather than render a confusing two-line "diff" of identical text — or nothing + * at all, which is impossible to understand — show the signature ONCE as a neutral context line so + * the reader still sees the member's type and can tell it's unchanged at the surface. + */ +function DiffPair({ from, to }: { from?: string; to?: string }) { + if (from && to && from === to) { + return ( +
+ + + {from} + public signature unchanged + +
+ ); + } + const diff = from && to ? tokenDiff(from, to) : undefined; + + return ( +
+ {from && ( + + + {diff ? : {from}} + + )} + {to && ( + + + {diff ? : {to}} + + )} +
+ ); +} + +// ── member-cause grouping (severity clusters + collapse-by-kind) ────────────── + +type CauseKind = 'add' | 'remove' | 'modify' | 'doc'; + +/** the severity buckets, ordered breaking → minor → patch. */ +const SEVERITY_TIERS: { key: string; label: string; match: (impact: string) => boolean }[] = [ + { key: 'BREAKING', label: 'Breaking', match: (i) => i === 'BREAKING' }, + { key: 'NON_BREAKING', label: 'Minor', match: (i) => i === 'NON_BREAKING' }, + { key: 'PATCH', label: 'Patch', match: (i) => i !== 'BREAKING' && i !== 'NON_BREAKING' }, +]; + +/** + * TS quick-info signatures are prefixed with the member kind and owning type, e.g. + * `(property) AttReact.oxlintConfigPath: string`. Pull the kind out (for collapse-by-kind) and strip + * the `(kind) Owner.` prefix so the snippet reads as a clean declaration (`oxlintConfigPath: string`). + */ +function parseSignature(sig: string | undefined, ownerName: string): { kind?: string; clean: string } { + if (!sig) return { clean: '' }; + const m = sig.match(/^\(([a-z][a-z ]*)\)\s+([\s\S]*)$/); + if (!m) return { clean: sig }; + let body = m[2]; + const qualifier = `${ownerName}.`; + if (body.startsWith(qualifier)) body = body.slice(qualifier.length); + return { kind: m[1].trim(), clean: body }; +} + +type ClassifiedCause = { detail: APIDiffDetail; type: CauseKind; kind?: string; clean: string }; + +function classifyCause(detail: APIDiffDetail, ownerName: string): ClassifiedCause { + const ck = detail.changeKind || ''; + if (ck === 'member-added') { + const { kind, clean } = parseSignature(detail.to, ownerName); + return { detail, type: 'add', kind, clean }; + } + if (ck === 'member-removed') { + const { kind, clean } = parseSignature(detail.from, ownerName); + return { detail, type: 'remove', kind, clean }; + } + // documentation-only changes carry prose (not a signature) in from/to — render as a note, never a snippet. + if (ck.includes('documentation')) return { detail, type: 'doc', clean: '' }; + return { detail, type: 'modify', clean: '' }; +} + +function pluralizeKind(kind: string, n: number): string { + if (n === 1) return kind; + if (kind.endsWith('y')) return `${kind.slice(0, -1)}ies`; + return `${kind}s`; +} + +/** leading identifier of a cleaned signature (`foo(): T` → `foo`, `bar: string` → `bar`). */ +function memberNameFromSignature(clean: string): string { + return clean.match(/^[A-Za-z0-9_$]+/)?.[0] || clean; +} + +/** concise label for a modified/structural cause — drops the inline `: from → to` the diff already shows. */ +function modifyLabel(detail: APIDiffDetail): string { + return (detail.description || detail.changeKind || 'changed').split(/:|—/)[0].trim(); +} + +/** + * one signature rendered with the diff-viewer's shiki highlighter. shiki paints sentinel colors that + * must be translated to Bit's design-system syntax vars via `resolveTokenColor` (rendering the raw + * sentinels shows garish yellow/red). Falls back to plain monospace while the grammar loads. + */ +function HighlightedSignature({ code }: { code: string }) { + const lines = useHighlightedLines(code, 'typescript'); + if (!lines) return <>{code}; + return ( + <> + {lines.map((tokens, li) => ( + + {li > 0 ? '\n' : null} + {tokens.map((t, ti) => { + const color = resolveTokenColor(t.color); + return ( + + {t.content} + + ); + })} + + ))} + + ); +} + +/** + * a single added/removed signature line. Rendered on a NEUTRAL surface (not a green/red fill) so the + * syntax colors — tuned for a light code background — always clear WCAG contrast; the add/remove + * signal comes from the gutter marker + the block's colored left edge, never from text-on-tint. + */ +function SignatureLine({ clean, tone }: { clean: string; tone: 'add' | 'remove' }) { + return ( + + + + + + + ); +} + +/** a collapsed add/remove cluster: one label ("4 properties added") over a stack of signature lines. */ +function CollapsedCauses({ causes, type }: { causes: ClassifiedCause[]; type: 'add' | 'remove' }) { + const verb = type === 'add' ? 'added' : 'removed'; + const kind = causes[0].kind || 'member'; + const single = causes.length === 1; + + return ( +
+
+ {single ? ( + <> + {memberNameFromSignature(causes[0].clean)} + + {kind} {verb} + + + ) : ( + <> + + {causes.length} {pluralizeKind(kind, causes.length)} + + {verb} + + )} +
+
+ {causes.map((c, i) => ( + + ))} +
+
+ ); +} + +/** collapse same-kind add/remove causes into one cluster each, preserving input order of first appearance. */ +function collapseByKind(causes: ClassifiedCause[]): ClassifiedCause[][] { + const buckets = new Map(); + for (const c of causes) { + const key = c.kind || 'member'; + const arr = buckets.get(key); + if (arr) arr.push(c); + else buckets.set(key, [c]); + } + return [...buckets.values()]; +} + +/** render one modified/structural cause: a concise label over the token-level before/after diff. */ +function ModifiedCause({ detail }: { detail: APIDiffDetail }) { + return ( +
+
+ {modifyLabel(detail)} +
+ {(detail.from || detail.to) && } +
+ ); +} + +/** + * a documentation-only cause. Beyond the label, it shows the actual doc/comment prose that was + * removed (red) and/or added (green) — so "documentation removed" reveals *what* was removed rather + * than just stating it. Doc text is prose (never a signature), so it renders as dark text on a faint + * tint (dark-on-tint clears contrast) with no syntax highlighting. Falls back to a plain note when + * neither side carries text. + */ +function NoteCause({ detail, ownerName }: { detail: APIDiffDetail; ownerName: string }) { + if (!detail.from && !detail.to && !detail.signature) { + return ( +
+ + {detail.description} +
+ ); + } + // the declaration this doc belongs to, shown as an unchanged context line beneath the doc diff. + const contextSignature = detail.signature ? parseSignature(detail.signature, ownerName).clean : undefined; + return ( +
+
+ {modifyLabel(detail)} + documentation {docVerb(detail.changeKind)} +
+
+ {detail.from && ( +
+ + {detail.from} +
+ )} + {detail.to && ( +
+ + {detail.to} +
+ )} + {contextSignature && ( + + + + + + + )} +
+
+ ); +} + +function docVerb(changeKind: string): string { + if (changeKind.includes('removed')) return 'removed'; + if (changeKind.includes('added')) return 'added'; + return 'changed'; +} + +/** + * the reworked cause list: member changes clustered by severity (breaking → minor → patch), and + * within each tier the additions/removals collapse by kind into signature snippets (with the diff- + * viewer's highlighting), modifications keep the token-level before/after diff, and doc-only changes + * render as notes. This makes a long flat list scannable by consequence and shows every add/remove + * as its real signature instead of a prose line. + */ +function CauseClusters({ details, ownerName }: { details: APIDiffDetail[]; ownerName: string }) { + const classified = details.map((d) => classifyCause(d, ownerName)); + + return ( +
+ {SEVERITY_TIERS.map((tier) => { + const tierCauses = classified.filter((c) => tier.match(c.detail.impact)); + if (tierCauses.length === 0) return null; + + const removes = tierCauses.filter((c) => c.type === 'remove'); + const modifies = tierCauses.filter((c) => c.type === 'modify'); + const adds = tierCauses.filter((c) => c.type === 'add'); + const notes = tierCauses.filter((c) => c.type === 'doc'); + + return ( +
+
+ {tier.label} + {tierCauses.length} +
+ {collapseByKind(removes).map((bucket, i) => ( + + ))} + {modifies.map((c, i) => ( + + ))} + {collapseByKind(adds).map((bucket, i) => ( + + ))} + {notes.map((c, i) => ( + + ))} +
+ ); + })} +
+ ); +} + +export type ApiChangeBlockProps = { + change: APIDiffChange; + /** anchor id used by the compare sidebar scroll-sync (`data-file-id=":"`) */ + anchorId?: string; + focused?: boolean; + dimmed?: boolean; + insightCtx?: ApiDiffInsightContext; +}; + +/** + * one API change, fully expanded: what changed (signatures), why it carries its + * impact (assessed change facts), and any slot-contributed insights. + */ +export function ApiChangeBlock({ change, anchorId, focused, dimmed, insightCtx }: ApiChangeBlockProps) { + const insights = useApiDiffInsights(); + const matching = insightCtx ? insights.filter((i) => !i.matches || i.matches(change, insightCtx)) : []; + + return ( +
+
+ + {change.exportName} + {change.schemaType} + + {change.status === 'ADDED' && added to {change.visibility} API} + {change.status === 'REMOVED' && ( + removed from {change.visibility} API + )} +
+ + {(change.baseSignature || change.compareSignature) && ( +
+ +
+ )} + + {change.changes && change.changes.length > 0 && ( + + )} + + {matching.length > 0 && ( +
+ {matching.map((insight) => ( +
+ {insight.render(change, insightCtx!)} +
+ ))} +
+ )} +
+ ); +} + +export type ApiDiffSlimRowProps = { + componentIdStr: string; + displayName: string; + chip: ReactNode; + detail?: ReactNode; + tone?: 'ok' | 'warn' | 'error'; + /** renders an expand affordance; children are shown when expanded */ + expandable?: boolean; + children?: ReactNode; +}; + +/** + * component-level one-liner for components without renderable public changes: + * "no API changes", "no API data" (with reason), errors, and internal-only changes + * (expandable). always carries the `data-component-id` anchor so sidebar selection + * can scroll to it. + */ +export function ApiDiffSlimRow({ + componentIdStr, + displayName, + chip, + detail, + tone = 'ok', + expandable, + children, +}: ApiDiffSlimRowProps) { + const [expanded, setExpanded] = useState(false); + const toneClass = tone === 'warn' ? styles.slimChipWarn : tone === 'error' ? styles.slimChipError : styles.slimChipOk; + + const content = ( + <> + {displayName} + {chip} + {detail && {detail}} + {expandable && } + + ); + + return ( +
+ {expandable ? ( + + ) : ( +
{content}
+ )} + {expanded && children &&
{children}
} +
+ ); +} + +/** + * A neutral blank state for when there's genuinely no API to compare — NEITHER version exposes a + * public API (e.g. both built before extraction, no extractor, or extraction disabled). Distinct + * from the amber "Schema unavailable" warning, which flags that ONE side couldn't be read (an + * actionable gap: pick a different version). Here there's nothing to act on, so it reads calm. + */ +export function ApiDiffBlankState({ + componentIdStr, + title, + detail, +}: { + componentIdStr: string; + title: string; + detail?: ReactNode; +}) { + return ( +
+ +
{title}
+ {detail &&
{detail}
} +
+ ); +} + +export type ComponentApiDiffSectionProps = { + /** component id without version — used for anchors and registry keys */ + componentIdStr: string; + displayName: string; + baseId?: string; + compareId?: string; + baseVersion?: string; + compareVersion?: string; + result: APIDiffResult | null | undefined; + loading?: boolean; + error?: string; + /** export name currently selected in the sidebar — gets the focus treatment */ + selectedExport?: string; +}; + +/** + * full per-component API diff treatment. renders one of: + * - loading shimmer + * - error / unavailable-with-reason slim row + * - "no API changes" slim row + * - internal-only expandable slim row + * - full section: header + public change blocks + collapsed internal section + * + * used by both the lane compare full-pane view and the single-component compare tab. + */ +export function ComponentApiDiffSection({ + componentIdStr, + displayName, + baseId, + compareId, + baseVersion, + compareVersion, + result, + loading, + error, + selectedExport, +}: ComponentApiDiffSectionProps) { + const [internalExpanded, setInternalExpanded] = useState(false); + + if (loading) { + return ( +
+
+ {displayName} + Analyzing API… +
+
+
+
+
+
+
+ ); + } + + if (error || result === null) { + return ( + + ); + } + + if (!result) { + // result === undefined with loading false means the query was skipped (same version on + // both sides / missing id). still render an anchored row so the component doesn't + // silently vanish from the pane and sidebar clicks have a scroll target. + return ( + + ); + } + + // NEITHER side has an API — there's genuinely nothing to compare (both built before extraction, + // no extractor, or extraction disabled). A calm blank state, not a warning: nothing to act on. + if (result.status === 'UNAVAILABLE') { + return ( + + ); + } + + // ONE side's API could not be read (e.g. that version was built before API extraction). This is + // NOT "no changes" — we couldn't compare at all — and it's actionable (pick another version), so it + // stays an amber warning naming the version/reason. + if (result.status !== 'COMPUTED') { + return ( + + ); + } + + const insightCtx: ApiDiffInsightContext = { componentId: componentIdStr, baseId, compareId, result }; + const publicChanges = result.publicChanges || []; + const internalChanges = result.internalChanges || []; + const unresolvedExports = result.unresolvedExports || []; + + if (publicChanges.length === 0 && internalChanges.length === 0) { + // Extraction couldn't read some exports but nothing actually changed — say so plainly instead of a + // clean "no API changes", so an incomplete analysis isn't mistaken for a verified no-op. + if (unresolvedExports.length > 0) { + return ( + + ); + } + // Computed successfully and the public API is identical — a real, verified no-op (distinct from + // "Schema unavailable" above, where we couldn't read the API at all). + return ( + + ); + } + + if (publicChanges.length === 0) { + return ( + 1 ? 's' : ''}`} + tone="ok" + expandable + > + {internalChanges.map((change, i) => ( + + ))} + + ); + } + + const stats = { + added: publicChanges.filter((c) => c.status === 'ADDED').length, + removed: publicChanges.filter((c) => c.status === 'REMOVED').length, + modified: publicChanges.filter((c) => c.status === 'MODIFIED').length, + breaking: publicChanges.filter((c) => c.impact === 'BREAKING').length, + }; + + return ( +
+
+ {displayName} + + {baseVersion && compareVersion && ( + + {baseVersion.slice(0, 7)} → {compareVersion.slice(0, 7)} + + )} + + {stats.added > 0 && +{stats.added} added} + {stats.removed > 0 && {stats.removed} removed} + {stats.modified > 0 && {stats.modified} modified} + {stats.breaking > 0 && {stats.breaking} breaking} + +
+ + {CHANGE_GROUPS.map((group) => { + const groupChanges = publicChanges.filter(group.match); + if (groupChanges.length === 0) return null; + return ( +
+
+ + {group.label} + {groupChanges.length} +
+ {groupChanges.map((change, i) => ( + + ))} +
+ ); + })} + + {internalChanges.length > 0 && ( +
+ + {internalExpanded && + internalChanges.map((change, i) => ( + + ))} +
+ )} + + {unresolvedExports.length > 0 && ( +
+ + + {unresolvedExports.length} export{unresolvedExports.length > 1 ? 's' : ''} couldn't be analyzed ( + {unresolvedExports.join(', ')}). Extraction was incomplete on one side, so this isn't reported as a + change. + +
+ )} +
+ ); +} diff --git a/components/ui/api-diff-view/index.ts b/components/ui/api-diff-view/index.ts new file mode 100644 index 000000000000..4c159575d2a4 --- /dev/null +++ b/components/ui/api-diff-view/index.ts @@ -0,0 +1,30 @@ +export { ApiDiffLaneView, ApiDiffFullView } from './api-diff-view'; +export type { ApiDiffLaneViewProps, ApiDiffFullViewProps, ComponentDiffEntry, ApiEntry } from './api-diff-view'; +export { + ComponentApiDiffSection, + ApiChangeBlock, + ApiDiffSlimRow, + ImpactBadge, + StatusIndicator, +} from './component-api-diff-section'; +export type { + ComponentApiDiffSectionProps, + ApiChangeBlockProps, + ApiDiffSlimRowProps, +} from './component-api-diff-section'; +export { ApiDiffInsightProvider, useApiDiffInsights } from './api-diff-insights'; +export type { ApiDiffInsight, ApiDiffInsightContext } from './api-diff-insights'; +export { useApiDiff, API_DIFF_QUERY, API_DIFF_RESULT_FIELDS, impactLabel, unavailableText } from './api-diff-model'; +export { ApiDiffDataProvider, useApiDiffData, QUERY_API_DIFFS, API_DIFF_PAGE_SIZE } from './api-diff-data-context'; +export type { ApiDiffDataContextModel, ApiDiffPair } from './api-diff-data-context'; +export type { + APIDiffResult, + APIDiffChange, + APIDiffDetail, + SchemaSideAvailability, + ImpactLevel, + APIDiffComputeStatus, + SchemaUnavailableReason, + UseApiDiffOptions, + UseApiDiffResult, +} from './api-diff-model'; diff --git a/components/ui/code-view/code-view.tsx b/components/ui/code-view/code-view.tsx index ed7895cc950c..57d7300f4c7f 100644 --- a/components/ui/code-view/code-view.tsx +++ b/components/ui/code-view/code-view.tsx @@ -124,7 +124,8 @@ export function CodeView({ componentId, currentFile, !!currentFileContent, - host + host, + true ); const loading = loadingFromProps || loadingFileContent; const location = useLocation(); diff --git a/components/ui/compare/lane-compare-hooks/use-lane-diff-status/index.ts b/components/ui/compare/lane-compare-hooks/use-lane-diff-status/index.ts new file mode 100644 index 000000000000..f56e441d80a1 --- /dev/null +++ b/components/ui/compare/lane-compare-hooks/use-lane-diff-status/index.ts @@ -0,0 +1,8 @@ +export { + useLaneDiffStatus, + LaneDiffStatusQueryResponse, + UseLaneDiffStatusResult, + UseLaneDiffStatus, + UseLaneDiffStatusOptions, + UseLaneDiffStatusProps, +} from './use-lane-diff-status'; diff --git a/components/ui/compare/lane-compare-hooks/use-lane-diff-status/use-lane-diff-status.ts b/components/ui/compare/lane-compare-hooks/use-lane-diff-status/use-lane-diff-status.ts new file mode 100644 index 000000000000..34fda8a85c77 --- /dev/null +++ b/components/ui/compare/lane-compare-hooks/use-lane-diff-status/use-lane-diff-status.ts @@ -0,0 +1,100 @@ +import { gql, useQuery } from '@apollo/client'; +import { LaneId } from '@teambit/lane-id'; +import { ComponentID, type ComponentIdObj } from '@teambit/component-id'; +import { LaneDiff, type PlainLaneDiff, type ChangeType } from '@teambit/lanes.entities.lane-diff'; + +export type LaneDiffStatusQueryResponse = { + lanes: { + diffStatus: { + source: { name: string; scope: string }; + target: { name: string; scope: string }; + upToDate: boolean; + componentsStatus: Array<{ + componentId: ComponentIdObj; + changes: ChangeType[]; + upToDate: boolean; + sourceHead: string; + targetHead?: string; + baseSource?: 'workspace' | 'scope'; + }>; + }; + }; +}; + +export const QUERY_LANE_DIFF_STATUS = gql` + query LaneDiffStatus($source: String!, $target: String!, $options: DiffStatusOptions) { + lanes { + id + diffStatus(source: $source, target: $target, options: $options) { + id + source { + name + scope + } + target { + name + scope + } + componentsStatus { + id + componentId { + scope + name + version + } + sourceHead + targetHead + baseSource + changes + upToDate + unrelated + } + } + } + } +`; + +export type UseLaneDiffStatusResult = { loading?: boolean; laneDiff?: LaneDiff }; +export type UseLaneDiffStatus = (props: UseLaneDiffStatusProps) => UseLaneDiffStatusResult; +export type UseLaneDiffStatusOptions = { + skipChanges?: boolean; + skipUpToDate?: boolean; +}; + +export type UseLaneDiffStatusProps = { + baseId?: string; + compareId?: string; + options?: UseLaneDiffStatusOptions; +}; + +export const useLaneDiffStatus: UseLaneDiffStatus = ({ baseId, compareId, options }) => { + // opt the lane-diff query into the GraphQL batching transport. when other batched queries fire in the + // same tick (e.g. the lane menu's lane-list + lane-status queries), they coalesce into a single HTTP + // round trip. solo, this still goes through BatchHttpLink and incurs only the `batchInterval` delay. + const { data, loading } = useQuery(QUERY_LANE_DIFF_STATUS, { + variables: { + source: compareId, + target: baseId, + options, + }, + skip: !baseId || !compareId, + context: { batch: true }, + }); + + const plainLaneDiff: PlainLaneDiff | undefined = data?.lanes.diffStatus && { + sourceLane: LaneId.from(data.lanes.diffStatus.source.name, data.lanes.diffStatus.source.scope).toString(), + targetLane: LaneId.from(data.lanes.diffStatus.target.name, data.lanes.diffStatus.target.scope).toString(), + diff: data.lanes.diffStatus.componentsStatus.map((c) => ({ + ...c, + changes: c.changes || [], + componentId: ComponentID.fromObject(c.componentId).toString(), + })), + }; + + const laneDiff = plainLaneDiff && LaneDiff.from(plainLaneDiff); + + return { + loading, + laneDiff, + }; +}; diff --git a/components/ui/compare/lane-compare-page/index.ts b/components/ui/compare/lane-compare-page/index.ts new file mode 100644 index 000000000000..280ff9d13031 --- /dev/null +++ b/components/ui/compare/lane-compare-page/index.ts @@ -0,0 +1,2 @@ +export { LaneComparePage } from './lane-compare-page'; +export type { LaneComparePageProps } from './lane-compare-page'; diff --git a/components/ui/compare/lane-compare-page/lane-compare-page.module.scss b/components/ui/compare/lane-compare-page/lane-compare-page.module.scss new file mode 100644 index 000000000000..2c40d5252265 --- /dev/null +++ b/components/ui/compare/lane-compare-page/lane-compare-page.module.scss @@ -0,0 +1,66 @@ +.laneComparePage { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} +.top { + display: flex; + padding: 24px; + padding-bottom: 8px; + align-items: flex-start; + flex-direction: column; + gap: 4px; + // width: 100%; +} + +.subTitle { + font-size: var(--bit-p-xs, 14px); + color: var(--on-background-medium-color, #707279); +} + +.title { + // same height as the lane selector + height: 34px; + display: flex; + gap: 8px; + align-items: center; + width: 100%; +} + +.rightIcon { + display: flex; + justify-self: center; + + img { + height: 12px; + display: flex; + align-self: center; + } +} + +.bottom { + display: flex; + overflow: auto; + height: 100%; +} + +.compareLane { + display: flex; + text-align: center; + font-size: 24px; + font-weight: 700; + border-radius: 6px; + box-sizing: border-box; + align-items: center; +} + +.baseSelectorContainer { + padding: 0px 4px; +} + +.laneIcon { + padding-right: 4px; + height: 16px; + font-size: var(--bit-p-sm, 16px); +} diff --git a/components/ui/compare/lane-compare-page/lane-compare-page.tsx b/components/ui/compare/lane-compare-page/lane-compare-page.tsx new file mode 100644 index 000000000000..f5b47e3699e3 --- /dev/null +++ b/components/ui/compare/lane-compare-page/lane-compare-page.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useLocation } from 'react-router-dom'; +import type { LaneCompareProps, LanesModel } from '@teambit/lanes'; +import { useLanes as defaultUseLanes } from '@teambit/lanes.hooks.use-lanes'; +import type { UseLanes } from '@teambit/lanes.hooks.use-lanes'; +import { LaneSelector } from '@teambit/lanes.ui.inputs.lane-selector'; +import type { LaneModel } from '@teambit/lanes.ui.models.lanes-model'; + +import styles from './lane-compare-page.module.scss'; + +export type LaneComparePageProps = { + getLaneCompare: (props: LaneCompareProps) => React.ReactNode; + // TODO:fix this and enable the eslint rule + // eslint-disable-next-line react/require-default-props + groupByScope?: boolean; + // TODO:fix this and enable the eslint rule + // eslint-disable-next-line react/require-default-props + useLanes?: UseLanes; + // TODO:fix this and enable the eslint rule + // eslint-disable-next-line react/require-default-props + searchLanes?: (search?: string) => LanesModel | undefined | null; +} & HTMLAttributes; + +export function LaneComparePage({ + getLaneCompare, + groupByScope = false, + // @ts-ignore TODO fix with luv. + useLanes = defaultUseLanes, + searchLanes, + ...rest +}: LaneComparePageProps) { + const { lanesModel, loading, fetchMoreLanes, hasMore, offset, limit } = useLanes(); + const location = useLocation(); + const [base, setBase] = useState(); + const defaultLane = lanesModel?.getDefaultLane(); + const compare = lanesModel?.viewedLane; + const nonMainLanes = lanesModel?.getNonMainLanes() || []; + useEffect(() => { + if (!base && !compare?.id.isDefault() && defaultLane) { + setBase(defaultLane); + } + if (!base && compare?.id.isDefault() && (nonMainLanes?.length ?? 0) > 0) { + const firstActive = nonMainLanes.find((l) => !l.deleted); + if (firstActive) setBase(firstActive); + } + }, [defaultLane, compare?.id.toString(), nonMainLanes?.length]); + + const LaneCompareComponent = getLaneCompare({ base, compare, groupBy: 'status' }); + + const lanes: Array = useMemo(() => { + return nonMainLanes.filter((l) => l.toString() !== compare?.id.toString() && !l.deleted); + }, [base?.id.toString(), compare?.id.toString(), lanesModel?.lanes.length]); + + useEffect(() => { + const collapser = document.querySelector('[class*="collapser"]') as HTMLElement; + const splitPane = document.querySelector('[class*="splitPane"]') as HTMLElement; + if (!collapser || !splitPane) return undefined; + + const firstPane = splitPane.firstElementChild as HTMLElement; + const wasOpen = firstPane && firstPane.offsetWidth > 50; + + if (wasOpen) { + firstPane.style.display = 'none'; + requestAnimationFrame(() => { + collapser.click(); + requestAnimationFrame(() => { + firstPane.style.display = ''; + }); + }); + } + + return () => { + if (wasOpen) { + const pane = splitPane.firstElementChild as HTMLElement; + if (pane && pane.offsetWidth <= 50) collapser.click(); + } + }; + }, []); + + if (!lanesModel) return null; + if (!lanesModel.viewedLane) return null; + if (!base) return null; + + return ( +
+
+
Compare
+
+
{compare?.id.name}
+
+ arrow-right +
+
+ `${location.pathname}${location.search}`} + onLaneSelected={(_, lane) => { + setBase(lane); + }} + loading={loading} + fetchMoreLanes={fetchMoreLanes} + hasMore={hasMore} + initialOffset={(offset ?? 0) + (limit ?? 0)} + /> +
+
+
+
{LaneCompareComponent}
+
+ ); +} diff --git a/components/ui/compare/lane-compare/base-source-indicator.module.scss b/components/ui/compare/lane-compare/base-source-indicator.module.scss new file mode 100644 index 000000000000..531486c27f80 --- /dev/null +++ b/components/ui/compare/lane-compare/base-source-indicator.module.scss @@ -0,0 +1,34 @@ +.indicator { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 12px; + line-height: 1; + color: var(--on-surface-medium-color, #707279); + + &[data-state='remote'] { + color: var(--on-surface-medium-color, #707279); + } + + &[data-state='loading'] { + color: var(--on-surface-medium-color, #707279); + } + + &[data-state='unavailable'] { + color: var(--error-color, #e62e5c); + } +} + +.spin { + animation: base-source-spin 0.9s linear infinite; +} + +@keyframes base-source-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/components/ui/compare/lane-compare/base-source-indicator.tsx b/components/ui/compare/lane-compare/base-source-indicator.tsx new file mode 100644 index 000000000000..ebc9bc74c1ae --- /dev/null +++ b/components/ui/compare/lane-compare/base-source-indicator.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Icon } from '@teambit/design.elements.icon'; +import { Tooltip } from '@teambit/design.ui.tooltip'; +import { useCompareData } from '@teambit/component.ui.component-compare.component-compare'; +import styles from './base-source-indicator.module.scss'; + +export type BaseSourceIndicatorProps = { + /** the compare-side id of the pair, used to look up the bulk-compare load state for this base */ + compareId: string; +}; + +/** + * Surfaces that a component's compared base was pulled from the remote scope (rather than already + * being in the workspace), plus its load state. The caller renders this only for remote-sourced + * bases; workspace-resolved bases show nothing. + * - spinner: the base diff is still being fetched from the remote. + * - error: the base diff failed to load from the remote. + * - cloud: the base was resolved from the remote scope and its diff is ready. + */ +export function BaseSourceIndicator({ compareId }: BaseSourceIndicatorProps) { + const compareData = useCompareData(); + const data = compareData?.getData(compareId); + const loading = Boolean(compareData?.loading) && data === undefined; + + if (loading) { + return ( + + + + + + ); + } + + if (data === null) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +} diff --git a/components/ui/compare/lane-compare/index.ts b/components/ui/compare/lane-compare/index.ts new file mode 100644 index 000000000000..f5ce20377c82 --- /dev/null +++ b/components/ui/compare/lane-compare/index.ts @@ -0,0 +1,9 @@ +export { LaneCompare } from './lane-compare'; +export type { LaneCompareProps } from './lane-compare'; +export type { LaneCompareContextModel } from './lane-compare.context'; +export { LaneCompareContext, useLaneCompareContext } from './lane-compare.context'; +export type { LaneCompareProviderProps, LaneCompareGroupBy } from './lane-compare.provider'; +export { LaneCompareProvider } from './lane-compare.provider'; +export { ChangeTypeGroupOrder } from './lane-compare.models'; +export type { LaneFilter } from './lane-compare.models'; +export { displayChangeType, extractCompsToDiff, filterDepKey as laneFilterDepKey } from './lane-compare.utils'; diff --git a/components/ui/compare/lane-compare/lane-compare.context.ts b/components/ui/compare/lane-compare/lane-compare.context.ts new file mode 100644 index 000000000000..17c270729c18 --- /dev/null +++ b/components/ui/compare/lane-compare/lane-compare.context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react'; +import type { LaneCompareState } from '@teambit/lanes.ui.compare.lane-compare-state'; +import type { LaneComponentDiff, LaneDiff } from '@teambit/lanes.entities.lane-diff'; +import type { ComponentID } from '@teambit/component-id'; +import type { DefaultLaneState, LaneFilter } from './lane-compare.models'; + +export type LaneCompareContextModel = { + laneCompareState: LaneCompareState; + setLaneCompareState: React.Dispatch>; + filters?: LaneFilter[]; + groupBy?: 'scope' | 'status'; + defaultLaneState: DefaultLaneState; + laneDiff?: LaneDiff; + loadingLaneDiff?: boolean; + componentsToDiff: [ComponentID | undefined, ComponentID | undefined][]; + groupedComponentsToDiff: Map | null; + laneComponentDiffByCompId: Map; +}; + +export const LaneCompareContext = createContext(undefined); + +export const useLaneCompareContext = (): LaneCompareContextModel | undefined => { + const context = useContext(LaneCompareContext); + return context; +}; diff --git a/components/ui/compare/lane-compare/lane-compare.models.ts b/components/ui/compare/lane-compare/lane-compare.models.ts new file mode 100644 index 000000000000..47a193c8bdf3 --- /dev/null +++ b/components/ui/compare/lane-compare/lane-compare.models.ts @@ -0,0 +1,22 @@ +import { ChangeType } from '@teambit/lanes.entities.lane-diff'; +import type { + ComponentCompareStateData, + ComponentCompareStateKey, +} from '@teambit/component.ui.component-compare.models.component-compare-state'; + +export type DefaultLaneState = ( + compId?: string +) => Partial>; +export type LaneFilterType = ChangeType | 'ALL'; +export const ChangeTypeGroupOrder = [ + ChangeType.NEW, + ChangeType.SOURCE_CODE, + ChangeType.ASPECTS, + ChangeType.DEPENDENCY, + ChangeType.NONE, +]; + +export type LaneFilter = { + type: string; + values: string[]; +}; diff --git a/components/ui/compare/lane-compare/lane-compare.module.scss b/components/ui/compare/lane-compare/lane-compare.module.scss new file mode 100644 index 000000000000..3402a1e1d5ec --- /dev/null +++ b/components/ui/compare/lane-compare/lane-compare.module.scss @@ -0,0 +1,578 @@ +$border-color: var(--border-medium-color, #e8ecf0); +$surface-color: var(--surface-color, #fff); +$accent-color: var(--bit-accent-color, #6c5ce7); +$text-primary: var(--on-surface-color, #1a1a2e); +$text-secondary: var(--on-surface-medium-color, #707279); +$text-muted: var(--on-background-medium-color, #a0aec0); + +.rootLaneCompare { + position: relative; + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +// --- Toolbar --- + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid $border-color; + background: $surface-color; + flex-shrink: 0; +} + +.searchInput { + width: 200px; + padding: 5px 10px; + border: 1px solid $border-color; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + outline: none; + color: $text-primary; + background: $surface-color; + transition: border-color 0.15s ease; + + &:focus { + border-color: $accent-color; + } + + &::placeholder { + color: $text-muted; + } +} + +.toolbarGroup { + display: flex; + align-items: center; + gap: 4px; +} + +.toolbarLabel { + font-size: 11px; + color: $text-muted; + margin-right: 2px; +} + +.toolbarBtn { + padding: 4px 10px; + border: 1px solid $border-color; + border-radius: 6px; + background: $surface-color; + font-size: 11px; + font-family: inherit; + color: $text-secondary; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } +} + +.toolbarBtnActive { + background: rgba(108, 92, 231, 0.12); + color: $accent-color; + border-color: $accent-color; + font-weight: 600; + + .toolbarBadge { + background: rgba(108, 92, 231, 0.15); + color: $accent-color; + } +} + +.toolbarCount { + margin-left: auto; + font-size: 11px; + color: $text-muted; +} + +.toolbarSeparator { + width: 1px; + height: 20px; + background: $border-color; + flex-shrink: 0; +} + +.toolbarBadge { + font-size: 10px; + font-weight: 600; + color: $text-muted; + background: rgba(0, 0, 0, 0.06); + padding: 0 5px; + border-radius: 8px; + margin-left: 2px; +} + +.sidebarHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + border-bottom: 1px solid $border-color; +} + +.sidebarCount { + background: rgba(108, 92, 231, 0.08); + color: $accent-color; + padding: 1px 7px; + border-radius: 10px; + font-size: 11px; +} + +.changeTagNew { + color: var(--positive-color, #37b26c); + background: rgba(55, 178, 108, 0.08); +} + +// --- Layout --- + +.layout { + display: flex; + flex: 1; + overflow: hidden; +} + +.sidebar { + width: 220px; + min-width: 220px; + border-right: 1px solid $border-color; + background: var(--background-color, #fdfdff); + overflow-y: auto; + flex-shrink: 0; +} + +.sidebarGroupHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: $text-muted; + text-transform: uppercase; + letter-spacing: 0.04em; + background: rgba(0, 0, 0, 0.02); + border-bottom: 1px solid $border-color; + margin-top: 4px; + + &:first-child { + margin-top: 0; + } +} + +.sidebarGroupCount { + font-size: 10px; + background: rgba(0, 0, 0, 0.04); + padding: 0 5px; + border-radius: 8px; +} + +.sidebarItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 12px 7px 16px; + border: none; + border-left: 2px solid transparent; + background: transparent; + font-size: 12.5px; + font-family: inherit; + color: $text-primary; + text-align: left; + cursor: pointer; + transition: + background 0.1s ease, + border-color 0.1s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } +} + +.sidebarItemActive { + border-left-color: $accent-color; + background: rgba(108, 92, 231, 0.05); + color: $accent-color; + font-weight: 500; +} + +.sidebarEnvIcon { + width: 16px; + height: 16px; + flex-shrink: 0; + border-radius: 3px; +} + +.sidebarEnvPlaceholder { + display: inline-block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.diffPane { + flex: 1; + // shrink below content width (default flex min-width:auto = min-content) so a wide diff scrolls + // inside its own file body instead of forcing the whole page horizontally wider. + min-width: 0; + overflow-y: auto; + background: var(--background-color, #f9fafb); + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; + + &:hover { + background: rgba(0, 0, 0, 0.25); + } + } + + // Tab visibility driven by `data-view-mode` on the pane. All `DeferredTab` siblings are mounted + // simultaneously to preserve their internal query/state caches across mode switches; CSS hides + // the ones that don't match the active view. Without this every tab stacked on top of each other, + // which is what made code show aspect data, config show code, and so on. + &[data-view-mode='code'] [data-tab-id]:not([data-tab-id='inline-code']) { + display: none; + } + &[data-view-mode='preview'] [data-tab-id]:not([data-tab-id='inline-preview']) { + display: none; + } + &[data-view-mode='dependencies'] [data-tab-id]:not([data-tab-id='inline-deps']) { + display: none; + } + &[data-view-mode='tests'] [data-tab-id]:not([data-tab-id='inline-tests']) { + display: none; + } + &[data-view-mode='config'] [data-tab-id]:not([data-tab-id='inline-config']) { + display: none; + } + &[data-view-mode='docs'] [data-tab-id]:not([data-tab-id='inline-docs']) { + display: none; + } + + // Per-panel visibility driven by `data-view-mode` on the pane. Every component panel is mounted + // regardless of the active view (the set is filtered by search only) so that switching modes never + // remounts panels — it's just this attribute flip. CSS then hides panels that have no changes for + // the active view, using the stable `data-has-*` attributes stamped in lane-compare.tsx. `docs`, + // `tests` and `api` show every panel, so they get no hide rule here. + &[data-view-mode='code'] [data-component-id]:not([data-has-code]) { + display: none; + } + &[data-view-mode='preview'] [data-component-id]:not([data-has-preview]) { + display: none; + } + &[data-view-mode='dependencies'] [data-component-id]:not([data-has-deps]) { + display: none; + } + &[data-view-mode='config'] [data-component-id]:not([data-has-config]) { + display: none; + } +} + +// A group with no panels visible in the active view. Hidden via a class (not removed from the tree) +// so its panels stay mounted and a later view switch is still a pure CSS flip. +.groupHidden { + display: none; +} + +// --- Loading --- + +.laneCompareLoading { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.laneCompareSkeleton { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.skeletonBar { + height: 12px; + background: linear-gradient( + 90deg, + var(--surface-neutral-color, #f0f2f5) 25%, + $border-color 50%, + var(--surface-neutral-color, #f0f2f5) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 4px; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +// --- Groups --- + +.laneCompareGroup { + --group-header-height: 42px; + + // Hard width clamp: a single-column `minmax(0, 1fr)` grid forces every descendant (header + + // component panels + their code diffs) to the group's own width, which is the pane width. A grid + // item in a `minmax(0, ...)` track cannot be widened by its content, so a long code line can only + // scroll inside its diff body — it can never widen a panel, the pane, or the page. This is applied + // here (a confirmed pane-width block) because `contain: inline-size` lower down was defeated by the + // `content-visibility`/context wrappers in between. + display: grid; + grid-template-columns: minmax(0, 1fr); + + &:not(:first-child) { + margin-top: 8px; + } + + // Containment for each component panel. A view-mode switch toggles `display` on heavy tab subtrees + // (code diffs, dependency tables) inside every panel; without containment a single layout read — + // e.g. the toolbar's toggle indicator measuring its own `offsetLeft` right after the switch — forces + // the browser to synchronously re-lay-out the ENTIRE pane in one pass (the ~1.2s "forced reflow" + // seen in the profiler). `content-visibility: auto` skips layout/paint for panels not near the + // viewport and makes each panel a containment boundary, so the reflow only touches what's on screen. + // `contain-intrinsic-size: auto ` keeps the scrollbar stable by remembering each panel's last + // rendered height (falling back to the estimate before first render). + [data-component-id] { + content-visibility: auto; + contain-intrinsic-size: auto 320px; + } +} + +.laneCompareGroupHeader { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; + background: var(--background-color, #f9fafb); + border-bottom: 1px solid $border-color; + position: sticky; + top: 0; + z-index: 4; +} + +.laneCompareGroupLabel { + flex: 1; +} + +.laneCompareGroupCount { + font-weight: 500; + color: $text-muted; + font-size: 12px; + background: rgba(0, 0, 0, 0.04); + padding: 2px 8px; + border-radius: 10px; +} + +// --- Component Section --- + +.inlineComponentSection { + background: $surface-color; + margin-bottom: 2px; + &:last-child { + margin-bottom: 0; + } +} + +.inlineComponentHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + background: $surface-color; + border-bottom: 1px solid $border-color; + border-left: 3px solid $accent-color; + position: sticky; + top: 0; + z-index: 3; +} + +.inlineComponentHeaderSelected { + background: rgba(108, 92, 231, 0.03); +} + +.componentEnvIcon { + width: 18px; + height: 18px; + flex-shrink: 0; + border-radius: 4px; +} + +.inlineComponentName { + font-size: 13px; + font-weight: 600; + color: $text-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.inlineComponentMeta { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.inlineChangeTags { + display: flex; + gap: 4px; +} + +.changeTag { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + letter-spacing: 0.02em; +} + +.changeTagCode { + color: $accent-color; + background: rgba(108, 92, 231, 0.08); +} + +.changeTagDeps { + color: var(--warning-color, #d6a022); + background: rgba(214, 160, 34, 0.08); +} + +.inlineComponentVersions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.inlineVersionHash { + padding: 3px 8px; + border-radius: 6px; + background: var(--surface-neutral-color, #f6f8fa); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + color: $text-secondary; +} + +.inlineVersionArrow { + color: $text-muted; + font-size: 10px; + opacity: 0.5; +} + +.inlineComponentEmpty { + padding: 16px; + text-align: center; + color: $text-muted; + font-size: 12px; + font-style: italic; +} + +// --- Sections --- + +.sectionHeader { + display: flex; + align-items: center; + padding: 6px 16px; + background: var(--surface-neutral-color, #f8f9fb); + border-bottom: 1px solid $border-color; +} + +.sectionLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; +} + +.newComponentBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(55, 178, 108, 0.04); + border-bottom: 1px solid rgba(55, 178, 108, 0.1); +} + +.newComponentLabel { + font-size: 11px; + font-weight: 600; + color: var(--positive-color, #37b26c); + background: rgba(55, 178, 108, 0.1); + padding: 2px 8px; + border-radius: 4px; +} + +.newComponentNote { + font-size: 11px; + color: $text-muted; + font-style: italic; +} + +.depsSection { + border-bottom: 1px solid $border-color; +} + +// --- Legacy compat --- +.componentCompareContainer { + height: auto; +} +.componentComparePanel { + border-bottom: none; +} +.groupedComponentCompareContainer { + padding: 0; +} +.changeTypeTitle { + padding: 16px 0; +} +.fullScreenIcon { + display: none; +} + +.emptyState { + display: flex; + flex: 1 1 auto; + min-height: 240px; + align-items: center; + justify-content: center; +} diff --git a/components/ui/compare/lane-compare/lane-compare.provider.tsx b/components/ui/compare/lane-compare/lane-compare.provider.tsx new file mode 100644 index 000000000000..06cec6b61edc --- /dev/null +++ b/components/ui/compare/lane-compare/lane-compare.provider.tsx @@ -0,0 +1,207 @@ +import type { ReactNode } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { LaneCompareState } from '@teambit/lanes.ui.compare.lane-compare-state'; +import { computeStateKey } from '@teambit/lanes.ui.compare.lane-compare-state'; +import type { ComponentCompareState } from '@teambit/component.ui.component-compare.models.component-compare-state'; +import type { MaybeLazyLoaded } from '@teambit/component.ui.component-compare.utils.lazy-loading'; +import { extractLazyLoadedData } from '@teambit/component.ui.component-compare.utils.lazy-loading'; +import { sortTabs } from '@teambit/component.ui.component-compare.utils.sort-tabs'; +import type { TabItem } from '@teambit/component.ui.component-compare.models.component-compare-props'; +import type { LaneId } from '@teambit/lane-id'; +import type { UseLaneDiffStatus } from '@teambit/lanes.ui.compare.lane-compare-hooks.use-lane-diff-status'; +import { useLaneDiffStatus as defaultUseLaneDiffStatus } from '@teambit/lanes.ui.compare.lane-compare-hooks.use-lane-diff-status'; +import type { LaneComponentDiff } from '@teambit/lanes.entities.lane-diff'; +import type { ComponentID } from '@teambit/component-id'; +import type { LaneCompareContextModel } from './lane-compare.context'; +import { LaneCompareContext } from './lane-compare.context'; +import type { LaneFilter } from './lane-compare.models'; +import { extractCompsToDiff, filterDepKey } from './lane-compare.utils'; + +export type LaneCompareGroupBy = 'scope' | 'status'; + +export type LaneCompareProviderProps = { + children: ReactNode; + /** + * default state of all component compare panels + */ + defaultComponentCompareState?: LaneCompareState; + /** + * filters to apply to component diffs + */ + filters?: Array; + /** + * filter out component diffs by type + */ + filter?: (laneComponentDiff?: LaneComponentDiff, filters?: Array) => boolean; + /** + * group component diffs by diff status or scope + */ + groupBy?: LaneCompareGroupBy; + /** + * hook to override fetching lane diff status for base and compare + */ + useLaneDiffStatus?: UseLaneDiffStatus; + base?: LaneId; + compare?: LaneId; + tabs?: MaybeLazyLoaded; +}; + +function LaneCompareProviderImpl({ + defaultComponentCompareState, + tabs, + children, + useLaneDiffStatus = defaultUseLaneDiffStatus, + base, + compare, + filters, + groupBy, + filter, +}: LaneCompareProviderProps) { + const { loading: loadingLaneDiff, laneDiff } = useLaneDiffStatus({ + baseId: base?.toString(), + compareId: compare?.toString(), + options: { + skipUpToDate: true, + }, + }); + + const componentsToDiff = useMemo( + () => extractCompsToDiff(laneDiff), + // include `laneDiff` itself: an Apollo cache merge can update it without flipping `loadingLaneDiff` + // or changing base/compare, and this derivation must not go stale (matches `groupedComponentsToDiff`). + [loadingLaneDiff, base?.toString(), compare?.toString(), laneDiff] + ); + + // canonical key of the current pair set — used to re-init compare state when the *set* changes, not + // just its size. two different diff sets can share a length, so keying on length alone would leave + // stale keys for the previous set; the key changes only on real membership changes, so it also avoids + // spuriously resetting state when componentsToDiff recomputes to the same set. + const componentsToDiffKey = useMemo( + () => componentsToDiff.map(([b, c]) => computeStateKey(b, c)).join('\n'), + [componentsToDiff] + ); + + const defaultLaneState = useCallback( + (compId?: string) => { + const sortedTabs = extractLazyLoadedData(tabs)?.sort(sortTabs); + + const defaultState = (compId && defaultComponentCompareState?.get(compId)) || {}; + + const value: ComponentCompareState = { + tabs: { + controlled: true, + id: sortedTabs?.[0]?.id, + element: sortedTabs?.[0]?.element, + }, + code: { + controlled: true, + }, + aspects: { + controlled: true, + }, + preview: { + controlled: true, + }, + versionPicker: { + element: null, + }, + ...defaultState, + }; + return value; + }, + [defaultComponentCompareState] + ); + + const [laneCompareState, setLaneCompareState] = useState(() => { + const state = new Map(); + componentsToDiff.forEach(([_base, _compare]) => { + const key = computeStateKey(_base, _compare); + const baseKey = computeStateKey(_base); + const compareKey = computeStateKey(undefined, _compare); + state.set(key, defaultLaneState(key)); + state.set(baseKey, defaultLaneState(baseKey)); + state.set(compareKey, defaultLaneState(compareKey)); + }); + return state; + }); + + useEffect(() => { + if (componentsToDiff.length > 0) { + const compareState: LaneCompareState = new Map(defaultComponentCompareState); + + componentsToDiff.forEach(([_base, _compare]) => { + const key = computeStateKey(_base, _compare); + const baseKey = computeStateKey(_base); + const compareKey = computeStateKey(undefined, _compare); + compareState.set(key, defaultLaneState(key)); + compareState.set(baseKey, defaultLaneState(baseKey)); + compareState.set(compareKey, defaultLaneState(compareKey)); + }); + + setLaneCompareState(compareState); + } + }, [loadingLaneDiff, componentsToDiffKey, base?.toString(), compare?.toString(), defaultComponentCompareState]); + + const laneComponentDiffByCompId = useMemo(() => { + if (!laneDiff) return new Map(); + return laneDiff.diff.reduce((accum, next) => { + accum.set(next.componentId.toStringWithoutVersion(), next as any); + return accum; + }, new Map()); + // `laneDiff` in deps for the same reason as `componentsToDiff`: track content, not just load state. + }, [base?.toString(), compare?.toString(), loadingLaneDiff, laneDiff]); + + const groupedComponentsToDiff = useMemo(() => { + if (laneComponentDiffByCompId.size === 0) return null; + return componentsToDiff + .filter((comps) => { + if (!filter) return comps; + const comp = comps[1] || comps[0]; + if (!comp) return comps; + return filter(laneComponentDiffByCompId.get(comp.toStringWithoutVersion()), filters); + }) + .reduce((accum, [baseId, compareId]) => { + const compareIdStrWithoutVersion = compareId?.toStringWithoutVersion(); + const laneCompDiff = !compareIdStrWithoutVersion + ? undefined + : laneComponentDiffByCompId.get(compareIdStrWithoutVersion); + + const changeType = laneCompDiff?.changeType; + + const key = !groupBy ? compareIdStrWithoutVersion : (groupBy === 'status' && changeType) || compareId?.scope; + + if (!key) { + return accum; + } + + const existing = accum.get(key) || []; + accum.set(key, existing.concat([[baseId, compareId]])); + return accum; + }, new Map>()); + }, [ + base?.toString(), + compare?.toString(), + laneComponentDiffByCompId.size, + loadingLaneDiff, + laneDiff, + filterDepKey(filters), + groupBy, + ]); + + const laneCompareContextModel: LaneCompareContextModel = { + laneCompareState, + setLaneCompareState, + filters, + groupBy, + defaultLaneState, + laneDiff, + loadingLaneDiff, + componentsToDiff, + groupedComponentsToDiff, + laneComponentDiffByCompId, + }; + + return {children}; +} + +export const LaneCompareProvider = React.memo(LaneCompareProviderImpl); diff --git a/components/ui/compare/lane-compare/lane-compare.tsx b/components/ui/compare/lane-compare/lane-compare.tsx new file mode 100644 index 000000000000..59d75af95cb2 --- /dev/null +++ b/components/ui/compare/lane-compare/lane-compare.tsx @@ -0,0 +1,653 @@ +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, + CompareSidebar, + FileRegistryProvider, + useFileRegistry, + DiffModeProvider, + 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 elRect = el.getBoundingClientRect(); + const paneRect = pane.getBoundingClientRect(); + pane.scrollTo({ top: elRect.top - paneRect.top + pane.scrollTop, behavior: 'instant' }); +} + +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)', +}; + +// ── 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'); + 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 changes: ChangeType[] = (diff as any)?.changes || []; + const changeType: ChangeType = (diff as any)?.changeType; + 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, + 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); + if (files === undefined) return c.changes.some((ch) => ch === ChangeType.SOURCE_CODE); + 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()] + ); + + const matchesView = useCallback( + (c: (typeof allComponents)[number], mode: ViewMode) => { + switch (mode) { + case 'code': + return hasCodeChanges(c); + case 'preview': + return compositionsMap.get(c.idStr) !== false; + case 'dependencies': + return c.changes.some((ch) => ch === ChangeType.DEPENDENCY); + case 'config': + return c.changes.some((ch) => ch === ChangeType.ASPECTS); + default: + // docs / tests / api show every component. + return true; + } + }, + [compositionsMap, hasCodeChanges] + ); + + // 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 = {}; + if (hasCodeChanges(c)) attrs['data-has-code'] = ''; + if (compositionsMap.get(c.idStr) !== false) attrs['data-has-preview'] = ''; + if (c.changes.some((ch) => ch === ChangeType.DEPENDENCY)) attrs['data-has-deps'] = ''; + if (c.changes.some((ch) => ch === ChangeType.ASPECTS)) 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, + preview: allComponents.filter((c) => compositionsMap.get(c.idStr) !== false).length, + docs: allComponents.length, + dependencies: allComponents.filter((c) => c.changes.some((ch) => ch === ChangeType.DEPENDENCY)).length, + tests: allComponents.length, + config: allComponents.filter((c) => c.changes.some((ch) => ch === ChangeType.ASPECTS)).length, + // the API view shows every component (sections or slim rows), matching matchesView('api'). + // counting only diffable pairs would zero the tab on an all-new lane and make the + // auto-view-switch effect yank the user away from the view's own empty state. + 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]); + + if (loadingLaneDiff) { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+
+ ); + } + + return ( + + +
+ {/* Toolbar */} + setViewMode(v as ViewMode)} + groupBy={groupBy} + onGroupByChange={(g) => setGroupBy(g as GroupBy)} + diffMode={diffMode} + onDiffModeChange={setDiffMode} + 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 ( + + + + + + ); +}); diff --git a/components/ui/compare/lane-compare/lane-compare.utils.ts b/components/ui/compare/lane-compare/lane-compare.utils.ts new file mode 100644 index 000000000000..92d882b710a6 --- /dev/null +++ b/components/ui/compare/lane-compare/lane-compare.utils.ts @@ -0,0 +1,30 @@ +import type { ComponentID } from '@teambit/component'; +import type { LaneDiff } from '@teambit/lanes.entities.lane-diff'; +import { ChangeType } from '@teambit/lanes.entities.lane-diff'; +import type { LaneFilter } from './lane-compare.models'; + +export function displayChangeType(changeType: ChangeType): string { + switch (changeType) { + case ChangeType.SOURCE_CODE: + return 'code'; + case ChangeType.NONE: + return 'no changes'; + default: + return changeType.toLowerCase(); + } +} + +export const filterDepKey: (filters?: Array) => string | undefined = (filters) => { + return filters?.map((f) => `${f.type}-${f.values.join()}`).join(); +}; + +export function extractCompsToDiff(laneDiff?: LaneDiff): [ComponentID | undefined, ComponentID | undefined][] { + return ( + (laneDiff?.diff && + laneDiff.diff.map((componentDiff) => [ + (componentDiff.targetHead && componentDiff.componentId.changeVersion(componentDiff.targetHead)) || undefined, + componentDiff.componentId.changeVersion(componentDiff.sourceHead), + ])) || + [] + ); +} diff --git a/components/ui/component-compare/component-compare/compare-data-context.tsx b/components/ui/component-compare/component-compare/compare-data-context.tsx new file mode 100644 index 000000000000..879192374e60 --- /dev/null +++ b/components/ui/component-compare/component-compare/compare-data-context.tsx @@ -0,0 +1,209 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { gql, useQuery } from '@apollo/client'; + +/** number of component pairs requested per bulk page */ +export const COMPARE_PAGE_SIZE = 25; + +export type ComponentComparePair = { + baseId: string; + compareId: string; +}; + +/** shape of a single bulk `compareComponents` result (mirrors the per-component `compareComponent` query) */ +export type CompareComponentData = { + id: string; + baseId: string; + compareId: string; + code: Array<{ + status?: string; + fileName: string; + diffOutput?: string; + baseContent?: string; + compareContent?: string; + }>; + aspects: Array<{ fieldName: string; diffOutput?: string }>; + tests?: Array<{ + status?: string; + fileName: string; + diffOutput?: string; + baseContent?: string; + compareContent?: string; + }>; +}; + +type CompareComponentsQueryResult = { + getHost: { id: string; compareComponents: Array } | null; +}; + +export const QUERY_COMPARE_COMPONENTS = gql` + query CompareComponents($pairs: [ComponentComparePair!]!, $offset: Int, $limit: Int, $host: String) { + getHost(id: $host) { + id + compareComponents(pairs: $pairs, offset: $offset, limit: $limit) { + id + baseId + compareId + code { + status + fileName + diffOutput + baseContent + compareContent + } + aspects { + fieldName + diffOutput + } + tests { + status + fileName + diffOutput + baseContent + compareContent + } + } + } + } +`; + +export type CompareDataContextModel = { + /** + * look up bulk compare data for a component by its `compareId`. + * `null` = the pair failed to compare, OR it was requested but paging finished/stopped early before + * reaching it (so it will never resolve); `undefined` = the pair is not in this provider's list, or + * its page has not loaded yet (check `loading` to disambiguate). + */ + getData: (compareId: string) => CompareComponentData | null | undefined; + /** true while pages are still loading; settles to false once every page loads or paging stops early */ + loading: boolean; + /** number of pairs whose data has loaded so far */ + loadedCount: number; +}; + +const CompareDataContext = createContext(undefined); + +export function useCompareData(): CompareDataContextModel | undefined { + return useContext(CompareDataContext); +} + +/** + * fires the bulk `compareComponents` query for the whole `pairs` list and exposes the results via context. + * loads sequentially in pages of COMPARE_PAGE_SIZE — page 1 first, then fetchMore for each subsequent page + * in the background until all pairs are covered. `pairs` is stabilized internally by content, so callers + * do not have to memoize it. `host` targets a specific GraphQL host (e.g. the workspace or a scope); + * omitting it resolves against the server default host. + */ +export function CompareDataProvider({ + pairs, + host, + children, +}: { + pairs: ComponentComparePair[]; + /** GraphQL host id, e.g. "teambit.scope/scope" or "workspace" — must match the host of the compared models */ + host?: string; + children: ReactNode; +}) { + // stabilize by content: a new array reference with the same pairs must not restart the query. + const pairsKey = JSON.stringify(pairs); + // eslint-disable-next-line react-hooks/exhaustive-deps + const stablePairs = useMemo(() => pairs, [pairsKey]); + + const skip = stablePairs.length === 0; + + const { data, loading, fetchMore } = useQuery(QUERY_COMPARE_COMPONENTS, { + variables: { pairs: stablePairs, offset: 0, limit: COMPARE_PAGE_SIZE, host }, + skip, + notifyOnNetworkStatusChange: true, + }); + + const results: Array = data?.getHost?.compareComponents ?? []; + const allLoaded = skip || results.length >= stablePairs.length; + + // flips true once paging terminates early — a page returns empty/short or a fetch fails — so the loop + // stops AND `loading` can settle even when fewer results than pairs came back. reactive (not a ref) so + // the derived `loading` flag updates; without it `loading` could stay true forever. reset on pairs change. + const [terminated, setTerminated] = useState(false); + useEffect(() => { + setTerminated(false); + }, [pairsKey]); + + // the pairs set this provider is currently paging — a fetchMore started for an earlier set can still + // resolve/reject after `pairs` changed, so its callbacks check this before terminating the new loop. + const pairsKeyRef = useRef(pairsKey); + useEffect(() => { + pairsKeyRef.current = pairsKey; + }, [pairsKey]); + + // every page loaded, or paging stopped early: no further requests will be made. + const done = allLoaded || terminated; + + // sequential background paging: whenever a page settles and pairs remain, request the next page. + useEffect(() => { + if (skip || loading || done) return; + // guard the async terminations below to this page's pairs set: an in-flight fetchMore from a + // previous pairsKey must not terminate the paging loop of a newer one (rapid target switches). + const activeKey = pairsKey; + const stillActive = () => pairsKeyRef.current === activeKey; + fetchMore({ + variables: { pairs: stablePairs, offset: results.length, limit: COMPARE_PAGE_SIZE, host }, + updateQuery: (prev: any, { fetchMoreResult }: any) => { + const prevHost = prev.getHost; + if (!fetchMoreResult || !prevHost) { + // nothing came back, or the accumulated result has no host (getHost: null) to page against → + // can't fetch further; terminate so the effect stops re-issuing fetchMore and loading settles. + if (stillActive()) setTerminated(true); + return prev; + } + // a fetchMore issued for a previous pairs set can resolve after `pairs` changed; discard its + // page rather than merging it into the current session's results (which would corrupt the new + // diff set) or terminating the new loop. + if (!stillActive()) return prev; + const newItems = fetchMoreResult.getHost?.compareComponents ?? []; + if (newItems.length === 0) setTerminated(true); + return { + getHost: { + ...prevHost, + compareComponents: [...prevHost.compareComponents, ...newItems], + }, + }; + }, + }).catch(() => { + // a failed page stops the sequence; pages already loaded stay usable. + if (stillActive()) setTerminated(true); + }); + }, [skip, loading, done, results.length, stablePairs, fetchMore, pairsKey, host]); + + const dataByCompareId = useMemo(() => { + const map = new Map(); + results.forEach((res, i) => { + // prefer the result's own compareId; fall back to positional alignment for null (failed) slots. + const key = res?.compareId ?? stablePairs[i]?.compareId; + if (key) map.set(key, res ?? null); + }); + return map; + }, [results, stablePairs]); + + // every compareId this provider was asked to fetch — lets getData tell "requested but unresolved" + // (paging stopped early) apart from "not in this provider's list" once paging is done. + const requestedCompareIds = useMemo(() => new Set(stablePairs.map((p) => p.compareId)), [stablePairs]); + + const value = useMemo( + () => ({ + getData: (compareId: string) => { + const found = dataByCompareId.get(compareId); + if (found !== undefined) return found; + // paging finished (fully, or stopped early on a short page / fetch error) but this requested + // pair never produced an entry → surface it as failed (null) rather than "still loading" + // (undefined), so consumers keyed on `getData(...) === undefined` don't spin forever. + if (done && requestedCompareIds.has(compareId)) return null; + return undefined; + }, + loading: !done, + loadedCount: results.length, + }), + [dataByCompareId, done, requestedCompareIds, results.length] + ); + + return {children}; +} diff --git a/components/ui/component-compare/component-compare/compare-sidebar.module.scss b/components/ui/component-compare/component-compare/compare-sidebar.module.scss new file mode 100644 index 000000000000..5a2c775b7882 --- /dev/null +++ b/components/ui/component-compare/component-compare/compare-sidebar.module.scss @@ -0,0 +1,414 @@ +$border-color: var(--border-medium-color, #e8ecf0); +$surface-color: var(--surface-color, #fff); +$bg-color: var(--background-color, #fdfdff); +$accent-color: var(--bit-accent-color, #6c5ce7); +$text-primary: var(--on-surface-color, #1a1a2e); +$text-secondary: var(--on-surface-medium-color, #707279); +$text-muted: var(--on-background-medium-color, #a0aec0); + +.sidebar { + width: 280px; + min-width: 280px; + border-right: 1px solid $border-color; + background: $bg-color; + overflow-y: auto; + height: 100%; + display: flex; + flex-direction: column; + flex-shrink: 0; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; + + &:hover { + background: rgba(0, 0, 0, 0.25); + } + } +} + +.sidebarHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + color: $text-secondary; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid $border-color; + flex-shrink: 0; +} + +.sidebarTitle { + font-size: 12px; +} + +.clearSelectionLink { + border: none; + background: none; + cursor: pointer; + padding: 0; + color: $accent-color; + font-size: 11px; + font-weight: 500; + font-family: inherit; + margin-left: auto; + opacity: 0.8; + + &:hover { + opacity: 1; + text-decoration: underline; + } +} + +.sidebarContent { + overflow-y: auto; + flex: 1; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; + + &:hover { + background: rgba(0, 0, 0, 0.25); + } + } +} + +.sidebarLoading { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.sidebarSkeleton { + padding: 4px 0; +} + +.skeleton { + height: 14px; + background: linear-gradient( + 90deg, + var(--surface-neutral-color, #f0f2f5) 25%, + $border-color 50%, + var(--surface-neutral-color, #f0f2f5) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 4px; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.sidebarGroup { + border-bottom: 1px solid $border-color; +} + +.sidebarGroupHeader { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 16px; + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; + font-size: 14px; + font-weight: 600; + color: $text-primary; + text-align: left; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } +} + +.chevron { + font-size: 10px; + transition: transform 0.15s ease; +} + +.chevronCollapsed { + transform: rotate(-90deg); +} + +.groupLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.groupCount { + font-weight: 400; + color: $text-muted; + font-size: 13px; +} + +.sidebarItems { + padding: 6px 0; +} + +// --- Component item with file tree --- + +.componentItemWrapper { + & + & { + margin-top: 2px; + } +} + +.sidebarItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px 8px 16px; + background: transparent; + border: none; + border-left: 2px solid transparent; + cursor: pointer; + font-family: inherit; + font-size: 13px; + color: $text-primary; + text-align: left; + transition: + background 0.1s ease, + border-color 0.1s ease; + border-radius: 0; + line-height: 1.4; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } + + &.sidebarItemSelected { + border-left-color: $accent-color; + background: rgba(108, 92, 231, 0.05); + color: $accent-color; + font-weight: 500; + } +} + +.fileChevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + cursor: pointer; + transition: transform 0.15s ease; + + &.chevronCollapsed { + transform: rotate(-90deg); + } +} + +.chevronIcon { + font-size: 9px; + color: $text-muted; +} + +.fileChevronPlaceholder { + display: inline-block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.envIcon { + width: 18px; + height: 18px; + flex-shrink: 0; + border-radius: 3px; +} + +.envIconPlaceholder { + display: inline-block; + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.componentName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sourceIndicator { + display: inline-flex; + align-items: center; + flex-shrink: 0; + margin-right: 2px; +} + +// --- Component status --- + +.componentStatus { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + flex-shrink: 0; + letter-spacing: 0.02em; + text-transform: uppercase; + color: $text-muted; + background: rgba(0, 0, 0, 0.04); +} + +.componentStatusNEW { + color: var(--positive-color, #37b26c); + background: rgba(55, 178, 108, 0.1); +} + +.componentStatusSOURCE_CODE { + color: var(--warning-color, #d6a022); + background: rgba(214, 160, 34, 0.08); +} + +.componentStatusDEPENDENCY { + color: var(--warning-color, #d6a022); + background: rgba(214, 160, 34, 0.08); +} + +.componentStatusASPECTS { + color: var(--warning-color, #d6a022); + background: rgba(214, 160, 34, 0.08); +} + +.fileCount { + font-size: 10px; + color: $text-muted; + font-weight: 500; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.04); + padding: 0 5px; + border-radius: 8px; + margin-left: auto; +} + +// --- File tree --- + +.fileTree { + padding: 4px 0 8px 0; +} + +.fileItem { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 5px 16px 5px 52px; + background: transparent; + border: none; + border-left: 2px solid transparent; + cursor: pointer; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12px; + color: $text-secondary; + text-align: left; + line-height: 1.5; + transition: background 0.1s ease; + + &:hover { + background: rgba(0, 0, 0, 0.02); + color: $text-primary; + } + + &.fileItemSelected { + border-left-color: $accent-color; + background: rgba(108, 92, 231, 0.05); + color: $accent-color; + } +} + +.fileStatus { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: $text-muted; +} + +.fileStatusNEW { + background: var(--positive-color, #37b26c); +} + +.fileStatusMODIFIED { + background: var(--warning-color, #d6a022); +} + +.fileStatusDELETED { + background: var(--negative-color, #e5534b); +} + +.fileName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// --- File status labels --- + +.fileStatusLabel { + font-size: 10px; + font-weight: 700; + flex-shrink: 0; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + color: $text-muted; +} + +.fileStatusLabelNEW { + color: var(--positive-color, #37b26c); + background: rgba(55, 178, 108, 0.1); +} + +.fileStatusLabelMODIFIED { + color: var(--warning-color, #d6a022); + background: rgba(214, 160, 34, 0.08); +} + +.fileStatusLabelDELETED { + color: var(--negative-color, #e5534b); + background: rgba(229, 83, 75, 0.08); +} diff --git a/components/ui/component-compare/component-compare/compare-sidebar.tsx b/components/ui/component-compare/component-compare/compare-sidebar.tsx new file mode 100644 index 000000000000..86167b06048c --- /dev/null +++ b/components/ui/component-compare/component-compare/compare-sidebar.tsx @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ // hoisted helper/components used before their definition +import React, { useState, useEffect } from 'react'; +import { Icon } from '@teambit/design.elements.icon'; +import type { FileInfo } from './file-registry'; +import styles from './compare-sidebar.module.scss'; + +export type CompareSidebarItem = { + id: string; + name: string; + envIcon?: string; + status?: 'NEW' | 'SOURCE_CODE' | 'DEPENDENCY' | 'ASPECTS' | 'NONE' | string; + files?: FileInfo[]; + /** optional indicator (e.g. where the base came from) rendered next to the component name */ + sourceIndicator?: React.ReactNode; +}; + +export type CompareSidebarGroup = { + key: string; + label: string; + icon?: string; + items: CompareSidebarItem[]; +}; + +export type CompareSidebarProps = { + groups: CompareSidebarGroup[]; + selectedId?: string; + selectedFile?: string; + onSelect: (id: string, fileName?: string) => void; + loading?: boolean; + className?: string; + defaultExpandFiles?: boolean; +}; + +export function CompareSidebar({ + groups, + selectedId, + selectedFile, + onSelect, + loading, + className, + defaultExpandFiles, +}: CompareSidebarProps) { + return ( +
+
+ Components + {selectedId && ( + + )} +
+
+ {loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+ ))} +
+ )} + {!loading && + groups.map((group) => ( + + ))} +
+
+ ); +} + +type SidebarGroupProps = { + group: CompareSidebarGroup; + selectedId?: string; + selectedFile?: string; + onSelect: (id: string, fileName?: string) => void; + isSingleGroup: boolean; + defaultExpandFiles?: boolean; +}; + +function SidebarGroup({ + group, + selectedId, + selectedFile, + onSelect, + isSingleGroup, + defaultExpandFiles, +}: SidebarGroupProps) { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ {!isSingleGroup && ( + + )} + {!collapsed && ( +
+ {group.items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +type SidebarComponentItemProps = { + item: CompareSidebarItem; + isSelected: boolean; + selectedFile?: string; + onSelect: (id: string, fileName?: string) => void; + defaultExpand?: boolean; +}; + +function SidebarComponentItem({ item, isSelected, selectedFile, onSelect, defaultExpand }: SidebarComponentItemProps) { + const [expanded, setExpanded] = useState(defaultExpand ?? false); + const hasFiles = item.files && item.files.length > 0; + + useEffect(() => { + if (defaultExpand && hasFiles) setExpanded(true); + }, [defaultExpand, hasFiles]); + + return ( +
+ + {expanded && hasFiles && ( +
+ {item.files!.map((file) => ( + + ))} +
+ )} +
+ ); +} + +const STATUS_LABELS: Record = { + NEW: 'New', + SOURCE_CODE: 'Modified', + DEPENDENCY: 'Modified', + ASPECTS: 'Modified', + MODIFIED: 'Modified', + DELETED: 'Deleted', +}; + +function formatStatus(status: string): string { + return STATUS_LABELS[status] || status; +} diff --git a/components/ui/component-compare/component-compare/compare-toolbar.module.scss b/components/ui/component-compare/component-compare/compare-toolbar.module.scss new file mode 100644 index 000000000000..b1d4d754024a --- /dev/null +++ b/components/ui/component-compare/component-compare/compare-toolbar.module.scss @@ -0,0 +1,89 @@ +$border-color: var(--border-medium-color, #e8ecf0); +$surface-color: var(--surface-color, #fff); +$accent-color: var(--bit-accent-color, #6c5ce7); +$text-primary: var(--on-surface-color, #1a1a2e); +$text-muted: var(--on-background-medium-color, #a0aec0); + +.toolbar { + --sidebar-width: 280px; + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid $border-color; + background: $surface-color; + gap: 0; +} + +.toolbarSearch { + width: calc(var(--sidebar-width) - 16px); + flex-shrink: 0; +} + +.toolbarLeft { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + flex: 1; + min-width: 0; + padding-left: 16px; + border-left: 1px solid $border-color; + + &::-webkit-scrollbar { + display: none; + } +} + +.toolbarOption { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; +} + +.toolbarBadge { + font-size: 11px; + font-weight: 600; + color: $text-muted; + background: rgba(0, 0, 0, 0.06); + padding: 1px 6px; + border-radius: 8px; + min-width: 16px; + text-align: center; +} + +.toolbarBadgeSkeleton { + display: inline-block; + width: 20px; + height: 12px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.06); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +.viewModeToggle, +.groupByToggle { + font-size: 13px; + height: 34px; +} + +.toolbarSeparator { + width: 1px; + height: 22px; + background: $border-color; +} + +.diffModeToggle { + font-size: 13px; + height: 34px; +} diff --git a/components/ui/component-compare/component-compare/compare-toolbar.tsx b/components/ui/component-compare/component-compare/compare-toolbar.tsx new file mode 100644 index 000000000000..831d943c80c9 --- /dev/null +++ b/components/ui/component-compare/component-compare/compare-toolbar.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { ToggleButton } from '@teambit/design.inputs.toggle-button'; +import { Icon } from '@teambit/design.elements.icon'; +import { Tooltip } from '@teambit/design.ui.tooltip'; +import { MultiSelect } from '@teambit/design.inputs.selectors.multi-select'; +import styles from './compare-toolbar.module.scss'; + +export type DiffMode = 'split' | 'unified'; + +export type CompareViewMode = { + id: string; + displayName: string; + icon?: string; +}; + +export type CompareGroupByOption = { + value: string; + label: string; +}; + +type SelectOption = { value: string; payload: string }; + +export type CompareToolbarProps = { + viewMode: string; + onViewModeChange: (mode: string) => void; + groupBy?: string; + onGroupByChange?: (groupBy: string) => void; + diffMode: DiffMode; + onDiffModeChange: (mode: DiffMode) => void; + viewModes: CompareViewMode[]; + groupByOptions?: CompareGroupByOption[]; + counts: Record; + /** + * Show the per-view-mode count badge. Counts always drive visibility (a mode with count 0 is + * hidden); this only controls the badge. Off for single-component compare, where a count would + * only ever be "1 component" and reads as meaningless. + */ + showCounts?: boolean; + loading?: boolean; + componentOptions?: SelectOption[]; + selectedComponents?: SelectOption[]; + onSelectedComponentsChange?: (selected: SelectOption[]) => void; + searchPlaceholder?: string; + className?: string; + sidebarWidth?: number; +}; + +const searchSelectStyles = { + control: () => ({ + fontSize: '13px', + padding: '2px 4px', + height: '32px', + width: '100%', + }), + singleValue: () => ({ + padding: 0, + }), +}; + +export function CompareToolbar({ + viewMode, + onViewModeChange, + groupBy, + onGroupByChange, + diffMode, + onDiffModeChange, + counts, + showCounts = true, + loading, + viewModes: registeredViewModes, + groupByOptions, + componentOptions, + selectedComponents, + onSelectedComponentsChange, + searchPlaceholder = 'Search components', + className, + sidebarWidth, +}: CompareToolbarProps) { + const viewModes = registeredViewModes + .map((vm) => ({ + value: vm.id, + label: vm.displayName, + icon: vm.icon, + count: counts[vm.id] ?? 0, + })) + .filter((vm) => vm.count > 0); + + const hasGroupBy = !!(groupByOptions && groupByOptions.length > 0 && onGroupByChange); + const groupByIndex = hasGroupBy + ? Math.max( + 0, + (groupByOptions as CompareGroupByOption[]).findIndex((g) => g.value === groupBy) + ) + : 0; + const viewModeIndex = viewModes.findIndex((v) => v.value === viewMode); + + return ( +
+ {componentOptions && onSelectedComponentsChange && ( +
+ onSelectedComponentsChange((value || []) as SelectOption[])} + className={styles.componentSearchSelect} + /> +
+ )} +
+ { + const newMode = viewModes[idx].value; + onViewModeChange(newMode); + }} + defaultIndex={viewModeIndex >= 0 ? viewModeIndex : 0} + options={viewModes.map((vm) => ({ + value: vm.value, + element: ( +
+ {vm.icon && } + {vm.label} + {showCounts && + (loading ? ( + + ) : ( + {vm.count} + ))} +
+ ), + }))} + className={styles.viewModeToggle} + /> + + {hasGroupBy && ( + <> +
+ + onGroupByChange!((groupByOptions as CompareGroupByOption[])[idx].value)} + defaultIndex={groupByIndex >= 0 ? groupByIndex : 0} + options={(groupByOptions as CompareGroupByOption[]).map((g) => ({ + value: g.value, + element: ( + + {g.label} + + ), + }))} + className={styles.groupByToggle} + /> + + )} + + {(viewMode === 'code' || viewMode === 'config' || viewMode === 'tests') && ( + <> +
+ + onDiffModeChange(idx === 0 ? 'split' : 'unified')} + defaultIndex={diffMode === 'split' ? 0 : 1} + options={[ + { value: 'split', element: Split }, + { value: 'unified', element: Unified }, + ]} + className={styles.diffModeToggle} + /> + + )} +
+
+ ); +} diff --git a/components/ui/component-compare/component-compare/component-compare.module.scss b/components/ui/component-compare/component-compare/component-compare.module.scss index eb042db13e69..ed79b817cafa 100644 --- a/components/ui/component-compare/component-compare/component-compare.module.scss +++ b/components/ui/component-compare/component-compare/component-compare.module.scss @@ -3,6 +3,8 @@ flex-direction: column; height: 100%; overflow: hidden; + // shrink below content width so a wide diff scrolls inside its own body, not the page. + min-width: 0; } .top { @@ -16,6 +18,14 @@ overflow: hidden; flex-direction: column; flex: 1; + // shrink below content width so a wide diff scrolls inside its own body, not the page. + min-width: 0; + + // direct children (the rendered tab subtrees, incl. the code diff) must also be allowed to + // shrink below their min-content width, otherwise a wide diff forces this column wider. + > * { + min-width: 0; + } &.hidden { display: none; @@ -130,3 +140,149 @@ background-color: var(--bit-accent-success-color, #37b26c); } } + +// --------------------------------------------------------------------------- +// InlineComponentCompare + ComponentCompareHeader (ported from new-changes) +// --------------------------------------------------------------------------- + +$inline-border-color: var(--border-medium-color, #e8ecf0); +$inline-surface-color: var(--surface-color, #fff); +$inline-accent-color: var(--bit-accent-color, #6c5ce7); +$inline-text-primary: var(--on-surface-color, #1a1a2e); +$inline-text-secondary: var(--on-surface-medium-color, #707279); +$inline-text-muted: var(--on-background-medium-color, #a0aec0); +$inline-code-font: 'JetBrains Mono', ui-monospace, monospace; + +.componentCompare { + background: $inline-surface-color; + margin-bottom: 2px; + --component-header-height: 44px; + + &:last-child { + margin-bottom: 0; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + background: $inline-surface-color; + border-bottom: 1px solid $inline-border-color; + border-left: 3px solid var(--component-accent, #{$inline-accent-color}); + position: sticky; + top: var(--group-header-height, 0px); + z-index: 3; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; +} + +.headerRight { + display: flex; + align-items: center; + gap: 14px; + flex-shrink: 0; +} + +.envIcon { + width: 20px; + height: 20px; + flex-shrink: 0; + border-radius: 4px; +} + +.envIconPlaceholder { + display: inline-block; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.componentName { + font-size: 14px; + font-weight: 600; + color: $inline-text-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.changeTags { + display: flex; + gap: 6px; +} + +.changeTag { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + letter-spacing: 0.02em; +} + +.versions { + display: flex; + align-items: center; + gap: 8px; +} + +.versionHash { + padding: 4px 10px; + border-radius: 6px; + background: var(--surface-neutral-color, #f6f8fa); + font-family: $inline-code-font; + font-size: 12px; + color: $inline-text-secondary; + text-decoration: none; + transition: + background 0.15s ease, + color 0.15s ease; + + &:hover { + background: rgba(108, 92, 231, 0.08); + color: $inline-accent-color; + } +} + +.versionArrow { + color: $inline-text-muted; + font-size: 11px; + opacity: 0.5; +} + +.skeleton { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.skeletonBar { + height: 12px; + background: linear-gradient( + 90deg, + var(--surface-neutral-color, #f0f2f5) 25%, + #{$inline-border-color} 50%, + var(--surface-neutral-color, #f0f2f5) 75% + ); + background-size: 200% 100%; + animation: inline-shimmer 1.5s ease-in-out infinite; + border-radius: 4px; +} + +@keyframes inline-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/components/ui/component-compare/component-compare/component-compare.tsx b/components/ui/component-compare/component-compare/component-compare.tsx index 7d1ed9a97abd..32aeea60c08f 100644 --- a/components/ui/component-compare/component-compare/component-compare.tsx +++ b/components/ui/component-compare/component-compare/component-compare.tsx @@ -1,8 +1,11 @@ /* eslint-disable react/destructuring-assignment */ /* eslint-disable react/require-default-props */ -import type { HTMLAttributes } from 'react'; -import React, { useContext, useMemo } from 'react'; +/* eslint-disable @typescript-eslint/no-use-before-define */ // hoisted function components used before their definition +import type { HTMLAttributes, ReactNode } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState, forwardRef } from 'react'; import classnames from 'classnames'; +import { useCode } from '@teambit/code.ui.queries.get-component-code'; +import { ComponentID as ComponentIdValue } from '@teambit/component-id'; import type { LegacyComponentLog } from '@teambit/legacy-component-log'; import type { ComponentID, NavPlugin } from '@teambit/component'; import { CollapsibleMenuNav, ComponentContext, ComponentDescriptorContext, useComponent } from '@teambit/component'; @@ -27,121 +30,19 @@ import { sortByDateDsc } from '@teambit/component.ui.component-compare.utils.sor import { extractLazyLoadedData } from '@teambit/component.ui.component-compare.utils.lazy-loading'; import { BlockSkeleton, WordSkeleton } from '@teambit/base-ui.loaders.skeleton'; import { ChangeType } from '@teambit/component.ui.component-compare.models.component-compare-change-type'; -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; -import { gql } from '@apollo/client'; +import { useApiDiff } from '@teambit/semantics.ui.api-diff-view'; +import type { APIDiffResult } from '@teambit/semantics.ui.api-diff-view'; +import type { ComponentComparePair, CompareComponentData } from './compare-data-context'; +import { useCompareData } from './compare-data-context'; +import { useFileRegistryRegister, useAspectRegistryRegister } from './file-registry'; import styles from './component-compare.module.scss'; // Extend ChangeType with API (the external enum doesn't have it yet) const ChangeTypeAPI = 'API' as unknown as ChangeType; -export type APIDiffDetail = { - changeKind: string; - description: string; - impact: string; - from?: string; - to?: string; -}; - -export type APIDiffChange = { - status: string; - visibility: string; - exportName: string; - schemaType: string; - schemaTypeRaw: string; - impact: string; - baseSignature?: string; - compareSignature?: string; - baseNode?: Record; - compareNode?: Record; - changes?: APIDiffDetail[]; -}; - -export type APIDiffResult = { - hasChanges: boolean; - impact: string; - publicChanges: APIDiffChange[]; - internalChanges: APIDiffChange[]; - changes: APIDiffChange[]; - added: number; - removed: number; - modified: number; - breaking: number; - nonBreaking: number; - patch: number; -}; - -const QUERY_API_DIFF = gql` - query ComponentCompareAPIDiff($baseId: String!, $compareId: String!) { - getHost { - id - apiDiff(baseId: $baseId, compareId: $compareId) { - hasChanges - impact - added - removed - modified - breaking - nonBreaking - patch - publicChanges { - status - visibility - exportName - schemaType - schemaTypeRaw - impact - baseSignature - compareSignature - baseNode - compareNode - changes { - changeKind - description - impact - from - to - } - } - internalChanges { - status - visibility - exportName - schemaType - schemaTypeRaw - impact - baseSignature - compareSignature - changes { - changeKind - description - impact - from - to - } - } - } - } - } -`; - -function useAPIDiffQuery( - baseId?: string, - compareId?: string, - skip?: boolean -): { loading?: boolean; apiDiffResult?: APIDiffResult | null } { - const { data, loading } = useDataQuery<{ - getHost: { apiDiff: APIDiffResult | null }; - }>(QUERY_API_DIFF, { - variables: { baseId, compareId }, - skip: skip || !baseId || !compareId || baseId === compareId, - }); - - return { - loading, - apiDiffResult: data?.getHost?.apiDiff, - }; -} +// single source of truth for the API diff model — see api-diff-view's api-diff-model. +export type { APIDiffResult, APIDiffChange, APIDiffDetail } from '@teambit/semantics.ui.api-diff-view'; const findPrevVersionFromCurrent = (compareVersion) => (_, index: number, logs: LegacyComponentLog[]) => { if (compareVersion === 'workspace' || logs.length === 1) return true; @@ -182,7 +83,7 @@ function CompareMenuTab({ }) { const hasChanged = useMemo( () => changed?.some((change) => change !== ChangeType.NONE && change !== ChangeType.NEW), - [changeTypeCss] + [changed] ); if (loading) return ; @@ -481,11 +382,9 @@ export function ComponentCompare(props: ComponentCompareProps) { skipComponentCompareQuery ); - const { loading: apiDiffLoading, apiDiffResult } = useAPIDiffQuery( - base?.id.toString(), - compare?.id.toString(), - hidden - ); + const { loading: apiDiffLoading, result: apiDiffResult } = useApiDiff(base?.id.toString(), compare?.id.toString(), { + skip: hidden, + }); const fileCompareDataByName = useMemo(() => { if (loading || compCompareLoading) return undefined; @@ -580,3 +479,406 @@ export function ComponentCompare(props: ComponentCompareProps) { ); } + +// --------------------------------------------------------------------------- +// InlineComponentCompare + ComponentCompareHeader (ported from new-changes) +// --------------------------------------------------------------------------- + +export type InlineComponentCompareProps = { + name: string; + baseId?: string; + compareId: string; + baseVersion?: string; + compareVersion?: string; + baseUrl?: string; + compareUrl?: string; + envIcon?: string; + changeTags?: Array<{ label: string; color: string }>; + accentColor?: string; + tabs?: TabItem[]; + allTabs?: TabItem[]; + children?: ReactNode; + className?: string; + host?: string; + previewUrl?: string; + /** + * Provide the base/compare model+descriptor directly instead of letting the inner context re-fetch + * them by id. Needed for the single-component compare page: in workspace local-changes mode + * `compareId` is deliberately set equal to `baseId` (to trigger the server's local diff for the + * bulk code query), so re-fetching by id would load the same snap for both sides — making deps/ + * config/preview identical. The page already holds the correct pair (base = the scope's published + * snap, compare = the live workspace component), so it passes them through here. `compareId` is + * still used as-is to key the bulk code/aspect/test data. Lane-compare omits these and re-fetches. + */ + baseOverride?: { model?: any; descriptor?: any; hasLocalChanges?: boolean }; + compareOverride?: { model?: any; descriptor?: any; hasLocalChanges?: boolean }; + /** + * Extra `data-*` attributes stamped on the panel root. Lane-compare uses these (`data-has-code`, + * `data-has-deps`, …) to drive per-view-mode visibility purely in CSS, so switching modes never + * remounts panels — it only flips the `[data-view-mode]` attribute on the pane. The object MUST be + * referentially stable across view-mode changes (derive it from view-mode-independent data) or it + * defeats React.memo and reintroduces the re-render storm this whole design avoids. + */ + dataAttributes?: Record; +}; + +/** + * forwardRef + React.memo. Without memo, every parent re-render (e.g. `setViewMode` in lane-compare) + * re-renders all 10 mounted panels and all their nested tab subtrees — which was the source of the + * "view-mode switching is slow" feedback. With memo + stable props (lane-compare's `allTabs` array + * is hoisted to module scope), a viewMode click is now just a CSS attribute change on the + * `[data-view-mode]` container; the panels don't reconcile at all. + */ +const InlineComponentCompareInner = forwardRef( + function InlineComponentCompare( + { + name, + baseId, + compareId, + baseVersion, + compareVersion, + baseUrl, + compareUrl, + envIcon, + changeTags, + accentColor, + tabs, + allTabs, + children, + className, + host = 'teambit.scope/scope', + baseOverride, + compareOverride, + dataAttributes, + }, + ref + ) { + const sectionRef = useRef(null); + const [hasBeenVisible, setHasBeenVisible] = useState(false); + + const setRefs = React.useCallback( + (node: HTMLDivElement | null) => { + (sectionRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') ref(node); + // eslint-disable-next-line no-param-reassign + else if (ref) (ref as React.MutableRefObject).current = node; + }, + [ref] + ); + + useEffect(() => { + const el = sectionRef.current; + if (!el) return undefined; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setHasBeenVisible(true); + observer.disconnect(); + } + }, + { rootMargin: '400px 0px' } + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const headerStyle = accentColor ? ({ '--component-accent': accentColor } as React.CSSProperties) : undefined; + + return ( +
+ + + {!baseId && !!compareId && } + + {!hasBeenVisible && } + + {hasBeenVisible && ( + + {allTabs + ? allTabs.map((tab) => ( + + {tab.element} + + )) + : tabs && tabs.map((tab) =>
{tab.element}
)} + {children} +
+ )} +
+ ); + } +); +export const InlineComponentCompare = React.memo(InlineComponentCompareInner); + +/** + * Tab wrapper that just stamps a `data-tab-id` for CSS-driven visibility (rules live in + * `lane-compare.module.scss`, scoped by `data-view-mode`). Previously gated children on + * `el.offsetParent !== null` to defer mounting until visible — that turned each view-mode switch + * into a fresh data-fetch storm because the lazily-mounted panel re-fired all its queries. We now + * mount all tabs eagerly and rely on Apollo's per-query cache (warmed by the lane-compare + * pre-fetch) to keep first paint cheap. The `data-tab-id` attribute remains so CSS can hide + * non-active tabs. + */ +export function DeferredTab({ tabId, children }: { tabId: string; children: ReactNode }) { + return
{children}
; +} + +function InlineContextProvider({ + baseId, + compareId, + host = 'teambit.scope/scope', + baseOverride, + compareOverride, + children, +}: { + baseId?: string; + compareId?: string; + host?: string; + baseOverride?: { model?: any; descriptor?: any; hasLocalChanges?: boolean }; + compareOverride?: { model?: any; descriptor?: any; hasLocalChanges?: boolean }; + children: ReactNode; +}) { + const isNew = !baseId && !!compareId; + + // When the caller supplies the model/descriptor (single-component page), use it and skip the fetch + // — re-fetching by id would be redundant and, in local-changes mode (compareId === baseId), wrong. + const { component: fetchedBaseModel, componentDescriptor: fetchedBaseDescriptor } = useComponent(host, baseId, { + skip: !baseId || !!baseOverride, + context: { batch: true }, + }); + const { component: fetchedCompareModel, componentDescriptor: fetchedCompareDescriptor } = useComponent( + host, + compareId, + { + skip: !compareId || !!compareOverride, + context: { batch: true }, + } + ); + + const baseModel = baseOverride?.model ?? fetchedBaseModel; + const baseDescriptor = baseOverride?.descriptor ?? fetchedBaseDescriptor; + const compareModel = compareOverride?.model ?? fetchedCompareModel; + const compareDescriptor = compareOverride?.descriptor ?? fetchedCompareDescriptor; + + const hasBase = !baseId || !!baseModel; + const hasCompare = !compareId || !!compareModel; + + const compareData = useCompareData(); + const componentCompareData = compareId ? compareData?.getData(compareId) : undefined; + // for non-new components with a compareId: undefined = bulk page not loaded yet, null = pair failed to compare. + // a component with no compareId (deleted in the compare lane) is never in the bulk pairs, so it is not "loading". + const compCompareLoading = !isNew && !!compareId && componentCompareData === undefined; + + const { fileTree: newCompFileTree, loading: newCompCodeLoading } = useCode(isNew ? compareModel?.id : undefined); + + const fileCompareDataByName = useMemo(() => { + if (isNew) { + if (newCompCodeLoading || !newCompFileTree) return undefined; + const lookup = new Map(); + newCompFileTree.forEach((fileName: string) => { + lookup.set(fileName, { fileName, baseContent: '', compareContent: undefined, status: 'NEW' }); + }); + return lookup; + } + if (compCompareLoading) return undefined; + if (!componentCompareData) return null; + const lookup = new Map(); + (componentCompareData.code || []).forEach((f: any) => { + lookup.set(f.fileName, f); + }); + return lookup; + }, [isNew, newCompCodeLoading, newCompFileTree, compCompareLoading, componentCompareData]); + + const fieldCompareDataByName = useMemo(() => { + if (compCompareLoading) return undefined; + if (!componentCompareData) return null; + const lookup = new Map(); + (componentCompareData.aspects || []).forEach((a: any) => lookup.set(a.fieldName, a)); + return lookup; + }, [compCompareLoading, componentCompareData]); + + const testCompareDataByName = useMemo(() => { + if (compCompareLoading) return undefined; + if (!componentCompareData) return null; + const lookup = new Map(); + (componentCompareData.tests || []).forEach((t: any) => lookup.set(t.fileName, t)); + return lookup; + }, [compCompareLoading, componentCompareData]); + + if (!hasBase || !hasCompare) { + return ; + } + + const contextValue = { + base: baseModel ? { model: baseModel, descriptor: baseDescriptor } : undefined, + compare: compareModel + ? { model: compareModel, descriptor: compareDescriptor, hasLocalChanges: compareOverride?.hasLocalChanges } + : undefined, + loading: compCompareLoading, + logsByVersion: new Map(), + fileCompareDataByName, + fieldCompareDataByName, + testCompareDataByName, + isFullScreen: false, + hidden: false, + }; + + return {children}; +} + +/** + * registers the file list of a NEW component (one with no base) into the FileRegistry for the sidebar. + * non-new components are fed in bulk by `RegistryFeeder`. `useCode` is an external hook that cannot + * forward a batch context, so these (minority) queries stay unbatched. + * compositions are intentionally not registered here — the FileRegistry compositions store has no + * readers; `lane-compare.tsx` derives composition info independently from `useLaneComponents`. + */ +export function NewComponentFileRegistrar({ compareId }: { compareId: string }) { + const newCompId = useMemo(() => ComponentIdValue.fromString(compareId), [compareId]); + const { fileTree: newCompFileTree, loading } = useCode(newCompId); + + const registryFiles = useMemo(() => { + if (loading || !newCompFileTree?.length) return undefined; + return newCompFileTree.map((n: string) => ({ name: n, status: 'NEW' })); + }, [loading, newCompFileTree]); + + useFileRegistryRegister(compareId.split('@')[0], registryFiles); + + return null; +} + +export type ComponentCompareHeaderProps = { + name: string; + envIcon?: string; + baseVersion?: string; + compareVersion?: string; + baseUrl?: string; + compareUrl?: string; + changeTags?: Array<{ label: string; color: string }>; +}; + +export function ComponentCompareHeader({ + name, + envIcon, + baseVersion, + compareVersion, + baseUrl, + compareUrl, + changeTags, +}: ComponentCompareHeaderProps) { + return ( +
+
+ {envIcon ? ( + + ) : ( + + )} + {name} +
+
+ {changeTags && changeTags.length > 0 && ( +
+ {changeTags.map((t) => ( + + {t.label} + + ))} +
+ )} +
+ {baseVersion && + (baseUrl ? ( + + {baseVersion} + + ) : ( + {baseVersion} + ))} + {baseVersion && compareVersion && } + {compareVersion && + (compareUrl ? ( + + {compareVersion} + + ) : ( + {compareVersion} + ))} +
+
+
+ ); +} + +function InlineSkeleton({ lines = 3 }: { lines?: number }) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( +
+ ))} +
+ ); +} + +/** registers one component's bulk compare data into the FileRegistry. renders nothing. */ +function CompareRegistryEntry({ compareId }: { compareId: string }) { + const compareData = useCompareData(); + const data: CompareComponentData | null | undefined = compareData?.getData(compareId); + const componentIdStr = compareId.split('@')[0]; + + // `data` undefined = the bulk page is still loading → register nothing (keep any prior entry). `data` + // null = the pair failed or paging terminated before reaching it → register an empty list so a stale + // file tree from a previous comparison is cleared rather than left behind in the sidebar registry. + const registryFiles = useMemo(() => { + if (data === undefined) return undefined; + if (data === null) return []; + return (data.code || []) + .filter((f) => f.status !== 'UNCHANGED') + .map((f) => ({ name: f.fileName, status: f.status })); + }, [data]); + + const aspectRegistryFiles = useMemo(() => { + if (data === undefined) return undefined; + if (data === null) return []; + return (data.aspects || []).map((a) => ({ name: a.fieldName, status: 'MODIFIED' })); + }, [data]); + + useFileRegistryRegister(componentIdStr, registryFiles); + useAspectRegistryRegister(componentIdStr, aspectRegistryFiles); + + return null; +} + +/** + * feeds the FileRegistry from the bulk `CompareDataProvider` for every component pair that has a base. + * renders one null-rendering `CompareRegistryEntry` per pair — no per-component queries are fired. + */ +export function RegistryFeeder({ pairs }: { pairs: ComponentComparePair[] }) { + return ( + <> + {pairs.map((pair) => ( + + ))} + + ); +} diff --git a/components/ui/component-compare/component-compare/diff-mode-context.tsx b/components/ui/component-compare/component-compare/diff-mode-context.tsx new file mode 100644 index 000000000000..206e7ab0f0b3 --- /dev/null +++ b/components/ui/component-compare/component-compare/diff-mode-context.tsx @@ -0,0 +1,14 @@ +import React, { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +export type DiffDisplayMode = 'split' | 'unified'; + +const DiffModeContext = createContext('split'); + +export function DiffModeProvider({ mode, children }: { mode: DiffDisplayMode; children: ReactNode }) { + return {children}; +} + +export function useDiffMode(): DiffDisplayMode { + return useContext(DiffModeContext); +} diff --git a/components/ui/component-compare/component-compare/file-registry.tsx b/components/ui/component-compare/component-compare/file-registry.tsx new file mode 100644 index 000000000000..58a7a6d19fe4 --- /dev/null +++ b/components/ui/component-compare/component-compare/file-registry.tsx @@ -0,0 +1,169 @@ +import React, { createContext, useCallback, useContext, useEffect, useRef, useSyncExternalStore } from 'react'; +import type { ReactNode } from 'react'; + +export type FileInfo = { name: string; status?: string }; + +type Listener = () => void; + +function filesSignature(files: FileInfo[]) { + return files.map((f) => `${f.name}:${f.status ?? ''}`).join('|'); +} + +class ComponentRegistry { + private files = new Map(); + + private aspectFiles = new Map(); + + /** changed API exports per component — populates the sidebar tree in API view mode */ + private apiEntries = new Map(); + + private compositions = new Map(); + + private listeners = new Set(); + + register(componentId: string, files: FileInfo[]) { + const existing = this.files.get(componentId); + if (existing && filesSignature(existing) === filesSignature(files)) return; + this.files.set(componentId, files); + this.notify(); + } + + getFiles(componentId: string) { + return this.files.get(componentId); + } + + registerAspects(componentId: string, files: FileInfo[]) { + const existing = this.aspectFiles.get(componentId); + if (existing && filesSignature(existing) === filesSignature(files)) return; + this.aspectFiles.set(componentId, files); + this.notify(); + } + + getAspectFiles(componentId: string) { + return this.aspectFiles.get(componentId); + } + + registerApiEntries(componentId: string, entries: FileInfo[]) { + const existing = this.apiEntries.get(componentId); + if (existing && filesSignature(existing) === filesSignature(entries)) return; + this.apiEntries.set(componentId, entries); + this.notify(); + } + + getApiEntries(componentId: string) { + return this.apiEntries.get(componentId); + } + + registerCompositions(componentId: string, hasCompositions: boolean) { + if (this.compositions.get(componentId) === hasCompositions) return; + this.compositions.set(componentId, hasCompositions); + this.notify(); + } + + getHasCompositions(componentId: string) { + return this.compositions.get(componentId); + } + + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getVersion() { + return this.version; + } + + private version = 0; + + /** the version value subscribers last observed — guards against flushing a no-op change */ + private flushedVersion = 0; + + private pendingNotify = false; + + /** + * coalesce a burst of registrations into a single asynchronous flush, then notify subscribers + * exactly once. crucial properties that keep this provably convergent and free of the + * "Maximum update depth exceeded" loop: + * - `version` only advances when a `register*` method detected a *real* content change (each + * one bails on an equal signature), so a re-render that registers nothing cannot bump it. + * - the flush is deferred to a microtask and guarded by `pendingNotify`, so N synchronous + * registrations collapse into one subscriber notification (one re-render), never N. + * - the flush bails when `version === flushedVersion`, so a scheduled flush whose change was + * already observed (e.g. via `useSyncExternalStore`'s snapshot) does no extra work. + * - listeners are snapshotted before iterating, so a subscriber that (un)subscribes while being + * notified cannot mutate the set mid-iteration. + */ + private notify() { + this.version += 1; + if (this.pendingNotify) return; + this.pendingNotify = true; + queueMicrotask(() => { + this.pendingNotify = false; + if (this.version === this.flushedVersion) return; + this.flushedVersion = this.version; + [...this.listeners].forEach((l) => l()); + }); + } +} + +const FileRegistryContext = createContext(undefined); + +export function FileRegistryProvider({ children }: { children: ReactNode }) { + const storeRef = useRef(undefined); + if (!storeRef.current) storeRef.current = new ComponentRegistry(); + return {children}; +} + +export function useFileRegistry() { + const store = useContext(FileRegistryContext); + + // Subscribe via `useSyncExternalStore` rather than a manual `useState` + `forceRender`. The + // snapshot is the store's monotonic `version`, which only advances on a real registration change. + // React re-renders this consumer solely when that number changes and bails when it is unchanged — + // so a re-render that triggers no genuine registration produces no new snapshot and cannot loop. + const subscribe = useCallback((onStoreChange: () => void) => store?.subscribe(onStoreChange) ?? (() => {}), [store]); + const getSnapshot = useCallback(() => store?.getVersion() ?? 0, [store]); + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return store; +} + +// `undefined` = not loaded yet (register nothing); `[]` = loaded with no changed files (register the +// empty list, so a component that becomes unchanged under a new base clears its stale sidebar entry +// instead of keeping the previous non-empty one). the signature encodes definedness so an +// `undefined → []` transition still re-runs the effect — otherwise both collapse to '' and it wouldn't. +// `register*()` bails on an equal signature, so a repeated empty registration never notifies. +function registerSignature(files: FileInfo[] | undefined): string | undefined { + return files === undefined ? undefined : filesSignature(files); +} + +export function useFileRegistryRegister(componentId: string | undefined, files: FileInfo[] | undefined) { + const store = useContext(FileRegistryContext); + const signature = registerSignature(files); + useEffect(() => { + if (store && componentId && files) { + store.register(componentId, files); + } + }, [store, componentId, signature]); +} + +export function useAspectRegistryRegister(componentId: string | undefined, files: FileInfo[] | undefined) { + const store = useContext(FileRegistryContext); + const signature = registerSignature(files); + useEffect(() => { + if (store && componentId && files) { + store.registerAspects(componentId, files); + } + }, [store, componentId, signature]); +} + +export function useCompositionsRegistryRegister(componentId: string | undefined, hasCompositions: boolean | undefined) { + const store = useContext(FileRegistryContext); + useEffect(() => { + if (store && componentId && hasCompositions !== undefined) { + store.registerCompositions(componentId, hasCompositions); + } + }, [store, componentId, hasCompositions]); +} diff --git a/components/ui/component-compare/component-compare/index.ts b/components/ui/component-compare/component-compare/index.ts index 4748286e24d2..4578967c0e20 100644 --- a/components/ui/component-compare/component-compare/index.ts +++ b/components/ui/component-compare/component-compare/index.ts @@ -1,2 +1,21 @@ export { ComponentCompare } from './component-compare'; export type { APIDiffResult, APIDiffChange, APIDiffDetail } from './component-compare'; +export { DiffModeProvider, useDiffMode } from './diff-mode-context'; +export type { DiffDisplayMode } from './diff-mode-context'; +export { + FileRegistryProvider, + useFileRegistry, + useFileRegistryRegister, + useAspectRegistryRegister, + useCompositionsRegistryRegister, +} from './file-registry'; +export type { FileInfo } from './file-registry'; +export { CompareToolbar } from './compare-toolbar'; +export type { CompareToolbarProps, CompareViewMode, CompareGroupByOption, DiffMode } from './compare-toolbar'; +export { CompareSidebar } from './compare-sidebar'; +export type { CompareSidebarProps, CompareSidebarItem, CompareSidebarGroup } from './compare-sidebar'; +export { InlineComponentCompare, ComponentCompareHeader } from './component-compare'; +export type { InlineComponentCompareProps, ComponentCompareHeaderProps } from './component-compare'; +export { CompareDataProvider, useCompareData, COMPARE_PAGE_SIZE } from './compare-data-context'; +export type { CompareDataContextModel, CompareComponentData, ComponentComparePair } from './compare-data-context'; +export { RegistryFeeder } from './component-compare'; diff --git a/components/ui/component-compare/context/component-compare-context.ts b/components/ui/component-compare/context/component-compare-context.ts index e7a2045b25e7..981e6cf30d82 100644 --- a/components/ui/component-compare/context/component-compare-context.ts +++ b/components/ui/component-compare/context/component-compare-context.ts @@ -1,6 +1,7 @@ import type { ComponentCompareModel } from '@teambit/component.ui.component-compare.models.component-compare-model'; import type { ComponentCompareState } from '@teambit/component.ui.component-compare.models.component-compare-state'; import type { ComponentCompareHooks } from '@teambit/component.ui.component-compare.models.component-compare-hooks'; +import type { APIDiffResult } from '@teambit/semantics.ui.api-diff-view'; import { createContext, useContext } from 'react'; export type StateAndHooks = { @@ -16,6 +17,8 @@ export type ComponentCompareContextType = ComponentCompareModel & StateAndHooks & { baseContext?: StateAndHooks; compareContext?: StateAndHooks; + /** API diff between base and compare — undefined while loading, null when not computable */ + apiDiffResult?: APIDiffResult | null; } & ViewState; export const ComponentCompareContext: React.Context = createContext< diff --git a/components/ui/component-compare/context/index.ts b/components/ui/component-compare/context/index.ts index abb8312bab0b..59b1d6ba7ec4 100644 --- a/components/ui/component-compare/context/index.ts +++ b/components/ui/component-compare/context/index.ts @@ -1,2 +1,4 @@ export { ComponentCompareContext, useComponentCompare } from './component-compare-context'; export type { StateAndHooks, ComponentCompareContextType } from './component-compare-context'; +export { InlineCompareEmpty } from './inline-compare-empty'; +export type { InlineCompareEmptyProps } from './inline-compare-empty'; diff --git a/components/ui/component-compare/context/inline-compare-empty.tsx b/components/ui/component-compare/context/inline-compare-empty.tsx new file mode 100644 index 000000000000..237db242ec82 --- /dev/null +++ b/components/ui/component-compare/context/inline-compare-empty.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +export type InlineCompareEmptyProps = { + /** primary message, e.g. "No code changes" / "No changes to compare" */ + message: string; + /** optional secondary line with more context */ + hint?: string; + /** + * when both are provided, a base → compare version pill row is rendered. identical versions are + * tinted neutral to signal "same version", different ones are color-coded (base neutral, compare accent). + */ + baseVersion?: string; + compareVersion?: string; +}; + +const ACCENT = 'var(--bit-accent-color, #6c5ce7)'; +const MUTED = 'var(--on-background-medium-color, #a0aec0)'; +const SECONDARY = 'var(--on-surface-medium-color, #707279)'; +const PRIMARY = 'var(--on-surface-color, #1a1a2e)'; +const BORDER = 'var(--border-medium-color, #e8ecf0)'; +const SURFACE = 'var(--surface-color, #fff)'; + +function VersionPill({ version, tone }: { version: string; tone: 'neutral' | 'base' | 'compare' }) { + const accent = tone === 'compare'; + return ( + + {version} + + ); +} + +/** + * Shared blank state for the compare surfaces (inline tabs, the single-component compare page, + * and lane-compare). Renders in place of an empty body so a no-change comparison reads as an + * intentional "nothing changed" state instead of a blank pane. + */ +export function InlineCompareEmpty({ message, hint, baseVersion, compareVersion }: InlineCompareEmptyProps) { + const hasPills = !!baseVersion && !!compareVersion; + const identical = hasPills && baseVersion === compareVersion; + + return ( +
+ {message} + + {hasPills && ( +
+
+ + + +
+
+ base + + compare +
+
+ )} + + {hint && {hint}} +
+ ); +} diff --git a/components/ui/component-meta/component-overview.module.scss b/components/ui/component-meta/component-overview.module.scss new file mode 100644 index 000000000000..18496f914178 --- /dev/null +++ b/components/ui/component-meta/component-overview.module.scss @@ -0,0 +1,122 @@ +@import '@teambit/base-ui.layout.breakpoints/_breakpoints.scss'; +@import '@teambit/ui-foundation.ui.constants.z-indexes/z-indexes.module.scss'; + +.componentTitle { + > h1 { + margin: 0; + } +} + +.badgeContainer { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-top: 8px; // this is to align the badges to the title text as opposed to the title line + + > * { + margin: 0 8px; + } +} + +.subTitle { + white-space: break-spaces; +} + +.titleRow { + margin-bottom: 20px; +} + +.contentTabs { + margin-bottom: 16px; + min-width: 70%; + max-width: 100%; + + @media screen and (max-width: $br-l) { + width: 100%; + } + + .nav > div { + font-size: 12px; + padding: 6px 12px; + } + + .tab { + border-bottom: 0; + } +} + +.copyBox { + background: var(--background-color, #ffffff); + border-top-left-radius: 0; +} + +.title { + letter-spacing: -1px; +} + +.copyIcon { + font-size: var(--bit-p-lg); +} + +.copyContainer { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + + > div { + display: flex; + } +} + +.pkgManagerPlaceholder { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + background-color: var(--primary-surface-color, #f6f5fe); + padding: 6px; + border-radius: 6px; +} + +.pkgManagerMenuItems { + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + cursor: pointer; + + > div { + padding: 4px; + + &:hover { + background-color: var(--primary-surface-color, #f6f5fe); + } + } +} + +.pkgManagerMenu { + display: flex; + z-index: $modal-z-index; + border-radius: 6px; +} + +.yarn { + height: 17px; +} + +.npm { + width: 30px; +} + +.pnpm { + height: 16px; +} + +.bit { + font-size: var(--bit-p-lg, 16px); +} + +.selected { + background-color: var(--primary-surface-color, #f6f5fe); +} diff --git a/components/ui/component-meta/component-overview.tsx b/components/ui/component-meta/component-overview.tsx new file mode 100644 index 000000000000..f472923e0788 --- /dev/null +++ b/components/ui/component-meta/component-overview.tsx @@ -0,0 +1,310 @@ +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import type { ComponentDescriptor } from '@teambit/component-descriptor'; +import { textColumn } from '@teambit/base-ui.layout.page-frame'; +import { H1 } from '@teambit/documenter.ui.heading'; +import { LabelList } from '@teambit/documenter.ui.label-list'; +import { Section, type SectionProps } from '@teambit/documenter.ui.section'; +import { Dropdown } from '@teambit/evangelist.surfaces.dropdown'; +import { snapToSemver } from '@teambit/component-package-version'; +import { CopyBox } from '@teambit/documenter.ui.copy-box'; +import { Separator } from '@teambit/design.ui.separator'; +import { ContentTabs } from '@teambit/design.navigation.content-tabs'; +import type { ContentTab } from '@teambit/design.navigation.content-tabs'; +import { Subtitle } from '@teambit/documenter.ui.sub-title'; +import { isBrowser } from '@teambit/ui-foundation.ui.is-browser'; +import { Row } from '@teambit/base-react.layout.row'; +import { Icon } from '@teambit/evangelist.elements.icon'; +import type { ComponentModel } from '@teambit/component'; +import { BadgePosition } from '@teambit/docs'; +import type { TitleBadge } from '@teambit/docs'; +import { Tooltip } from '@teambit/design.ui.tooltip'; + +import styles from './component-overview.module.scss'; + +export type ComponentOverviewProps = { + displayName: string; + abstract?: string; + labels: string[]; + packageName: string; + elementsUrl?: string; + titleBadges?: TitleBadge[]; + componentDescriptor?: ComponentDescriptor; + component?: ComponentModel; + pkgManager?: 'npm' | 'yarn' | 'pnpm' | 'bit'; +} & SectionProps; + +export function ComponentOverview({ + displayName, + abstract, + titleBadges, + labels, + packageName, + elementsUrl, + componentDescriptor, + component, + pkgManager = 'npm', + ...rest +}: ComponentOverviewProps) { + const host = component?.host; + const isWorkspace = host === 'teambit.workspace/workspace'; + + const packageNameTabTitle = isWorkspace ? `Use package` : `Install package`; + const componentId = component?.id; + const componentIdVersion = componentId?.version ?? undefined; + const latestVersion = component?.latest ?? undefined; + const packageVersion = componentIdVersion === latestVersion ? '' : `@${snapToSemver(componentIdVersion as string)}`; + + const [selectedPkgManager, setSelectedPkgManager] = React.useState(pkgManager); + + const installCmd = useMemo(() => { + if (selectedPkgManager === 'yarn') return 'add'; + if (selectedPkgManager === 'bit') return 'install'; + return 'i'; + }, [selectedPkgManager]); + + const copyTextInstall = useMemo( + () => `${selectedPkgManager} ${installCmd} ${packageName}${packageVersion}`, + [selectedPkgManager, packageName, packageVersion, installCmd] + ); + + const copyTextImport = `bit import ${componentId?.toStringWithoutVersion() ?? ''}`; + + const tooltipContent = useMemo(() => `Copy ${selectedPkgManager} install command`, [selectedPkgManager]); + + const SelectedPkgManagerPlaceholder = useMemo(() => { + switch (selectedPkgManager) { + case 'npm': + return npm-logo; + case 'pnpm': + return pnpm-logo; + case 'yarn': + return yarn-logo; + case 'bit': + return ; + default: + return null; + } + }, [selectedPkgManager]); + + const tabsComponentId: ContentTab[] = [ + { + component: function TabPackageName() { + return {packageNameTabTitle}; + }, + content: isWorkspace ? ( + {packageName} + ) : ( + ( + + {children} + + )} + CopyIcon={ + + + + } + CopyWidget={ +
+ + {SelectedPkgManagerPlaceholder} + +
+ } + clickOutside + clickPlaceholderToggles + position="bottom-start" + clickToggles + > +
+
setSelectedPkgManager('npm')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setSelectedPkgManager('npm')} + aria-label="Select npm package manager" + > + npm-logo +
+
setSelectedPkgManager('pnpm')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setSelectedPkgManager('pnpm')} + aria-label="Select pnpm package manager" + > + pnpm-logo +
+
setSelectedPkgManager('yarn')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setSelectedPkgManager('yarn')} + aria-label="Select yarn package manager" + > + yarn-logo +
+
setSelectedPkgManager('bit')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setSelectedPkgManager('bit')} + aria-label="Select bit package manager" + > + +
+
+ +
+ } + > + {packageName} + + ), + }, + { + component: function TabComponentID() { + return Modify component; + }, + content: isWorkspace ? ( + {componentId ? componentId.toStringWithoutVersion() : ''} + ) : ( + ( + + {children} + + )} + CopyIcon={ +
+
+ +
+
+ +
+
+ } + > + {componentId ? componentId.toStringWithoutVersion() : ''} +
+ ), + }, + ]; + + let finalElementsUrl = elementsUrl; + if (finalElementsUrl && !finalElementsUrl.startsWith('http')) { + const origin = isBrowser ? window.location.origin : undefined; + finalElementsUrl = origin && elementsUrl ? `${origin}${elementsUrl}` : undefined; + if (finalElementsUrl) { + tabsComponentId.push({ + component: function TabElementUrl() { + return Elements url; + }, + content: {finalElementsUrl}, + }); + } + } + + return ( +
+
+ +
+

{displayName}

+
+ +
+ + {abstract && ( + <> + {abstract} + + + )} + + + {labels} + + + +
+ +
+ +
+
+ +
+ ); +} + +export type BadgeSectionProps = { + badges: TitleBadge[] | undefined; + position: BadgePosition; + componentDescriptor?: ComponentDescriptor; + component?: ComponentModel; +}; +export function BadgeSection({ badges, position, componentDescriptor, component }: BadgeSectionProps) { + return ( +
+ {badges + // eslint-disable-next-line react/prop-types + ?.filter((badge) => { + return ( + (position === BadgePosition.Title && !badge.position) || // default position is title + badge.position === position + ); + }) + // @ts-ignore + ?.sort((a, b) => a?.weight - b?.weight) + ?.map((titleBadge, index) => { + return ( + + ); + })} +
+ ); +} diff --git a/components/ui/component-meta/index.ts b/components/ui/component-meta/index.ts new file mode 100644 index 000000000000..671da270d6a6 --- /dev/null +++ b/components/ui/component-meta/index.ts @@ -0,0 +1,2 @@ +export { ComponentOverview, BadgeSection } from './component-overview'; +export type { ComponentOverviewProps, BadgeSectionProps } from './component-overview'; diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/card-overlays.module.scss b/components/ui/components-overview/card-overlays.module.scss similarity index 100% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/card-overlays.module.scss rename to components/ui/components-overview/card-overlays.module.scss diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/card-overlays.tsx b/components/ui/components-overview/card-overlays.tsx similarity index 100% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/card-overlays.tsx rename to components/ui/components-overview/card-overlays.tsx diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-filter-panel.tsx b/components/ui/components-overview/components-overview-filter-panel.tsx similarity index 91% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-filter-panel.tsx rename to components/ui/components-overview/components-overview-filter-panel.tsx index 30dfa26e7281..4cbdcb2a78e0 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-filter-panel.tsx +++ b/components/ui/components-overview/components-overview-filter-panel.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; import { ToggleButton } from '@teambit/design.inputs.toggle-button'; import { BaseFilter } from '@teambit/component.filters.base-filter'; -import type { WorkspaceItem, AggregationType } from './workspace-overview.types'; -import styles from './workspace-overview.module.scss'; +import type { WorkspaceItem, AggregationType } from './components-overview.types'; +import styles from './components-overview.module.scss'; -export interface WorkspaceFilterPanelProps { +export interface ComponentsOverviewFilterPanelProps { aggregation: AggregationType; onAggregationChange: (agg: AggregationType) => void; availableAggregations: AggregationType[]; @@ -21,7 +21,7 @@ const LABELS: Record = { none: 'None', }; -export function WorkspaceFilterPanel({ +export function ComponentsOverviewFilterPanel({ aggregation, onAggregationChange, availableAggregations, @@ -30,7 +30,7 @@ export function WorkspaceFilterPanel({ onNamespacesChange, activeScopes, onScopesChange, -}: WorkspaceFilterPanelProps) { +}: ComponentsOverviewFilterPanelProps) { const namespaceOptions = useMemo( () => [...new Set(items.map((i) => i.component.id.namespace || '/'))].map((v) => ({ diff --git a/components/ui/components-overview/components-overview-skeleton.module.scss b/components/ui/components-overview/components-overview-skeleton.module.scss new file mode 100644 index 000000000000..5537b2577393 --- /dev/null +++ b/components/ui/components-overview/components-overview-skeleton.module.scss @@ -0,0 +1,114 @@ +/* The container, sticky header, command bar, content padding and card grid are reused from + components-overview.module.scss so the skeleton is pixel-identical to the real overview. Only the + placeholder shapes live here. */ + +/* ---- Filter-bar placeholders ---- */ + +.pill { + width: 120px; + height: 32px; + border-radius: 8px; + background: var(--surface01-color, color-mix(in srgb, var(--on-background-color, #000) 6%, transparent)); +} + +.toggle { + margin-left: auto; + width: 96px; + height: 32px; + border-radius: 8px; + background: var(--surface01-color, color-mix(in srgb, var(--on-background-color, #000) 6%, transparent)); +} + +/* ---- Card placeholder — mirrors hope-component-card.module.scss ---- */ + +.card { + position: relative; + border-radius: 10px; + overflow: hidden; + background: var(--surface-color); + border: 1px solid var(--border-medium-color); + animation: fadeIn 0.4s ease both; +} + +.preview { + position: relative; + height: 180px; + overflow: hidden; + background: var(--surface-color); + border-bottom: 1px solid var(--border-medium-color); +} + +.footer { + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.scopeBadge { + width: 20px; + height: 20px; + border-radius: 5px; + flex-shrink: 0; + background: var(--surface02-color, var(--surface01-color)); +} + +.name { + flex: 1; + min-width: 0; + height: 11px; + border-radius: 4px; + background: var(--surface02-color, var(--surface01-color)); + // keep names visually varied so the row doesn't read as a solid bar + max-width: 62%; +} + +.hash { + width: 42px; + height: 11px; + border-radius: 4px; + background: var(--surface01-color); + flex-shrink: 0; +} + +/* ---- Shimmer sweep across the preview block ---- */ + +.shimmer { + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--on-background-color, #000) 4%, transparent) 50%, + transparent 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s ease-in-out infinite; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .card, + .shimmer { + animation: none; + } +} diff --git a/components/ui/components-overview/components-overview-skeleton.tsx b/components/ui/components-overview/components-overview-skeleton.tsx new file mode 100644 index 000000000000..8dce00b3ebc1 --- /dev/null +++ b/components/ui/components-overview/components-overview-skeleton.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import classnames from 'classnames'; +import { ComponentGrid } from '@teambit/explorer.ui.gallery.component-grid'; +import overviewStyles from './components-overview.module.scss'; +import styles from './components-overview-skeleton.module.scss'; + +export type ComponentsOverviewSkeletonProps = { + /** number of placeholder cards to render. default: 12 */ + count?: number; + /** replace the default filter-bar placeholder with custom header content */ + header?: ReactNode; + className?: string; +}; + +/** + * Loading placeholder for the components grid. Renders through the SAME `ComponentGrid` + `.cardGrid` + * class the real `ComponentsOverview` uses, so the column count / card size is pixel-identical and the + * grid doesn't reflow when live data lands. The card body mirrors `HopeComponentCard` (180px preview + * block + footer row with scope badge, name, hash). Shown while the light workspace query is in + * flight; reusable by any view that renders `ComponentsOverview` (workspace overview, lane overview). + */ +export function ComponentsOverviewSkeleton({ count = 12, header, className }: ComponentsOverviewSkeletonProps) { + return ( +
+
+ {header || ( +
+
+ + +
+ +
+ )} +
+
+ + {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+
+ + + +
+
+ ))} + +
+
+ ); +} diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-overview.module.scss b/components/ui/components-overview/components-overview.module.scss similarity index 59% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-overview.module.scss rename to components/ui/components-overview/components-overview.module.scss index 3cf92249f348..04eb8590b1a5 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-overview.module.scss +++ b/components/ui/components-overview/components-overview.module.scss @@ -11,6 +11,14 @@ padding: 20px 40px 80px; } +.stickyHeader { + position: sticky; + top: 0; + z-index: $modal-z-index - 1; + background: color-mix(in srgb, var(--bit-accent-color, #6c5ce7) 3%, var(--background-color, #fff)); + border-bottom: 1px solid var(--border-medium-color); +} + /* ---- Command bar ---- */ .commandBar { @@ -20,11 +28,6 @@ gap: 12px; flex-wrap: wrap; padding: 12px 40px; - background: color-mix(in srgb, var(--bit-accent-color, #6c5ce7) 3%, var(--background-color, #fff)); - border-bottom: 1px solid var(--border-medium-color); - position: sticky; - top: 0; - z-index: $modal-z-index - 1; } .leftCluster { @@ -32,6 +35,7 @@ align-items: center; gap: 8px; flex-wrap: wrap; + min-width: 0; // critical: lets children shrink instead of overflow } .rightCluster { @@ -39,6 +43,7 @@ align-items: center; gap: 8px; margin-left: auto; + min-width: 0; // same reason — the ToggleButton was overflowing instead of wrapping } .aggToggle { @@ -52,6 +57,7 @@ .commandBar :global(.baseFilter) { max-width: 220px; height: 32px; + min-width: 0; // lets the filter shrink with its parent on narrow viewports } .commandBar :global(.control) { @@ -68,6 +74,40 @@ box-shadow: 0 24px 60px -12px rgba(20, 0, 104, 0.2); } +/* ---- Mobile layout ---- + On narrow viewports the filter dropdowns and the aggregation toggle were rendering side-by-side + in a row that couldn't fit, so each was truncating mid-word ("Namespa", "Sco…") and the toggle + was wrapping over the filters. Stack the two clusters vertically and let each take the full + available width. The base-filter `max-width: 220px` cap is removed at this breakpoint so the + pill expands to whatever the parent allows. */ +@media (max-width: 720px) { + .commandBar { + padding: 12px 16px; + flex-direction: column; + align-items: stretch; + } + + .leftCluster, + .rightCluster { + width: 100%; + margin-left: 0; + } + + .leftCluster :global(.baseFilter) { + flex: 1 1 0; + max-width: none; + min-width: 120px; + } + + .rightCluster { + overflow-x: auto; + } + + .aggToggle { + width: 100%; + } +} + /* ---- Section ---- */ .section { @@ -76,7 +116,7 @@ .sectionHeader { position: sticky; - top: 57px; + top: var(--components-overview-sticky-height, 57px); z-index: $nav-z-index; background: var(--background-color); padding: 12px 0 18px; diff --git a/components/ui/components-overview/components-overview.tsx b/components/ui/components-overview/components-overview.tsx new file mode 100644 index 000000000000..3b9f63aa6f8b --- /dev/null +++ b/components/ui/components-overview/components-overview.tsx @@ -0,0 +1,170 @@ +import type { ReactNode } from 'react'; +import React, { useMemo, useRef, useLayoutEffect } from 'react'; +import classnames from 'classnames'; +import { ComponentGrid } from '@teambit/explorer.ui.gallery.component-grid'; +import compact from 'lodash.compact'; +import { ScopeID } from '@teambit/scopes.scope-id'; +import { useCloudScopes } from '@teambit/cloud.hooks.use-cloud-scopes'; +import type { ComponentModel } from '@teambit/component'; +import type { ComponentDescriptor } from '@teambit/component-descriptor'; +import { useComponentsAggregation } from './use-components-aggregation'; +import { useQueryParamWithDefault, useListParamWithDefault } from './use-query-param-with-default'; +import { NamespaceHeader } from './namespace-header'; +import { HopeComponentCard } from './hope-component-card'; +import type { AggregationType, WorkspaceItem } from './components-overview.types'; +import { ComponentsOverviewFilterPanel } from './components-overview-filter-panel'; +import styles from './components-overview.module.scss'; + +export type ComponentsOverviewProps = { + components: ComponentModel[]; + componentDescriptors: ComponentDescriptor[]; + /** link target for a card. Default: `${id.fullName}?scope=${id.scope}` */ + getHref?: (component: ComponentModel) => string; + /** rendered above the filter command bar */ + header?: ReactNode; + /** rendered inside the content container, after the sections */ + footer?: ReactNode; + /** rendered when the filtered set is empty */ + emptyState?: ReactNode; + /** localStorage key prefix for persisted filter/aggregation prefs */ + storageNamespace?: string; + /** forwarded to HopeComponentCard */ + showPreview?: boolean; + className?: string; +}; + +export function ComponentsOverview({ + components, + componentDescriptors, + getHref, + header, + footer, + emptyState, + storageNamespace = 'components-overview', + showPreview, + className, +}: ComponentsOverviewProps) { + const storageKeyPrefix = `${storageNamespace}:`; + + const containerRef = useRef(null); + const stickyRef = useRef(null); + + useLayoutEffect(() => { + const containerEl = containerRef.current; + const stickyEl = stickyRef.current; + if (!containerEl || !stickyEl) return undefined; + const apply = () => { + containerEl.style.setProperty('--components-overview-sticky-height', `${stickyEl.offsetHeight}px`); + }; + apply(); + const observer = new ResizeObserver(apply); + observer.observe(stickyEl); + return () => observer.disconnect(); + }, []); + + const uniqueScopes = useMemo(() => [...new Set(components.map((c) => c.id.scope))], [components]); + const { cloudScopes } = useCloudScopes(uniqueScopes); + const cloudMap = useMemo(() => new Map((cloudScopes || []).map((s) => [s.id.toString(), s])), [cloudScopes]); + const compDescriptorMap = useMemo( + () => new Map(componentDescriptors.map((d) => [d.id.toString(), d])), + [componentDescriptors] + ); + + const items: WorkspaceItem[] = useMemo( + () => + compact( + components.map((component) => { + if (component.deprecation?.isDeprecate) return null; + const descriptor = compDescriptorMap.get(component.id.toString()); + if (!descriptor) return null; + const cloudScope = cloudMap.get(component.id.scope); + const scope = + cloudScope || + (ScopeID.isValid(component.id.scope) && { id: ScopeID.fromString(component.id.scope) }) || + undefined; + return { + component, + componentDescriptor: descriptor, + scope: scope + ? { + id: scope.id, + icon: (scope as any).icon, + backgroundIconColor: (scope as any).backgroundIconColor, + } + : undefined, + }; + }) + ), + [components, compDescriptorMap, cloudMap] + ); + + const [aggregation, setAggregation] = useQueryParamWithDefault('aggregation', 'namespaces', { + storageKeyPrefix, + }); + const [activeNamespaces, setActiveNamespaces] = useListParamWithDefault('ns', { storageKeyPrefix }); + const [activeScopes, setActiveScopes] = useListParamWithDefault('scopes', { storageKeyPrefix }); + + const filters = useMemo( + () => ({ namespaces: activeNamespaces, scopes: activeScopes, statuses: new Set() as any }), + [activeNamespaces, activeScopes] + ); + + const { groups, groupType, availableAggregations, filteredCount } = useComponentsAggregation( + items, + aggregation, + filters + ); + + return ( +
+
+ {header} + + +
+ +
+ {filteredCount === 0 && emptyState} + + {groups.map((group) => ( +
+ {groupType !== 'none' && ( +
+ +
+ )} + + + {group.items.map((item) => ( + + ))} + +
+ ))} + + {footer} +
+
+ ); +} diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-overview.types.ts b/components/ui/components-overview/components-overview.types.ts similarity index 100% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/workspace-overview.types.ts rename to components/ui/components-overview/components-overview.types.ts diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/filter-utils.ts b/components/ui/components-overview/filter-utils.ts similarity index 94% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/filter-utils.ts rename to components/ui/components-overview/filter-utils.ts index 5a5d3615a260..f1d20883d267 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/filter-utils.ts +++ b/components/ui/components-overview/filter-utils.ts @@ -1,4 +1,4 @@ -import type { WorkspaceItem, ComponentStatus } from './workspace-overview.types'; +import type { WorkspaceItem, ComponentStatus } from './components-overview.types'; export interface ActiveFilters { namespaces: string[]; diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/hope-component-card.module.scss b/components/ui/components-overview/hope-component-card.module.scss similarity index 100% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/hope-component-card.module.scss rename to components/ui/components-overview/hope-component-card.module.scss diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/hope-component-card.tsx b/components/ui/components-overview/hope-component-card.tsx similarity index 96% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/hope-component-card.tsx rename to components/ui/components-overview/hope-component-card.tsx index 4aeac2ac1bb5..cbce42686d6a 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/hope-component-card.tsx +++ b/components/ui/components-overview/hope-component-card.tsx @@ -16,6 +16,7 @@ export type HopeComponentCardProps = { componentDescriptor: ComponentDescriptor; scope?: { id: ScopeID; icon?: string; backgroundIconColor?: string }; showPreview?: boolean; + getHref?: (component: ComponentModel) => string; }; export function HopeComponentCard({ @@ -23,6 +24,7 @@ export function HopeComponentCard({ componentDescriptor, scope, showPreview: showPreviewProp, + getHref, }: HopeComponentCardProps) { const [shouldShowPreview, setShouldShowPreview] = useState(Boolean(showPreviewProp)); const prevServerUrlRef = useRef(component.server?.url); @@ -47,7 +49,7 @@ export function HopeComponentCard({ const isQueued = status === 'queued'; const isChanged = status === 'changed'; - const href = `${component.id.fullName}?scope=${component.id.scope}`; + const href = getHref ? getHref(component) : `${component.id.fullName}?scope=${component.id.scope}`; const loadPreviewVisible = component.compositions.length > 0 && !isBuilding && !shouldShowPreview; diff --git a/components/ui/components-overview/index.ts b/components/ui/components-overview/index.ts new file mode 100644 index 000000000000..be0261814a92 --- /dev/null +++ b/components/ui/components-overview/index.ts @@ -0,0 +1,18 @@ +export { ComponentsOverview } from './components-overview'; +export type { ComponentsOverviewProps } from './components-overview'; +export { ComponentsOverviewSkeleton } from './components-overview-skeleton'; +export type { ComponentsOverviewSkeletonProps } from './components-overview-skeleton'; +export { HopeComponentCard } from './hope-component-card'; +export type { HopeComponentCardProps } from './hope-component-card'; +export { NamespaceHeader } from './namespace-header'; +export { ComponentsOverviewFilterPanel } from './components-overview-filter-panel'; +export { useComponentsAggregation } from './use-components-aggregation'; +export { useQueryParamWithDefault, useListParamWithDefault } from './use-query-param-with-default'; +export type { + WorkspaceItem, + AggregationType, + AggregationGroup, + AggregationResult, + ComponentStatus, + Density, +} from './components-overview.types'; diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-header.module.scss b/components/ui/components-overview/namespace-header.module.scss similarity index 100% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-header.module.scss rename to components/ui/components-overview/namespace-header.module.scss diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-header.tsx b/components/ui/components-overview/namespace-header.tsx similarity index 96% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-header.tsx rename to components/ui/components-overview/namespace-header.tsx index cd33f22e877a..116ac07e1cec 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-header.tsx +++ b/components/ui/components-overview/namespace-header.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { getComponentStatus } from './filter-utils'; -import type { WorkspaceItem } from './workspace-overview.types'; +import type { WorkspaceItem } from './components-overview.types'; import styles from './namespace-header.module.scss'; export interface NamespaceHeaderProps { diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-sort.ts b/components/ui/components-overview/namespace-sort.ts similarity index 95% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-sort.ts rename to components/ui/components-overview/namespace-sort.ts index 4b12b2f8c818..1f01ff8eb2db 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/namespace-sort.ts +++ b/components/ui/components-overview/namespace-sort.ts @@ -1,4 +1,4 @@ -import type { WorkspaceItem } from './workspace-overview.types'; +import type { WorkspaceItem } from './components-overview.types'; export const PRIORITY_HIGH = ['ui', 'pages'] as string[]; export const PRIORITY_MED = ['design'] as string[]; diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/use-workspace-aggregation.ts b/components/ui/components-overview/use-components-aggregation.ts similarity index 95% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/use-workspace-aggregation.ts rename to components/ui/components-overview/use-components-aggregation.ts index 5cd9eab9e747..b414ebf12a07 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/use-workspace-aggregation.ts +++ b/components/ui/components-overview/use-components-aggregation.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { sortNamespacesAdvanced, sortItemsByNamespace } from './namespace-sort'; import { filterItems, type ActiveFilters } from './filter-utils'; -import type { WorkspaceItem, AggregationType, AggregationGroup, AggregationResult } from './workspace-overview.types'; +import type { WorkspaceItem, AggregationType, AggregationGroup, AggregationResult } from './components-overview.types'; -export function useWorkspaceAggregation( +export function useComponentsAggregation( items: WorkspaceItem[], aggregation: AggregationType, filters: ActiveFilters diff --git a/scopes/workspace/workspace/ui/workspace/workspace-overview/use-query-param-with-default.ts b/components/ui/components-overview/use-query-param-with-default.ts similarity index 85% rename from scopes/workspace/workspace/ui/workspace/workspace-overview/use-query-param-with-default.ts rename to components/ui/components-overview/use-query-param-with-default.ts index 747ef59c70d0..f09a05656157 100644 --- a/scopes/workspace/workspace/ui/workspace/workspace-overview/use-query-param-with-default.ts +++ b/components/ui/components-overview/use-query-param-with-default.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; -const STORAGE_KEY_PREFIX = 'workspace-overview:'; +const DEFAULT_STORAGE_KEY_PREFIX = 'components-overview:'; export interface QueryParamOptions { /** persist value to localStorage across sessions. Default: true */ @@ -11,11 +11,11 @@ export interface QueryParamOptions { export function useQueryParamWithDefault( paramName: string, fallback: T, - options: QueryParamOptions = {} + options: QueryParamOptions & { storageKeyPrefix?: string } = {} ): [T, (value: T | null) => void] { - const { persist = true } = options; + const { persist = true, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX } = options; const [searchParams, setSearchParams] = useSearchParams(); - const storageKey = STORAGE_KEY_PREFIX + paramName; + const storageKey = storageKeyPrefix + paramName; const value = useMemo((): T => { const urlValue = searchParams.get(paramName); @@ -51,11 +51,11 @@ export function useQueryParamWithDefault( export function useListParamWithDefault( paramName: string, - options: QueryParamOptions = {} + options: QueryParamOptions & { storageKeyPrefix?: string } = {} ): [string[], (values: string[]) => void] { - const { persist = false } = options; + const { persist = false, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX } = options; const [searchParams, setSearchParams] = useSearchParams(); - const storageKey = STORAGE_KEY_PREFIX + paramName; + const storageKey = storageKeyPrefix + paramName; const values = useMemo((): string[] => { const urlValue = searchParams.get(paramName); diff --git a/components/ui/deps-diff-table/deps-diff-table.module.scss b/components/ui/deps-diff-table/deps-diff-table.module.scss new file mode 100644 index 000000000000..0b3b5abaddbf --- /dev/null +++ b/components/ui/deps-diff-table/deps-diff-table.module.scss @@ -0,0 +1,114 @@ +$border-color: var(--border-medium-color, #e8ecf0); +$text-primary: var(--on-surface-color, #2b2b2b); +$text-secondary: var(--on-surface-medium-color, #707279); +$text-muted: var(--on-background-medium-color, #a0aec0); +$code-font: 'JetBrains Mono', ui-monospace, monospace; + +.container { + overflow-x: auto; + width: 100%; +} + +.empty { + padding: 16px; + text-align: center; + color: $text-muted; + font-size: 14px; + font-style: italic; +} + +.table { + width: 100%; + border-collapse: collapse; + color: $text-primary; + text-align: left; + white-space: pre; +} + +.headerRow { + th { + padding: 12px; + font-size: 16px; + font-weight: 500; + line-height: 24px; + vertical-align: top; + border-bottom: 1px solid $border-color; + } +} + +.row { + td { + padding: 12px; + font-size: 16px; + line-height: 24px; + font-weight: 400; + vertical-align: middle; + border-bottom: 1px solid var(--border-light-color, rgba(0, 0, 0, 0.04)); + } + + &:hover td { + background: var(--surface-neutral-hover-color, rgba(0, 0, 0, 0.015)); + } +} + +.statusCol { + width: 32px; + text-align: center; +} + +.depCol { + // takes remaining space +} + +.depId { + color: $text-primary; + font-size: 14px; +} + +.depLifecycle { + margin-left: 8px; + font-size: 14px; + padding: 4px; + border-radius: 8px; + background: var(--surface-neutral-color, #f4f5f6); + border: 1px solid var(--border-light-color, #ededed); + color: $text-secondary; +} + +.versionCol { + font-size: 14px; + color: $text-secondary; + min-width: 100px; +} + +.new { + color: var(--positive-color, #37b26c); +} +.deleted { + color: var(--negative-color, #e62e5c); +} +.modified { + color: var(--process-color, #0984e3); +} + +.versionWithIcon { + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.fixedWidthVersion { + display: inline-block; + min-width: 80px; +} + +.compareUrl { + display: inline-flex; + text-decoration: none; +} + +.compareIcon { + display: inline-flex; + margin-left: 12px; + vertical-align: middle; +} diff --git a/components/ui/deps-diff-table/deps-diff-table.tsx b/components/ui/deps-diff-table/deps-diff-table.tsx new file mode 100644 index 000000000000..6cad9163d712 Binary files /dev/null and b/components/ui/deps-diff-table/deps-diff-table.tsx differ diff --git a/components/ui/deps-diff-table/index.ts b/components/ui/deps-diff-table/index.ts new file mode 100644 index 000000000000..cdfddad8df67 --- /dev/null +++ b/components/ui/deps-diff-table/index.ts @@ -0,0 +1,2 @@ +export { DepsDiffTable, computeDepsDiff } from './deps-diff-table'; +export type { DepsDiffTableProps, DepDiffEntry, DepStatus, RawDep } from './deps-diff-table'; diff --git a/components/ui/diff-viewer/diff-model.ts b/components/ui/diff-viewer/diff-model.ts new file mode 100644 index 000000000000..eef3afe82a42 --- /dev/null +++ b/components/ui/diff-viewer/diff-model.ts @@ -0,0 +1,287 @@ +import { diffLines } from 'diff'; + +export type DiffLineType = 'context' | 'add' | 'del'; + +/** one line in the diff, carrying its old/new line numbers and any intra-line changed char ranges. */ +export type DiffLineItem = { + type: DiffLineType; + oldLn?: number; + newLn?: number; + text: string; + /** half-open `[start, end)` character ranges that actually changed (paired add/del lines only). */ + intra?: Array<[number, number]>; +}; + +/** a contiguous block of rendered lines, or a collapsed gap of unchanged lines that can be expanded. */ +export type DiffSection = + | { kind: 'lines'; items: DiffLineItem[] } + | { kind: 'gap'; id: string; hidden: DiffLineItem[] }; + +export type DiffStats = { additions: number; deletions: number }; + +const DEFAULT_CONTEXT = 3; + +/** + * Compute a full, line-aligned diff of two file contents — every line is present (context included), + * with old/new line numbers assigned and intra-line changed ranges filled in for paired add/del + * lines. Keeping the complete line list (rather than only hunks) lets collapsed gaps be expanded + * later without recomputing. + */ +export function computeDiffLines(oldContent: string, newContent: string): DiffLineItem[] { + const parts = diffLines(oldContent ?? '', newContent ?? ''); + const items: DiffLineItem[] = []; + let oldLn = 1; + let newLn = 1; + + for (const part of parts) { + const lines = part.value.split('\n'); + // a trailing newline produces a spurious empty final element — drop it. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + for (const text of lines) { + if (part.added) items.push({ type: 'add', newLn: newLn++, text }); + else if (part.removed) items.push({ type: 'del', oldLn: oldLn++, text }); + else items.push({ type: 'context', oldLn: oldLn++, newLn: newLn++, text }); + } + } + + fillIntraLineRanges(items); + return items; +} + +export function statsFromItems(items: DiffLineItem[]): DiffStats { + let additions = 0; + let deletions = 0; + for (const it of items) { + if (it.type === 'add') additions++; + else if (it.type === 'del') deletions++; + } + return { additions, deletions }; +} + +/** + * Within each change block (a run of deletions immediately followed by a run of additions), pair + * `del[k]` with `add[k]` and compute character-level changes so a one-character edit is visible + * instead of two near-identical lines (GitHub's intra-line highlight). + */ +function fillIntraLineRanges(items: DiffLineItem[]): void { + let i = 0; + while (i < items.length) { + if (items[i].type !== 'del') { + i++; + continue; + } + let d = i; + while (d < items.length && items[d].type === 'del') d++; + let a = d; + while (a < items.length && items[a].type === 'add') a++; + const dels = items.slice(i, d); + const adds = items.slice(d, a); + const pairs = Math.min(dels.length, adds.length); + for (let k = 0; k < pairs; k++) { + const { delRanges, addRanges } = intraLineDiff(dels[k].text, adds[k].text); + // skip the highlight when essentially the whole line changed — it's just noise then. + if (!coversWholeLine(delRanges, dels[k].text.length)) dels[k].intra = delRanges; + if (!coversWholeLine(addRanges, adds[k].text.length)) adds[k].intra = addRanges; + } + i = a > i ? a : i + 1; + } +} + +function coversWholeLine(ranges: Array<[number, number]>, len: number): boolean { + if (len === 0) return true; + const changed = ranges.reduce((sum, [s, e]) => sum + (e - s), 0); + return changed >= len * 0.9; +} + +/** split a line into word / whitespace / punctuation tokens so the diff aligns on real boundaries. */ +function tokenizeLine(s: string): string[] { + return s.match(/[A-Za-z0-9_$]+|\s+|[^A-Za-z0-9_$\s]/g) || []; +} + +// Cap the intra-line LCS: it allocates an (m+1)×(n+1) matrix in the token counts of the two lines, so a +// pathological line (minified/generated, thousands of tokens) would build a multi-million-cell table and +// stall — or crash — the viewer, and this runs for every del/add pair in a block. Past either cap we skip +// character-level highlighting for that pair; the line still renders as fully changed, just without +// intra-line ranges. +const MAX_INTRA_LINE_CHARS = 2000; +const MAX_INTRA_LINE_CELLS = 1_000_000; + +/** + * Token-level (word) diff of two lines via an LCS walk, returning the half-open character ranges that + * changed on each side. Adjacent changed ranges are merged so the highlight reads as one span. Lines + * past a size cap fall back to no intra-line ranges to keep the computation bounded. + */ +export function intraLineDiff( + from: string, + to: string +): { delRanges: Array<[number, number]>; addRanges: Array<[number, number]> } { + if (from.length > MAX_INTRA_LINE_CHARS || to.length > MAX_INTRA_LINE_CHARS) { + return { delRanges: [], addRanges: [] }; + } + const a = tokenizeLine(from); + const b = tokenizeLine(to); + const m = a.length; + const n = b.length; + if (m * n > MAX_INTRA_LINE_CELLS) { + return { delRanges: [], addRanges: [] }; + } + const dp: number[][] = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0)); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const delRanges: Array<[number, number]> = []; + const addRanges: Array<[number, number]> = []; + let i = 0; + let j = 0; + let aOff = 0; + let bOff = 0; + const pushChanged = (ranges: Array<[number, number]>, off: number, tok: string) => { + if (!tok.trim()) return; // don't emphasize whitespace-only tokens + const last = ranges[ranges.length - 1]; + if (last && last[1] === off) last[1] = off + tok.length; + else ranges.push([off, off + tok.length]); + }; + while (i < m && j < n) { + if (a[i] === b[j]) { + aOff += a[i].length; + bOff += b[j].length; + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + pushChanged(delRanges, aOff, a[i]); + aOff += a[i].length; + i++; + } else { + pushChanged(addRanges, bOff, b[j]); + bOff += b[j].length; + j++; + } + } + while (i < m) { + pushChanged(delRanges, aOff, a[i]); + aOff += a[i].length; + i++; + } + while (j < n) { + pushChanged(addRanges, bOff, b[j]); + bOff += b[j].length; + j++; + } + return { delRanges, addRanges }; +} + +/** + * Group the full line list into rendered line-blocks and collapsible gaps of unchanged lines. Long + * stretches of unchanged code between changes are collapsed to `context` lines on each side; the rest + * becomes a `gap` that can be expanded on demand. + */ +export function buildSections(items: DiffLineItem[], context: number = DEFAULT_CONTEXT): DiffSection[] { + const changedIdx: number[] = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type !== 'context') changedIdx.push(i); + } + if (changedIdx.length === 0) { + // an unchanged file: collapse everything into one expandable gap. + return items.length ? [{ kind: 'gap', id: 'gap-all', hidden: items }] : []; + } + + const sections: DiffSection[] = []; + const first = changedIdx[0]; + const last = changedIdx[changedIdx.length - 1]; + + // leading unchanged region (file start → first change) + emitUnchanged(sections, items, 0, first, context, 'lead'); + + // changed region plus interleaved unchanged gaps + let cursor = first; + while (cursor <= last) { + // emit the maximal run of "visible" lines starting at cursor: changed lines plus short gaps. + const runStart = cursor; + let runEnd = cursor; + while (runEnd <= last) { + if (items[runEnd].type !== 'context') { + runEnd++; + continue; + } + // measure the unchanged stretch + let u = runEnd; + while (u <= last && items[u].type === 'context') u++; + const gapLen = u - runEnd; + if (gapLen <= context * 2) { + runEnd = u; // short gap — keep inline + } else { + break; // long gap — end this run, collapse below + } + } + sections.push({ kind: 'lines', items: items.slice(runStart, runEnd) }); + if (runEnd > last) { + cursor = runEnd; + break; + } + // collapse the long unchanged stretch, keeping `context` lines on each side + let u = runEnd; + while (u <= last && items[u].type === 'context') u++; + const keepTopEnd = runEnd + context; + const keepBottomStart = u - context; + sections.push({ kind: 'lines', items: items.slice(runEnd, keepTopEnd) }); + sections.push({ kind: 'gap', id: `gap-${runEnd}`, hidden: items.slice(keepTopEnd, keepBottomStart) }); + sections.push({ kind: 'lines', items: items.slice(keepBottomStart, u) }); + cursor = u; + } + + // trailing unchanged region (last change → file end) + emitUnchanged(sections, items, last + 1, items.length, context, 'trail'); + + return sections; +} + +function emitUnchanged( + sections: DiffSection[], + items: DiffLineItem[], + start: number, + end: number, + context: number, + edge: 'lead' | 'trail' +): void { + const len = end - start; + if (len <= 0) return; + if (len <= context) { + sections.push({ kind: 'lines', items: items.slice(start, end) }); + return; + } + if (edge === 'lead') { + sections.push({ kind: 'gap', id: `gap-${start}`, hidden: items.slice(start, end - context) }); + sections.push({ kind: 'lines', items: items.slice(end - context, end) }); + } else { + sections.push({ kind: 'lines', items: items.slice(start, start + context) }); + sections.push({ kind: 'gap', id: `gap-${start}`, hidden: items.slice(start + context, end) }); + } +} + +export type SplitRow = { left?: DiffLineItem; right?: DiffLineItem }; + +/** + * Pair lines for side-by-side rendering: deletions align to the left column, additions to the right, + * context lines occupy both. Unbalanced add/del runs leave an empty cell on the shorter side. + */ +export function pairForSplit(items: DiffLineItem[]): SplitRow[] { + const rows: SplitRow[] = []; + let i = 0; + while (i < items.length) { + const it = items[i]; + if (it.type === 'context') { + rows.push({ left: it, right: it }); + i++; + continue; + } + const dels: DiffLineItem[] = []; + const adds: DiffLineItem[] = []; + while (i < items.length && items[i].type === 'del') dels.push(items[i++]); + while (i < items.length && items[i].type === 'add') adds.push(items[i++]); + const max = Math.max(dels.length, adds.length); + for (let k = 0; k < max; k++) rows.push({ left: dels[k], right: adds[k] }); + } + return rows; +} diff --git a/components/ui/diff-viewer/diff-viewer.composition.tsx b/components/ui/diff-viewer/diff-viewer.composition.tsx new file mode 100644 index 000000000000..79fce887404f --- /dev/null +++ b/components/ui/diff-viewer/diff-viewer.composition.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { DiffViewer } from './diff-viewer'; + +const wrap: React.CSSProperties = { padding: 24, maxWidth: 1100, fontFamily: 'sans-serif' }; + +// --- TypeScript sample (modified) --- + +const tsOld = `import { useState } from 'react'; + +export type CounterProps = { + initial: number; +}; + +export function Counter({ initial }: CounterProps) { + const [count, setCount] = useState(initial); + // increment by one + const inc = () => setCount(count + 1); + return ; +} +`; + +const tsNew = `import { useCallback, useState } from 'react'; + +export type CounterProps = { + initial: number; + step?: number; +}; + +export function Counter({ initial, step = 1 }: CounterProps) { + const [count, setCount] = useState(initial); + // increment by the configured step + const inc = useCallback(() => setCount((c) => c + step), [step]); + return ; +} +`; + +export const TypeScriptDiffSplit = () => ( +
+ +
+); + +export const TypeScriptDiffUnified = () => ( +
+ +
+); + +// --- SCSS sample (intra-line + multi-language) --- + +const scssOld = `.card { + border-radius: 8px; + padding: 12px; + color: #333; +} +`; +const scssNew = `.card { + border-radius: 10px; + padding: 16px 12px; + color: var(--on-surface-color, #2b2b2b); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} +`; + +export const ScssDiff = () => ( +
+ +
+); + +// --- Python sample --- + +const pyOld = `def greet(name): + print("Hello, " + name) + + +def main(): + greet("world") +`; +const pyNew = `def greet(name: str) -> None: + print(f"Hello, {name}!") + + +def main() -> None: + for who in ["world", "bit"]: + greet(who) +`; + +export const PythonDiff = () => ( +
+ +
+); + +// --- New file --- + +export const NewFile = () => ( +
+ +
+); + +// --- Large diff (virtualization + expand context) --- + +function makeLargeOld(): string { + const lines: string[] = []; + for (let i = 1; i <= 600; i++) lines.push(`export const value${i} = ${i};`); + return lines.join('\n'); +} +function makeLargeNew(): string { + const lines: string[] = []; + for (let i = 1; i <= 600; i++) { + if (i === 120) lines.push(`export const value${i} = ${i} * 2; // doubled`); + else if (i === 400) lines.push(`export const value${i} = ${i} + 100;`); + else lines.push(`export const value${i} = ${i};`); + } + return lines.join('\n'); +} + +export const LargeVirtualizedDiff = () => ( +
+ +
+); diff --git a/components/ui/diff-viewer/diff-viewer.module.scss b/components/ui/diff-viewer/diff-viewer.module.scss new file mode 100644 index 000000000000..8e6d4fb968bd --- /dev/null +++ b/components/ui/diff-viewer/diff-viewer.module.scss @@ -0,0 +1,430 @@ +$code-font: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + +$border: var(--border-medium-color, #ededed); +$surface: var(--surface-color, #fff); +$surface-1: var(--surface01-color, #fffbff); +$surface-neutral: var(--surface-neutral-color, #f8f9fb); +$text: var(--on-surface-color, #2b2b2b); +$text-medium: var(--on-background-medium-color, #707279); +$text-muted: var(--on-surface-medium-color, #9598a1); +$accent: var(--bit-accent-color, #6c5ce7); +$positive: var(--positive-color, #37b26c); +$negative: var(--negative-color, #e62e5c); +$add-tint: var(--positive-surface-color, #f3faf6); +$del-tint: var(--negative-surface-color, #fdf2f5); + +.diffViewer { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + background: $surface; + font-family: $code-font; + color: $text; + // CRITICAL containment: size the diff-viewer's width from its container only, ignoring its (wide) + // code content, AND stop that content from contributing to any ancestor's intrinsic width. Without + // this, a long code line makes the whole element — and the page — grow horizontally instead of the + // body scrolling internally. `contain: inline-size` pins the width to the parent regardless of how + // wide the diff is, so the wide track can only scroll inside `.body`. + contain: inline-size; + width: 100%; +} + +// --- header --- + +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: $surface-neutral; + border-bottom: 1px solid $border; +} + +.collapseBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: $text-medium; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, $accent 10%, transparent); + color: $accent; + } +} + +.chevron { + transition: transform 0.15s ease; +} +.chevronOpen { + transform: rotate(90deg); +} + +.fileName { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + color: $text; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.statusChip { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 5px; + flex-shrink: 0; +} +.status_new { + color: $positive; + background: color-mix(in srgb, $positive 12%, transparent); +} +.status_deleted { + color: $negative; + background: color-mix(in srgb, $negative 12%, transparent); +} +.status_modified { + color: var(--warning-color, #d6a022); + background: color-mix(in srgb, var(--warning-color, #d6a022) 14%, transparent); +} +.status_renamed { + color: $accent; + background: color-mix(in srgb, $accent 12%, transparent); +} + +.stats { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + margin-left: auto; + flex-shrink: 0; +} +.add { + color: $positive; +} +.del { + color: $negative; +} + +.changeBar { + display: flex; + gap: 2px; +} +.barAdd, +.barDel, +.barNeutral { + width: 7px; + height: 7px; + border-radius: 2px; +} +.barAdd { + background: $positive; +} +.barDel { + background: $negative; +} +.barNeutral { + background: $border; +} + +.viewToggle { + display: inline-flex; + padding: 2px; + border-radius: 8px; + background: color-mix(in srgb, $text-medium 8%, transparent); + flex-shrink: 0; +} +.toggleBtn { + border: none; + background: transparent; + color: $text-medium; + font-size: 12px; + font-weight: 600; + font-family: inherit; + padding: 3px 10px; + border-radius: 6px; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; + + &:hover { + color: $text; + } +} +.toggleActive { + background: $surface; + color: $accent; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + + &:hover { + color: $accent; + } +} + +// --- body --- + +.body { + overflow: auto; + background: $surface; + font-size: 12.5px; + // sticky gutters need a known scroll context + position: relative; + // allow the body to shrink inside flex/grid hosts so it scrolls its own (wide) content + // horizontally instead of forcing the page wider. + min-width: 0; +} + +// Unified: the track grows to the longest line so the body scrolls it horizontally (one scrollbar). +.track { + width: max-content; + min-width: 100%; +} + +// Split: pin the track to the pane width so the two `minmax(0, 1fr)` columns resolve to a strict +// 50/50 (both sides always visible, no whole-diff horizontal scroll). Long lines scroll inside their +// own half via `.splitCell .code { overflow-x: auto }`. Without this the `width: max-content` above +// would size the track to the content and each 1fr column would balloon to the longest line. +.trackSplit { + width: 100%; +} + +// Wrap mode: long lines soft-wrap instead of scrolling — no horizontal scroll anywhere, both split +// sides stay at a strict 50%, and every character stays visible. Rows grow to fit the wrapped text. +.wrap { + .track { + width: 100%; + } + + .unifiedRow { + grid-template-columns: 50px 50px 22px minmax(0, 1fr); + } + + .row { + height: auto; + min-height: var(--diff-row-h, 22px); + align-items: stretch; + } + + .code { + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow: visible; + padding-top: 1px; + padding-bottom: 1px; + } + + // no per-side horizontal scrollbars when wrapping + .splitCell .code { + overflow-x: visible; + } +} + +// --- rows (fixed height for clean virtualization) --- + +.row { + height: var(--diff-row-h, 22px); + line-height: var(--diff-row-h, 22px); + background-color: $surface; + // fill the track so line backgrounds span the full width even when the code is narrow. + width: 100%; +} + +// `--diff-code-width` (the longest line, in ch) is the code column's MIN so every line fits and all +// rows share one width (alignment); `1fr` lets it grow to fill the container when the code is +// narrower than the pane. Narrow → fills width; wide → the body scrolls. Same shared min keeps the +// virtualized rows aligned. +.unifiedRow { + display: grid; + grid-template-columns: 50px 50px 22px minmax(var(--diff-code-width, 60ch), 1fr); +} + +// Split view: each side is a strict 50% so BOTH are always visible and the row never exceeds the +// pane (no page overflow regardless of line length). Long lines scroll inside their own side. +.splitRow { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.splitCell { + display: grid; + grid-template-columns: 50px minmax(0, 1fr); + background-color: inherit; + min-width: 0; + + &:first-child { + border-right: 1px solid $border; + } + + // a long line scrolls horizontally within its half (overlay scrollbar on macOS, thin elsewhere) + // instead of widening the row / page. + .code { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + } +} + +.emptyCell { + background: color-mix(in srgb, $text-medium 4%, transparent); +} + +.gutter { + background: inherit; + color: var(--diff-line-num, $text-muted); + text-align: right; + padding: 0 8px; + font-size: 11px; + user-select: none; + white-space: nowrap; + overflow: hidden; +} + +.sign { + text-align: center; + padding: 0; + font-weight: 700; +} + +// unified-view line-number gutters stay pinned while the code scrolls horizontally. +// each sticky column needs an increasing `left` offset so they don't stack on top of each other. +.unifiedRow { + .gutterNum:nth-of-type(1) { + position: sticky; + left: 0; + z-index: 2; + } + .gutterNum:nth-of-type(2) { + position: sticky; + left: 50px; + z-index: 2; + } + .sign { + position: sticky; + left: 100px; + z-index: 2; + } +} + +.code { + font-family: $code-font; + white-space: pre; + padding: 0 10px; + color: $text; + tab-size: 2; + overflow: hidden; +} + +// add / del tints (subtle, Bit-flavored — not GitHub greens/reds) + +.addLine { + background-color: $add-tint; + + .sign { + color: $positive; + } + &.unifiedRow .gutterNum:last-of-type, + .gutterNum { + color: color-mix(in srgb, $positive 70%, $text-muted); + } +} + +.delLine { + background-color: $del-tint; + + .sign { + color: $negative; + } + .gutterNum { + color: color-mix(in srgb, $negative 70%, $text-muted); + } +} + +// left accent rail on changed lines +.unifiedRow.addLine, +.splitCell.addLine { + box-shadow: inset 2px 0 0 $positive; +} +.unifiedRow.delLine, +.splitCell.delLine { + box-shadow: inset 2px 0 0 $negative; +} + +// intra-line (word-level) change emphasis +.intra { + background: color-mix(in srgb, $accent 16%, transparent); + border-radius: 2px; + padding: 1px 0; +} +.addLine .intra { + background: color-mix(in srgb, $positive 26%, transparent); +} +.delLine .intra { + background: color-mix(in srgb, $negative 26%, transparent); +} + +// --- expand-context (gap) rows --- + +.gapRow { + display: grid; + grid-template-columns: 50px 1fr; + align-items: center; + background: color-mix(in srgb, $accent 4%, $surface); + border-top: 1px solid color-mix(in srgb, $border 70%, transparent); + border-bottom: 1px solid color-mix(in srgb, $border 70%, transparent); + color: $text-medium; +} + +.gapControls { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + height: 100%; +} + +.gapBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 11px; + padding: 0; + border: none; + background: transparent; + color: $accent; + cursor: pointer; + opacity: 0.7; + + &:hover { + opacity: 1; + } +} + +.gapLabel { + justify-self: start; + border: none; + background: transparent; + color: $text-medium; + font-family: $code-font; + font-size: 11.5px; + padding: 0 10px; + cursor: pointer; + letter-spacing: 0.01em; + + &:hover { + color: $accent; + text-decoration: underline; + } +} diff --git a/components/ui/diff-viewer/diff-viewer.tsx b/components/ui/diff-viewer/diff-viewer.tsx new file mode 100644 index 000000000000..b5c2c370d7c9 --- /dev/null +++ b/components/ui/diff-viewer/diff-viewer.tsx @@ -0,0 +1,631 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { DiffLineItem, DiffSection } from './diff-model'; +import { computeDiffLines, buildSections, statsFromItems, pairForSplit } from './diff-model'; +import type { HlLines, HlToken } from './highlighter'; +import { useHighlightedLines, langFromFileName } from './highlighter'; +import { resolveTokenColor } from './shiki-bit-theme'; +import styles from './diff-viewer.module.scss'; + +export type DiffViewMode = 'unified' | 'split'; +export type DiffFileStatus = 'new' | 'deleted' | 'modified' | 'renamed'; + +export type DiffViewerProps = { + /** path shown in the header; also used to infer the language when `language` is omitted. */ + fileName: string; + /** original (base) file content. */ + oldContent: string; + /** modified (compare) file content. */ + newContent: string; + /** language id override (otherwise inferred from the file extension). */ + language?: string; + /** controlled view mode. */ + view?: DiffViewMode; + /** initial view mode when uncontrolled. */ + defaultView?: DiffViewMode; + onViewChange?: (view: DiffViewMode) => void; + status?: DiffFileStatus; + /** allow collapsing the whole file body from the header. */ + collapsible?: boolean; + defaultCollapsed?: boolean; + /** unchanged context lines kept around each change before collapsing (default 3). */ + contextLines?: number; + /** max body height before the diff scrolls + virtualizes internally (default 640). */ + maxHeight?: number; + /** virtualize + inner-scroll the body past `maxHeight` (default true). Set false to render the + * file fully expanded with no inner scroll — e.g. when stacking many files under one page scroll. */ + virtualize?: boolean; + /** render the file header bar (default true). */ + showHeader?: boolean; + /** show the per-file Unified/Split toggle in the header (default true). Set false when the host + * provides a single global view-mode control for all files. */ + showViewToggle?: boolean; + /** soft-wrap long lines instead of scrolling them horizontally (default false). Best for stacked, + * non-virtualized diffs (e.g. a changes view) where per-line/side scrollbars would be noise. */ + wrap?: boolean; + className?: string; +}; + +const ROW_H = 22; +const VIRTUALIZE_THRESHOLD = 120; +const OVERSCAN = 14; +const EXPAND_CHUNK = 20; + +type GapState = { top: number; bottom: number }; + +/** a single rendered row: a code line (or a left/right pair) or a collapsed-gap expander. */ +type RenderRow = + | { kind: 'unified'; item: DiffLineItem; key: string } + | { kind: 'split'; left?: DiffLineItem; right?: DiffLineItem; key: string } + | { kind: 'gap'; id: string; hidden: DiffLineItem[]; state: GapState; key: string }; + +export function DiffViewer({ + fileName, + oldContent, + newContent, + language, + view: controlledView, + defaultView = 'split', + onViewChange, + status = 'modified', + collapsible = true, + defaultCollapsed = false, + contextLines = 3, + maxHeight = 640, + virtualize = true, + showHeader = true, + showViewToggle = true, + wrap = false, + className, +}: DiffViewerProps) { + const [uncontrolledView, setUncontrolledView] = useState(defaultView); + const view = controlledView ?? uncontrolledView; + const setView = useCallback( + (next: DiffViewMode) => { + if (controlledView === undefined) setUncontrolledView(next); + onViewChange?.(next); + }, + [controlledView, onViewChange] + ); + + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const [gapStates, setGapStates] = useState>({}); + + const lang = language ?? langFromFileName(fileName); + + const items = useMemo(() => computeDiffLines(oldContent, newContent), [oldContent, newContent]); + const stats = useMemo(() => statsFromItems(items), [items]); + const sections = useMemo(() => buildSections(items, contextLines), [items, contextLines]); + + // tokenize each whole file once; multi-line constructs stay correct and lines are looked up by number. + const oldHl = useHighlightedLines(oldContent, lang); + const newHl = useHighlightedLines(newContent, lang); + + // a stable horizontal track width so virtualized rows align and the gutters can stay sticky. + const codeWidthCh = useMemo(() => { + let max = 0; + for (const it of items) if (it.text.length > max) max = it.text.length; + return Math.min(Math.max(max, 40), 400); + }, [items]); + + const expandGap = useCallback((id: string, hiddenLen: number, dir: 'top' | 'bottom' | 'all') => { + setGapStates((prev) => { + const cur = prev[id] ?? { top: 0, bottom: 0 }; + const remaining = hiddenLen - cur.top - cur.bottom; + if (remaining <= 0) return prev; + if (dir === 'all') return { ...prev, [id]: { top: cur.top + remaining, bottom: cur.bottom } }; + const add = Math.min(EXPAND_CHUNK, remaining); + if (dir === 'top') return { ...prev, [id]: { ...cur, top: cur.top + add } }; + return { ...prev, [id]: { ...cur, bottom: cur.bottom + add } }; + }); + }, []); + + const rows = useMemo(() => buildRenderRows(sections, gapStates, view), [sections, gapStates, view]); + + return ( +
+ {showHeader && ( + setCollapsed((c) => !c)} + /> + )} + {!collapsed && ( + + )} +
+ ); +} + +// --- Header --- + +function DiffHeader({ + fileName, + status, + additions, + deletions, + view, + onViewChange, + showViewToggle, + collapsible, + collapsed, + onToggleCollapse, +}: { + fileName: string; + status: DiffFileStatus; + additions: number; + deletions: number; + view: DiffViewMode; + onViewChange: (v: DiffViewMode) => void; + showViewToggle: boolean; + collapsible: boolean; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const statusLabel = status[0].toUpperCase() + status.slice(1); + return ( +
+ {collapsible && ( + + )} + {fileName} + {statusLabel} +
+ {additions > 0 && +{additions}} + {deletions > 0 && −{deletions}} + +
+ {showViewToggle && ( +
+ + +
+ )} +
+ ); +} + +// --- Body + virtualization --- + +const Row = React.memo(function Row({ + row, + view, + oldHl, + newHl, + onExpand, +}: { + row: RenderRow; + view: DiffViewMode; + oldHl: HlLines | null; + newHl: HlLines | null; + onExpand: (id: string, hiddenLen: number, dir: 'top' | 'bottom' | 'all') => void; +}) { + if (row.kind === 'gap') { + return