diff --git a/.bitmap b/.bitmap index f78ad5728807..65d5cc84f8fd 100644 --- a/.bitmap +++ b/.bitmap @@ -1323,6 +1323,13 @@ "mainFile": "index.ts", "rootDir": "scopes/code/ui/code-compare" }, + "ui/code-editor": { + "scope": "", + "version": "", + "defaultScope": "teambit.code", + "mainFile": "index.ts", + "rootDir": "scopes/code/ui/code-editor" + }, "ui/code-tab-page": { "scope": "teambit.code", "version": "0.0.614", @@ -1335,6 +1342,12 @@ "mainFile": "index.ts", "rootDir": "scopes/code/ui/code-tab-tree" }, + "ui/code-view": { + "scope": "teambit.code", + "version": "5e2b49420c500d4d88e1b05aa232061abc0fa0a7", + "mainFile": "index.ts", + "rootDir": "components/ui/code-view" + }, "ui/compare/lane-compare": { "scope": "teambit.lanes", "version": "0.0.92", diff --git a/components/ui/code-view/code-view.module.scss b/components/ui/code-view/code-view.module.scss new file mode 100644 index 000000000000..9523b653e9eb --- /dev/null +++ b/components/ui/code-view/code-view.module.scss @@ -0,0 +1,84 @@ +@import '~@teambit/ui-foundation.ui.constants.z-indexes/z-indexes.module.scss'; + +.codeViewTitle { + display: flex; + height: 40px; +} + +.img { + width: 20px; + margin-right: 10px; +} + +.fileName { + display: flex; + align-items: baseline; +} + +.componentCodeViewContainer { + display: flex; + width: calc(100% - 80px); + flex: 1; + padding: 24px 40px; + flex-direction: column; + transition: min-height 0.4s ease-in-out; + height: calc(100% - 48px); + min-height: calc(100% - 48px); + ˝ &.loading { + padding: 0px 24px; + } +} + +.componentCodeEditorContainer { + display: flex; + flex: 1; + transition: min-height 0.4s ease-in-out; + position: relative; + background: var(--on-surface-neutral-low-color, #282828) !important; + height: calc(100% - 40px); + min-height: calc(100% - 40px); + + > section { + overflow: hidden; + } +} + +.loader { + width: 100%; + color: var(--on-surface-neutral-low-color, #282828); + background-image: linear-gradient(to right, currentColor 0%, #2b2b2b 30%, #2b2b2b 50%, currentColor 100%); + opacity: 1; + visibility: visible; + transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out; + position: absolute; + height: 220px; + + > div { + background-image: linear-gradient(to right, currentColor 0%, #2b2b2b 30%, #2b2b2b 50%, currentColor 100%); + visibility: visible; + opacity: 1; + transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out; + } +} + +.hideLoader, +.hideLoader > div { + opacity: 0; + pointer-events: none; + visibility: hidden; +} + +.themeContainer { + height: 100%; + min-height: 100%; + display: flex; + width: 100%; + flex: 1; + + > div { + width: 100%; + height: 100%; + min-height: 100%; + display: flex; + } +} diff --git a/components/ui/code-view/code-view.tsx b/components/ui/code-view/code-view.tsx new file mode 100644 index 000000000000..6dd9a6a08664 --- /dev/null +++ b/components/ui/code-view/code-view.tsx @@ -0,0 +1,154 @@ +import { H1 } from '@teambit/documenter.ui.heading'; +import classNames from 'classnames'; +import React, { HTMLAttributes, useMemo } from 'react'; +import { OnMount, Monaco } from '@monaco-editor/react'; +import { useFileContent } from '@teambit/code.ui.queries.get-file-content'; +import { CodeEditor } from '@teambit/code.ui.code-editor'; +import { LineSkeleton } from '@teambit/base-ui.loaders.skeleton'; +import { ThemeSwitcher } from '@teambit/design.themes.theme-toggler'; +import { DarkTheme } from '@teambit/design.themes.dark-theme'; +import { staticStorageUrl } from '@teambit/base-ui.constants.storage'; +import { ComponentID } from '@teambit/component'; +import styles from './code-view.module.scss'; +import { setupLanguage } from './monaco-language-init'; + +export type CodeViewProps = { + componentId: ComponentID; + currentFile?: string; + currentFileContent?: string; + icon?: string; + loading?: boolean; +} & HTMLAttributes; + +// 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', +}; + +export function CodeView({ + className, + componentId, + currentFile, + icon, + currentFileContent, + loading: loadingFromProps, +}: CodeViewProps) { + const monacoRef = React.useRef<{ + editor: any; + monaco: Monaco; + }>(); + + const { fileContent: downloadedFileContent, loading: loadingFileContent } = useFileContent( + componentId, + currentFile, + !!currentFileContent + ); + + const loading = loadingFromProps || loadingFileContent; + const fileContent = currentFileContent || downloadedFileContent; + const title = useMemo(() => currentFile?.split('/').pop(), [currentFile]); + const language = useMemo(() => { + if (!currentFile) return languageOverrides.ts; + const fileEnding = currentFile?.split('.').pop(); + return languageOverrides[fileEnding || ''] || fileEnding; + }, [currentFile]); + + const handleEditorDidMount: OnMount = (editor, monaco) => { + /** + * disable syntax check + * ts cant validate all types because imported files aren't available to the editor + */ + monacoRef.current = { monaco, editor }; + + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.Latest, + allowNonTsExtensions: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.CommonJS, + jsx: monaco.languages.typescript.JsxEmit.React, + noEmit: true, + typeRoots: ['node_modules/@types'], + jsxFactory: 'JSXAlone.createElement', + reactNamespace: 'React', + esModuleInterop: true, + }); + + setupLanguage(monaco, language).catch(() => {}); + + monaco.editor.defineTheme('bit', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'delimiter', foreground: 'ffffff' }, + { token: 'delimiter.angle', foreground: '808080' }, + { token: 'tag.dom', foreground: '569cd6' }, + { token: 'tag.custom', foreground: '4ec9b0' }, + { token: 'delimiter.bracket', foreground: 'd7ba7d' }, + { token: 'attribute.key', foreground: '9cdcfe' }, + { token: 'attribute.value', foreground: 'ce9178' }, + { token: 'delimiter.bracket', foreground: 'd7ba7d' }, + { token: 'jsx.attribute.value', foreground: 'ce9178' }, + ], + + colors: { + 'scrollbar.shadow': '#222222', + 'diffEditor.insertedTextBackground': '#1C4D2D', + 'diffEditor.removedTextBackground': '#761E24', + 'editor.selectionBackground': '#5A5A5A', + 'editor.overviewRulerBorder': '#6a57fd', + 'editor.lineHighlightBorder': '#6a57fd', + }, + }); + + monaco.editor.setTheme('bit'); + }; + const codeEditor = useMemo( + () => ( + } + /> + ), + [fileContent] + ); + + if (!fileContent && !loading && currentFile) return ; + + return ( +
+
+

+ {currentFile && } + {title} +

+
+
+ + + {loading ? null : codeEditor} + +
+
+ ); +} + +function EmptyCodeView() { + return ( +
+ +
Nothing to show
+
+ ); +} + +export function CodeViewLoader({ className, ...rest }: React.HTMLAttributes) { + return ; +} diff --git a/components/ui/code-view/index.ts b/components/ui/code-view/index.ts new file mode 100644 index 000000000000..3dd57f347c74 --- /dev/null +++ b/components/ui/code-view/index.ts @@ -0,0 +1,2 @@ +export { CodeView } from './code-view'; +export type { CodeViewProps } from './code-view'; diff --git a/components/ui/code-view/monaco-language-init.ts b/components/ui/code-view/monaco-language-init.ts new file mode 100644 index 000000000000..92e5d2152c6c --- /dev/null +++ b/components/ui/code-view/monaco-language-init.ts @@ -0,0 +1,141 @@ +/* eslint-disable guard-for-in */ +/* eslint-disable no-restricted-syntax */ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { Monaco } from '@monaco-editor/react'; + +export const richLanguageConfiguration: monaco.languages.LanguageConfiguration = { + wordPattern: /(-?\d*\.\d\w*)|([^`~!@#%^&*()-=+[\]{}\\|;:'",.<>/?\s]+)/g, + comments: { + lineComment: '//', + blockComment: ['/*', '*/'], + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + __electricCharacterSupport: { + docComment: { open: '/**', close: ' */' }, + }, + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string'] }, + { open: "'", close: "'", notIn: ['string', 'comment'] }, + { open: '`', close: '`' }, + ], +}; + +export const customTokenizer: monaco.languages.IMonarchLanguage = { + tokenizer: { + root: [ + [/<(?=[A-Za-z])/, { token: 'delimiter.angle', next: '@tag' }], + [/<\/(?=[A-Za-z])/, { token: 'delimiter.angle', next: '@closingTag' }], + ], + tag: [ + [/\s+/, ''], + [ + /([A-Za-z_$][A-Za-z0-9_.$]*)(?=\s+[\s\n]*[/>]|[\s\n]*=|[\s\n]*)/, + { + cases: { + '[A-Z$][\\w$]*(?!\\s*=)': 'tag.custom', + '[a-z$][\\w$]*(?!\\s*=)': 'tag.dom', + '@default': '@attribute', + }, + }, + ], + [/([A-Za-z_$][\w$-]*)(?=\s*=)/, 'attribute.key'], + [/\/>/, { token: 'delimiter.angle', next: '@pop' }], + [/>/, { token: 'delimiter.angle', next: '@pop' }], + [/=/, 'delimiter'], + [/"/, { token: 'attribute.value', next: '@doubleString' }], + [/'/, { token: 'attribute.value', next: '@singleString' }], + [/{/, { token: 'delimiter.bracket', next: '@jsxAttributeValue' }], + ], + attribute: [ + [/([A-Za-z_$][\w$-]*)(?=\s*=)/, 'attribute.key'], + [/\/>/, { token: 'delimiter.angle', next: '@pop' }], + [/>/, { token: 'delimiter.angle', next: '@pop' }], + [/=/, 'delimiter'], + [/"/, { token: 'attribute.value', next: '@doubleString' }], + [/'/, { token: 'attribute.value', next: '@singleString' }], + [/{/, { token: 'delimiter.bracket', next: '@jsxAttributeValue' }], + [/\s+/, ''], + [/[^=\s>]+/, 'attribute.value'], + ], + closingTag: [ + [/\s+/, ''], + [ + /([A-Za-z_$][A-Za-z0-9_.$]*)(?=[\s\n]*[/>]|[\s\n]*)/, + { cases: { '[A-Z$][\\w$]*': 'tag.custom', '@default': 'tag.dom' }, next: '@pop' }, + ], + [/>/, { token: 'delimiter.angle', next: '@pop' }], + ], + doubleString: [ + [/[^"]+/, 'attribute.value'], + [/"/, { token: 'attribute.value', next: '@pop' }], + ], + singleString: [ + [/[^']+/, 'attribute.value'], + [/'/, { token: 'attribute.value', next: '@pop' }], + ], + jsxAttributeValue: [ + [/{/, { token: 'delimiter.bracket', next: '@nestedJsxAttributeValue' }], + [/}/, { token: 'delimiter.bracket', next: '@pop' }], + [/<(?=[A-Za-z])/, { token: 'delimiter.angle', next: '@tag' }], + [/[^{}]+/, 'attribute.key'], + ], + nestedJsxAttributeValue: [ + [/{/, { token: 'delimiter.bracket', next: '@nestedJsxAttributeValue' }], + [/}/, { token: 'delimiter.bracket', next: '@pop' }], + [/[^{}]+/, 'attribute.key'], + ], + }, +}; + +/** + * Modifies the tokenizer of a given language in a Monaco Editor instance with custom tokens. + * The function operates in a non-destructive manner, ensuring the base object reference remains unaffected. + * + * How it works: + * 1. Retrieves all the available language configurations from the Monaco Editor instance. + * 2. Filters out the desired language (either 'typescript' or 'javascript'). + * 3. Executes the loader method, which is available for all registered languages, and retrieves the language model containing the tokenizer data. + * 4. Modifies the tokenizer data with the custom tokens defined in `customTokenizer`. + * + * Note: The modifications are applied by prepending custom tokens to the existing tokenizer categories. If a category from `customTokenizer` does not exist in the language model, it is created. + * + * @param monacoEditor - The Monaco Editor instance from which to retrieve the language configurations. + * @param language - The language to modify. Must be either 'typescript' or 'javascript'. + */ + +export async function setupLanguage(monacoEditor: Monaco, language: 'typescript' | 'javascript') { + const allLangs = monacoEditor.languages.getLanguages() as any; + + const { language: languageModel } = await allLangs.find(({ id }) => id === language).loader(); + + for (const key in customTokenizer) { + const value = customTokenizer[key]; + if (key === 'tokenizer') { + for (const category in value) { + const tokenDefs = value[category]; + // eslint-disable-next-line no-prototype-builtins + if (!languageModel.tokenizer.hasOwnProperty(category)) { + languageModel.tokenizer[category] = []; + } + if (Array.isArray(tokenDefs)) { + // eslint-disable-next-line prefer-spread + languageModel.tokenizer[category].unshift.apply(languageModel.tokenizer[category], tokenDefs); + } + } + } else if (Array.isArray(value)) { + // eslint-disable-next-line no-prototype-builtins + if (!languageModel.hasOwnProperty(key)) { + languageModel[key] = []; + } + // eslint-disable-next-line prefer-spread + languageModel[key].unshift.apply(languageModel[key], value); + } + } +} diff --git a/components/ui/compare/lane-compare-page/lane-compare-page.module.scss b/components/ui/compare/lane-compare-page/lane-compare-page.module.scss index 9ebd3df4bebc..cc4146630559 100644 --- a/components/ui/compare/lane-compare-page/lane-compare-page.module.scss +++ b/components/ui/compare/lane-compare-page/lane-compare-page.module.scss @@ -4,6 +4,7 @@ height: 100%; width: 100%; } + .top { display: flex; padding: 24px; @@ -42,6 +43,7 @@ .bottom { display: flex; overflow: auto; + height: 100%; } .compareLane { diff --git a/components/ui/component-compare/component-compare/component-compare.tsx b/components/ui/component-compare/component-compare/component-compare.tsx index ea837623333b..ca3308231ae0 100644 --- a/components/ui/component-compare/component-compare/component-compare.tsx +++ b/components/ui/component-compare/component-compare/component-compare.tsx @@ -51,6 +51,7 @@ export function ComponentCompare(props: ComponentCompareProps) { Loader = CompareLoader, baseContext, compareContext, + isFullScreen, ...rest } = props; const baseVersion = useCompareQueryParam('baseVersion'); @@ -148,6 +149,7 @@ export function ComponentCompare(props: ComponentCompareProps) { compareContext, fieldCompareDataByName, fileCompareDataByName, + isFullScreen, }; const changes = diff --git a/scopes/code/ui/code-compare/code-compare-view/code-compare-view.tsx b/scopes/code/ui/code-compare/code-compare-view/code-compare-view.tsx index ba432b679081..5dff2d099599 100644 --- a/scopes/code/ui/code-compare/code-compare-view/code-compare-view.tsx +++ b/scopes/code/ui/code-compare/code-compare-view/code-compare-view.tsx @@ -49,11 +49,12 @@ export function CodeCompareView({ editor: any; monaco: Monaco; }>(); - + const changedLinesRef = useRef(0); const { baseId, compareId, modifiedFileContent, originalFileContent, modifiedPath, originalPath, loading } = useCodeCompare({ fileName, }); + const containerRef = useRef(null); const componentCompareContext = useComponentCompare(); const getDefaultView: () => EditorViewMode = () => { @@ -78,7 +79,6 @@ export function CodeCompareView({ const fileEnding = fileName?.split('.').pop(); return languageOverrides[fileEnding || ''] || fileEnding; }, [fileName]); - const containerRef = useRef(null); const isFullScreen = !!componentCompareContext?.isFullScreen; useEffect(() => { @@ -114,7 +114,7 @@ export function CodeCompareView({ return displayedLines; }; - const changedLinesRef = useRef(0); + const updateChangedLines = () => { if (!monacoRef.current) return; @@ -242,7 +242,7 @@ export function CodeCompareView({ if (containerElement) { resizeObserver = new ResizeObserver(() => { - updateEditorHeight(); + setTimeout(() => updateEditorHeight()); }); resizeObserver.observe(containerElement); } diff --git a/scopes/code/ui/code-compare/code-compare.tsx b/scopes/code/ui/code-compare/code-compare.tsx index b5d481028c2a..14ba53826134 100644 --- a/scopes/code/ui/code-compare/code-compare.tsx +++ b/scopes/code/ui/code-compare/code-compare.tsx @@ -45,7 +45,7 @@ export function CodeCompare({ fileIconSlot, className, CodeView = CodeCompareVie const anyFileHasDiffStatus = useRef(false); const fileTree = useMemo(() => { - const allFiles = uniq(baseFileTree.concat(compareFileTree)); + const allFiles = uniq(baseFileTree.concat(compareFileTree)); anyFileHasDiffStatus.current = false; // sort by diff status return !fileCompareDataByName diff --git a/scopes/code/ui/code-editor/code-editor.tsx b/scopes/code/ui/code-editor/code-editor.tsx new file mode 100644 index 000000000000..b66dd3dc454e --- /dev/null +++ b/scopes/code/ui/code-editor/code-editor.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import classnames from 'classnames'; +import Editor, { OnMount, BeforeMount, OnChange } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { darkMode } from '@teambit/base-ui.theme.dark-theme'; + +export type CodeEditorProps = { + filePath?: string; + fileContent?: string; + language?: string; + height?: string; + className?: string; + options?: monaco.editor.IStandaloneEditorConstructionOptions; + beforeMount?: BeforeMount; + onMount?: OnMount; + onChange?: OnChange; + Loader?: React.ReactNode; +}; + +export const DEFAULT_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = { + 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', +}; + +export function CodeEditor({ + fileContent, + filePath, + language, + beforeMount, + onMount, + onChange, + Loader, + options, + className, + height, +}: CodeEditorProps) { + const defaultLang = React.useMemo(() => { + if (!filePath) return languageOverrides.ts; + const fileEnding = filePath?.split('.').pop(); + return languageOverrides[fileEnding || ''] || fileEnding; + }, [filePath]); + + return ( + + ); +} diff --git a/scopes/code/ui/code-editor/index.ts b/scopes/code/ui/code-editor/index.ts new file mode 100644 index 000000000000..31ddb86fa07d --- /dev/null +++ b/scopes/code/ui/code-editor/index.ts @@ -0,0 +1,2 @@ +export { CodeEditor } from './code-editor'; +export type { CodeEditorProps } from './code-editor'; diff --git a/scopes/component/component-compare/component-compare.ui.runtime.tsx b/scopes/component/component-compare/component-compare.ui.runtime.tsx index 0704d1180f88..d99c4c1e2d34 100644 --- a/scopes/component/component-compare/component-compare.ui.runtime.tsx +++ b/scopes/component/component-compare/component-compare.ui.runtime.tsx @@ -35,7 +35,15 @@ export class ComponentCompareUI { const routes = props?.routes || (() => flatten(this.routeSlot.values())); const host = props?.host || this.host; - return ; + return ( + + ); }; getAspectsComparePage = () => { diff --git a/workspace.jsonc b/workspace.jsonc index f70efbc5b3f6..db2fb7f1ee11 100644 --- a/workspace.jsonc +++ b/workspace.jsonc @@ -82,7 +82,6 @@ "@teambit/bvm.config": "0.2.2", "@teambit/capsule": "0.0.12", "@teambit/code.ui.code-compare-section": "^0.0.5", - "@teambit/code.ui.code-view": "^0.0.508", "@teambit/code.ui.dependency-tree": "^0.0.546", "@teambit/code.ui.hooks.use-code-params": "^0.0.496", "@teambit/code.ui.object-formatter": "0.0.1",