diff --git a/.bitmap b/.bitmap index e0034763fa8d..e2ad04703c3d 100644 --- a/.bitmap +++ b/.bitmap @@ -1486,13 +1486,6 @@ "mainFile": "index.ts", "rootDir": "scopes/component/renaming" }, - "renderers/default-node-renderers": { - "name": "renderers/default-node-renderers", - "scope": "teambit.api-reference", - "version": "0.0.40", - "mainFile": "index.ts", - "rootDir": "components/renderers/default-node-renderers" - }, "ripple": { "name": "ripple", "scope": "teambit.cloud", @@ -1794,6 +1787,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", @@ -2320,4 +2327,4 @@ "rootDir": "scopes/dependencies/yarn" }, "$schema-version": "17.0.0" -} \ No newline at end of file +} 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-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..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 @@ -1,88 +1,70 @@ -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 React, { useEffect, useState } from 'react'; +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; + language?: string; + 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; +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, originalPath, modifiedPath, language, - handleEditorDidMount, ignoreWhitespace, wordWrap, diffOnly, editorViewMode, - Loader, - DiffEditor, fullScreen, }: CodeCompareEditorProps) { + const maxHeight = useDiffHeight(fullScreen); 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..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 @@ -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,50 @@ 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 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'; - }; + const fileCompareDataByName = componentCompareContext?.fileCompareDataByName; + const fileStatus = fileCompareDataByName?.get(fileName)?.status; + const baseIdString = baseId?.toString(); + const compareIdString = compareId?.toString(); + + let defaultView: EditorViewMode = 'split'; + if ( + !baseId || + (compareId && baseId.isEqual(compareId)) || + !originalFileContent || + !modifiedFileContent || + fileStatus === '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`); - }; + React.useEffect(() => { + setView(defaultView); + }, [defaultView, fileName, baseIdString, compareIdString, originalFileContent, modifiedFileContent, fileStatus]); - useEffect(() => { - if (!monacoRef.current?.editor) return; - const modifiedEditor = monacoRef.current?.editor.getModifiedEditor(); - - 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 +89,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..0533e361be2e --- /dev/null +++ b/components/ui/code-editor/code-editor.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server.node'; +import { CodeEditor } from './code-editor'; +import { CodeEditorProvider } from './code-editor.provider'; + +jest.mock('@teambit/code.ui.diff-viewer', () => ({ + langFromFileName: () => 'typescript', + normalizeLanguage: (language?: string) => language, + resolveTokenColor: () => undefined, + useHighlightedLines: () => null, +})); + +describe('CodeEditor', () => { + it('renders code without an injected editor or CDN runtime', () => { + const markup = renderToStaticMarkup( + + + + ); + + 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 921237de0fdf..47411006f3a0 100644 --- a/components/ui/code-editor/code-editor.tsx +++ b/components/ui/code-editor/code-editor.tsx @@ -1,11 +1,19 @@ 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, + normalizeLanguage, + 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 +21,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 = normalizeLanguage(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..1efb11e3f928 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspect-view/compare-aspect-view.module.scss @@ -0,0 +1,83 @@ +$editor-padding: 56px; + +.componentCompareAspectViewContainer { + width: 100%; + height: 100%; +} +.componentCompareAspectDiffEditorContainer { + padding: 0px 8px; + display: flex; + flex-direction: column; + height: calc(100% - $editor-padding); + min-height: 0; + overflow-y: auto; +} + +.name { + display: flex; + align-items: baseline; + padding: 8px; +} + +.componentCompareAspectConfigDiff, +.componentCompareAspectCalculatedDiff { + display: flex; + flex: 1 0 auto; + padding: 0px 8px; + min-height: 0; + 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..08393ccf67a2 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.tsx @@ -0,0 +1,86 @@ +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 getUpdatedUrlFromQuery = hook?.useUpdatedUrlFromQuery ?? useUpdatedUrlFromQuery; + + const useHref = (node) => { + const href = getUpdatedUrlFromQuery( + { aspect: node.id }, + () => query, + () => location + ); + return state?.controlled ? controlledHref : href; + }; + + 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.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 new file mode 100644 index 000000000000..516e986d4dd2 --- /dev/null +++ b/components/ui/component-compare/compare-aspects/compare-aspects/compare-aspects.widgets.tsx @@ -0,0 +1,45 @@ +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 { + 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'; + } + 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/components/ui/component-compare/component-compare/compare-sidebar.module.scss b/components/ui/component-compare/component-compare/compare-sidebar.module.scss index 2ed06b728764..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,11 +37,28 @@ $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; 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/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/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/diff-viewer/diff-model.spec.ts b/components/ui/diff-viewer/diff-model.spec.ts new file mode 100644 index 000000000000..da9029ba4d69 --- /dev/null +++ b/components/ui/diff-viewer/diff-model.spec.ts @@ -0,0 +1,61 @@ +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 }]); + }); +}); + +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']); + }); + + 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 eef3afe82a42..633f0f546a16 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,20 @@ 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 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[] = []; let oldLn = 1; let newLn = 1; @@ -38,9 +53,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 +71,16 @@ export function computeDiffLines(oldContent: string, newContent: string): DiffLi return items; } +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n'); +} + +function splitSourceLines(content: string): string[] { + const lines = content.split(/\r?\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; @@ -183,6 +216,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.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..d5718dc273b2 --- /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.node'; +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 374bb1a2e32e..a4a6c8c236f7 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'; @@ -19,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. */ @@ -52,6 +55,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 = @@ -64,6 +68,7 @@ export function DiffViewer({ oldContent, newContent, language, + ignoreTrimWhitespace = false, view: controlledView, defaultView = 'split', onViewChange, @@ -89,13 +94,21 @@ 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 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<{ + 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); @@ -105,20 +118,26 @@ 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((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 +165,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 +275,7 @@ function DiffBody({ codeWidthCh, maxHeight, virtualize, + constrainHeight, onExpand, }: { rows: RenderRow[]; @@ -265,16 +286,33 @@ 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); 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. @@ -297,22 +335,22 @@ 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; 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`, }; return ( -
+
{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)} +
); } @@ -385,10 +425,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/highlighter.ts b/components/ui/diff-viewer/highlighter.ts index d602249a754d..d87703ddb34f 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>(); @@ -135,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 }; @@ -152,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; - 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]); @@ -171,6 +142,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..269ccc60f000 100644 --- a/components/ui/diff-viewer/index.ts +++ b/components/ui/diff-viewer/index.ts @@ -1,7 +1,8 @@ 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 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'; 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); +} diff --git a/components/ui/inline-config-compare/inline-config-compare.tsx b/components/ui/inline-config-compare/inline-config-compare.tsx index 1b60de461fca..00485eb59a62 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'; @@ -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, @@ -125,6 +127,21 @@ export function InlineConfigCompare() { // key on the stable Apollo data refs — baseAspects/compareAspects are re-derived (`|| []`) every render }, [loading, baseData, compareData]); + const sidebarAspects = useMemo(() => { + // 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, + })); + }, [loading, aspectDiffs]); + + // 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); + if (loading) { return ; } @@ -143,7 +160,7 @@ export function InlineConfigCompare() { return (
{aspect.configChanged && ( diff --git a/scopes/component/component-compare/component-compare-page.module.scss b/scopes/component/component-compare/component-compare-page.module.scss index ff780ac2d0e1..df0eacdae4c4 100644 --- a/scopes/component/component-compare/component-compare-page.module.scss +++ b/scopes/component/component-compare/component-compare-page.module.scss @@ -21,6 +21,16 @@ justify-content: center; } +.layout { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; + container-type: inline-size; + container-name: component-compare-layout; +} + @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..7d7fda1175d6 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,10 @@ 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 pendingScrollCleanupRef = useRef<(() => void) | undefined>(undefined); const syncUrl = React.useCallback((key: string, value: string | undefined) => { const url = new URL(window.location.href); @@ -318,6 +335,7 @@ function CompareView({ const componentCompare = useComponentCompare(); const compareData = useCompareData(); + const fileRegistry = useFileRegistry(); const data = compareData?.compareDataFor(compareId); const dataLoading = compareData?.loading ?? false; @@ -404,6 +422,52 @@ 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(() => { + 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; @@ -442,33 +506,117 @@ 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} - +
+ {viewMode !== 'dependencies' && ( + { + const nextId = id || undefined; + setSelectedId(nextId); + setSelectedFile(fileName); + pendingScrollCleanupRef.current?.(); + pendingScrollCleanupRef.current = nextId + ? scrollToCompareSelection(diffPaneRef.current, nextId, fileName) + : undefined; + }} + /> + )} + +
+ + {/* 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 +): (() => void) | undefined { + if (!pane) return undefined; + const selector = fileName + ? `[data-file-id="${CSS.escape(componentId)}:${CSS.escape(fileName)}"]` + : `[data-component-id="${CSS.escape(componentId)}"]`; + + 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 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() { return ( <> diff --git a/workspace.jsonc b/workspace.jsonc index b1f000c08f69..5ee8b93655e3 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", @@ -81,19 +80,20 @@ "@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.api-node-details": "~0.0.88", + "@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.87", + "@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.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.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.88", @@ -106,7 +106,6 @@ "@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.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 +163,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", @@ -846,7 +844,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",