From 8e13b627108ed4a41e33a5197572cfb052a66222 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Mon, 14 Sep 2026 22:25:09 -0400 Subject: [PATCH 01/17] fix(ui): migrate remaining Monaco views to Shiki --- .bitmap | 67 +++- .../api-node-details.module.scss | 92 +++++ .../api-node-details/api-node-details.tsx | 108 ++++++ .../extract-code-block.spec.ts | 21 ++ .../api-node-details/extract-code-block.ts | 23 ++ .../renderers/api-node-details/index.ts | 2 + .../code-compare-editor.provider.tsx | 15 +- .../code-compare-editor.tsx | 85 ++--- .../code-compare-view.module.scss | 8 +- .../code-compare-view/code-compare-view.tsx | 349 +++--------------- .../ui/code-editor/code-editor.module.scss | 24 ++ .../ui/code-editor/code-editor.provider.tsx | 36 +- .../ui/code-editor/code-editor.spec.tsx | 24 ++ components/ui/code-editor/code-editor.tsx | 126 +++---- .../compare-aspect-view.module.scss | 81 ++++ .../compare-aspect-view.tsx | 83 +++++ .../compare-aspect-view/config-editor.tsx | 60 +++ .../compare-aspect-view/index.ts | 1 + .../compare-aspects.module.scss | 64 ++++ .../compare-aspects/compare-aspects.tsx | 83 +++++ .../compare-aspects.widgets.tsx | 55 +++ .../compare-aspects/compare-aspects/index.ts | 2 + workspace.jsonc | 8 +- 23 files changed, 947 insertions(+), 470 deletions(-) create mode 100644 components/renderers/api-node-details/api-node-details.module.scss create mode 100644 components/renderers/api-node-details/api-node-details.tsx create mode 100644 components/renderers/api-node-details/extract-code-block.spec.ts create mode 100644 components/renderers/api-node-details/extract-code-block.ts create mode 100644 components/renderers/api-node-details/index.ts create mode 100644 components/ui/code-editor/code-editor.module.scss create mode 100644 components/ui/code-editor/code-editor.spec.tsx create mode 100644 components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss create mode 100644 components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.tsx create mode 100644 components/ui/component-compare/compare-aspects/compare-aspect-view/config-editor.tsx create mode 100644 components/ui/component-compare/compare-aspects/compare-aspect-view/index.ts create mode 100644 components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.module.scss create mode 100644 components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx create mode 100644 components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx create mode 100644 components/ui/component-compare/compare-aspects/compare-aspects/index.ts diff --git a/.bitmap b/.bitmap index bd76583ddd97..e1316499e7ca 100644 --- a/.bitmap +++ b/.bitmap @@ -217,7 +217,51 @@ "scope": "teambit.harmony", "version": "2.2.49", "mainFile": "index.ts", - "rootDir": "scopes/harmony/bit" + "rootDir": "scopes/harmony/bit", + "config": { + "teambit.dependencies/dependency-resolver": { + "policy": { + "dependencies": { + "@teambit/base-react.navigation.link": "2.0.31", + "@teambit/ui-foundation.ui.navigation.react-router-adapter": "6.1.3", + "@apollo/client": "3.6.9", + "@yarnpkg/cli": "3.6.1", + "@yarnpkg/core": "3.5.2", + "@yarnpkg/plugin-pack": "3.2.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "graphql": "15.8.0", + "browserslist": "4.23.3", + "reflect-metadata": "0.1.13", + "mz": "2.7.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "core-js": "3.13.0", + "postcss": "8.4.18", + "buffer": "6.0.3", + "process": "0.11.10", + "react-router-dom": "6.3.0", + "typescript": "5.9.2", + "@parcel/css": "^1.8.3", + "lightningcss": "^1.20.0", + "@swc/css": "^0.0.20", + "@teambit/legacy": "2.1.0" + }, + "devDependencies": {}, + "peerDependencies": { + "@teambit/base-react.navigation.link": "-", + "@teambit/legacy": "-", + "@teambit/ui-foundation.ui.navigation.react-router-adapter": "-", + "browserslist": "-", + "graphql": "-", + "react": "-", + "react-dom": "-", + "react-router-dom": "-", + "core-js": "-" + } + } + } + } }, "bit-map": { "name": "bit-map", @@ -1486,6 +1530,13 @@ "mainFile": "index.ts", "rootDir": "scopes/component/renaming" }, + "renderers/api-node-details": { + "name": "renderers/api-node-details", + "scope": "teambit.api-reference", + "version": "0.0.88", + "mainFile": "index.ts", + "rootDir": "components/renderers/api-node-details" + }, "renderers/default-node-renderers": { "name": "renderers/default-node-renderers", "scope": "teambit.api-reference", @@ -1794,6 +1845,20 @@ "mainFile": "index.ts", "rootDir": "components/ui/component-compare/changelog" }, + "ui/component-compare/compare-aspects/compare-aspect-view": { + "name": "ui/component-compare/compare-aspects/compare-aspect-view", + "scope": "teambit.component", + "version": "0.0.16", + "mainFile": "index.ts", + "rootDir": "components/ui/component-compare/compare-aspects/compare-aspect-view" + }, + "ui/component-compare/compare-aspects/compare-aspects": { + "name": "ui/component-compare/compare-aspects/compare-aspects", + "scope": "teambit.component", + "version": "0.0.155", + "mainFile": "index.ts", + "rootDir": "components/ui/component-compare/compare-aspects/compare-aspects" + }, "ui/component-compare/component-compare": { "name": "ui/component-compare/component-compare", "scope": "teambit.component", diff --git a/components/renderers/api-node-details/api-node-details.module.scss b/components/renderers/api-node-details/api-node-details.module.scss new file mode 100644 index 000000000000..9bec09b1b93f --- /dev/null +++ b/components/renderers/api-node-details/api-node-details.module.scss @@ -0,0 +1,92 @@ +.apiNodeDetailsContainer { + padding: 24px 0px; + display: flex; + height: calc(100% - 40px); +} +.apiDetails { + overflow-y: auto; + width: 100%; + padding-right: 16px; +} +.schemaNodesIndex { + flex: 0; + height: 100%; +} +.apiNodeDetailsNameContainer { + display: flex; + flex-direction: row; + padding-bottom: 24px; + align-items: center; +} +.apiNodeDetailsName { + padding-right: 8px; +} +.apiTypeIcon { + img { + height: 18px; + } + padding-right: 8px; +} +.codeEditorContainer { + > section { + border-radius: 8px; + } +} +.editor { + background-color: var(--surface-neutral-color, #f8f9fb); +} +.apiNodeDetailsComment { + font-size: var(--bit-p-xs); + padding-bottom: 24px; +} +.apiNodeDetailsSignatureContainer { + display: flex; + flex-direction: column; + padding-bottom: 24px; +} +.apiNodeDetailsExample { + padding-bottom: 24px; +} +.apiNodeDetailsExampleTitle { + padding: 16px 0px; +} +.apiNodeImplementationDrawer { + width: 100%; +} +.apiNodeDetailsLocationContainer { + display: flex; + flex-direction: row; + align-items: center; + line-height: 16px; + padding-bottom: 24px; +} + +.apiNodeImplementationCodeView { + padding: 16px 0px; + > h1 { + display: none !important; + } +} +.apiNodeDetailsLocation { + display: flex; + width: 100%; +} +.apiNodeDetailsLocationIcon { + display: flex; + padding-right: 8px; + img { + height: 14px; + } +} +.apiNodeDetailsLocationLink { + max-width: fit-content; + font-size: var(--bit-p-xs); +} +.apiNodeDetailsMembersContainer { + padding-bottom: 16px; +} + +.apiNodeDetailsLink { + font-size: var(--bit-p-xs); + padding-bottom: 24px; +} diff --git a/components/renderers/api-node-details/api-node-details.tsx b/components/renderers/api-node-details/api-node-details.tsx new file mode 100644 index 000000000000..c25a3d1c861d --- /dev/null +++ b/components/renderers/api-node-details/api-node-details.tsx @@ -0,0 +1,108 @@ +import React, { useRef, useState } from 'react'; +import { H6 } from '@teambit/documenter.ui.heading'; +import { CodeEditor } from '@teambit/code.ui.code-editor'; +import classnames from 'classnames'; +import type { APINodeRenderProps } from '@teambit/api-reference.models.api-node-renderer'; +import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; +import { SchemaNodesIndex } from '@teambit/api-reference.renderers.schema-nodes-index'; + +import { extractCodeBlock } from './extract-code-block'; +import styles from './api-node-details.module.scss'; + +const INDEX_THRESHOLD_WIDTH = 600; + +export type APINodeDetailsProps = APINodeRenderProps & { + displaySignature?: string; + options?: { + hideIndex?: boolean; + }; +}; + +export function APINodeDetails({ + apiNode: { + api: { + signature: defaultSignature, + doc, + location: { filePath }, + }, + }, + displaySignature, + children, + // retained in the public renderer contract; this component does not render child nodes itself. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + renderers: _renderers, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + apiRefModel: _apiRefModel, + options, + ...rest +}: APINodeDetailsProps) { + const query = useQuery(); + const rootRef = useRef(null) as React.MutableRefObject; + const [containerSize] = useState<{ width?: number }>({}); + const currentQueryParams = query.toString(); + const indexHidden = (containerSize.width ?? 0) < INDEX_THRESHOLD_WIDTH; + + const example = (doc?.tags || []).find((tag) => tag.tagName === 'example'); + const comment = + doc?.comment ?? doc?.tags?.filter((tag) => tag.comment).reduce((acc, tag) => acc.concat(`${tag.comment}\n`), ''); + const linkComment = doc?.tags?.find((tag) => tag.tagName === 'link')?.comment; + + let linkPlaceholder: string | undefined; + let linkURL: string | undefined; + if (linkComment) { + const parts = linkComment.split(' '); + linkURL = parts.find((part) => part.startsWith('http')); + linkPlaceholder = parts.filter((part) => part !== linkURL).join(' '); + } + + const signature = displaySignature || defaultSignature; + const extractedExample = example?.comment ? extractCodeBlock(example.comment) : undefined; + + return ( +
+
+ {comment &&
{comment}
} + {linkComment && ( +
+ {linkPlaceholder && {linkPlaceholder}: } + + {linkURL} + +
+ )} + {signature && ( +
+ +
+ )} + {example?.comment && ( +
+
Example
+
+ +
+
+ )} + {children} +
+ {!options?.hideIndex && !indexHidden && ( + + )} +
+ ); +} diff --git a/components/renderers/api-node-details/extract-code-block.spec.ts b/components/renderers/api-node-details/extract-code-block.spec.ts new file mode 100644 index 000000000000..590e49f9a926 --- /dev/null +++ b/components/renderers/api-node-details/extract-code-block.spec.ts @@ -0,0 +1,21 @@ +import { extractCodeBlock } from './extract-code-block'; + +describe('extractCodeBlock', () => { + it('should extract code block with language specifier', () => { + const text = '```typescript\nconst foo = "bar";\n```'; + const result = extractCodeBlock(text); + expect(result).toEqual({ lang: 'typescript', code: 'const foo = "bar";\n' }); + }); + + it('should extract code block without language specifier', () => { + const text = '```\nconst foo = "bar";\n```'; + const result = extractCodeBlock(text); + expect(result).toEqual({ lang: '', code: 'const foo = "bar";\n' }); + }); + + it('should return null if no code block is found', () => { + const text = 'const foo = "bar";'; + const result = extractCodeBlock(text); + expect(result).toBeNull(); + }); +}); diff --git a/components/renderers/api-node-details/extract-code-block.ts b/components/renderers/api-node-details/extract-code-block.ts new file mode 100644 index 000000000000..d961ab107a1c --- /dev/null +++ b/components/renderers/api-node-details/extract-code-block.ts @@ -0,0 +1,23 @@ +/** + * Extracts the code block and its language specifier enclosed between triple backticks (```) from a given text string. + * + * @param text - The text string from which to extract the code block. + * + * @returns An object containing the extracted code and language specifier, or null if no match is found. + */ +export function extractCodeBlock(text: string): { lang: string; code: string } | null { + let processedText = text; + if (text.endsWith(';') && !text.endsWith('```')) { + processedText = text.slice(0, -1) + '```'; + } + const regex = /```([\w+-]*)\s*([\s\S]*?)```/; + + const match = processedText.match(regex); + + if (match) { + const lang = match[1]; + const code = match[2]; + return { lang, code }; + } + return null; +} diff --git a/components/renderers/api-node-details/index.ts b/components/renderers/api-node-details/index.ts new file mode 100644 index 000000000000..f1d9dd8d66a4 --- /dev/null +++ b/components/renderers/api-node-details/index.ts @@ -0,0 +1,2 @@ +export { APINodeDetails, APINodeDetailsProps } from './api-node-details'; +export { extractCodeBlock } from './extract-code-block'; diff --git a/components/ui/code-compare/code-compare-editor/code-compare-editor.provider.tsx b/components/ui/code-compare/code-compare-editor/code-compare-editor.provider.tsx index cd8468021323..3bb7567acb64 100644 --- a/components/ui/code-compare/code-compare-editor/code-compare-editor.provider.tsx +++ b/components/ui/code-compare/code-compare-editor/code-compare-editor.provider.tsx @@ -1,21 +1,14 @@ /* eslint-disable react/prop-types */ import type { ReactNode } from 'react'; -import React, { createContext, useContext } from 'react'; - -const CodeCompareEditorContext = createContext(null); +import React from 'react'; type CodeCompareEditorProviderProps = { children: ReactNode; }; export const CodeCompareEditorProvider: React.FC = ({ children }) => { - const DiffEditor = React.lazy(() => { - return import('@monaco-editor/react').then((module) => ({ default: module.DiffEditor })); - }); - - return {children}; + return <>{children}; }; -export const useCodeCompareEditor = () => { - return useContext(CodeCompareEditorContext); -}; +/** @deprecated the Shiki diff renderer no longer needs an injected editor component. */ +export const useCodeCompareEditor = () => null; diff --git a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx index 925592f7aca0..62f8bc1353fc 100644 --- a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx +++ b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx @@ -1,20 +1,16 @@ import React from 'react'; -import type { DiffEditorProps, DiffOnMount } from '@monaco-editor/react'; -import { loader } from '@monaco-editor/react'; -import { darkMode } from '@teambit/base-ui.theme.dark-theme'; +import { DiffViewer } from '@teambit/code.ui.diff-viewer'; import type { EditorSettingsState } from '../code-compare-editor-settings'; -loader.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.48.0/min/vs' } }); - export type CodeCompareEditorProps = { language: string; - handleEditorDidMount: DiffOnMount; + handleEditorDidMount?: (...args: any[]) => void; Loader: React.ReactNode; modifiedFileContent?: string; originalFileContent?: string; originalPath: string; modifiedPath: string; - DiffEditor: React.FC; + DiffEditor?: React.ComponentType | null; fullScreen?: boolean; } & EditorSettingsState; @@ -24,65 +20,34 @@ export function CodeCompareEditor({ originalPath, modifiedPath, language, - handleEditorDidMount, ignoreWhitespace, wordWrap, diffOnly, editorViewMode, - Loader, - DiffEditor, fullScreen, }: CodeCompareEditorProps) { + const normalizeWhitespace = (content = '') => + ignoreWhitespace + ? content + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + : content; + return ( - }> - - + ); } diff --git a/components/ui/code-compare/code-compare-view/code-compare-view.module.scss b/components/ui/code-compare/code-compare-view/code-compare-view.module.scss index db9d383712a9..e7a9d6496f29 100644 --- a/components/ui/code-compare/code-compare-view/code-compare-view.module.scss +++ b/components/ui/code-compare/code-compare-view/code-compare-view.module.scss @@ -2,6 +2,7 @@ .componentCompareCodeViewContainer { width: 100%; + min-height: 250px; display: flex; flex-direction: column; background: var(--on-surface-neutral-low-color, #282828) !important; @@ -11,11 +12,14 @@ .componentCompareCodeDiffEditorContainer { display: flex; flex: 1; + min-width: 0; + min-height: 220px; + padding: 8px; transition: height 0.4s ease-in-out; position: relative; - > section { - overflow: hidden; + > * { + width: 100%; } } diff --git a/components/ui/code-compare/code-compare-view/code-compare-view.tsx b/components/ui/code-compare/code-compare-view/code-compare-view.tsx index 6e3fc8402a3f..c3fdebedc39b 100644 --- a/components/ui/code-compare/code-compare-view/code-compare-view.tsx +++ b/components/ui/code-compare/code-compare-view/code-compare-view.tsx @@ -1,14 +1,14 @@ import type { HTMLAttributes, ComponentType } from 'react'; -import React, { useMemo, useRef, useState, useEffect } from 'react'; +import React, { useMemo, useState } from 'react'; import { LineSkeleton } from '@teambit/base-ui.loaders.skeleton'; -import type { DiffOnMount, Monaco } from '@monaco-editor/react'; import type { FileIconSlot } from '@teambit/code'; import flatten from 'lodash.flatten'; import classNames from 'classnames'; import type { FileIconMatch } from '@teambit/code.ui.utils.get-file-icon'; +import { langFromFileName } from '@teambit/code.ui.diff-viewer'; import { useComponentCompare } from '@teambit/component.ui.component-compare.context'; import type { WidgetProps } from '@teambit/ui-foundation.ui.tree.tree-node'; -import { CodeCompareEditor, useCodeCompareEditor } from '../code-compare-editor'; +import { CodeCompareEditor } from '../code-compare-editor'; import type { EditorViewMode } from '../code-compare-editor-settings'; import { CodeCompareEditorSettings } from '../code-compare-editor-settings'; import { CodeCompareNavigation } from '../code-compare-navigation'; @@ -25,17 +25,6 @@ export type CodeCompareViewProps = { widgets?: ComponentType>[]; } & HTMLAttributes; -// a translation list of specific monaco languages that are not the same as their file ending. -const languageOverrides: Record = { - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - mdx: 'markdown', - md: 'markdown', - vue: 'html', -}; - export function CodeCompareViewLoader({ className, ...rest }: React.HTMLAttributes) { return ; } @@ -49,284 +38,47 @@ export function CodeCompareView({ fileIconSlot, widgets, }: CodeCompareViewProps) { - const monacoRef = useRef< - | { - editor?: any; - monaco?: Monaco; - } - | undefined - >(undefined); - const { - baseId, - compareId, - modifiedFileContent, - originalFileContent, - modifiedPath, - originalPath, - loading: loadingData, - } = useCodeCompare({ - fileName, - }); + const { baseId, compareId, modifiedFileContent, originalFileContent, modifiedPath, originalPath, loading } = + useCodeCompare({ fileName }); const componentCompareContext = useComponentCompare(); - const DiffEditor = useCodeCompareEditor(); - const [loading, setLoading] = useState(Boolean(loadingData)); - const [isDiffComputed, setIsDiffComputed] = useState(false); - - useEffect(() => { - if (loading !== loadingData) { - setLoading(Boolean(loadingData)); - } - if (isDiffComputed && loadingData) setIsDiffComputed(false); - }, [loadingData]); + const fileCompareDataByName = componentCompareContext?.fileCompareDataByName; - const getDefaultView: () => EditorViewMode = () => { - if (!baseId) return 'inline'; - if (baseId && compareId && baseId.isEqual(compareId)) return 'inline'; - if (!originalFileContent || !modifiedFileContent) return 'inline'; - if (componentCompareContext?.fileCompareDataByName?.get(fileName)?.status === 'UNCHANGED') return 'inline'; - return 'split'; - }; + let defaultView: EditorViewMode = 'split'; + if ( + !baseId || + (compareId && baseId.isEqual(compareId)) || + !originalFileContent || + !modifiedFileContent || + fileCompareDataByName?.get(fileName)?.status === 'UNCHANGED' + ) { + defaultView = 'inline'; + } const fileIconMatchers: FileIconMatch[] = useMemo(() => flatten(fileIconSlot?.values()), [fileIconSlot]); + const [ignoreWhitespace, setIgnoreWhitespace] = useState(false); + const [view, setView] = useState(defaultView); + const [wrap, setWrap] = useState(true); + const [diffOnly, setDiffOnly] = useState(true); - const [ignoreWhitespace, setIgnoreWhitespace] = useState(false); - const [view, setView] = useState(getDefaultView()); - const [wrap, setWrap] = useState(true); - const [diffOnly, setDiffOnly] = useState(true); - - const language = useMemo(() => { - if (!fileName) return languageOverrides.ts; - const fileEnding = fileName?.split('.').pop() ?? ''; - return languageOverrides[fileEnding] || fileEnding; - }, [fileName]); - const containerRef = useRef(null); - const isFullScreen = !!componentCompareContext?.isFullScreen; - - useEffect(() => { - const updatedView = getDefaultView(); - if (view !== updatedView) setView(updatedView); - }, [ - baseId?.toString(), - originalFileContent, - modifiedFileContent, - componentCompareContext?.fileCompareDataByName?.size, - compareId?.toString(), - ]); - - const [containerHeight, setContainerHeight] = useState(isFullScreen ? '100%' : undefined); - - const getEditorHeight = (editorInstance: any) => { - if (!monacoRef.current?.monaco) return 0; - - const model = editorInstance.getModel(); - - if (!model) return 0; - - const lineHeight = editorInstance.getOption(monacoRef.current.monaco.editor.EditorOption.lineHeight); - const lineCount = editorInstance.getModel()?.getLineCount() || 1; - const height = editorInstance.getTopForLineNumber(lineCount + 1) + lineHeight; - - return height; - }; - - const updateEditorHeight = () => { - if (isFullScreen) return; - if (!monacoRef.current?.monaco) return; - - const originalEditor = monacoRef.current.editor.getOriginalEditor(); - const modifiedEditor = monacoRef.current.editor.getModifiedEditor(); - - const originalModel = originalEditor.getModel(); - const modifiedModel = modifiedEditor.getModel(); - - if (!originalModel || !modifiedModel) { - return; - } - - const diffResult = monacoRef.current.editor.getLineChanges() ?? []; - - const originalContentHeight = getEditorHeight(originalEditor); - const modifiedContentHeight = getEditorHeight(modifiedEditor); - - if (!originalContentHeight && !modifiedContentHeight) return; - - const maxHeight = - Math.max(Math.max(originalContentHeight, modifiedContentHeight), 250) + (diffResult.length > 0 ? 24 : 0); - - const originalDomNode = originalEditor.getDomNode()?.parentElement; - const modifiedDomNode = modifiedEditor.getDomNode()?.parentElement; - - if (!originalDomNode || !modifiedDomNode) { - return; - } - - modifiedDomNode.style.height = `${maxHeight}px`; - monacoRef.current.editor.layout(); - setContainerHeight(() => `${maxHeight}px`); - }; - - useEffect(() => { - if (!monacoRef.current?.editor) return; - const modifiedEditor = monacoRef.current?.editor.getModifiedEditor(); + React.useEffect(() => { + setView(defaultView); + }, [defaultView]); - if (!modifiedEditor) return; - - const modifiedDomNode = modifiedEditor.getDomNode()?.parentElement; - - if (!modifiedDomNode) return; - - const modifiedDomNodeHeight = modifiedDomNode?.style.height; - - if (modifiedDomNodeHeight !== containerHeight) { - modifiedDomNode.style.height = containerHeight; - monacoRef.current?.editor.layout(); - } - }, [containerHeight]); - - useEffect(() => { - if (containerHeight !== '100%' && isFullScreen) { - setContainerHeight('100%'); - } - if (!isFullScreen && containerHeight === '100%') { - updateEditorHeight(); - } - }, [isFullScreen, componentCompareContext]); - - const handleEditorDidMount: DiffOnMount = React.useCallback( - (editor, monaco) => { - /** - * disable syntax check - * ts cant validate all types because imported files aren't available to the editor - */ - monacoRef.current = { monaco, editor }; - if (monacoRef.current) { - monacoRef.current?.monaco?.languages?.typescript?.typescriptDefaults?.setDiagnosticsOptions({ - noSemanticValidation: true, - noSyntaxValidation: true, - }); - } - - monaco.editor.defineTheme('bit', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'scrollbar.shadow': '#222222', - 'diffEditor.insertedTextBackground': '#1C4D2D', - 'diffEditor.removedTextBackground': '#761E24', - 'editor.selectionBackground': '#5A5A5A', - 'editor.overviewRulerBorder': '#6a57fd', - 'editor.lineHighlightBorder': '#6a57fd', - }, - }); - - monaco.editor.setTheme('bit'); - - if (!originalFileContent || !modifiedFileContent) { - setIsDiffComputed(() => { - updateEditorHeight(); - return true; - }); - } - - editor.onDidUpdateDiff(() => { - setIsDiffComputed(() => { - updateEditorHeight(); - return true; - }); - }); - - editor.getModifiedEditor().onDidContentSizeChange(() => { - const originalFileIsEmpty = editor.getOriginalEditor().getModel()?.getLineCount() === 1; - const modifiedFileIsEmpty = editor.getModifiedEditor().getModel()?.getLineCount() === 1; - const hasDiff = (monacoRef.current?.editor.getLineChanges() ?? []).length > 0; - - if (originalFileIsEmpty || modifiedFileIsEmpty) { - updateEditorHeight(); - } - - if (isDiffComputed) { - updateEditorHeight(); - } - - if (!isDiffComputed && hasDiff) { - setTimeout(() => { - setIsDiffComputed(() => { - updateEditorHeight(); - return true; - }); - }, 150); - } - }); - }, - [fileName, view, compareId?.toString(), componentCompareContext?.hidden, loading, files.length] - ); - - const diffEditor = useMemo( - () => ( - } - /> - ), - [ - modifiedFileContent, - originalFileContent, - ignoreWhitespace, - view, - wrap, - fileName, - loading, - files.length, - modifiedPath, - originalPath, - language, - compareId?.toString(), - baseId?.toString(), - DiffEditor, - diffOnly, - ] - ); - - const containerHeightStyle = isFullScreen - ? '100%' - : (!!containerHeight && `calc(${containerHeight} + 30px)`) || '250px'; - - const codeContainerHeightStyle = isFullScreen ? 'calc(100% - 30px)' : (containerHeight ?? '220px'); - const fileCompareDataByName = componentCompareContext?.fileCompareDataByName; - const codeNavFiles = React.useMemo(() => { + const codeNavFiles = useMemo(() => { return files.filter((file) => { if (file === fileName) return true; - const codeCompareDataForFile = fileCompareDataByName?.get(file) ?? null; - const status = codeCompareDataForFile?.status; + const status = fileCompareDataByName?.get(file)?.status; if (componentCompareContext?.compare && !componentCompareContext.base && !status) return true; - if (status && status !== 'UNCHANGED') return true; - return false; + return Boolean(status && status !== 'UNCHANGED'); }); - }, [files.length, fileName, fileCompareDataByName?.size]); + }, [files, fileName, fileCompareDataByName, componentCompareContext?.compare, componentCompareContext?.base]); - const hideLoader = !loading && files.length > 0 && !!containerHeight && isDiffComputed; + const isFullScreen = Boolean(componentCompareContext?.isFullScreen); return (
{files.length > 0 && ( @@ -334,46 +86,47 @@ export function CodeCompareView({ files={codeNavFiles} selectedFile={fileName} fileIconMatchers={fileIconMatchers} - onTabClicked={(id, event) => { - if (id !== fileName) setIsDiffComputed(false); - onTabClicked?.(id, event); - }} + onTabClicked={onTabClicked} getHref={getHref} widgets={widgets} Menu={ setDiffOnly(value)} + onDiffOnlyChanged={setDiffOnly} ignoreWhitespace={ignoreWhitespace} editorViewMode={view} - onViewModeChanged={(value) => setView(value)} - onWordWrapChanged={(value) => setWrap(value)} - onIgnoreWhitespaceChanged={(value) => setIgnoreWhitespace(value)} + onViewModeChanged={setView} + onWordWrapChanged={setWrap} + onIgnoreWhitespaceChanged={setIgnoreWhitespace} /> } /> )}
- - {loading ? null : diffEditor} + {loading ? ( + + ) : ( + } + fullScreen={isFullScreen} + /> + )}
); diff --git a/components/ui/code-editor/code-editor.module.scss b/components/ui/code-editor/code-editor.module.scss new file mode 100644 index 000000000000..8cfbe18ac7b1 --- /dev/null +++ b/components/ui/code-editor/code-editor.module.scss @@ -0,0 +1,24 @@ +$code-font: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + +.codeEditor { + box-sizing: border-box; + width: 100%; + max-width: 100%; + overflow: auto; + border: 1px solid var(--border-medium-color, #ededed); + border-radius: 8px; + background: var(--surface-neutral-color, #f8f9fb); + color: var(--on-surface-color, #2b2b2b); +} + +.pre { + box-sizing: border-box; + min-width: max-content; + margin: 0; + padding: 8px 12px; + font-family: $code-font; + font-size: 12.5px; + line-height: 20px; + tab-size: 2; + white-space: pre; +} diff --git a/components/ui/code-editor/code-editor.provider.tsx b/components/ui/code-editor/code-editor.provider.tsx index ceebc128638e..ce3905b26b1d 100644 --- a/components/ui/code-editor/code-editor.provider.tsx +++ b/components/ui/code-editor/code-editor.provider.tsx @@ -1,33 +1,17 @@ import type { ReactNode } from 'react'; -import React, { createContext, useContext, useEffect, useState } from 'react'; -import type { EditorProps } from '@monaco-editor/react'; - -const CodeEditorContext = createContext | null>(null); +import React from 'react'; type CodeEditorProviderProps = { children: ReactNode; }; -export const CodeEditorProvider: React.FC = ({ children }) => { - const [Editor, setEditor] = useState | null>(null); - - useEffect(() => { - if (typeof window !== 'undefined') { - const loadEditor = async () => { - const { default: MonacoEditor } = await import('@monaco-editor/react'); - setEditor(() => MonacoEditor); - }; - - loadEditor().catch((error) => { - // eslint-disable-next-line no-console - console.error('Failed to load Monaco Editor:', error); - }); - } - }, []); +/** + * @deprecated CodeEditor is now a static Shiki renderer and no longer needs a runtime provider. + * Kept as a pass-through so independently-versioned consumers remain compatible. + */ +export const CodeEditorProvider: React.FC = ({ children }) => <>{children}; - return {children}; -}; - -export const useCodeEditor = () => { - return useContext(CodeEditorContext); -}; +/** + * @deprecated CodeEditor no longer requires an injected editor implementation. + */ +export const useCodeEditor = () => null; diff --git a/components/ui/code-editor/code-editor.spec.tsx b/components/ui/code-editor/code-editor.spec.tsx new file mode 100644 index 000000000000..fe6531d38174 --- /dev/null +++ b/components/ui/code-editor/code-editor.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { CodeEditor } from './code-editor'; +import { CodeEditorProvider } from './code-editor.provider'; + +jest.mock('@teambit/code.ui.diff-viewer', () => ({ + langFromFileName: () => 'typescript', + resolveTokenColor: () => undefined, + useHighlightedLines: () => null, +})); + +describe('CodeEditor', () => { + it('renders code without an injected editor or CDN runtime', () => { + const { container } = render( + + + + ); + + const renderer = container.querySelector('[data-code-renderer="shiki"]'); + expect(renderer).toBeInTheDocument(); + expect(renderer).toHaveTextContent('export const answer = 42;'); + }); +}); diff --git a/components/ui/code-editor/code-editor.tsx b/components/ui/code-editor/code-editor.tsx index 921237de0fdf..3793e7a46cd5 100644 --- a/components/ui/code-editor/code-editor.tsx +++ b/components/ui/code-editor/code-editor.tsx @@ -1,11 +1,14 @@ import React from 'react'; import classnames from 'classnames'; -import type { OnMount, BeforeMount, OnChange, EditorProps } from '@monaco-editor/react'; -import { loader } from '@monaco-editor/react'; -import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; -import { darkMode } from '@teambit/base-ui.theme.dark-theme'; +import { langFromFileName, resolveTokenColor, useHighlightedLines } from '@teambit/code.ui.diff-viewer'; +import styles from './code-editor.module.scss'; -loader.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.48.0/min/vs' } }); +/** + * Kept deliberately broad for backwards compatibility with consumers that still pass the old + * Monaco callbacks and options. The static renderer does not execute them. + */ +type LegacyEditorCallback = (...args: any[]) => void; +type LegacyEditorComponent = React.ComponentType; export type CodeEditorProps = { filePath?: string; @@ -13,80 +16,69 @@ export type CodeEditorProps = { language?: string; height?: string; className?: string; - options?: monaco.editor.IStandaloneEditorConstructionOptions; - beforeMount?: BeforeMount; - onMount?: OnMount; - onChange?: OnChange; + options?: Record; + beforeMount?: LegacyEditorCallback; + onMount?: LegacyEditorCallback; + onChange?: LegacyEditorCallback; Loader?: React.ReactNode; - Editor?: React.FC | null; + Editor?: LegacyEditorComponent | null; }; -export const DEFAULT_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = { +/** + * @deprecated Monaco-specific options are ignored by the static code renderer. + */ +export const DEFAULT_EDITOR_OPTIONS: Record = { readOnly: true, minimap: { enabled: false }, - scrollbar: { alwaysConsumeMouseWheel: true, vertical: 'auto' }, scrollBeyondLastLine: false, - folding: false, - overviewRulerLanes: 0, - overviewRulerBorder: false, wordWrap: 'off', - wrappingStrategy: undefined, - fixedOverflowWidgets: true, - renderLineHighlight: 'none', - lineHeight: 20, - padding: { top: 8, bottom: 8 }, - hover: { enabled: false }, - cursorBlinking: 'smooth', }; -// a translation list of specific monaco languages that are not the same as their file ending. -const languageOverrides = { - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - mdx: 'markdown', - md: 'markdown', -}; +function HighlightedCode({ content, language }: { content: string; language?: string }) { + const lines = useHighlightedLines(content, language); + + if (!lines) return <>{content}; -export function CodeEditor({ - fileContent, - filePath, - language, - beforeMount, - onMount, - onChange, - Loader, - options, - className, - height, - Editor, -}: CodeEditorProps) { - const defaultLang = React.useMemo(() => { - if (!filePath) return languageOverrides.ts; - const fileEnding = filePath?.split('.').pop(); - return languageOverrides[fileEnding || ''] || fileEnding; - }, [filePath]); + return ( + <> + {lines.map((tokens, lineIndex) => ( + + {lineIndex > 0 ? '\n' : null} + {tokens.map((token, tokenIndex) => { + const color = resolveTokenColor(token.color); + return ( + + {token.content} + + ); + })} + + ))} + + ); +} - if (!Editor) { - return <>{Loader ?? null}; - } +/** + * Read-only code renderer used by API Reference. + * + * This component intentionally retains the former Monaco-shaped props so independently-versioned + * API renderer components can migrate without a coordinated release. Rendering is now handled by + * the shared Shiki highlighter and does not load executable editor code from a CDN. + */ +export function CodeEditor({ fileContent = '', filePath, language, className, height }: CodeEditorProps) { + const resolvedLanguage = language || langFromFileName(filePath) || 'typescript'; return ( - }> - - +
+
+        
+          
+        
+      
+
); } diff --git a/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss new file mode 100644 index 000000000000..4066627088b6 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss @@ -0,0 +1,81 @@ +$editor-padding: 56px; + +.componentCompareAspectViewContainer { + width: 100%; + height: 100%; +} +.componentCompareAspectDiffEditorContainer { + padding: 0px 8px; + display: flex; + flex-direction: column; + height: calc(100% - $editor-padding); +} + +.name { + display: flex; + align-items: baseline; + padding: 8px; +} + +.componentCompareAspectConfigDiff, +.componentCompareAspectCalculatedDiff { + display: flex; + flex: 1; + padding: 0px 8px; + height: 100%; + flex-direction: column; +} + +.diffEditor { + width: 100%; +} + +.diffWithActions { + position: relative; + width: 100%; +} + +.copyActions { + position: absolute; + z-index: 1; + top: 8px; + right: 18px; + display: flex; + gap: 8px; +} + +.loader { + display: flex; + align-items: center; + justify-items: center; + height: 100%; + width: 100%; + color: var(--on-surface-neutral-low-color, #282828); + background-image: linear-gradient(to right, currentColor 0%, #2b2b2b 30%, #2b2b2b 50%, currentColor 100%); + > div { + background-image: linear-gradient(to right, currentColor 0%, #2b2b2b 30%, #2b2b2b 50%, currentColor 100%); + } +} + +.copyButton { + cursor: pointer; + border: 0; + padding: 4px; + border-radius: 4px; + color: inherit; + background: var(--surface-color, #fff); + opacity: 0; + + &.isLabelVisible { + opacity: 1; + } +} + +.diffWithActions:hover .copyButton, +.copyButton:focus-visible { + opacity: 1; +} + +.message { + left: 0; +} diff --git a/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.tsx b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.tsx new file mode 100644 index 000000000000..3f4efc28bb9a --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.tsx @@ -0,0 +1,83 @@ +import type { HTMLAttributes } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { H4 } from '@teambit/documenter.ui.heading'; +import { BlockSkeleton } from '@teambit/base-ui.loaders.skeleton'; +import { DiffViewer } from '@teambit/code.ui.diff-viewer'; +import type { ComponentAspectData } from '@teambit/component.ui.component-compare.compare-aspects.models.component-compare-aspects-model'; +import { ConfigDiffEditor } from './config-editor'; +import styles from './compare-aspect-view.module.scss'; + +export type CompareAspectViewProps = { + baseAspectData?: ComponentAspectData; + compareAspectData?: ComponentAspectData; + loading?: boolean; + name?: string; +} & HTMLAttributes; + +export function CompareAspectView({ + baseAspectData, + compareAspectData, + name, + loading, + className, +}: CompareAspectViewProps) { + const title = useMemo(() => name?.split('/').pop(), [name]); + const originalConfig = JSON.stringify(baseAspectData?.config, null, 2) || ''; + const modifiedConfig = JSON.stringify(compareAspectData?.config, null, 2) || ''; + const originalData = JSON.stringify(baseAspectData?.data, null, 2) || ''; + const modifiedData = JSON.stringify(compareAspectData?.data, null, 2) || ''; + + return ( +
+
+

+ {title} +

+
+
+
+

+ Config +

+ {loading ? ( + + ) : ( + + )} +
+
+

+ Calculated Data +

+ {loading ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function AspectsCompareViewLoader() { + return ; +} diff --git a/components/ui/component-compare/compare-aspects/compare-aspect-view/config-editor.tsx b/components/ui/component-compare/compare-aspects/compare-aspect-view/config-editor.tsx new file mode 100644 index 000000000000..83fffe330645 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspect-view/config-editor.tsx @@ -0,0 +1,60 @@ +import type { HTMLAttributes } from 'react'; +import React, { useState } from 'react'; +import copy from 'copy-to-clipboard'; +import classNames from 'classnames'; +import { CopiedMessage } from '@teambit/documenter.ui.copied-message'; +import { DiffViewer } from '@teambit/code.ui.diff-viewer'; +import { Icon } from '@teambit/design.elements.icon'; + +import styles from './compare-aspect-view.module.scss'; + +export type ConfigDiffEditorProps = { + original?: string; + modified?: string; + name?: string; +} & HTMLAttributes; + +export function ConfigDiffEditor({ original = '', modified = '', name = 'aspect config' }: ConfigDiffEditorProps) { + return ( +
+
+ {original && } + {modified && } +
+ +
+ ); +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const [isCopied, setCopied] = useState(false); + + const handleCopy = () => { + copy(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/components/ui/component-compare/compare-aspects/compare-aspect-view/index.ts b/components/ui/component-compare/compare-aspects/compare-aspect-view/index.ts new file mode 100644 index 000000000000..3125e08383ba --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspect-view/index.ts @@ -0,0 +1 @@ +export { CompareAspectView, CompareAspectViewProps } from './compare-aspect-view'; diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.module.scss b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.module.scss new file mode 100644 index 000000000000..a550b1ccdf19 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.module.scss @@ -0,0 +1,64 @@ +@import '@teambit/ui-foundation.ui.constants.z-indexes/z-indexes.module.scss'; + +.componentCompareAspectContainer { + display: flex; + height: 100%; + width: 100%; +} + +.left { + min-width: 200px; + //cannot assign max-width directly to right side + max-width: calc(100% - 200px); + display: flex; + background: var(--on-surface-neutral-low-color, #282828); +} + +.right { + position: relative; + overflow-y: auto; + // this is to fix the right panel when it gets too big or too small + min-width: 200px; + max-width: calc(100% - 200px); +} + +.splitter { + position: relative; + background: var(--on-surface-neutral-low-color, #282828); + + > :first-child { + z-index: $pane-splitter-zIndex; + position: absolute; + } +} + +.collapser { + right: 1px; +} + +.depNode { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 8px; +} + +.loader { + display: flex; + align-items: center; + justify-items: center; + height: 100%; + width: 100%; + color: var(--on-surface-neutral-low-color, #282828); +} + +.themeContainer { + min-height: calc(100% - 56px); + display: flex; + width: 100%; + > div { + width: 100%; + display: flex; + min-height: calc(100% - 56px); + } +} diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx new file mode 100644 index 000000000000..36bca9271976 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import classNames from 'classnames'; +import { HoverSplitter } from '@teambit/base-ui.surfaces.split-pane.hover-splitter'; +import { Collapser } from '@teambit/ui-foundation.ui.buttons.collapser'; +import { SplitPane, Pane, Layout } from '@teambit/base-ui.surfaces.split-pane.split-pane'; +import { useIsMobile } from '@teambit/ui-foundation.ui.hooks.use-is-mobile'; +import { CodeCompareTree } from '@teambit/code.ui.code-compare'; +import { useUpdatedUrlFromQuery } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url'; +import { useLocation } from '@teambit/base-react.navigation.link'; +import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; +import { ComponentCompareAspectsContext } from '@teambit/component.ui.component-compare.compare-aspects.context'; +import { useCompareAspectsQuery } from '@teambit/component.ui.component-compare.compare-aspects.hooks.use-compare-aspects'; +import { CompareAspectView } from '@teambit/component.ui.component-compare.compare-aspects.compare-aspect-view'; +import { DarkTheme } from '@teambit/design.themes.dark-theme'; +import { ThemeSwitcher } from '@teambit/design.themes.theme-toggler'; + +import { Widget } from './compare-aspects.widgets'; + +import styles from './compare-aspects.module.scss'; + +export type ComponentCompareAspectsProps = { host: string } & HTMLAttributes; + +/** Legacy aspects route retained on the shared Shiki diff renderer for backwards-compatible URLs. */ +export function ComponentCompareAspects({ host, className }: ComponentCompareAspectsProps) { + const context = useCompareAspectsQuery(host); + const { loading, selectedBase, selectedCompare, selected, hook, aspectNames, state } = context; + const isMobile = useIsMobile(); + const [isSidebarOpen, setSidebarOpenness] = useState(!isMobile); + const sidebarOpenness = isSidebarOpen ? Layout.row : Layout.left; + const controlledHref = useUpdatedUrlFromQuery({}); + const query = useQuery(); + const location = useLocation() || { pathname: '/' }; + + const getHref = (node) => { + if (hook?.useUpdatedUrlFromQuery || state?.controlled) return controlledHref; + const queryObj = Object.fromEntries(query.entries()); + const queryString = new URLSearchParams({ ...queryObj, aspect: node.id }).toString(); + return `${location.pathname}?${queryString}`; + }; + + return ( + + + + + + + + e.stopPropagation()} // avoid split-pane drag + onClick={() => setSidebarOpenness((x) => !x)} + tooltipContent={`${isSidebarOpen ? 'Hide' : 'Show'} aspects tree`} + className={styles.collapser} + /> + + + + + + + + ); +} diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx new file mode 100644 index 000000000000..f534ed315036 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { WidgetProps } from '@teambit/ui-foundation.ui.tree.tree-node'; +import { isEqual } from 'lodash'; +import { useAspectCompare } from '@teambit/component.ui.component-compare.compare-aspects.context'; +import type { ComponentAspectData } from '@teambit/component.ui.component-compare.compare-aspects.models.component-compare-aspects-model'; +import { CompareStatusResolver } from '@teambit/component.ui.component-compare.status-resolver'; +import type { CompareStatus } from '@teambit/component.ui.component-compare.status-resolver'; + +export function Widget({ node }: WidgetProps) { + const fileName = node.id; + + const componentCompareAspectsContext = useAspectCompare(); + + if (componentCompareAspectsContext?.loading) return null; + + const base = componentCompareAspectsContext?.base; + const compare = componentCompareAspectsContext?.compare; + + const matchingBaseAspect = base?.find((baseAspect) => baseAspect.id === fileName); + const matchingCompareAspect = compare?.find((compareAspect) => compareAspect.id === fileName); + + if (!matchingBaseAspect && !matchingCompareAspect) return null; + + const status = getAspectStatus(matchingBaseAspect, matchingCompareAspect); + + if (!status) return null; + + return ; +} + +export function getAspectStatus(aspectA?: ComponentAspectData, aspectB?: ComponentAspectData): CompareStatus | null { + const isUndefined = (data) => data === undefined; + const isDeleted = (base, compare) => { + return isUndefined(compare) && !isUndefined(base); + }; + const isNew = (base, compare) => { + return !isUndefined(compare) && isUndefined(base); + }; + + const baseConfig = aspectA?.config; + const baseData = aspectA?.data; + const compareConfig = aspectB?.config; + const compareData = aspectB?.data; + + if (isDeleted(baseConfig, compareConfig) || isDeleted(baseData, compareData)) { + return 'deleted'; + } + if (isNew(baseConfig, compareConfig) || isNew(baseData, compareData)) { + return 'new'; + } + if (!isEqual(baseConfig, compareConfig) || !isEqual(baseData, compareData)) { + return 'modified'; + } + return null; +} diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/index.ts b/components/ui/component-compare/compare-aspects/compare-aspects/index.ts new file mode 100644 index 000000000000..ccbcf0922c23 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/index.ts @@ -0,0 +1,2 @@ +export { ComponentCompareAspects, ComponentCompareAspectsProps } from './compare-aspects'; +export { Widget, getAspectStatus } from './compare-aspects.widgets'; diff --git a/workspace.jsonc b/workspace.jsonc index c8c4141624db..275e7a3ae350 100644 --- a/workspace.jsonc +++ b/workspace.jsonc @@ -50,7 +50,6 @@ "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@modelcontextprotocol/sdk": "^1.22.0", - "@monaco-editor/react": "4.7.0", "@parcel/watcher": "^2.5.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.4", "@pnpm/colorize-semver-diff": "1.0.1", @@ -82,7 +81,6 @@ "@teambit/api-reference.models.api-reference-model": "~0.0.55", "@teambit/api-reference.overview.api-reference-table-of-contents": "~0.0.43", "@teambit/api-reference.overview.renderers.grouped-schema-nodes-overview-summary": "~0.0.78", - "@teambit/api-reference.renderers.api-node-details": "~0.0.88", "@teambit/api-reference.renderers.class": "~0.0.91", "@teambit/api-reference.renderers.decorator": "~0.0.45", "@teambit/api-reference.renderers.enum": "~0.0.89", @@ -106,7 +104,6 @@ "@teambit/api-reference.renderers.variable": "~0.0.85", "@teambit/api-reference.sections.api-reference-page": "~0.0.120", "@teambit/api-reference.sections.api-reference-section": "~0.0.16", - "@teambit/api-reference.utils.code-editor-options": "~0.0.16", "@teambit/api-reference.utils.copy-schema-node": "~0.0.48", "@teambit/api-reference.utils.custom-prism-syntax-highlighter-theme": "~0.0.16", "@teambit/api-reference.utils.group-schema-node-by-signature": "~0.0.45", @@ -164,7 +161,6 @@ "@teambit/component.ui.artifacts.artifacts-tree": "~0.0.28", "@teambit/component.ui.artifacts.models.component-artifacts-model": "~0.0.12", "@teambit/component.ui.artifacts.queries.use-component-artifacts": "~0.0.14", - "@teambit/component.ui.component-compare.compare-aspects.compare-aspects": "~0.0.155", "@teambit/component.ui.component-compare.hooks.use-component-compare": "~0.0.113", "@teambit/component.ui.component-compare.hooks.use-component-compare-url": "~0.0.13", "@teambit/component.ui.component-compare.layouts.compare-split-layout-preset": "~0.0.10", @@ -686,6 +682,9 @@ "packageManager": "teambit.dependencies/pnpm", "rootComponents": true, "overrides": { + // Published API renderer packages pin api-node-details@0.0.88. Resolve those nested copies + // to the imported Shiki implementation until the renderer packages are released against it. + "@teambit/api-reference.renderers.api-node-details": "file:components/renderers/api-node-details", // unify all React-19 contexts on a single exact version; react-dom throws on any // react/react-dom mismatch, which breaks env-aspect loading. v17 (react 17) contexts are unaffected. "react@19": "19.2.7", @@ -846,7 +845,6 @@ "graphql": "15.8.0", "browserslist": "4.23.3", "reflect-metadata": "0.1.13", - "monaco-editor": "0.52.2", "mz": "2.7.0", "react": "19.2.7", "react-dom": "19.2.7", From 25668ea73372a80aff371d84f516768ace18945d Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 09:18:23 -0400 Subject: [PATCH 02/17] fix(ui): address Shiki migration review feedback --- .../api-node-details/api-node-details.tsx | 17 +++++- .../code-compare-editor.tsx | 41 +++++++++----- .../normalize-whitespace.spec.ts | 15 ++++++ .../normalize-whitespace.ts | 8 +++ .../ui/code-editor/code-editor.spec.tsx | 1 + components/ui/code-editor/code-editor.tsx | 9 +++- .../compare-aspects/compare-aspects.tsx | 20 ++++--- .../compare-aspects.widgets.spec.ts | 21 ++++++++ .../compare-aspects.widgets.tsx | 28 ++++------ components/ui/diff-viewer/diff-model.spec.ts | 13 +++++ components/ui/diff-viewer/diff-model.ts | 1 + components/ui/diff-viewer/diff-viewer.tsx | 53 ++++++++++++------- components/ui/diff-viewer/highlighter.ts | 51 ++---------------- components/ui/diff-viewer/index.ts | 3 +- components/ui/diff-viewer/language.spec.ts | 20 +++++++ components/ui/diff-viewer/language.ts | 51 ++++++++++++++++++ 16 files changed, 244 insertions(+), 108 deletions(-) create mode 100644 components/ui/code-compare/code-compare-editor/normalize-whitespace.spec.ts create mode 100644 components/ui/code-compare/code-compare-editor/normalize-whitespace.ts create mode 100644 components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.spec.ts create mode 100644 components/ui/diff-viewer/diff-model.spec.ts create mode 100644 components/ui/diff-viewer/language.spec.ts create mode 100644 components/ui/diff-viewer/language.ts diff --git a/components/renderers/api-node-details/api-node-details.tsx b/components/renderers/api-node-details/api-node-details.tsx index c25a3d1c861d..59c3190a12db 100644 --- a/components/renderers/api-node-details/api-node-details.tsx +++ b/components/renderers/api-node-details/api-node-details.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { H6 } from '@teambit/documenter.ui.heading'; import { CodeEditor } from '@teambit/code.ui.code-editor'; import classnames from 'classnames'; @@ -38,10 +38,23 @@ export function APINodeDetails({ }: APINodeDetailsProps) { const query = useQuery(); const rootRef = useRef(null) as React.MutableRefObject; - const [containerSize] = useState<{ width?: number }>({}); + const [containerSize, setContainerSize] = useState<{ width?: number }>({}); const currentQueryParams = query.toString(); const indexHidden = (containerSize.width ?? 0) < INDEX_THRESHOLD_WIDTH; + useEffect(() => { + const container = rootRef.current; + if (!container) return undefined; + + const updateSize = () => setContainerSize({ width: container.getBoundingClientRect().width }); + updateSize(); + + if (typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(updateSize); + observer.observe(container); + return () => observer.disconnect(); + }, [currentQueryParams]); + const example = (doc?.tags || []).find((tag) => tag.tagName === 'example'); const comment = doc?.comment ?? doc?.tags?.filter((tag) => tag.comment).reduce((acc, tag) => acc.concat(`${tag.comment}\n`), ''); diff --git a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx index 62f8bc1353fc..ab7164c1ffac 100644 --- a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx +++ b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { DiffViewer } from '@teambit/code.ui.diff-viewer'; import type { EditorSettingsState } from '../code-compare-editor-settings'; +import { normalizeWhitespace } from './normalize-whitespace'; export type CodeCompareEditorProps = { language: string; @@ -14,6 +15,29 @@ export type CodeCompareEditorProps = { fullScreen?: boolean; } & EditorSettingsState; +const REGULAR_DIFF_HEIGHT = 640; +const FULLSCREEN_CHROME_HEIGHT = 160; +const MIN_FULLSCREEN_DIFF_HEIGHT = 220; + +function useDiffHeight(fullScreen?: boolean) { + const [height, setHeight] = useState(REGULAR_DIFF_HEIGHT); + + useEffect(() => { + if (!fullScreen) { + setHeight(REGULAR_DIFF_HEIGHT); + return undefined; + } + + const updateHeight = () => + setHeight(Math.max(MIN_FULLSCREEN_DIFF_HEIGHT, window.innerHeight - FULLSCREEN_CHROME_HEIGHT)); + updateHeight(); + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + }, [fullScreen]); + + return height; +} + export function CodeCompareEditor({ modifiedFileContent, originalFileContent, @@ -26,24 +50,17 @@ export function CodeCompareEditor({ editorViewMode, fullScreen, }: CodeCompareEditorProps) { - const normalizeWhitespace = (content = '') => - ignoreWhitespace - ? content - .split('\n') - .map((line) => line.trimEnd()) - .join('\n') - : content; - + const maxHeight = useDiffHeight(fullScreen); return ( { + it('ignores indentation-only and trailing-whitespace changes', () => { + expect(normalizeWhitespace(' const answer = 42; ', true)).toBe('const answer = 42;'); + }); + + it('preserves substantive internal spacing', () => { + expect(normalizeWhitespace('const answer = 42;', true)).toBe('const answer = 42;'); + }); + + it('returns content unchanged when whitespace is significant', () => { + expect(normalizeWhitespace(' const answer = 42; ')).toBe(' const answer = 42; '); + }); +}); diff --git a/components/ui/code-compare/code-compare-editor/normalize-whitespace.ts b/components/ui/code-compare/code-compare-editor/normalize-whitespace.ts new file mode 100644 index 000000000000..3c931187f001 --- /dev/null +++ b/components/ui/code-compare/code-compare-editor/normalize-whitespace.ts @@ -0,0 +1,8 @@ +/** Match the previous editor's ignore-trim-whitespace behavior without changing internal spacing. */ +export function normalizeWhitespace(content = '', ignoreWhitespace = false): string { + if (!ignoreWhitespace) return content; + return content + .split('\n') + .map((line) => line.trim()) + .join('\n'); +} diff --git a/components/ui/code-editor/code-editor.spec.tsx b/components/ui/code-editor/code-editor.spec.tsx index fe6531d38174..60e0b29a20b8 100644 --- a/components/ui/code-editor/code-editor.spec.tsx +++ b/components/ui/code-editor/code-editor.spec.tsx @@ -5,6 +5,7 @@ import { CodeEditorProvider } from './code-editor.provider'; jest.mock('@teambit/code.ui.diff-viewer', () => ({ langFromFileName: () => 'typescript', + normalizeLanguage: (language?: string) => language, resolveTokenColor: () => undefined, useHighlightedLines: () => null, })); diff --git a/components/ui/code-editor/code-editor.tsx b/components/ui/code-editor/code-editor.tsx index 3793e7a46cd5..ba06e65f7ad0 100644 --- a/components/ui/code-editor/code-editor.tsx +++ b/components/ui/code-editor/code-editor.tsx @@ -1,6 +1,11 @@ import React from 'react'; import classnames from 'classnames'; -import { langFromFileName, resolveTokenColor, useHighlightedLines } from '@teambit/code.ui.diff-viewer'; +import { + langFromFileName, + normalizeLanguage, + resolveTokenColor, + useHighlightedLines, +} from '@teambit/code.ui.diff-viewer'; import styles from './code-editor.module.scss'; /** @@ -66,7 +71,7 @@ function HighlightedCode({ content, language }: { content: string; language?: st * the shared Shiki highlighter and does not load executable editor code from a CDN. */ export function CodeEditor({ fileContent = '', filePath, language, className, height }: CodeEditorProps) { - const resolvedLanguage = language || langFromFileName(filePath) || 'typescript'; + const resolvedLanguage = normalizeLanguage(language) || langFromFileName(filePath) || 'typescript'; return (
{ - if (hook?.useUpdatedUrlFromQuery || state?.controlled) return controlledHref; - const queryObj = Object.fromEntries(query.entries()); - const queryString = new URLSearchParams({ ...queryObj, aspect: node.id }).toString(); - return `${location.pathname}?${queryString}`; + const useHref = (node) => { + const hrefFromHook = + hook?.useUpdatedUrlFromQuery?.( + { aspect: node.id }, + () => query, + () => location + ) ?? null; + const defaultHref = useUpdatedUrlFromQuery( + { aspect: node.id }, + () => query, + () => location + ); + return hrefFromHook || (state?.controlled ? controlledHref : defaultHref); }; return ( @@ -71,7 +79,7 @@ export function ComponentCompareAspects({ host, className }: ComponentCompareAsp currentFile={selected} drawerName={'ASPECTS'} widgets={[Widget]} - getHref={getHref} + getHref={useHref} onTreeNodeSelected={hook?.onClick} open={isSidebarOpen} /> diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.spec.ts b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.spec.ts new file mode 100644 index 000000000000..cbc3a4828a87 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.spec.ts @@ -0,0 +1,21 @@ +import type { ComponentAspectData } from '@teambit/component.ui.component-compare.compare-aspects.models.component-compare-aspects-model'; +import { getAspectStatus } from './compare-aspects.widgets'; + +const aspect = (config?: Record, data?: Record) => + ({ id: 'example.aspect', config, data }) as ComponentAspectData; + +describe('getAspectStatus', () => { + it('classifies whole-aspect presence as new or deleted', () => { + expect(getAspectStatus(aspect(), undefined)).toBe('deleted'); + expect(getAspectStatus(undefined, aspect())).toBe('new'); + }); + + it('classifies nested config or data presence changes as modified', () => { + expect(getAspectStatus(aspect(undefined, {}), aspect({}, {}))).toBe('modified'); + expect(getAspectStatus(aspect({}, {}), aspect({}, undefined))).toBe('modified'); + }); + + it('returns no status for equal aspects', () => { + expect(getAspectStatus(aspect({ enabled: true }, {}), aspect({ enabled: true }, {}))).toBeNull(); + }); +}); diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx index f534ed315036..516e986d4dd2 100644 --- a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx @@ -29,25 +29,15 @@ export function Widget({ node }: WidgetProps) { } export function getAspectStatus(aspectA?: ComponentAspectData, aspectB?: ComponentAspectData): CompareStatus | null { - const isUndefined = (data) => data === undefined; - const isDeleted = (base, compare) => { - return isUndefined(compare) && !isUndefined(base); - }; - const isNew = (base, compare) => { - return !isUndefined(compare) && isUndefined(base); - }; - - const baseConfig = aspectA?.config; - const baseData = aspectA?.data; - const compareConfig = aspectB?.config; - const compareData = aspectB?.data; - - if (isDeleted(baseConfig, compareConfig) || isDeleted(baseData, compareData)) { - return 'deleted'; - } - if (isNew(baseConfig, compareConfig) || isNew(baseData, compareData)) { - return 'new'; - } + if (aspectA && !aspectB) return 'deleted'; + if (!aspectA && aspectB) return 'new'; + if (!aspectA || !aspectB) return null; + + const baseConfig = aspectA.config; + const baseData = aspectA.data; + const compareConfig = aspectB.config; + const compareData = aspectB.data; + if (!isEqual(baseConfig, compareConfig) || !isEqual(baseData, compareData)) { return 'modified'; } diff --git a/components/ui/diff-viewer/diff-model.spec.ts b/components/ui/diff-viewer/diff-model.spec.ts new file mode 100644 index 000000000000..d0708c5115bf --- /dev/null +++ b/components/ui/diff-viewer/diff-model.spec.ts @@ -0,0 +1,13 @@ +import { buildSections, computeDiffLines } from './diff-model'; + +describe('buildSections', () => { + it('shows a complete unchanged file when context covers the file', () => { + const items = computeDiffLines('one\ntwo\nthree', 'one\ntwo\nthree'); + expect(buildSections(items, Number.MAX_SAFE_INTEGER)).toEqual([{ kind: 'lines', items }]); + }); + + it('collapses a complete unchanged file when context is limited', () => { + const items = computeDiffLines('one\ntwo\nthree', 'one\ntwo\nthree'); + expect(buildSections(items, 1)).toEqual([{ kind: 'gap', id: 'gap-all', hidden: items }]); + }); +}); diff --git a/components/ui/diff-viewer/diff-model.ts b/components/ui/diff-viewer/diff-model.ts index eef3afe82a42..838320c9acfb 100644 --- a/components/ui/diff-viewer/diff-model.ts +++ b/components/ui/diff-viewer/diff-model.ts @@ -183,6 +183,7 @@ export function buildSections(items: DiffLineItem[], context: number = DEFAULT_C if (items[i].type !== 'context') changedIdx.push(i); } if (changedIdx.length === 0) { + if (context >= items.length) return items.length ? [{ kind: 'lines', items }] : []; // an unchanged file: collapse everything into one expandable gap. return items.length ? [{ kind: 'gap', id: 'gap-all', hidden: items }] : []; } diff --git a/components/ui/diff-viewer/diff-viewer.tsx b/components/ui/diff-viewer/diff-viewer.tsx index 374bb1a2e32e..5452dc9a2b81 100644 --- a/components/ui/diff-viewer/diff-viewer.tsx +++ b/components/ui/diff-viewer/diff-viewer.tsx @@ -3,7 +3,8 @@ 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 { useHighlightedLines } from './highlighter'; +import { langFromFileName, normalizeLanguage } from './language'; import { resolveTokenColor } from './shiki-bit-theme'; import styles from './diff-viewer.module.scss'; @@ -52,6 +53,7 @@ const OVERSCAN = 14; const EXPAND_CHUNK = 20; type GapState = { top: number; bottom: number }; +const EMPTY_GAP_STATES: Record = {}; /** a single rendered row: a code line (or a left/right pair) or a collapsed-gap expander. */ type RenderRow = @@ -89,13 +91,18 @@ export function DiffViewer({ ); const [collapsed, setCollapsed] = useState(defaultCollapsed); - const [gapStates, setGapStates] = useState>({}); - const lang = language ?? langFromFileName(fileName); + const lang = normalizeLanguage(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]); + const [gapStateStore, setGapStateStore] = useState<{ + sections?: DiffSection[]; + states: Record; + }>({ states: {} }); + // A rebuilt section model gets an empty expansion state immediately, before effects run. + const gapStates = gapStateStore.sections === sections ? gapStateStore.states : EMPTY_GAP_STATES; // tokenize each whole file once; multi-line constructs stay correct and lines are looked up by number. const oldHl = useHighlightedLines(oldContent, lang); @@ -108,17 +115,23 @@ export function DiffViewer({ 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 expandGap = useCallback( + (id: string, hiddenLen: number, dir: 'top' | 'bottom' | 'all') => { + setGapStateStore((prev) => { + const states = prev.sections === sections ? prev.states : {}; + const cur = states[id] ?? { top: 0, bottom: 0 }; + const remaining = hiddenLen - cur.top - cur.bottom; + if (remaining <= 0) return prev; + if (dir === 'all') { + return { sections, states: { ...states, [id]: { top: cur.top + remaining, bottom: cur.bottom } } }; + } + const add = Math.min(EXPAND_CHUNK, remaining); + if (dir === 'top') return { sections, states: { ...states, [id]: { ...cur, top: cur.top + add } } }; + return { sections, states: { ...states, [id]: { ...cur, bottom: cur.bottom + add } } }; + }); + }, + [sections] + ); const rows = useMemo(() => buildRenderRows(sections, gapStates, view), [sections, gapStates, view]); @@ -146,9 +159,10 @@ export function DiffViewer({ newHl={newHl} codeWidthCh={codeWidthCh} maxHeight={maxHeight} - // wrap makes rows variable-height (height: auto), which invalidates the fixed-ROW_H - // windowing math; force full, non-windowed rendering (page scrolls) whenever wrap is on. + // Wrap makes rows variable-height, which invalidates fixed-row windowing. Render all rows + // but retain the requested scroll cap through constrainHeight. virtualize={virtualize && !wrap} + constrainHeight={virtualize} onExpand={expandGap} /> )} @@ -255,6 +269,7 @@ function DiffBody({ codeWidthCh, maxHeight, virtualize, + constrainHeight, onExpand, }: { rows: RenderRow[]; @@ -265,6 +280,8 @@ function DiffBody({ maxHeight: number; /** when false, the file renders fully expanded with no inner scroll/windowing (page scrolls). */ virtualize: boolean; + /** keep the body bounded even when wrapping disables fixed-row virtualization. */ + constrainHeight: boolean; onExpand: (id: string, hiddenLen: number, dir: 'top' | 'bottom' | 'all') => void; }) { const scrollRef = useRef(null); @@ -304,8 +321,8 @@ function DiffBody({ const offsetY = start * ROW_H; const bodyStyle: React.CSSProperties = { - // no cap when virtualization is off — the file expands and the host page scrolls. - maxHeight: virtualize ? maxHeight : undefined, + // Wrapped rows cannot use fixed-height virtualization, but still need the caller's scroll cap. + maxHeight: constrainHeight ? maxHeight : undefined, // a stable horizontal track so every (virtualized) row aligns; gutters stay sticky over it. ['--diff-code-width' as any]: `${codeWidthCh}ch`, ['--diff-row-h' as any]: `${ROW_H}px`, diff --git a/components/ui/diff-viewer/highlighter.ts b/components/ui/diff-viewer/highlighter.ts index d602249a754d..636d9b2ba525 100644 --- a/components/ui/diff-viewer/highlighter.ts +++ b/components/ui/diff-viewer/highlighter.ts @@ -14,53 +14,6 @@ export type HlToken = { content: string; color?: string }; /** A file's tokens, indexed by line (line `n` is `lines[n - 1]`). */ export type HlLines = HlToken[][]; -/** map a file extension to a shiki language id (and the aliases shiki itself understands). */ -const EXTENSION_TO_LANG: Record = { - ts: 'typescript', - mts: 'typescript', - cts: 'typescript', - tsx: 'tsx', - js: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - jsx: 'jsx', - json: 'json', - jsonc: 'jsonc', - json5: 'jsonc', - css: 'css', - scss: 'scss', - sass: 'scss', - less: 'less', - html: 'html', - htm: 'html', - vue: 'vue', - md: 'markdown', - markdown: 'markdown', - mdx: 'mdx', - yml: 'yaml', - yaml: 'yaml', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - sh: 'shellscript', - bash: 'shellscript', - zsh: 'shellscript', - graphql: 'graphql', - gql: 'graphql', - sql: 'sql', - // jest snapshots (`x.spec.ts.snap` / `.snap`) are JS modules (exports[`...`] = `...`), and - // `split('.').pop()` reduces every variant to the same `snap` extension. - snap: 'javascript', -}; - -export function langFromFileName(fileName?: string): string | undefined { - if (!fileName) return undefined; - const ext = fileName.split('.').pop()?.toLowerCase(); - if (!ext) return undefined; - return EXTENSION_TO_LANG[ext]; -} - let highlighterPromise: Promise | undefined; const loadedLangs = new Set(); const langPromises = new Map>(); @@ -154,7 +107,7 @@ export function useHighlightedLines(content: string | undefined, lang: string | let cancelled = false; if (!lang || content === undefined) return undefined; if (loadedLangs.has(lang) && highlighterInstance) return undefined; - ensureLanguage(lang).then((ok) => { + void ensureLanguage(lang).then((ok) => { if (!ok || cancelled) return undefined; // ensure the sync instance is captured before we ask the tree to re-tokenize return getHighlighter().then((hl) => { @@ -171,6 +124,8 @@ export function useHighlightedLines(content: string | undefined, lang: string | // small files: memoized so re-renders from view/expand state don't re-tokenize the whole file. const syncLines = useMemo(() => { + // Reading version makes the grammar-load state an explicit input to this memoized tokenization. + void version; if (!lang || content === undefined || isLarge) return null; return tokenize(content, lang); }, [content, lang, isLarge, version]); diff --git a/components/ui/diff-viewer/index.ts b/components/ui/diff-viewer/index.ts index ea26595dafcf..dad45f343567 100644 --- a/components/ui/diff-viewer/index.ts +++ b/components/ui/diff-viewer/index.ts @@ -2,6 +2,7 @@ export { DiffViewer } from './diff-viewer'; export type { DiffViewerProps, DiffViewMode, DiffFileStatus } from './diff-viewer'; export { computeDiffLines, buildSections, statsFromItems, pairForSplit, intraLineDiff } from './diff-model'; export type { DiffLineItem, DiffLineType, DiffSection, DiffStats, SplitRow } from './diff-model'; -export { useHighlightedLines, langFromFileName } from './highlighter'; +export { useHighlightedLines } from './highlighter'; +export { langFromFileName, normalizeLanguage } from './language'; export type { HlToken, HlLines } from './highlighter'; export { bitShikiTheme, resolveTokenColor, BIT_THEME_NAME } from './shiki-bit-theme'; diff --git a/components/ui/diff-viewer/language.spec.ts b/components/ui/diff-viewer/language.spec.ts new file mode 100644 index 000000000000..72e2d6c2f08f --- /dev/null +++ b/components/ui/diff-viewer/language.spec.ts @@ -0,0 +1,20 @@ +import { langFromFileName, normalizeLanguage } from './language'; + +describe('language normalization', () => { + const aliases = [ + ['ts', 'typescript'], + ['JS', 'javascript'], + ['sh', 'shellscript'], + ['yml', 'yaml'], + ]; + + aliases.forEach(([input, expected]) => { + it(`maps ${input} to the supported ${expected} grammar`, () => { + expect(normalizeLanguage(input)).toBe(expected); + }); + }); + + it('normalizes file extensions through the same alias map', () => { + expect(langFromFileName('example.TS')).toBe('typescript'); + }); +}); diff --git a/components/ui/diff-viewer/language.ts b/components/ui/diff-viewer/language.ts new file mode 100644 index 000000000000..a5d0ebec9e53 --- /dev/null +++ b/components/ui/diff-viewer/language.ts @@ -0,0 +1,51 @@ +/** Map file extensions and common fenced-code aliases to Shiki language ids. */ +const EXTENSION_TO_LANG: Record = { + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + tsx: 'tsx', + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + jsx: 'jsx', + json: 'json', + jsonc: 'jsonc', + json5: 'jsonc', + css: 'css', + scss: 'scss', + sass: 'scss', + less: 'less', + html: 'html', + htm: 'html', + vue: 'vue', + md: 'markdown', + markdown: 'markdown', + mdx: 'mdx', + yml: 'yaml', + yaml: 'yaml', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + sh: 'shellscript', + bash: 'shellscript', + zsh: 'shellscript', + graphql: 'graphql', + gql: 'graphql', + sql: 'sql', + // Jest snapshots (`x.spec.ts.snap` / `.snap`) are JavaScript modules. + snap: 'javascript', +}; + +/** Normalize explicit language names and common aliases to Shiki grammar ids. */ +export function normalizeLanguage(language?: string): string | undefined { + const normalized = language?.trim().toLowerCase(); + if (!normalized) return undefined; + return EXTENSION_TO_LANG[normalized] ?? normalized; +} + +export function langFromFileName(fileName?: string): string | undefined { + if (!fileName) return undefined; + const ext = fileName.split('.').pop(); + return normalizeLanguage(ext); +} From 808b1e24616dd1f15a92dfc7288deba28bb495a3 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 09:25:12 -0400 Subject: [PATCH 03/17] fix(diff): preserve source whitespace when matching lines --- .../code-compare-editor.tsx | 6 +-- .../normalize-whitespace.spec.ts | 15 -------- .../normalize-whitespace.ts | 8 ---- components/ui/diff-viewer/diff-model.spec.ts | 26 +++++++++++++ components/ui/diff-viewer/diff-model.ts | 37 ++++++++++++++++--- components/ui/diff-viewer/diff-viewer.tsx | 23 ++++++++++-- components/ui/diff-viewer/index.ts | 2 +- 7 files changed, 82 insertions(+), 35 deletions(-) delete mode 100644 components/ui/code-compare/code-compare-editor/normalize-whitespace.spec.ts delete mode 100644 components/ui/code-compare/code-compare-editor/normalize-whitespace.ts diff --git a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx index ab7164c1ffac..16efd2c99a18 100644 --- a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx +++ b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from 'react'; import { DiffViewer } from '@teambit/code.ui.diff-viewer'; import type { EditorSettingsState } from '../code-compare-editor-settings'; -import { normalizeWhitespace } from './normalize-whitespace'; export type CodeCompareEditorProps = { language: string; @@ -55,8 +54,9 @@ export function CodeCompareEditor({ { - it('ignores indentation-only and trailing-whitespace changes', () => { - expect(normalizeWhitespace(' const answer = 42; ', true)).toBe('const answer = 42;'); - }); - - it('preserves substantive internal spacing', () => { - expect(normalizeWhitespace('const answer = 42;', true)).toBe('const answer = 42;'); - }); - - it('returns content unchanged when whitespace is significant', () => { - expect(normalizeWhitespace(' const answer = 42; ')).toBe(' const answer = 42; '); - }); -}); diff --git a/components/ui/code-compare/code-compare-editor/normalize-whitespace.ts b/components/ui/code-compare/code-compare-editor/normalize-whitespace.ts deleted file mode 100644 index 3c931187f001..000000000000 --- a/components/ui/code-compare/code-compare-editor/normalize-whitespace.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Match the previous editor's ignore-trim-whitespace behavior without changing internal spacing. */ -export function normalizeWhitespace(content = '', ignoreWhitespace = false): string { - if (!ignoreWhitespace) return content; - return content - .split('\n') - .map((line) => line.trim()) - .join('\n'); -} diff --git a/components/ui/diff-viewer/diff-model.spec.ts b/components/ui/diff-viewer/diff-model.spec.ts index d0708c5115bf..cadb785d1d06 100644 --- a/components/ui/diff-viewer/diff-model.spec.ts +++ b/components/ui/diff-viewer/diff-model.spec.ts @@ -11,3 +11,29 @@ describe('buildSections', () => { expect(buildSections(items, 1)).toEqual([{ kind: 'gap', id: 'gap-all', hidden: items }]); }); }); + +describe('computeDiffLines', () => { + it('ignores edge whitespace for matching while preserving both rendered sources', () => { + const items = computeDiffLines(' const answer = 42; \n', 'const answer = 42;\n', { + ignoreTrimWhitespace: true, + }); + + expect(items).toEqual([ + { + type: 'context', + oldLn: 1, + newLn: 1, + text: 'const answer = 42;', + oldText: ' const answer = 42; ', + newText: 'const answer = 42;', + }, + ]); + }); + + it('preserves substantive internal-space changes', () => { + const items = computeDiffLines('const answer = 42;\n', 'const answer = 42;\n', { + ignoreTrimWhitespace: true, + }); + expect(items.map(({ type }) => type)).toEqual(['del', 'add']); + }); +}); diff --git a/components/ui/diff-viewer/diff-model.ts b/components/ui/diff-viewer/diff-model.ts index 838320c9acfb..995871299b44 100644 --- a/components/ui/diff-viewer/diff-model.ts +++ b/components/ui/diff-viewer/diff-model.ts @@ -8,6 +8,9 @@ export type DiffLineItem = { oldLn?: number; newLn?: number; text: string; + /** Side-specific source text for context lines matched with comparison-only normalization. */ + oldText?: string; + newText?: string; /** half-open `[start, end)` character ranges that actually changed (paired add/del lines only). */ intra?: Array<[number, number]>; }; @@ -27,8 +30,18 @@ const DEFAULT_CONTEXT = 3; * 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 ?? ''); +export type ComputeDiffOptions = { ignoreTrimWhitespace?: boolean }; + +export function computeDiffLines( + oldContent: string, + newContent: string, + options: ComputeDiffOptions = {} +): DiffLineItem[] { + const oldSourceLines = splitSourceLines(oldContent ?? ''); + const newSourceLines = splitSourceLines(newContent ?? ''); + const parts = diffLines(oldContent ?? '', newContent ?? '', { + ignoreWhitespace: options.ignoreTrimWhitespace, + }); const items: DiffLineItem[] = []; let oldLn = 1; let newLn = 1; @@ -38,9 +51,17 @@ export function computeDiffLines(oldContent: string, newContent: string): DiffLi // 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 }); + if (part.added) { + items.push({ type: 'add', newLn, text: newSourceLines[newLn - 1] ?? text }); + newLn++; + } else if (part.removed) { + items.push({ type: 'del', oldLn, text: oldSourceLines[oldLn - 1] ?? text }); + oldLn++; + } else { + const oldText = oldSourceLines[oldLn - 1] ?? text; + const newText = newSourceLines[newLn - 1] ?? text; + items.push({ type: 'context', oldLn: oldLn++, newLn: newLn++, text: newText, oldText, newText }); + } } } @@ -48,6 +69,12 @@ export function computeDiffLines(oldContent: string, newContent: string): DiffLi return items; } +function splitSourceLines(content: string): string[] { + const lines = content.split('\n'); + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} + export function statsFromItems(items: DiffLineItem[]): DiffStats { let additions = 0; let deletions = 0; diff --git a/components/ui/diff-viewer/diff-viewer.tsx b/components/ui/diff-viewer/diff-viewer.tsx index 5452dc9a2b81..831ca1f92c7d 100644 --- a/components/ui/diff-viewer/diff-viewer.tsx +++ b/components/ui/diff-viewer/diff-viewer.tsx @@ -20,6 +20,8 @@ export type DiffViewerProps = { newContent: string; /** language id override (otherwise inferred from the file extension). */ language?: string; + /** ignore leading/trailing whitespace while matching lines without altering rendered source. */ + ignoreTrimWhitespace?: boolean; /** controlled view mode. */ view?: DiffViewMode; /** initial view mode when uncontrolled. */ @@ -66,6 +68,7 @@ export function DiffViewer({ oldContent, newContent, language, + ignoreTrimWhitespace = false, view: controlledView, defaultView = 'split', onViewChange, @@ -94,7 +97,10 @@ export function DiffViewer({ const lang = normalizeLanguage(language) ?? langFromFileName(fileName); - const items = useMemo(() => computeDiffLines(oldContent, newContent), [oldContent, newContent]); + const items = useMemo( + () => computeDiffLines(oldContent, newContent, { ignoreTrimWhitespace }), + [oldContent, newContent, ignoreTrimWhitespace] + ); const stats = useMemo(() => statsFromItems(items), [items]); const sections = useMemo(() => buildSections(items, contextLines), [items, contextLines]); const [gapStateStore, setGapStateStore] = useState<{ @@ -357,7 +363,9 @@ function UnifiedRow({ item, oldHl, newHl }: { item: DiffLineItem; oldHl: HlLines {item.oldLn ?? ''} {item.newLn ?? ''} {sign} - {renderLineContent(hlForLine(item, oldHl, newHl), item.text, item.intra)} + + {renderLineContent(hlForLine(item, oldHl, newHl), item.newText ?? item.text, item.intra)} + ); } @@ -402,10 +410,19 @@ function SplitCell({ } const tone = item.type === 'add' ? styles.addLine : item.type === 'del' ? styles.delLine : undefined; const num = side === 'left' ? item.oldLn : item.newLn; + const text = side === 'left' ? (item.oldText ?? item.text) : (item.newText ?? item.text); + const highlighted = + side === 'left' + ? item.oldLn + ? (oldHl?.[item.oldLn - 1] ?? null) + : null + : item.newLn + ? (newHl?.[item.newLn - 1] ?? null) + : null; return (
{num ?? ''} - {renderLineContent(hlForLine(item, oldHl, newHl), item.text, item.intra)} + {renderLineContent(highlighted, text, item.intra)}
); } diff --git a/components/ui/diff-viewer/index.ts b/components/ui/diff-viewer/index.ts index dad45f343567..269ccc60f000 100644 --- a/components/ui/diff-viewer/index.ts +++ b/components/ui/diff-viewer/index.ts @@ -1,7 +1,7 @@ export { DiffViewer } from './diff-viewer'; export type { DiffViewerProps, DiffViewMode, DiffFileStatus } from './diff-viewer'; export { computeDiffLines, buildSections, statsFromItems, pairForSplit, intraLineDiff } from './diff-model'; -export type { DiffLineItem, DiffLineType, DiffSection, DiffStats, SplitRow } from './diff-model'; +export type { ComputeDiffOptions, DiffLineItem, DiffLineType, DiffSection, DiffStats, SplitRow } from './diff-model'; export { useHighlightedLines } from './highlighter'; export { langFromFileName, normalizeLanguage } from './language'; export type { HlToken, HlLines } from './highlighter'; From 8519fa65612f68a4ad52983e7dd1aed4c6a9f1a3 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 09:30:07 -0400 Subject: [PATCH 04/17] fix(api-reference): render symbol links as text --- .../api-node-details/api-node-details.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/components/renderers/api-node-details/api-node-details.tsx b/components/renderers/api-node-details/api-node-details.tsx index 59c3190a12db..fe3a94d0b87b 100644 --- a/components/renderers/api-node-details/api-node-details.tsx +++ b/components/renderers/api-node-details/api-node-details.tsx @@ -82,10 +82,16 @@ export function APINodeDetails({ {comment &&
{comment}
} {linkComment && (
- {linkPlaceholder && {linkPlaceholder}: } - - {linkURL} - + {linkURL ? ( + <> + {linkPlaceholder && {linkPlaceholder}: } + + {linkURL} + + + ) : ( + {linkComment} + )}
)} {signature && ( From aa439acfd1c59cb0442f558c46c1f90a66d8a5c6 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 09:38:55 -0400 Subject: [PATCH 05/17] fix(ui): align policy and preserve example indentation --- .bitmap | 10 +++++----- .../api-node-details/extract-code-block.spec.ts | 6 ++++++ .../renderers/api-node-details/extract-code-block.ts | 4 +++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.bitmap b/.bitmap index e1316499e7ca..ebb0430c756b 100644 --- a/.bitmap +++ b/.bitmap @@ -222,9 +222,9 @@ "teambit.dependencies/dependency-resolver": { "policy": { "dependencies": { - "@teambit/base-react.navigation.link": "2.0.31", + "@teambit/base-react.navigation.link": "2.0.33", "@teambit/ui-foundation.ui.navigation.react-router-adapter": "6.1.3", - "@apollo/client": "3.6.9", + "@apollo/client": "3.12.11", "@yarnpkg/cli": "3.6.1", "@yarnpkg/core": "3.5.2", "@yarnpkg/plugin-pack": "3.2.0", @@ -234,8 +234,8 @@ "browserslist": "4.23.3", "reflect-metadata": "0.1.13", "mz": "2.7.0", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.7", + "react-dom": "19.2.7", "core-js": "3.13.0", "postcss": "8.4.18", "buffer": "6.0.3", @@ -244,7 +244,7 @@ "typescript": "5.9.2", "@parcel/css": "^1.8.3", "lightningcss": "^1.20.0", - "@swc/css": "^0.0.20", + "@swc/css": "~0.0.20", "@teambit/legacy": "2.1.0" }, "devDependencies": {}, diff --git a/components/renderers/api-node-details/extract-code-block.spec.ts b/components/renderers/api-node-details/extract-code-block.spec.ts index 590e49f9a926..2079b1dc0422 100644 --- a/components/renderers/api-node-details/extract-code-block.spec.ts +++ b/components/renderers/api-node-details/extract-code-block.spec.ts @@ -13,6 +13,12 @@ describe('extractCodeBlock', () => { expect(result).toEqual({ lang: '', code: 'const foo = "bar";\n' }); }); + it('preserves indentation on the first code line', () => { + const text = '```typescript\n const foo = "bar";\n```'; + const result = extractCodeBlock(text); + expect(result).toEqual({ lang: 'typescript', code: ' const foo = "bar";\n' }); + }); + it('should return null if no code block is found', () => { const text = 'const foo = "bar";'; const result = extractCodeBlock(text); diff --git a/components/renderers/api-node-details/extract-code-block.ts b/components/renderers/api-node-details/extract-code-block.ts index d961ab107a1c..98878bc0750d 100644 --- a/components/renderers/api-node-details/extract-code-block.ts +++ b/components/renderers/api-node-details/extract-code-block.ts @@ -10,7 +10,9 @@ export function extractCodeBlock(text: string): { lang: string; code: string } | if (text.endsWith(';') && !text.endsWith('```')) { processedText = text.slice(0, -1) + '```'; } - const regex = /```([\w+-]*)\s*([\s\S]*?)```/; + // Consume horizontal fence padding and at most one line break. A broad `\s*` here would also + // consume indentation belonging to the first code line. + const regex = /```([\w+-]*)[^\S\r\n]*(?:\r?\n)?([\s\S]*?)```/; const match = processedText.match(regex); From 9532a5f37ea0af94926db7fa3b416d356ab6488a Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 09:46:30 -0400 Subject: [PATCH 06/17] fix(ui): preserve compare routing and rendering semantics --- .../renderers/api-node-details/api-node-details.tsx | 6 +++++- .../code-compare-editor/code-compare-editor.tsx | 2 +- .../code-compare-view/code-compare-view.tsx | 2 +- .../compare-aspects/compare-aspects.tsx | 11 +++-------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/components/renderers/api-node-details/api-node-details.tsx b/components/renderers/api-node-details/api-node-details.tsx index fe3a94d0b87b..b13a754ad037 100644 --- a/components/renderers/api-node-details/api-node-details.tsx +++ b/components/renderers/api-node-details/api-node-details.tsx @@ -57,7 +57,11 @@ export function APINodeDetails({ const example = (doc?.tags || []).find((tag) => tag.tagName === 'example'); const comment = - doc?.comment ?? doc?.tags?.filter((tag) => tag.comment).reduce((acc, tag) => acc.concat(`${tag.comment}\n`), ''); + doc?.comment ?? + doc?.tags + ?.filter((tag) => tag.comment && tag.tagName !== 'example' && tag.tagName !== 'link') + .map((tag) => `@${tag.tagName} ${tag.comment}`) + .join('\n'); const linkComment = doc?.tags?.find((tag) => tag.tagName === 'link')?.comment; let linkPlaceholder: string | undefined; diff --git a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx index 16efd2c99a18..a20a5cd74e43 100644 --- a/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx +++ b/components/ui/code-compare/code-compare-editor/code-compare-editor.tsx @@ -3,7 +3,7 @@ import { DiffViewer } from '@teambit/code.ui.diff-viewer'; import type { EditorSettingsState } from '../code-compare-editor-settings'; export type CodeCompareEditorProps = { - language: string; + language?: string; handleEditorDidMount?: (...args: any[]) => void; Loader: React.ReactNode; modifiedFileContent?: string; diff --git a/components/ui/code-compare/code-compare-view/code-compare-view.tsx b/components/ui/code-compare/code-compare-view/code-compare-view.tsx index c3fdebedc39b..5bfdda22291c 100644 --- a/components/ui/code-compare/code-compare-view/code-compare-view.tsx +++ b/components/ui/code-compare/code-compare-view/code-compare-view.tsx @@ -114,7 +114,7 @@ export function CodeCompareView({ ) : ( { - const hrefFromHook = - hook?.useUpdatedUrlFromQuery?.( - { aspect: node.id }, - () => query, - () => location - ) ?? null; - const defaultHref = useUpdatedUrlFromQuery( + const href = getUpdatedUrlFromQuery( { aspect: node.id }, () => query, () => location ); - return hrefFromHook || (state?.controlled ? controlledHref : defaultHref); + return state?.controlled && !hook?.useUpdatedUrlFromQuery ? controlledHref : href; }; return ( From afcee762295fbdcb9ff8cc7eefd4409dfa9aa329 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 09:52:41 -0400 Subject: [PATCH 07/17] fix(compare): reset view for each selected file --- .../code-compare/code-compare-view/code-compare-view.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/ui/code-compare/code-compare-view/code-compare-view.tsx b/components/ui/code-compare/code-compare-view/code-compare-view.tsx index 5bfdda22291c..2b9ca9e4495b 100644 --- a/components/ui/code-compare/code-compare-view/code-compare-view.tsx +++ b/components/ui/code-compare/code-compare-view/code-compare-view.tsx @@ -43,6 +43,9 @@ export function CodeCompareView({ const componentCompareContext = useComponentCompare(); const fileCompareDataByName = componentCompareContext?.fileCompareDataByName; + const fileStatus = fileCompareDataByName?.get(fileName)?.status; + const baseIdString = baseId?.toString(); + const compareIdString = compareId?.toString(); let defaultView: EditorViewMode = 'split'; if ( @@ -50,7 +53,7 @@ export function CodeCompareView({ (compareId && baseId.isEqual(compareId)) || !originalFileContent || !modifiedFileContent || - fileCompareDataByName?.get(fileName)?.status === 'UNCHANGED' + fileStatus === 'UNCHANGED' ) { defaultView = 'inline'; } @@ -63,7 +66,7 @@ export function CodeCompareView({ React.useEffect(() => { setView(defaultView); - }, [defaultView]); + }, [defaultView, fileName, baseIdString, compareIdString, originalFileContent, modifiedFileContent, fileStatus]); const codeNavFiles = useMemo(() => { return files.filter((file) => { From c27e3d4d20430c54c2c366233a7dbd5beafd427f Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 13:13:20 -0400 Subject: [PATCH 08/17] fix(ui): consume released API renderer packages --- .bitmap | 53 +------ .../api-node-details.module.scss | 92 ------------ .../api-node-details/api-node-details.tsx | 131 ------------------ .../extract-code-block.spec.ts | 27 ---- .../api-node-details/extract-code-block.ts | 25 ---- .../renderers/api-node-details/index.ts | 2 - .../renderers/default-node-renderers/index.ts | 60 -------- .../ui/code-editor/code-editor.spec.tsx | 9 +- workspace.jsonc | 37 +++-- 9 files changed, 24 insertions(+), 412 deletions(-) delete mode 100644 components/renderers/api-node-details/api-node-details.module.scss delete mode 100644 components/renderers/api-node-details/api-node-details.tsx delete mode 100644 components/renderers/api-node-details/extract-code-block.spec.ts delete mode 100644 components/renderers/api-node-details/extract-code-block.ts delete mode 100644 components/renderers/api-node-details/index.ts delete mode 100644 components/renderers/default-node-renderers/index.ts diff --git a/.bitmap b/.bitmap index ebb0430c756b..7cf0e7dfe6ad 100644 --- a/.bitmap +++ b/.bitmap @@ -222,42 +222,7 @@ "teambit.dependencies/dependency-resolver": { "policy": { "dependencies": { - "@teambit/base-react.navigation.link": "2.0.33", - "@teambit/ui-foundation.ui.navigation.react-router-adapter": "6.1.3", - "@apollo/client": "3.12.11", - "@yarnpkg/cli": "3.6.1", - "@yarnpkg/core": "3.5.2", - "@yarnpkg/plugin-pack": "3.2.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "graphql": "15.8.0", - "browserslist": "4.23.3", - "reflect-metadata": "0.1.13", - "mz": "2.7.0", - "react": "19.2.7", - "react-dom": "19.2.7", - "core-js": "3.13.0", - "postcss": "8.4.18", - "buffer": "6.0.3", - "process": "0.11.10", - "react-router-dom": "6.3.0", - "typescript": "5.9.2", - "@parcel/css": "^1.8.3", - "lightningcss": "^1.20.0", - "@swc/css": "~0.0.20", - "@teambit/legacy": "2.1.0" - }, - "devDependencies": {}, - "peerDependencies": { - "@teambit/base-react.navigation.link": "-", - "@teambit/legacy": "-", - "@teambit/ui-foundation.ui.navigation.react-router-adapter": "-", - "browserslist": "-", - "graphql": "-", - "react": "-", - "react-dom": "-", - "react-router-dom": "-", - "core-js": "-" + "monaco-editor": "-" } } } @@ -1530,20 +1495,6 @@ "mainFile": "index.ts", "rootDir": "scopes/component/renaming" }, - "renderers/api-node-details": { - "name": "renderers/api-node-details", - "scope": "teambit.api-reference", - "version": "0.0.88", - "mainFile": "index.ts", - "rootDir": "components/renderers/api-node-details" - }, - "renderers/default-node-renderers": { - "name": "renderers/default-node-renderers", - "scope": "teambit.api-reference", - "version": "0.0.38", - "mainFile": "index.ts", - "rootDir": "components/renderers/default-node-renderers" - }, "ripple": { "name": "ripple", "scope": "teambit.cloud", @@ -2385,4 +2336,4 @@ "rootDir": "scopes/dependencies/yarn" }, "$schema-version": "17.0.0" -} \ No newline at end of file +} diff --git a/components/renderers/api-node-details/api-node-details.module.scss b/components/renderers/api-node-details/api-node-details.module.scss deleted file mode 100644 index 9bec09b1b93f..000000000000 --- a/components/renderers/api-node-details/api-node-details.module.scss +++ /dev/null @@ -1,92 +0,0 @@ -.apiNodeDetailsContainer { - padding: 24px 0px; - display: flex; - height: calc(100% - 40px); -} -.apiDetails { - overflow-y: auto; - width: 100%; - padding-right: 16px; -} -.schemaNodesIndex { - flex: 0; - height: 100%; -} -.apiNodeDetailsNameContainer { - display: flex; - flex-direction: row; - padding-bottom: 24px; - align-items: center; -} -.apiNodeDetailsName { - padding-right: 8px; -} -.apiTypeIcon { - img { - height: 18px; - } - padding-right: 8px; -} -.codeEditorContainer { - > section { - border-radius: 8px; - } -} -.editor { - background-color: var(--surface-neutral-color, #f8f9fb); -} -.apiNodeDetailsComment { - font-size: var(--bit-p-xs); - padding-bottom: 24px; -} -.apiNodeDetailsSignatureContainer { - display: flex; - flex-direction: column; - padding-bottom: 24px; -} -.apiNodeDetailsExample { - padding-bottom: 24px; -} -.apiNodeDetailsExampleTitle { - padding: 16px 0px; -} -.apiNodeImplementationDrawer { - width: 100%; -} -.apiNodeDetailsLocationContainer { - display: flex; - flex-direction: row; - align-items: center; - line-height: 16px; - padding-bottom: 24px; -} - -.apiNodeImplementationCodeView { - padding: 16px 0px; - > h1 { - display: none !important; - } -} -.apiNodeDetailsLocation { - display: flex; - width: 100%; -} -.apiNodeDetailsLocationIcon { - display: flex; - padding-right: 8px; - img { - height: 14px; - } -} -.apiNodeDetailsLocationLink { - max-width: fit-content; - font-size: var(--bit-p-xs); -} -.apiNodeDetailsMembersContainer { - padding-bottom: 16px; -} - -.apiNodeDetailsLink { - font-size: var(--bit-p-xs); - padding-bottom: 24px; -} diff --git a/components/renderers/api-node-details/api-node-details.tsx b/components/renderers/api-node-details/api-node-details.tsx deleted file mode 100644 index b13a754ad037..000000000000 --- a/components/renderers/api-node-details/api-node-details.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { H6 } from '@teambit/documenter.ui.heading'; -import { CodeEditor } from '@teambit/code.ui.code-editor'; -import classnames from 'classnames'; -import type { APINodeRenderProps } from '@teambit/api-reference.models.api-node-renderer'; -import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; -import { SchemaNodesIndex } from '@teambit/api-reference.renderers.schema-nodes-index'; - -import { extractCodeBlock } from './extract-code-block'; -import styles from './api-node-details.module.scss'; - -const INDEX_THRESHOLD_WIDTH = 600; - -export type APINodeDetailsProps = APINodeRenderProps & { - displaySignature?: string; - options?: { - hideIndex?: boolean; - }; -}; - -export function APINodeDetails({ - apiNode: { - api: { - signature: defaultSignature, - doc, - location: { filePath }, - }, - }, - displaySignature, - children, - // retained in the public renderer contract; this component does not render child nodes itself. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - renderers: _renderers, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - apiRefModel: _apiRefModel, - options, - ...rest -}: APINodeDetailsProps) { - const query = useQuery(); - const rootRef = useRef(null) as React.MutableRefObject; - const [containerSize, setContainerSize] = useState<{ width?: number }>({}); - const currentQueryParams = query.toString(); - const indexHidden = (containerSize.width ?? 0) < INDEX_THRESHOLD_WIDTH; - - useEffect(() => { - const container = rootRef.current; - if (!container) return undefined; - - const updateSize = () => setContainerSize({ width: container.getBoundingClientRect().width }); - updateSize(); - - if (typeof ResizeObserver === 'undefined') return undefined; - const observer = new ResizeObserver(updateSize); - observer.observe(container); - return () => observer.disconnect(); - }, [currentQueryParams]); - - const example = (doc?.tags || []).find((tag) => tag.tagName === 'example'); - const comment = - doc?.comment ?? - doc?.tags - ?.filter((tag) => tag.comment && tag.tagName !== 'example' && tag.tagName !== 'link') - .map((tag) => `@${tag.tagName} ${tag.comment}`) - .join('\n'); - const linkComment = doc?.tags?.find((tag) => tag.tagName === 'link')?.comment; - - let linkPlaceholder: string | undefined; - let linkURL: string | undefined; - if (linkComment) { - const parts = linkComment.split(' '); - linkURL = parts.find((part) => part.startsWith('http')); - linkPlaceholder = parts.filter((part) => part !== linkURL).join(' '); - } - - const signature = displaySignature || defaultSignature; - const extractedExample = example?.comment ? extractCodeBlock(example.comment) : undefined; - - return ( -
-
- {comment &&
{comment}
} - {linkComment && ( -
- {linkURL ? ( - <> - {linkPlaceholder && {linkPlaceholder}: } - - {linkURL} - - - ) : ( - {linkComment} - )} -
- )} - {signature && ( -
- -
- )} - {example?.comment && ( -
-
Example
-
- -
-
- )} - {children} -
- {!options?.hideIndex && !indexHidden && ( - - )} -
- ); -} diff --git a/components/renderers/api-node-details/extract-code-block.spec.ts b/components/renderers/api-node-details/extract-code-block.spec.ts deleted file mode 100644 index 2079b1dc0422..000000000000 --- a/components/renderers/api-node-details/extract-code-block.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { extractCodeBlock } from './extract-code-block'; - -describe('extractCodeBlock', () => { - it('should extract code block with language specifier', () => { - const text = '```typescript\nconst foo = "bar";\n```'; - const result = extractCodeBlock(text); - expect(result).toEqual({ lang: 'typescript', code: 'const foo = "bar";\n' }); - }); - - it('should extract code block without language specifier', () => { - const text = '```\nconst foo = "bar";\n```'; - const result = extractCodeBlock(text); - expect(result).toEqual({ lang: '', code: 'const foo = "bar";\n' }); - }); - - it('preserves indentation on the first code line', () => { - const text = '```typescript\n const foo = "bar";\n```'; - const result = extractCodeBlock(text); - expect(result).toEqual({ lang: 'typescript', code: ' const foo = "bar";\n' }); - }); - - it('should return null if no code block is found', () => { - const text = 'const foo = "bar";'; - const result = extractCodeBlock(text); - expect(result).toBeNull(); - }); -}); diff --git a/components/renderers/api-node-details/extract-code-block.ts b/components/renderers/api-node-details/extract-code-block.ts deleted file mode 100644 index 98878bc0750d..000000000000 --- a/components/renderers/api-node-details/extract-code-block.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Extracts the code block and its language specifier enclosed between triple backticks (```) from a given text string. - * - * @param text - The text string from which to extract the code block. - * - * @returns An object containing the extracted code and language specifier, or null if no match is found. - */ -export function extractCodeBlock(text: string): { lang: string; code: string } | null { - let processedText = text; - if (text.endsWith(';') && !text.endsWith('```')) { - processedText = text.slice(0, -1) + '```'; - } - // Consume horizontal fence padding and at most one line break. A broad `\s*` here would also - // consume indentation belonging to the first code line. - const regex = /```([\w+-]*)[^\S\r\n]*(?:\r?\n)?([\s\S]*?)```/; - - const match = processedText.match(regex); - - if (match) { - const lang = match[1]; - const code = match[2]; - return { lang, code }; - } - return null; -} diff --git a/components/renderers/api-node-details/index.ts b/components/renderers/api-node-details/index.ts deleted file mode 100644 index f1d9dd8d66a4..000000000000 --- a/components/renderers/api-node-details/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { APINodeDetails, APINodeDetailsProps } from './api-node-details'; -export { extractCodeBlock } from './extract-code-block'; diff --git a/components/renderers/default-node-renderers/index.ts b/components/renderers/default-node-renderers/index.ts deleted file mode 100644 index 1c3cd14101e3..000000000000 --- a/components/renderers/default-node-renderers/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { classRenderer } from '@teambit/api-reference.renderers.class'; -import { interfaceRenderer } from '@teambit/api-reference.renderers.interface'; -import { typeRenderer } from '@teambit/api-reference.renderers.type'; -import { functionRenderer } from '@teambit/api-reference.renderers.function'; -import { enumRenderer } from '@teambit/api-reference.renderers.enum'; -import { variableRenderer } from '@teambit/api-reference.renderers.variable'; -import { unresolvedRenderer } from '@teambit/api-reference.renderers.unresolved'; -import { typeRefRenderer } from '@teambit/api-reference.renderers.type-ref'; -import { typeUnionRenderer } from '@teambit/api-reference.renderers.type-union'; -import { typeIntersectionRenderer } from '@teambit/api-reference.renderers.type-intersection'; -import { typeLiteralRenderer } from '@teambit/api-reference.renderers.type-literal'; -import { parameterRenderer } from '@teambit/api-reference.renderers.parameter'; -import { inferenceTypeRenderer } from '@teambit/api-reference.renderers.inference-type'; -import { typeArrayRenderer } from '@teambit/api-reference.renderers.type-array'; -import { tupleTypeRenderer } from '@teambit/api-reference.renderers.tuple-type'; -import { thisRenderer } from '@teambit/api-reference.renderers.this'; -import { decoratorRenderer } from '@teambit/api-reference.renderers.decorator'; -import type { APINodeRenderer } from '@teambit/api-reference.models.api-node-renderer'; - -export const defaultNodeRenderers: APINodeRenderer[] = [ - classRenderer, - interfaceRenderer, - typeRenderer, - functionRenderer, - enumRenderer, - variableRenderer, - unresolvedRenderer, - typeRefRenderer, - typeUnionRenderer, - typeIntersectionRenderer, - typeLiteralRenderer, - parameterRenderer, - inferenceTypeRenderer, - typeArrayRenderer, - tupleTypeRenderer, - thisRenderer, - decoratorRenderer, -]; - -export { - classRenderer, - interfaceRenderer, - typeRenderer, - functionRenderer, - enumRenderer, - variableRenderer, - unresolvedRenderer, - typeRefRenderer, - typeUnionRenderer, - typeIntersectionRenderer, - typeLiteralRenderer, - parameterRenderer, - inferenceTypeRenderer, - typeArrayRenderer, - tupleTypeRenderer, - thisRenderer, - decoratorRenderer, -}; - -export default defaultNodeRenderers; diff --git a/components/ui/code-editor/code-editor.spec.tsx b/components/ui/code-editor/code-editor.spec.tsx index 60e0b29a20b8..3dd02547584e 100644 --- a/components/ui/code-editor/code-editor.spec.tsx +++ b/components/ui/code-editor/code-editor.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { CodeEditor } from './code-editor'; import { CodeEditorProvider } from './code-editor.provider'; @@ -12,14 +12,13 @@ jest.mock('@teambit/code.ui.diff-viewer', () => ({ describe('CodeEditor', () => { it('renders code without an injected editor or CDN runtime', () => { - const { container } = render( + const markup = renderToStaticMarkup( ); - const renderer = container.querySelector('[data-code-renderer="shiki"]'); - expect(renderer).toBeInTheDocument(); - expect(renderer).toHaveTextContent('export const answer = 42;'); + expect(markup).toContain('data-code-renderer="shiki"'); + expect(markup).toContain('export const answer = 42;'); }); }); diff --git a/workspace.jsonc b/workspace.jsonc index 275e7a3ae350..5ee8b93655e3 100644 --- a/workspace.jsonc +++ b/workspace.jsonc @@ -80,28 +80,30 @@ "@teambit/api-reference.models.api-node-renderer": "~0.0.55", "@teambit/api-reference.models.api-reference-model": "~0.0.55", "@teambit/api-reference.overview.api-reference-table-of-contents": "~0.0.43", - "@teambit/api-reference.overview.renderers.grouped-schema-nodes-overview-summary": "~0.0.78", - "@teambit/api-reference.renderers.class": "~0.0.91", - "@teambit/api-reference.renderers.decorator": "~0.0.45", - "@teambit/api-reference.renderers.enum": "~0.0.89", - "@teambit/api-reference.renderers.function": "~0.0.92", - "@teambit/api-reference.renderers.grouped-schema-nodes-summary": "~0.0.87", + "@teambit/api-reference.overview.renderers.grouped-schema-nodes-overview-summary": "~0.0.79", + "@teambit/api-reference.renderers.api-node-details": "~0.0.89", + "@teambit/api-reference.renderers.class": "~0.0.92", + "@teambit/api-reference.renderers.decorator": "~0.0.46", + "@teambit/api-reference.renderers.default-node-renderers": "~0.0.39", + "@teambit/api-reference.renderers.enum": "~0.0.90", + "@teambit/api-reference.renderers.function": "~0.0.93", + "@teambit/api-reference.renderers.grouped-schema-nodes-summary": "~0.0.88", "@teambit/api-reference.renderers.inference-type": "~0.0.68", - "@teambit/api-reference.renderers.interface": "~0.0.89", + "@teambit/api-reference.renderers.interface": "~0.0.90", "@teambit/api-reference.renderers.parameter": "~0.0.74", - "@teambit/api-reference.renderers.react": "~0.0.73", - "@teambit/api-reference.renderers.schema-node-member-summary": "~0.0.87", - "@teambit/api-reference.renderers.schema-nodes-index": "~0.0.77", - "@teambit/api-reference.renderers.this": "~0.0.74", + "@teambit/api-reference.renderers.react": "~0.0.74", + "@teambit/api-reference.renderers.schema-node-member-summary": "~0.0.88", + "@teambit/api-reference.renderers.schema-nodes-index": "~0.0.78", + "@teambit/api-reference.renderers.this": "~0.0.75", "@teambit/api-reference.renderers.tuple-type": "~0.0.36", - "@teambit/api-reference.renderers.type": "~0.0.87", + "@teambit/api-reference.renderers.type": "~0.0.88", "@teambit/api-reference.renderers.type-array": "~0.0.57", "@teambit/api-reference.renderers.type-intersection": "~0.0.57", - "@teambit/api-reference.renderers.type-literal": "~0.0.80", - "@teambit/api-reference.renderers.type-ref": "~0.0.104", + "@teambit/api-reference.renderers.type-literal": "~0.0.81", + "@teambit/api-reference.renderers.type-ref": "~0.0.105", "@teambit/api-reference.renderers.type-union": "~0.0.57", - "@teambit/api-reference.renderers.unresolved": "~0.0.85", - "@teambit/api-reference.renderers.variable": "~0.0.85", + "@teambit/api-reference.renderers.unresolved": "~0.0.86", + "@teambit/api-reference.renderers.variable": "~0.0.86", "@teambit/api-reference.sections.api-reference-page": "~0.0.120", "@teambit/api-reference.sections.api-reference-section": "~0.0.16", "@teambit/api-reference.utils.copy-schema-node": "~0.0.48", @@ -682,9 +684,6 @@ "packageManager": "teambit.dependencies/pnpm", "rootComponents": true, "overrides": { - // Published API renderer packages pin api-node-details@0.0.88. Resolve those nested copies - // to the imported Shiki implementation until the renderer packages are released against it. - "@teambit/api-reference.renderers.api-node-details": "file:components/renderers/api-node-details", // unify all React-19 contexts on a single exact version; react-dom throws on any // react/react-dom mismatch, which breaks env-aspect loading. v17 (react 17) contexts are unaffected. "react@19": "19.2.7", From 92f31e9f690f5559339863b085c59c595aebf046 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 13:30:08 -0400 Subject: [PATCH 09/17] fix(diff): keep long unified lines reachable --- .../ui/diff-viewer/diff-viewer.module.scss | 6 ++++++ .../ui/diff-viewer/diff-viewer.spec.tsx | 19 +++++++++++++++++++ components/ui/diff-viewer/diff-viewer.tsx | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 components/ui/diff-viewer/diff-viewer.spec.tsx diff --git a/components/ui/diff-viewer/diff-viewer.module.scss b/components/ui/diff-viewer/diff-viewer.module.scss index 8e6d4fb968bd..01061d4ee7c5 100644 --- a/components/ui/diff-viewer/diff-viewer.module.scss +++ b/components/ui/diff-viewer/diff-viewer.module.scss @@ -247,6 +247,12 @@ $del-tint: var(--negative-surface-color, #fdf2f5); .unifiedRow { display: grid; grid-template-columns: 50px 50px 22px minmax(var(--diff-code-width, 60ch), 1fr); + + // Let unusually wide glyphs extend the shared `ch`-based estimate. The body owns horizontal + // scrolling, so visible overflow remains reachable without widening the page. + .code { + overflow: visible; + } } // Split view: each side is a strict 50% so BOTH are always visible and the row never exceeds the diff --git a/components/ui/diff-viewer/diff-viewer.spec.tsx b/components/ui/diff-viewer/diff-viewer.spec.tsx new file mode 100644 index 000000000000..ffa0fbb1bba0 --- /dev/null +++ b/components/ui/diff-viewer/diff-viewer.spec.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { DiffViewer } from './diff-viewer'; + +jest.mock('./highlighter', () => ({ + useHighlightedLines: () => null, +})); + +describe('DiffViewer', () => { + it('keeps unified lines longer than 400 characters horizontally reachable', () => { + const longLine = 'x'.repeat(500); + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('--diff-code-width:500ch'); + expect(markup).toContain(longLine); + }); +}); diff --git a/components/ui/diff-viewer/diff-viewer.tsx b/components/ui/diff-viewer/diff-viewer.tsx index 831ca1f92c7d..eca0d2b9cb8f 100644 --- a/components/ui/diff-viewer/diff-viewer.tsx +++ b/components/ui/diff-viewer/diff-viewer.tsx @@ -118,7 +118,7 @@ export function DiffViewer({ 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); + return Math.max(max, 40); }, [items]); const expandGap = useCallback( From 7e8884e7e07a41f243e2de5a2a9a7d35bc8b6bf5 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 13:47:32 -0400 Subject: [PATCH 10/17] fix(ui): preserve static renderer compatibility --- .../ui/code-editor/code-editor.spec.tsx | 10 +++++++++ components/ui/code-editor/code-editor.tsx | 2 +- components/ui/diff-viewer/diff-model.spec.ts | 22 +++++++++++++++++++ components/ui/diff-viewer/diff-model.ts | 14 ++++++++---- components/ui/diff-viewer/diff-viewer.tsx | 2 +- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/components/ui/code-editor/code-editor.spec.tsx b/components/ui/code-editor/code-editor.spec.tsx index 3dd02547584e..8d03d585fa93 100644 --- a/components/ui/code-editor/code-editor.spec.tsx +++ b/components/ui/code-editor/code-editor.spec.tsx @@ -21,4 +21,14 @@ describe('CodeEditor', () => { expect(markup).toContain('data-code-renderer="shiki"'); expect(markup).toContain('export const answer = 42;'); }); + + it('fills its parent when given a full height', () => { + const markup = renderToStaticMarkup( + + + + ); + + expect(markup).toContain('style="height:100%"'); + }); }); diff --git a/components/ui/code-editor/code-editor.tsx b/components/ui/code-editor/code-editor.tsx index ba06e65f7ad0..47411006f3a0 100644 --- a/components/ui/code-editor/code-editor.tsx +++ b/components/ui/code-editor/code-editor.tsx @@ -76,7 +76,7 @@ export function CodeEditor({ fileContent = '', filePath, language, className, he return (
diff --git a/components/ui/diff-viewer/diff-model.spec.ts b/components/ui/diff-viewer/diff-model.spec.ts
index cadb785d1d06..da9029ba4d69 100644
--- a/components/ui/diff-viewer/diff-model.spec.ts
+++ b/components/ui/diff-viewer/diff-model.spec.ts
@@ -36,4 +36,26 @@ describe('computeDiffLines', () => {
     });
     expect(items.map(({ type }) => type)).toEqual(['del', 'add']);
   });
+
+  it('normalizes CRLF lines without trimming other whitespace', () => {
+    const items = computeDiffLines('const answer = 41;\r\n  next();\r\n', 'const answer = 42;\r\n  next();\r\n');
+
+    expect(items.map(({ type, text }) => ({ type, text }))).toEqual([
+      { type: 'del', text: 'const answer = 41;' },
+      { type: 'add', text: 'const answer = 42;' },
+      { type: 'context', text: '  next();' },
+    ]);
+    expect(
+      items.every(({ text, oldText, newText }) => ![text, oldText, newText].some((line) => line?.includes('\r')))
+    ).toBe(true);
+  });
+
+  it('normalizes unchanged CRLF lines', () => {
+    const items = computeDiffLines('one\r\ntwo\r\n', 'one\r\ntwo\r\n');
+
+    expect(items.map(({ text, oldText, newText }) => ({ text, oldText, newText }))).toEqual([
+      { text: 'one', oldText: 'one', newText: 'one' },
+      { text: 'two', oldText: 'two', newText: 'two' },
+    ]);
+  });
 });
diff --git a/components/ui/diff-viewer/diff-model.ts b/components/ui/diff-viewer/diff-model.ts
index 995871299b44..633f0f546a16 100644
--- a/components/ui/diff-viewer/diff-model.ts
+++ b/components/ui/diff-viewer/diff-model.ts
@@ -37,9 +37,11 @@ export function computeDiffLines(
   newContent: string,
   options: ComputeDiffOptions = {}
 ): DiffLineItem[] {
-  const oldSourceLines = splitSourceLines(oldContent ?? '');
-  const newSourceLines = splitSourceLines(newContent ?? '');
-  const parts = diffLines(oldContent ?? '', newContent ?? '', {
+  const oldSource = normalizeLineEndings(oldContent ?? '');
+  const newSource = normalizeLineEndings(newContent ?? '');
+  const oldSourceLines = splitSourceLines(oldSource);
+  const newSourceLines = splitSourceLines(newSource);
+  const parts = diffLines(oldSource, newSource, {
     ignoreWhitespace: options.ignoreTrimWhitespace,
   });
   const items: DiffLineItem[] = [];
@@ -69,8 +71,12 @@ export function computeDiffLines(
   return items;
 }
 
+function normalizeLineEndings(content: string): string {
+  return content.replace(/\r\n/g, '\n');
+}
+
 function splitSourceLines(content: string): string[] {
-  const lines = content.split('\n');
+  const lines = content.split(/\r?\n/);
   if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
   return lines;
 }
diff --git a/components/ui/diff-viewer/diff-viewer.tsx b/components/ui/diff-viewer/diff-viewer.tsx
index eca0d2b9cb8f..c02495356899 100644
--- a/components/ui/diff-viewer/diff-viewer.tsx
+++ b/components/ui/diff-viewer/diff-viewer.tsx
@@ -335,7 +335,7 @@ function DiffBody({
   };
 
   return (
-    
+
Date: Tue, 15 Sep 2026 14:08:56 -0400 Subject: [PATCH 11/17] test(ui): use node server renderer in capsules --- components/ui/code-editor/code-editor.spec.tsx | 2 +- components/ui/diff-viewer/diff-viewer.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ui/code-editor/code-editor.spec.tsx b/components/ui/code-editor/code-editor.spec.tsx index 8d03d585fa93..0533e361be2e 100644 --- a/components/ui/code-editor/code-editor.spec.tsx +++ b/components/ui/code-editor/code-editor.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; +import { renderToStaticMarkup } from 'react-dom/server.node'; import { CodeEditor } from './code-editor'; import { CodeEditorProvider } from './code-editor.provider'; diff --git a/components/ui/diff-viewer/diff-viewer.spec.tsx b/components/ui/diff-viewer/diff-viewer.spec.tsx index ffa0fbb1bba0..d5718dc273b2 100644 --- a/components/ui/diff-viewer/diff-viewer.spec.tsx +++ b/components/ui/diff-viewer/diff-viewer.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; +import { renderToStaticMarkup } from 'react-dom/server.node'; import { DiffViewer } from './diff-viewer'; jest.mock('./highlighter', () => ({ From fdae28bea1daa2aae9db8d6721eb50a77a45bf1f Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 14:33:44 -0400 Subject: [PATCH 12/17] fix(compare): restore view-aware sidebar --- .../component-compare-page.module.scss | 8 ++ .../component-compare-page.tsx | 132 +++++++++++++++--- 2 files changed, 117 insertions(+), 23 deletions(-) diff --git a/scopes/component/component-compare/component-compare-page.module.scss b/scopes/component/component-compare/component-compare-page.module.scss index ff780ac2d0e1..3ab547ec727a 100644 --- a/scopes/component/component-compare/component-compare-page.module.scss +++ b/scopes/component/component-compare/component-compare-page.module.scss @@ -21,6 +21,14 @@ justify-content: center; } +.layout { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + @keyframes compareSkeletonShimmer { 0% { background-position: 200% 0; diff --git a/scopes/component/component-compare/component-compare-page.tsx b/scopes/component/component-compare/component-compare-page.tsx index bee96f6f5bba..1978430f14c3 100644 --- a/scopes/component/component-compare/component-compare-page.tsx +++ b/scopes/component/component-compare/component-compare-page.tsx @@ -1,5 +1,5 @@ import type { HTMLAttributes } from 'react'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import classnames from 'classnames'; import { useSearchParams } from 'react-router-dom'; import { ComponentContext, ComponentDescriptorContext, useComponent } from '@teambit/component'; @@ -16,6 +16,7 @@ import { groupByVersion } from '@teambit/component.ui.component-compare.utils.gr import type { LegacyComponentLog } from '@teambit/legacy-component-log'; import { CompareDataProvider, + CompareSidebar, CompareToolbar, CompareToolbarActions, DiffModeProvider, @@ -24,8 +25,14 @@ import { InlineComponentCompare, RegistryFeeder, useCompareData, + useFileRegistry, +} from '@teambit/component.ui.component-compare.component-compare'; +import type { + CompareSidebarGroup, + CompareViewMode, + ComponentComparePair, + FileInfo, } from '@teambit/component.ui.component-compare.component-compare'; -import type { CompareViewMode, ComponentComparePair } from '@teambit/component.ui.component-compare.component-compare'; import { computeDepsDiff } from '@teambit/dependencies.ui.deps-diff-table'; import { useApiDiff } from '@teambit/semantics.ui.api-diff-view'; @@ -47,6 +54,12 @@ export type ComponentComparePageProps = { type ViewMode = 'code' | 'preview' | 'docs' | 'dependencies' | 'tests' | 'config' | 'api'; type DiffMode = 'split' | 'unified'; +function apiChangeStatusToFileStatus(status: string): string { + if (status === 'ADDED') return 'NEW'; + if (status === 'REMOVED') return 'DELETED'; + return 'MODIFIED'; +} + // The single-component compare offers the same view modes as lane-compare. Which ones actually // appear is driven by real per-view content counts (see `CompareView`) — mirroring how the cloud // changes view derives available views from each component's change types, so a user never lands @@ -293,6 +306,9 @@ function CompareView({ const [viewMode, setViewModeState] = useState((searchParams.get('view') as ViewMode) || 'code'); const [diffMode, setDiffModeState] = useState((searchParams.get('diffMode') as DiffMode) || 'split'); const [showAllDeps, setShowAllDeps] = useState(false); + const [selectedId, setSelectedId] = useState(); + const [selectedFile, setSelectedFile] = useState(); + const diffPaneRef = useRef(null); const syncUrl = React.useCallback((key: string, value: string | undefined) => { const url = new URL(window.location.href); @@ -318,6 +334,7 @@ function CompareView({ const componentCompare = useComponentCompare(); const compareData = useCompareData(); + const fileRegistry = useFileRegistry(); const data = compareData?.compareDataFor(compareId); const dataLoading = compareData?.loading ?? false; @@ -404,6 +421,43 @@ function CompareView({ return acc; }, [loading, registeredModeIds, isNew, data, componentCompare, apiTabHasContent]); + const componentId = compareId.split('@')[0]; + const fileRegistryVersion = fileRegistry?.getVersion(); + const sidebarFiles = useMemo(() => { + // The registry object is stable; its monotonic version invalidates this view-derived projection. + void fileRegistryVersion; + if (viewMode === 'code') return fileRegistry?.getFiles(componentId); + if (viewMode === 'config') return fileRegistry?.getAspectFiles(componentId); + if (viewMode === 'tests') { + return (data?.tests || []) + .filter((file) => file.status && file.status !== 'UNCHANGED') + .map((file) => ({ name: file.fileName, status: file.status })); + } + if (viewMode === 'api') { + return (apiDiffResult?.publicChanges || []).map((change) => ({ + name: change.exportName, + status: apiChangeStatusToFileStatus(change.status), + })); + } + return undefined; + }, [viewMode, fileRegistry, fileRegistryVersion, componentId, data?.tests, apiDiffResult]); + + const sidebarGroups = useMemo( + () => [ + { + key: 'component', + label: 'Component', + items: [{ id: componentId, name, files: sidebarFiles }], + }, + ], + [componentId, name, sidebarFiles] + ); + + useEffect(() => { + setSelectedId(undefined); + setSelectedFile(undefined); + }, [viewMode]); + // If the active view has no content, fall back to the first view that does. useEffect(() => { if (loading) return; @@ -442,33 +496,65 @@ function CompareView({ loading={loading} /> -
- - {/* The API view isn't an inline tab — render it inside the inline context (so it sees the - resolved base/compare pair) only while it's the active view, so its diff query fires - on-demand. CSS hides the inline `[data-tab-id]` panels when `data-view-mode='api'`. */} - {viewMode === 'api' ? apiTab : null} - +
+ { + const nextId = id || undefined; + setSelectedId(nextId); + setSelectedFile(fileName); + if (nextId) scrollToCompareSelection(diffPaneRef.current, nextId, fileName); + }} + /> + +
+ + {/* The API view isn't an inline tab — render it inside the inline context (so it sees the + resolved base/compare pair) only while it's the active view, so its diff query fires + on-demand. CSS hides the inline `[data-tab-id]` panels when `data-view-mode='api'`. */} + {viewMode === 'api' ? apiTab : null} + +
); } +function scrollToCompareSelection(pane: HTMLDivElement | null, componentId: string, fileName?: string) { + if (!pane) return; + const selector = fileName + ? `[data-file-id="${CSS.escape(componentId)}:${CSS.escape(fileName)}"]` + : `[data-component-id="${CSS.escape(componentId)}"]`; + + requestAnimationFrame(() => { + const element = pane.querySelector(selector); + if (!element) return; + const elementRect = element.getBoundingClientRect(); + const paneRect = pane.getBoundingClientRect(); + pane.scrollTo({ top: elementRect.top - paneRect.top + pane.scrollTop, behavior: 'smooth' }); + }); +} + function CompareViewSkeleton() { return ( <> From e1f7864288ed9224a0456274022959960d337c83 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 14:37:57 -0400 Subject: [PATCH 13/17] fix(ui): handle large and transient diff states --- .../compare-aspect-view.module.scss | 6 ++-- components/ui/diff-viewer/diff-viewer.tsx | 19 +++++++++-- components/ui/diff-viewer/highlighter.ts | 32 +++++++++++++++---- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss index 4066627088b6..1efb11e3f928 100644 --- a/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss +++ b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss @@ -9,6 +9,8 @@ $editor-padding: 56px; display: flex; flex-direction: column; height: calc(100% - $editor-padding); + min-height: 0; + overflow-y: auto; } .name { @@ -20,9 +22,9 @@ $editor-padding: 56px; .componentCompareAspectConfigDiff, .componentCompareAspectCalculatedDiff { display: flex; - flex: 1; + flex: 1 0 auto; padding: 0px 8px; - height: 100%; + min-height: 0; flex-direction: column; } diff --git a/components/ui/diff-viewer/diff-viewer.tsx b/components/ui/diff-viewer/diff-viewer.tsx index c02495356899..a4a6c8c236f7 100644 --- a/components/ui/diff-viewer/diff-viewer.tsx +++ b/components/ui/diff-viewer/diff-viewer.tsx @@ -293,11 +293,26 @@ function DiffBody({ const scrollRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const rafRef = useRef(null); + const previousViewRef = useRef(view); const total = rows.length; const totalHeight = total * ROW_H; // only window when virtualization is enabled AND the file is large enough to warrant it. const windowing = virtualize && total > VIRTUALIZE_THRESHOLD; + const maxScrollTop = windowing ? Math.max(0, totalHeight - maxHeight) : 0; + // Clamp during render as well as synchronizing state below. This prevents even one paint from + // slicing beyond the new row model when a split/unified switch reduces the number of rows. + const effectiveScrollTop = Math.min(scrollTop, maxScrollTop); + + useEffect(() => { + const viewChanged = previousViewRef.current !== view; + previousViewRef.current = view; + setScrollTop((current) => { + const next = viewChanged ? 0 : Math.min(current, maxScrollTop); + if (scrollRef.current && scrollRef.current.scrollTop !== next) scrollRef.current.scrollTop = next; + return current === next ? current : next; + }); + }, [view, maxScrollTop]); // cancel a pending scroll RAF on unmount — DiffBody is conditionally mounted (collapse hides it), // so a queued frame could otherwise fire setScrollTop() after unmount. @@ -320,8 +335,8 @@ function DiffBody({ let end = total; if (windowing) { const viewport = maxHeight; - start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN); - end = Math.min(total, Math.ceil((scrollTop + viewport) / ROW_H) + OVERSCAN); + start = Math.max(0, Math.floor(effectiveScrollTop / ROW_H) - OVERSCAN); + end = Math.min(total, Math.ceil((effectiveScrollTop + viewport) / ROW_H) + OVERSCAN); } const visible = windowing ? rows.slice(start, end) : rows; const offsetY = start * ROW_H; diff --git a/components/ui/diff-viewer/highlighter.ts b/components/ui/diff-viewer/highlighter.ts index 636d9b2ba525..d87703ddb34f 100644 --- a/components/ui/diff-viewer/highlighter.ts +++ b/components/ui/diff-viewer/highlighter.ts @@ -88,6 +88,7 @@ function tokenize(content: string, lang: string): HlLines | null { * blocked the main thread while diffs streamed in, which froze view-mode clicks. */ const SYNC_TOKENIZE_LIMIT = 20_000; +const LANGUAGE_RETRY_DELAYS_MS = [250, 1000, 3000]; type DeferredTokens = { content: string; lang: string; lines: HlLines | null }; @@ -105,18 +106,35 @@ export function useHighlightedLines(content: string | undefined, lang: string | useEffect(() => { let cancelled = false; + let retryTimer: ReturnType | undefined; if (!lang || content === undefined) return undefined; if (loadedLangs.has(lang) && highlighterInstance) return undefined; - void ensureLanguage(lang).then((ok) => { - if (!ok || cancelled) return undefined; + + let retryIndex = 0; + const loadLanguage = async () => { + const ok = await ensureLanguage(lang); + if (cancelled) return; + if (!ok) { + // Unsupported languages intentionally stay plain text. Supported grammars retry a few times + // because highlighter initialization and lazy chunks can fail transiently (for example while + // a dev-server rebuild replaces a chunk). The hook remains mounted during those failures. + const delay = LANG_IMPORTERS[lang] ? LANGUAGE_RETRY_DELAYS_MS[retryIndex] : undefined; + retryIndex += 1; + if (delay !== undefined) retryTimer = setTimeout(() => void loadLanguage(), delay); + return; + } + // ensure the sync instance is captured before we ask the tree to re-tokenize - return getHighlighter().then((hl) => { - highlighterInstance = hl; - if (!cancelled) setVersion((n) => n + 1); - }); - }); + const hl = await getHighlighter(); + if (cancelled) return; + highlighterInstance = hl; + setVersion((n) => n + 1); + }; + + void loadLanguage(); return () => { cancelled = true; + if (retryTimer !== undefined) clearTimeout(retryTimer); }; }, [content, lang]); From 71a5d9d3908e5678d37215c243aa092b147b28db Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 14:49:53 -0400 Subject: [PATCH 14/17] fix(compare): align and synchronize sidebar --- .../compare-sidebar.module.scss | 4 ++- .../component-compare.module.scss | 2 ++ .../component-compare/file-registry.tsx | 13 +++++++- .../inline-config-compare.tsx | 15 +++++++++- .../component-compare-page.tsx | 30 ++++++++++--------- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/components/ui/component-compare/component-compare/compare-sidebar.module.scss b/components/ui/component-compare/component-compare/compare-sidebar.module.scss index 2ed06b728764..d0153faf9692 100644 --- a/components/ui/component-compare/component-compare/compare-sidebar.module.scss +++ b/components/ui/component-compare/component-compare/compare-sidebar.module.scss @@ -41,7 +41,9 @@ $text-muted: var(--on-background-medium-color, #a0aec0); display: flex; align-items: center; justify-content: space-between; - padding: 10px 16px; + height: 44px; + padding: 0 16px; + box-sizing: border-box; font-size: 12px; font-weight: 600; color: $text-secondary; 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 b0b833b29eb8..705598754f46 100644 --- a/components/ui/component-compare/component-compare/component-compare.module.scss +++ b/components/ui/component-compare/component-compare/component-compare.module.scss @@ -176,6 +176,8 @@ $inline-code-font: 'JetBrains Mono', ui-monospace, monospace; align-items: center; justify-content: space-between; gap: 16px; + height: var(--component-header-height, 44px); + box-sizing: border-box; padding: 12px 20px; background: $inline-surface-color; border-bottom: 1px solid $inline-border-color; diff --git a/components/ui/component-compare/component-compare/file-registry.tsx b/components/ui/component-compare/component-compare/file-registry.tsx index 2d3b38e5fa65..43fbfac4df54 100644 --- a/components/ui/component-compare/component-compare/file-registry.tsx +++ b/components/ui/component-compare/component-compare/file-registry.tsx @@ -132,7 +132,18 @@ class ComponentRegistry { } } -const FileRegistryContext = createContext(undefined); +// Compare views are contributed by separately packaged UI components. A bundler can therefore load +// more than one copy of this module even though they all render under the same provider. Keep the +// context identity on `globalThis` so a registrar from one package instance and a consumer from +// another still share the provider's registry (React context matching is identity-based). +const FILE_REGISTRY_CONTEXT_KEY = '__teambitComponentCompareFileRegistryContext__'; +type FileRegistryGlobal = typeof globalThis & { + [FILE_REGISTRY_CONTEXT_KEY]?: React.Context; +}; +const fileRegistryGlobal = globalThis as FileRegistryGlobal; +const FileRegistryContext = + fileRegistryGlobal[FILE_REGISTRY_CONTEXT_KEY] || + (fileRegistryGlobal[FILE_REGISTRY_CONTEXT_KEY] = createContext(undefined)); export function FileRegistryProvider({ children }: { children: ReactNode }) { const storeRef = useRef(undefined); diff --git a/components/ui/inline-config-compare/inline-config-compare.tsx b/components/ui/inline-config-compare/inline-config-compare.tsx index 1b60de461fca..8248c83ba11b 100644 --- a/components/ui/inline-config-compare/inline-config-compare.tsx +++ b/components/ui/inline-config-compare/inline-config-compare.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { gql } from '@apollo/client'; import { useComponentCompare, InlineCompareEmpty } from '@teambit/component.ui.component-compare.context'; import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; -import { useDiffMode } from '@teambit/component.ui.component-compare.component-compare'; +import { useAspectRegistryRegister, useDiffMode } from '@teambit/component.ui.component-compare.component-compare'; import { DiffLoadingSkeleton } from '@teambit/code.ui.inline-diff-viewer'; import { DiffViewer, computeDiffLines, statsFromItems, type DiffViewMode } from '@teambit/code.ui.diff-viewer'; @@ -125,6 +125,19 @@ export function InlineConfigCompare() { // key on the stable Apollo data refs — baseAspects/compareAspects are re-derived (`|| []`) every render }, [loading, baseData, compareData]); + const sidebarAspects = useMemo(() => { + if (loading) return undefined; + return aspectDiffs.map(({ aspectId }) => ({ + name: aspectId.split('/').pop() || aspectId, + status: 'MODIFIED', + })); + }, [loading, aspectDiffs]); + + // Register the same short aspect names used by the rendered data-file-id anchors. The bulk + // compare response exposes changed field names instead, which are useful as an early placeholder + // but cannot scroll to these aspect-level sections. + useAspectRegistryRegister(componentIdStr || undefined, sidebarAspects); + if (loading) { return ; } diff --git a/scopes/component/component-compare/component-compare-page.tsx b/scopes/component/component-compare/component-compare-page.tsx index 1978430f14c3..775a33abd4e7 100644 --- a/scopes/component/component-compare/component-compare-page.tsx +++ b/scopes/component/component-compare/component-compare-page.tsx @@ -497,20 +497,22 @@ function CompareView({ />
- { - const nextId = id || undefined; - setSelectedId(nextId); - setSelectedFile(fileName); - if (nextId) scrollToCompareSelection(diffPaneRef.current, nextId, fileName); - }} - /> + {viewMode !== 'dependencies' && ( + { + const nextId = id || undefined; + setSelectedId(nextId); + setSelectedFile(fileName); + if (nextId) scrollToCompareSelection(diffPaneRef.current, nextId, fileName); + }} + /> + )}
Date: Tue, 15 Sep 2026 14:54:31 -0400 Subject: [PATCH 15/17] fix(compare): make deferred selections reliable --- .../compare-aspects/compare-aspects.tsx | 2 +- .../inline-config-compare.tsx | 6 +- .../component-compare-page.tsx | 76 +++++++++++++++++-- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx index 1804ad3fe180..08393ccf67a2 100644 --- a/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx @@ -39,7 +39,7 @@ export function ComponentCompareAspects({ host, className }: ComponentCompareAsp () => query, () => location ); - return state?.controlled && !hook?.useUpdatedUrlFromQuery ? controlledHref : href; + return state?.controlled ? controlledHref : href; }; return ( diff --git a/components/ui/inline-config-compare/inline-config-compare.tsx b/components/ui/inline-config-compare/inline-config-compare.tsx index 8248c83ba11b..f09d9d8856e0 100644 --- a/components/ui/inline-config-compare/inline-config-compare.tsx +++ b/components/ui/inline-config-compare/inline-config-compare.tsx @@ -128,12 +128,12 @@ export function InlineConfigCompare() { const sidebarAspects = useMemo(() => { if (loading) return undefined; return aspectDiffs.map(({ aspectId }) => ({ - name: aspectId.split('/').pop() || aspectId, + name: aspectId, status: 'MODIFIED', })); }, [loading, aspectDiffs]); - // Register the same short aspect names used by the rendered data-file-id anchors. The bulk + // Register the same canonical aspect IDs used by the rendered data-file-id anchors. The bulk // compare response exposes changed field names instead, which are useful as an early placeholder // but cannot scroll to these aspect-level sections. useAspectRegistryRegister(componentIdStr || undefined, sidebarAspects); @@ -156,7 +156,7 @@ export function InlineConfigCompare() { return (
{aspect.configChanged && ( diff --git a/scopes/component/component-compare/component-compare-page.tsx b/scopes/component/component-compare/component-compare-page.tsx index 775a33abd4e7..7d7fda1175d6 100644 --- a/scopes/component/component-compare/component-compare-page.tsx +++ b/scopes/component/component-compare/component-compare-page.tsx @@ -309,6 +309,7 @@ function CompareView({ const [selectedId, setSelectedId] = useState(); const [selectedFile, setSelectedFile] = useState(); const diffPaneRef = useRef(null); + const pendingScrollCleanupRef = useRef<(() => void) | undefined>(undefined); const syncUrl = React.useCallback((key: string, value: string | undefined) => { const url = new URL(window.location.href); @@ -454,10 +455,19 @@ function CompareView({ ); useEffect(() => { + pendingScrollCleanupRef.current?.(); + pendingScrollCleanupRef.current = undefined; setSelectedId(undefined); setSelectedFile(undefined); }, [viewMode]); + useEffect( + () => () => { + pendingScrollCleanupRef.current?.(); + }, + [] + ); + // If the active view has no content, fall back to the first view that does. useEffect(() => { if (loading) return; @@ -509,7 +519,10 @@ function CompareView({ const nextId = id || undefined; setSelectedId(nextId); setSelectedFile(fileName); - if (nextId) scrollToCompareSelection(diffPaneRef.current, nextId, fileName); + pendingScrollCleanupRef.current?.(); + pendingScrollCleanupRef.current = nextId + ? scrollToCompareSelection(diffPaneRef.current, nextId, fileName) + : undefined; }} /> )} @@ -542,19 +555,66 @@ function CompareView({ ); } -function scrollToCompareSelection(pane: HTMLDivElement | null, componentId: string, fileName?: string) { - if (!pane) return; +function scrollToCompareSelection( + pane: HTMLDivElement | null, + componentId: string, + fileName?: string +): (() => void) | undefined { + if (!pane) return undefined; const selector = fileName ? `[data-file-id="${CSS.escape(componentId)}:${CSS.escape(fileName)}"]` : `[data-component-id="${CSS.escape(componentId)}"]`; - requestAnimationFrame(() => { + let cancelled = false; + let observer: MutationObserver | undefined; + let timeoutId: ReturnType | undefined; + let frameId: number | undefined; + let settleCleanup: (() => void) | undefined; + + const findAndScroll = () => { + if (cancelled) return false; const element = pane.querySelector(selector); - if (!element) return; - const elementRect = element.getBoundingClientRect(); - const paneRect = pane.getBoundingClientRect(); - pane.scrollTo({ top: elementRect.top - paneRect.top + pane.scrollTop, behavior: 'smooth' }); + if (!element) return false; + observer?.disconnect(); + if (timeoutId) clearTimeout(timeoutId); + settleCleanup = scrollElementToPaneTop(pane, element); + return true; + }; + + frameId = requestAnimationFrame(() => { + if (findAndScroll()) return; + observer = new MutationObserver(findAndScroll); + observer.observe(pane, { childList: true, subtree: true }); + timeoutId = setTimeout(() => observer?.disconnect(), 5000); }); + + return () => { + cancelled = true; + if (frameId !== undefined) cancelAnimationFrame(frameId); + if (timeoutId) clearTimeout(timeoutId); + observer?.disconnect(); + settleCleanup?.(); + }; +} + +function scrollElementToPaneTop(pane: HTMLDivElement, element: Element) { + let frameId: number | undefined; + let cancelled = false; + let passes = 0; + + const settle = () => { + if (cancelled || !pane.isConnected || !element.isConnected) return; + const drift = element.getBoundingClientRect().top - pane.getBoundingClientRect().top; + pane.scrollTo({ top: drift + pane.scrollTop, behavior: 'instant' }); + passes += 1; + if (Math.abs(drift) > 2 && passes < 4) frameId = requestAnimationFrame(settle); + }; + + settle(); + return () => { + cancelled = true; + if (frameId !== undefined) cancelAnimationFrame(frameId); + }; } function CompareViewSkeleton() { From e587b4fb9fc487db2d2efeccd88bf7edc42dba22 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 15:11:59 -0400 Subject: [PATCH 16/17] fix(compare): preserve sidebar statuses and narrow layouts --- .../component-compare/compare-sidebar.module.scss | 15 +++++++++++++++ .../inline-config-compare.tsx | 6 ++++-- .../component-compare-page.module.scss | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/components/ui/component-compare/component-compare/compare-sidebar.module.scss b/components/ui/component-compare/component-compare/compare-sidebar.module.scss index d0153faf9692..40d4086390c6 100644 --- a/components/ui/component-compare/component-compare/compare-sidebar.module.scss +++ b/components/ui/component-compare/component-compare/compare-sidebar.module.scss @@ -37,6 +37,21 @@ $text-muted: var(--on-background-medium-color, #a0aec0); } } +// At narrow embedded widths, keep the diff usable by falling back to the pre-sidebar layout. +// Use a container query because compare can be embedded in a pane much narrower than the viewport. +@container component-compare-layout (max-width: 640px) { + .sidebar { + display: none; + } +} + +// Fallback for browsers without container-query support. +@media screen and (max-width: 640px) { + .sidebar { + display: none; + } +} + .sidebarHeader { display: flex; align-items: center; diff --git a/components/ui/inline-config-compare/inline-config-compare.tsx b/components/ui/inline-config-compare/inline-config-compare.tsx index f09d9d8856e0..d599cdbc7158 100644 --- a/components/ui/inline-config-compare/inline-config-compare.tsx +++ b/components/ui/inline-config-compare/inline-config-compare.tsx @@ -43,6 +43,7 @@ type AspectData = { type AspectDiff = { aspectId: string; + status: 'NEW' | 'DELETED' | 'MODIFIED'; configChanged: boolean; baseConfig: string; compareConfig: string; @@ -112,6 +113,7 @@ export function InlineConfigCompare() { diffs.push({ aspectId, + status: !baseAspect ? 'NEW' : !compareAspect ? 'DELETED' : 'MODIFIED', configChanged, baseConfig, compareConfig, @@ -127,9 +129,9 @@ export function InlineConfigCompare() { const sidebarAspects = useMemo(() => { if (loading) return undefined; - return aspectDiffs.map(({ aspectId }) => ({ + return aspectDiffs.map(({ aspectId, status }) => ({ name: aspectId, - status: 'MODIFIED', + status, })); }, [loading, aspectDiffs]); diff --git a/scopes/component/component-compare/component-compare-page.module.scss b/scopes/component/component-compare/component-compare-page.module.scss index 3ab547ec727a..df0eacdae4c4 100644 --- a/scopes/component/component-compare/component-compare-page.module.scss +++ b/scopes/component/component-compare/component-compare-page.module.scss @@ -27,6 +27,8 @@ min-width: 0; min-height: 0; overflow: hidden; + container-type: inline-size; + container-name: component-compare-layout; } @keyframes compareSkeletonShimmer { From 4c67e39551ae1efe14410268b7805685ad121337 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Tue, 15 Sep 2026 15:14:16 -0400 Subject: [PATCH 17/17] fix(config-compare): register only canonical targets --- .../component-compare/component-compare.tsx | 13 ++++--------- .../inline-config-compare/inline-config-compare.tsx | 4 +++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/components/ui/component-compare/component-compare/component-compare.tsx b/components/ui/component-compare/component-compare/component-compare.tsx index 18338546db0b..a0ddaeb8a2c4 100644 --- a/components/ui/component-compare/component-compare/component-compare.tsx +++ b/components/ui/component-compare/component-compare/component-compare.tsx @@ -34,7 +34,7 @@ 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, useFileRegistry } from './file-registry'; +import { useFileRegistryRegister, useFileRegistry } from './file-registry'; import styles from './component-compare.module.scss'; @@ -1019,20 +1019,15 @@ function CompareRegistryEntry({ compareId }: { compareId: string }) { .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. + * feeds code files into the FileRegistry from the bulk `CompareDataProvider` for every component pair + * that has a base. Configuration entries are deliberately registered by `InlineConfigCompare` only: + * the bulk response contains field names rather than the canonical aspect IDs used by its DOM anchors. * renders one null-rendering `CompareRegistryEntry` per pair — no per-component queries are fired. */ export function RegistryFeeder({ pairs }: { pairs: ComponentComparePair[] }) { diff --git a/components/ui/inline-config-compare/inline-config-compare.tsx b/components/ui/inline-config-compare/inline-config-compare.tsx index d599cdbc7158..00485eb59a62 100644 --- a/components/ui/inline-config-compare/inline-config-compare.tsx +++ b/components/ui/inline-config-compare/inline-config-compare.tsx @@ -128,7 +128,9 @@ export function InlineConfigCompare() { }, [loading, baseData, compareData]); const sidebarAspects = useMemo(() => { - if (loading) return undefined; + // Clear entries from the previous base/compare pair while the canonical aspect IDs load. The + // bulk compare result only has field names, which are not valid targets for this view's anchors. + if (loading) return []; return aspectDiffs.map(({ aspectId, status }) => ({ name: aspectId, status,