Diff editor (#9458)

Closes  enso-org/cloud#940
This commit is contained in:
Sergei Garin 2024-03-23 22:44:56 +04:00 committed by GitHub
parent 6665c22eb9
commit 7c3e316239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 2738 additions and 387 deletions

View File

@ -30,14 +30,13 @@ const NAME = 'enso'
* `yargs` is a modules we explicitly want the default imports of. * `yargs` is a modules we explicitly want the default imports of.
* `node:process` is here because `process.on` does not exist on the namespace import. */ * `node:process` is here because `process.on` does not exist on the namespace import. */
const DEFAULT_IMPORT_ONLY_MODULES = const DEFAULT_IMPORT_ONLY_MODULES =
'@vitejs\\u002Fplugin-react|node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*|@modyfi\\u002Fvite-plugin-yaml|is-network-error|validator.+' '@vitejs\\u002Fplugin-react|node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|tiny-invariant|clsx|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*|@modyfi\\u002Fvite-plugin-yaml|is-network-error|validator.+'
const OUR_MODULES = 'enso-.*' const OUR_MODULES = 'enso-.*'
const RELATIVE_MODULES = const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations|#\\u002F.*' 'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations|#\\u002F.*'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|ajv\\u002Fdist\\u002F2020|${RELATIVE_MODULES}` const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|ajv\\u002Fdist\\u002F2020|${RELATIVE_MODULES}`
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)' const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const JSX = ':matches(JSXElement, JSXFragment)' const JSX = ':matches(JSXElement, JSXFragment)'
const NOT_PASCAL_CASE = '/^(?!do[A-Z])(?!_?([A-Z][a-z0-9]*)+$)/'
const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/' const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/'
const WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+' const WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+'
const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/` const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/`
@ -117,11 +116,6 @@ const RESTRICTED_SYNTAXES = [
message: message:
'No aliases to primitives - consider using brands instead: `string & { _brand: "BrandName"; }`', 'No aliases to primitives - consider using brands instead: `string & { _brand: "BrandName"; }`',
}, },
{
// Matches functions and arrow functions, but not methods.
selector: `:matches(FunctionDeclaration[id.name=${NOT_PASCAL_CASE}]:has(${JSX}), VariableDeclarator[id.name=${NOT_PASCAL_CASE}]:has(:matches(ArrowFunctionExpression.init ${JSX})))`,
message: 'Use `PascalCase` for React components',
},
{ {
// Matches other functions, non-consts, and consts not at the top level. // Matches other functions, non-consts, and consts not at the top level.
selector: `:matches(FunctionDeclaration[id.name=${NOT_CAMEL_CASE}]:not(:has(${JSX})), VariableDeclarator[id.name=${NOT_CAMEL_CASE}]:has(ArrowFunctionExpression.init:not(:has(${JSX}))), :matches(VariableDeclaration[kind^=const], Program :not(ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const], ExportNamedDeclaration > * VariableDeclaration[kind=const]) > VariableDeclarator[id.name=${NOT_CAMEL_CASE}]:not(:has(ArrowFunctionExpression)))`, selector: `:matches(FunctionDeclaration[id.name=${NOT_CAMEL_CASE}]:not(:has(${JSX})), VariableDeclarator[id.name=${NOT_CAMEL_CASE}]:has(ArrowFunctionExpression.init:not(:has(${JSX}))), :matches(VariableDeclaration[kind^=const], Program :not(ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const], ExportNamedDeclaration > * VariableDeclaration[kind=const]) > VariableDeclarator[id.name=${NOT_CAMEL_CASE}]:not(:has(ArrowFunctionExpression)))`,
@ -228,11 +222,6 @@ const RESTRICTED_SYNTAXES = [
selector: 'CallExpression[callee.name=toastAndLog][arguments.0.value=/\\.$/]', selector: 'CallExpression[callee.name=toastAndLog][arguments.0.value=/\\.$/]',
message: '`toastAndLog` already includes a trailing `.`', message: '`toastAndLog` already includes a trailing `.`',
}, },
{
selector:
'JSXElement[closingElement!=null]:not(:has(.children:matches(JSXText[raw=/\\S/], :not(JSXText))))',
message: 'Use self-closing tags (`<tag />`) for tags without children',
},
] ]
// ============================ // ============================
@ -275,7 +264,7 @@ export default [
...tsEslint.configs.recommended?.rules, ...tsEslint.configs.recommended?.rules,
...tsEslint.configs['recommended-requiring-type-checking']?.rules, ...tsEslint.configs['recommended-requiring-type-checking']?.rules,
...tsEslint.configs.strict?.rules, ...tsEslint.configs.strict?.rules,
...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules,
eqeqeq: ['error', 'always', { null: 'never' }], eqeqeq: ['error', 'always', { null: 'never' }],
'jsdoc/require-jsdoc': [ 'jsdoc/require-jsdoc': [
'error', 'error',
@ -303,8 +292,10 @@ export default [
'prefer-const': 'error', 'prefer-const': 'error',
// Not relevant because TypeScript checks types. // Not relevant because TypeScript checks types.
'react/prop-types': 'off', 'react/prop-types': 'off',
'react/self-closing-comp': 'error',
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error', 'react-hooks/exhaustive-deps': 'error',
'react/jsx-pascal-case': ['error', { allowNamespace: true }],
// Prefer `interface` over `type`. // Prefer `interface` over `type`.
'@typescript-eslint/consistent-type-definitions': 'error', '@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/consistent-type-imports': 'error',

View File

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<rect x="16.2426" y="17.6569" width="14" height="2" transform="rotate(-135 16.2426 17.6569)" fill="white" />
<rect x="6.34302" y="16.2426" width="14" height="2" transform="rotate(-45 6.34302 16.2426)" fill="white" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@ -43,7 +43,14 @@
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-toastify": "^9.1.3", "react-toastify": "^9.1.3",
"ts-results": "^3.3.0", "ts-results": "^3.3.0",
"validator": "^13.11.0" "validator": "^13.11.0",
"monaco-editor": "0.47.0",
"@monaco-editor/react": "4.6.0",
"@tanstack/react-query": "^5.27.5",
"clsx": "^1.1.1",
"tiny-invariant": "^1.3.3",
"tailwind-merge": "^2.2.1",
"react-aria-components": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-syntax-import-assertions": "^7.23.3", "@babel/plugin-syntax-import-assertions": "^7.23.3",
@ -76,6 +83,7 @@
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",
"react-toastify": "^9.1.3", "react-toastify": "^9.1.3",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-react-aria-components": "^1.1.1",
"ts-plugin-namespace-auto-import": "^1.0.0", "ts-plugin-namespace-auto-import": "^1.0.0",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^4.4.9", "vite": "^4.4.9",

View File

@ -35,6 +35,7 @@
* {@link authProvider.FullUserSession}). */ * {@link authProvider.FullUserSession}). */
import * as React from 'react' import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import * as toastify from 'react-toastify' import * as toastify from 'react-toastify'
@ -63,6 +64,8 @@ import SetUsername from '#/pages/authentication/SetUsername'
import Dashboard from '#/pages/dashboard/Dashboard' import Dashboard from '#/pages/dashboard/Dashboard'
import Subscribe from '#/pages/subscribe/Subscribe' import Subscribe from '#/pages/subscribe/Subscribe'
import * as rootComponent from '#/components/Root'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import LocalBackend from '#/services/LocalBackend' import LocalBackend from '#/services/LocalBackend'
@ -141,11 +144,12 @@ export default function App(props: AppProps) {
// This is a React component even though it does not contain JSX. // This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const Router = detect.isOnElectron() ? router.HashRouter : router.BrowserRouter const Router = detect.isOnElectron() ? router.HashRouter : router.BrowserRouter
const queryClient = React.useMemo(() => new reactQuery.QueryClient(), [])
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
// will redirect the user between the login/register pages and the dashboard. // will redirect the user between the login/register pages and the dashboard.
return ( return (
<> <reactQuery.QueryClientProvider client={queryClient}>
<toastify.ToastContainer <toastify.ToastContainer
position="top-center" position="top-center"
theme="light" theme="light"
@ -160,7 +164,7 @@ export default function App(props: AppProps) {
<AppRouter {...props} /> <AppRouter {...props} />
</LocalStorageProvider> </LocalStorageProvider>
</Router> </Router>
</> </reactQuery.QueryClientProvider>
) )
} }
@ -186,6 +190,9 @@ function AppRouter(props: AppProps) {
window.navigate = navigate window.navigate = navigate
} }
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings()) const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
const [root] = React.useState<React.RefObject<HTMLElement>>(() => ({
current: document.getElementById('enso-dashboard'),
}))
React.useEffect(() => { React.useEffect(() => {
const savedInputBindings = localStorage.get('inputBindings') const savedInputBindings = localStorage.get('inputBindings')
@ -274,7 +281,11 @@ function AppRouter(props: AppProps) {
isClick = true isClick = true
} }
const onMouseUp = (event: MouseEvent) => { const onMouseUp = (event: MouseEvent) => {
if (isClick && !eventModule.isElementTextInput(event.target)) { if (
isClick &&
!eventModule.isElementTextInput(event.target) &&
!eventModule.isElementPartOfMonaco(event.target)
) {
const selection = document.getSelection() const selection = document.getSelection()
const app = document.getElementById('app') const app = document.getElementById('app')
const appContainsSelection = const appContainsSelection =
@ -359,5 +370,10 @@ function AppRouter(props: AppProps) {
</SessionProvider> </SessionProvider>
) )
result = <LoggerProvider logger={logger}>{result}</LoggerProvider> result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
result = (
<rootComponent.Root rootRef={root} navigate={navigate}>
{result}
</rootComponent.Root>
)
return result return result
} }

View File

@ -0,0 +1,67 @@
/**
* @file Button.tsx
*
* Button component
*/
import clsx from 'clsx'
import * as reactAriaComponents from 'react-aria-components'
import * as tailwindMerge from 'tailwind-merge'
import SvgMask from '#/components/SvgMask'
/**
* Props for the Button component
*/
export interface ButtonProps extends reactAriaComponents.ButtonProps {
readonly variant: 'icon'
readonly icon?: string
/**
* FIXME: This is not yet implemented
* The position of the icon in the button
* @default 'start'
*/
readonly iconPosition?: 'end' | 'start'
}
const DEFAULT_CLASSES =
'flex cursor-pointer rounded-sm border border-transparent transition-opacity duration-200 ease-in-out'
const FOCUS_CLASSES =
'focus-visible:outline-offset-2 focus:outline-none focus-visible:outline focus-visible:outline-primary'
const ICON_CLASSES = 'opacity-50 hover:opacity-100'
const EXTRA_CLICK_ZONE_CLASSES = 'flex relative before:inset-[-12px] before:absolute before:z-10'
const DISABLED_CLASSES = 'disabled:opacity-50 disabled:cursor-not-allowed'
/**
* A button allows a user to perform an action, with mouse, touch, and keyboard interactions.
*/
export function Button(props: ButtonProps) {
const { className, children, icon, ...ariaButtonProps } = props
const classes = clsx(DEFAULT_CLASSES, DISABLED_CLASSES, FOCUS_CLASSES, ICON_CLASSES)
const childrenFactory = () => {
return icon != null ? (
<>
<div className={EXTRA_CLICK_ZONE_CLASSES}>
<SvgMask src={icon} />
</div>
</>
) : (
children
)
}
return (
<reactAriaComponents.Button
className={values =>
tailwindMerge.twMerge(
classes,
typeof className === 'function' ? className(values) : className
)
}
{...ariaButtonProps}
>
{childrenFactory()}
</reactAriaComponents.Button>
)
}

View File

@ -0,0 +1,86 @@
/**
* @file
* A dialog is an overlay shown above other content in an application.
* Can be used to display alerts, confirmations, or other content.
*/
import * as React from 'react'
import * as reactAriaComponents from 'react-aria-components'
import * as tailwindMerge from 'tailwind-merge'
import Dismiss from 'enso-assets/dismiss.svg'
import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal'
import type * as types from './types'
const MODAL_CLASSES =
'fixed z-1 top-0 left-0 right-0 bottom-0 bg-black/[15%] flex items-center justify-center text-center'
const DIALOG_CLASSES =
'relative flex flex-col overflow-hidden rounded-xl text-left align-middle text-slate-700 shadow-2xl bg-clip-padding border border-black/10 before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default'
const MODAL_CLASSES_BY_TYPE = {
modal: 'p-4',
popover: '',
fullscreen: 'p-4',
} satisfies Record<types.DialogType, string>
const DIALOG_CLASSES_BY_TYPE = {
modal: 'w-full max-w-md min-h-[200px] h-[90vh] max-h-[90vh]',
popover: 'rounded-lg',
fullscreen: 'w-full h-full max-w-full max-h-full bg-clip-border',
} satisfies Record<types.DialogType, string>
/**
* A dialog is an overlay shown above other content in an application.
* Can be used to display alerts, confirmations, or other content.
*/
export function Dialog(props: types.DialogProps) {
const {
children,
title,
type = 'modal',
isDismissible = true,
isKeyboardDismissDisabled = false,
className,
...ariaDialogProps
} = props
const root = portal.useStrictPortalContext()
return (
<reactAriaComponents.Modal
className={tailwindMerge.twMerge(MODAL_CLASSES, [MODAL_CLASSES_BY_TYPE[type]])}
isDismissable={isDismissible}
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
UNSTABLE_portalContainer={root.current}
>
<reactAriaComponents.Dialog
className={tailwindMerge.twMerge(DIALOG_CLASSES, [DIALOG_CLASSES_BY_TYPE[type]], className)}
{...ariaDialogProps}
>
{opts => (
<>
{typeof title === 'string' && (
<reactAriaComponents.Header className="center sticky flex flex-none border-b px-3.5 py-2.5 text-primary shadow">
<h2 className="text-l my-0 font-semibold leading-6">{title}</h2>
<ariaComponents.Button
variant="icon"
className="my-auto ml-auto"
onPress={opts.close}
icon={Dismiss}
/>
</reactAriaComponents.Header>
)}
<div className="flex-1 shrink-0">
{typeof children === 'function' ? children(opts) : children}
</div>
</>
)}
</reactAriaComponents.Dialog>
</reactAriaComponents.Modal>
)
}

View File

@ -0,0 +1,45 @@
/**
* @file
*
* A DialogTrigger opens a dialog when a trigger element is pressed.
*/
import * as React from 'react'
import * as reactAriaComponents from 'react-aria-components'
import * as modalProvider from '#/providers/ModalProvider'
import type * as types from './types'
const PLACEHOLDER = <div />
/**
* A DialogTrigger opens a dialog when a trigger element is pressed.
*/
export function DialogTrigger(props: types.DialogTriggerProps) {
const { children, onOpenChange, ...triggerProps } = props
const { setModal, unsetModal } = modalProvider.useSetModal()
const onOpenChangeInternal = React.useCallback(
(isOpened: boolean) => {
if (isOpened) {
// we're using a placeholder here just to let the rest of the code know that the modal is open
setModal(PLACEHOLDER)
} else {
unsetModal()
}
onOpenChange?.(isOpened)
},
[setModal, unsetModal, onOpenChange]
)
return (
<reactAriaComponents.DialogTrigger
children={children}
onOpenChange={onOpenChangeInternal}
{...triggerProps}
/>
)
}

View File

@ -0,0 +1,8 @@
/**
* @file
*
* Re-exports the Dialog component.
*/
export * from './Dialog'
export * from './types'
export * from './DialogTrigger'

View File

@ -0,0 +1,30 @@
/**
* @file
* Contains the types for the Dialog component.
*/
import type * as reactAriaComponents from 'react-aria-components'
/**
*
*/
export type DialogType = 'fullscreen' | 'modal' | 'popover'
/**
* The props for the Dialog component.
*/
export interface DialogProps extends reactAriaComponents.DialogProps {
/**
* The type of dialog to render.
* @default 'modal'
*/
readonly type?: DialogType
readonly title?: string
readonly isDismissible?: boolean
readonly onOpenChange?: (isOpen: boolean) => void
readonly isKeyboardDismissDisabled?: boolean
}
/**
* The props for the DialogTrigger component.
*/
export interface DialogTriggerProps extends reactAriaComponents.DialogTriggerProps {}

View File

@ -0,0 +1,50 @@
/**
* @file
*
* A tooltip displays a description of an element on hover or focus.
*/
import * as reactAriaComponents from 'react-aria-components'
import * as tailwindMerge from 'tailwind-merge'
import * as portal from '#/components/Portal'
/**
*
*/
export interface TooltipProps
extends Omit<reactAriaComponents.TooltipProps, 'offset' | 'UNSTABLE_portalContainer'> {}
const DEFAULT_CLASSES = 'z-1 flex bg-neutral-800 text-white p-2 rounded-md shadow-lg text-xs'
const DEFAULT_CONTAINER_PADDING = 4
const DEFAULT_OFFSET = 4
/**
* A tooltip displays a description of an element on hover or focus.
*/
export function Tooltip(props: TooltipProps) {
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
const root = portal.useStrictPortalContext()
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
return (
<reactAriaComponents.Tooltip
offset={DEFAULT_OFFSET}
containerPadding={containerPadding}
UNSTABLE_portalContainer={root.current}
className={values =>
tailwindMerge.twMerge(
classes,
typeof className === 'function' ? className(values) : className
)
}
{...ariaTooltipProps}
/>
)
}
// Re-export the TooltipTrigger component from react-aria-components
// eslint-disable-next-line no-restricted-syntax
export { TooltipTrigger } from 'react-aria-components'

View File

@ -0,0 +1,7 @@
/**
* @file index.ts
* Index file for Aria Components
*/
export * from './Button/Button'
export * from './Tooltip/Tooltip'
export * from './Dialog'

View File

@ -58,7 +58,8 @@ export default function MenuEntry(props: MenuEntryProps) {
<button <button
disabled={disabled} disabled={disabled}
title={title} title={title}
className={`flex h-row place-content-between items-center rounded-menu-entry p-menu-entry text-left selectable hover:bg-hover-bg enabled:active disabled:bg-transparent ${ className={`items -center flex h-row
place-content-between rounded-menu-entry p-menu-entry text-left selectable enabled:active hover:bg-hover-bg disabled:bg-transparent ${
isContextMenuEntry ? 'px-context-menu-entry-x' : '' isContextMenuEntry ? 'px-context-menu-entry-x' : ''
}`} }`}
onClick={event => { onClick={event => {

View File

@ -0,0 +1,44 @@
/**
* @file Portal component
* Renders its children outside the current DOM hierarchy
*/
import * as React from 'react'
import * as reactDom from 'react-dom'
import type * as types from './types'
import * as usePortal from './usePortal'
/**
* This component renders its children outside the current DOM hierarchy.
*
* React [doesn't support](https://github.com/facebook/react/issues/13097) portal API in SSR, so, if you want to
* render a Portal in SSR, use prop `disabled`.
*
* By default, Portal's children render under the `<Root />` component.
*
* ***Important***: Since React doesn't support portals on SSR, `<Portal />` children render in the next tick.
* If you need to make some computations, use the `onMount` callback
* @see https://reactjs.org/docs/portals.html
* @example ```jsx
* <div>
* Portal will be rendered outside me!
*
* <Portal>
* <div>some content will be showed outside of parent container</div>
* </Portal>
* </div>
* ```
*/
export default function Portal(props: types.PortalProps): React.JSX.Element | null {
const { children, mountRoot, isDisabled } = usePortal.usePortal(props)
if (isDisabled) {
return <>{children}</>
} else if (mountRoot) {
return reactDom.createPortal(children, mountRoot)
} else {
return null
}
}

View File

@ -0,0 +1,42 @@
/**
* @file
* Provides the context for the Portal component
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
const PortalContext = React.createContext<React.RefObject<Element | null> | null>(null)
/**
* Allows to access the root element for the Portal component
*/
export function usePortalContext() {
const root = React.useContext(PortalContext)
return { root } as const
}
/**
* Allows to access the root element for the Portal component
* @throws invariant the `PortalProvider` is not in the component tree
*/
export function useStrictPortalContext() {
const root = React.useContext(PortalContext)
invariant(
root != null && root.current != null,
'You should use `PortalProvider` to access the `Portal` component'
)
// This is safe because we are using `invariant` to check if the `PortalProvider` is in the component tree
// and the root element is not `null`
// eslint-disable-next-line no-restricted-syntax
return root as { current: Element }
}
/**
* Specifies the root element for the Portal component
*/
// eslint-disable-next-line no-restricted-syntax
export const PortalProvider = PortalContext.Provider

View File

@ -0,0 +1,12 @@
/**
* @file
*
* Re-exports the Portal component and its related types and hooks.
*/
import Portal from './Portal'
export * from './PortalProvider'
export * from './types'
// eslint-disable-next-line no-restricted-syntax
export default Portal

View File

@ -0,0 +1,27 @@
/**
* @file
* Types for the Portal component
*/
import type * as React from 'react'
/**
* The props for the Portal component
*/
export interface PortalProps {
/**
* Ref, where `<Portal />` should render its children
* By default it renders under `<Root />`
* @default null
*/
readonly root?: React.MutableRefObject<HTMLElement | null> | React.RefObject<HTMLElement | null>
/**
* Disables portal's API
* @default false
*/
readonly isDisabled?: boolean
/**
* Callback, will be called after portal's children mounted
*/
readonly onMount?: () => void
readonly children?: React.ReactNode
}

View File

@ -0,0 +1,50 @@
/**
* @file
* The hook contains the logic for mounting the children into the portal.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as portalProvider from './PortalProvider'
import type * as types from './types'
/**
* The hook contains the logic for mounting the children into the portal.
* @internal
*/
export function usePortal(props: types.PortalProps) {
const { children, isDisabled = false, root = null, onMount } = props
const onMountRef = React.useRef(onMount)
const portalContext = portalProvider.usePortalContext()
const [mountRoot, setMountRoot] = React.useState<Element | null>(null)
onMountRef.current = onMount
React.useEffect(() => {
if (!isDisabled) {
const contextRoot = portalContext.root
const currentRoot = root?.current ?? null
const currentContextRoot = contextRoot?.current ?? null
invariant(
!(contextRoot == null && currentRoot == null),
'Before using Portal, you need to specify a root, where the component should be mounted or put the component under the <Root /> component'
)
setMountRoot(currentRoot ?? currentContextRoot)
}
}, [root, portalContext.root, isDisabled])
React.useEffect(() => {
if (isDisabled || mountRoot) {
onMountRef.current?.()
}
}, [isDisabled, mountRoot])
return {
isDisabled,
children,
mountRoot,
}
}

View File

@ -0,0 +1,35 @@
/**
* @file
* The root component with required providers
*/
import * as React from 'react'
import * as reactAriaComponents from 'react-aria-components'
import * as portal from '#/components/Portal'
/**
* Props for the root component
*/
export interface RootProps extends React.PropsWithChildren {
readonly rootRef: React.RefObject<HTMLElement>
readonly navigate: (path: string) => void
readonly locale?: string
}
/**
* The root component with required providers
*/
export function Root(props: RootProps) {
const { children, rootRef, navigate, locale = 'en-US' } = props
return (
<portal.PortalProvider value={rootRef}>
<reactAriaComponents.RouterProvider navigate={navigate}>
<reactAriaComponents.I18nProvider locale={locale}>
{children}
</reactAriaComponents.I18nProvider>
</reactAriaComponents.RouterProvider>
</portal.PortalProvider>
)
}

View File

@ -1,12 +1,12 @@
/** @file A selection brush to indicate the area being selected by the mouse drag action. */ /** @file A selection brush to indicate the area being selected by the mouse drag action. */
import * as React from 'react' import * as React from 'react'
import * as reactDom from 'react-dom'
import * as animationHooks from '#/hooks/animationHooks' import * as animationHooks from '#/hooks/animationHooks'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import Portal from '#/components/Portal'
import * as eventModule from '#/utilities/event' import * as eventModule from '#/utilities/event'
import type * as geometry from '#/utilities/geometry' import type * as geometry from '#/utilities/geometry'
@ -169,15 +169,14 @@ export default function SelectionBrush(props: SelectionBrushProps) {
width: `${rectangle.width}px`, width: `${rectangle.width}px`,
height: `${rectangle.height}px`, height: `${rectangle.height}px`,
} }
return (
return reactDom.createPortal( <Portal>
<div <div
className={`pointer-events-none fixed z-1 box-content rounded-selection-brush border-transparent bg-selection-brush transition-border-margin ${ className={`pointer-events-none fixed z-1 box-content rounded-selection-brush border-transparent bg-selection-brush transition-border-margin ${
hidden ? 'm border-0' : '-m-selection-brush-border border-selection-brush' hidden ? 'm border-0' : '-m-selection-brush-border border-selection-brush'
}`} }`}
style={brushStyle} style={brushStyle}
/>, />
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion </Portal>
document.getElementById('enso-dashboard')!
) )
} }

View File

@ -21,7 +21,7 @@ export default function SubmitButton(props: SubmitButtonProps) {
<button <button
disabled={disabled} disabled={disabled}
type="submit" type="submit"
className={`flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth selectable hover:bg-blue-700 focus:bg-blue-700 focus:outline-none enabled:active`} className={`flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth selectable enabled:active hover:bg-blue-700 focus:bg-blue-700 focus:outline-none`}
> >
{text} {text}
<SvgMask src={icon} /> <SvgMask src={icon} />

View File

@ -0,0 +1,74 @@
/**
* @file
*
* Diff view for 2 asset versions for a specific project
*/
import * as react from '@monaco-editor/react'
import Spinner, * as spinnerModule from '#/components/Spinner'
import type * as backendService from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
import * as useFetchVersionContent from './useFetchVersionContent'
/**
* Props for the AssetDiffView component
*/
export interface AssetDiffViewProps {
readonly versionId: string
readonly latestVersionId: string
readonly projectId: backendService.ProjectId
readonly backend: RemoteBackend
}
/**
* Diff view for asset versions
*/
export function AssetDiffView(props: AssetDiffViewProps) {
const { versionId, projectId, backend, latestVersionId } = props
const versionContent = useFetchVersionContent.useFetchVersionContent({
versionId,
projectId,
backend,
})
const headContent = useFetchVersionContent.useFetchVersionContent({
versionId: latestVersionId,
projectId,
backend,
})
const loader = (
<div className="flex h-full w-full items-center justify-center">
<Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
</div>
)
if (versionContent.isError || headContent.isError) {
return <div className="p-indent-8 text-center">Failed to load content</div>
} else if (versionContent.isPending || headContent.isPending) {
return loader
} else {
return (
<react.DiffEditor
beforeMount={monaco => {
monaco.editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [],
// This comes from third-party code and we can't change it
// eslint-disable-next-line @typescript-eslint/naming-convention
colors: { 'editor.background': '#00000000' },
})
}}
original={versionContent.data}
modified={headContent.data}
language="enso"
options={{ readOnly: true }}
loading={loader}
theme={'myTheme'}
/>
)
}
}

View File

@ -0,0 +1,5 @@
/**
* @file
* Re-exports the AssetDiffView component.
*/
export * from './AssetDiffView'

View File

@ -0,0 +1,55 @@
/**
* @file
*
* Fetches the content of a projects Main.enso file with specified version.
*/
import * as reactQuery from '@tanstack/react-query'
import type * as backendService from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
/**
*
*/
export interface FetchVersionContentProps {
readonly projectId: backendService.ProjectId
readonly versionId: string
readonly backend: RemoteBackend
readonly omitMetadataFromContent?: boolean
}
const MS_IN_SECOND = 1000
const HUNDRED = 100
const HUNDRED_SECONDS = HUNDRED * MS_IN_SECOND
/**
* Fetches the content of a version.
*/
export function useFetchVersionContent(params: FetchVersionContentProps) {
const { versionId, backend, projectId, omitMetadataFromContent = true } = params
return reactQuery.useQuery({
queryKey: ['versionContent', versionId],
queryFn: () => backend.getFileContent(projectId, versionId),
select: data => (omitMetadataFromContent ? omitMetadata(data) : data),
staleTime: HUNDRED_SECONDS,
})
}
/**
* Removes the metadata from the content of a version.
*/
function omitMetadata(file: string): string {
let [withoutMetadata] = file.split('#### METADATA ####')
if (withoutMetadata == null) {
return file
} else {
while (withoutMetadata[withoutMetadata.length - 1] === '\n') {
withoutMetadata = withoutMetadata.slice(0, -1)
}
return withoutMetadata
}
}

View File

@ -6,7 +6,7 @@ import * as localStorageProvider from '#/providers/LocalStorageProvider'
import type * as assetEvent from '#/events/assetEvent' import type * as assetEvent from '#/events/assetEvent'
import AssetProperties from '#/layouts/AssetProperties' import AssetProperties from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions' import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
import type Category from '#/layouts/CategorySwitcher/Category' import type Category from '#/layouts/CategorySwitcher/Category'
import * as backend from '#/services/Backend' import * as backend from '#/services/Backend'
@ -139,7 +139,7 @@ export default function AssetPanel(props: AssetPanelProps) {
dispatchAssetEvent={dispatchAssetEvent} dispatchAssetEvent={dispatchAssetEvent}
/> />
)} )}
<AssetVersions hidden={tab !== AssetPanelTab.versions} item={item} /> {tab === AssetPanelTab.versions && <AssetVersions item={item} />}
</> </>
)} )}
</div> </div>

View File

@ -1,29 +1,71 @@
/** @file Displays information describing a specific version of an asset. */ /** @file Displays information describing a specific version of an asset. */
import * as React from 'react' import Duplicate from 'enso-assets/duplicate.svg'
import type * as backend from '#/services/Backend' import * as ariaComponents from '#/components/AriaComponents'
import * as backendService from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
import * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as assetDiffView from './AssetDiffView'
// ==================== // ====================
// === AssetVersion === // === AssetVersion ===
// ==================== // ====================
/** Props for a {@link AssetVersion}. */ /** Props for a {@link AssetVersion}. */
export interface AssetVersionProps { export interface AssetVersionProps {
readonly item: backendService.AnyAsset
readonly number: number readonly number: number
readonly version: backend.S3ObjectVersion readonly version: backendService.S3ObjectVersion
readonly latestVersion: backendService.S3ObjectVersion
readonly backend: RemoteBackend
} }
/** Displays information describing a specific version of an asset. */ /** Displays information describing a specific version of an asset. */
export default function AssetVersion(props: AssetVersionProps) { export default function AssetVersion(props: AssetVersionProps) {
const { number, version } = props const { number, version, item, backend, latestVersion } = props
const isProject = item.type === backendService.AssetType.project
const versionName = `Version ${number}`
return ( return (
<div className="flex cursor-pointer select-none flex-col overflow-y-auto rounded-default p-version transition-colors hover:bg-frame"> <div className="flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2">
<div>version {number}</div> <div className="flex flex-1 flex-col">
<div className="text-xs text-not-selected"> <div>
{versionName} {version.isLatest && `(Latest)`}
</div>
<time className="text-xs text-not-selected">
on {dateTime.formatDateTime(new Date(version.lastModified))} on {dateTime.formatDateTime(new Date(version.lastModified))}
</time>
</div>
<div className="flex items-center gap-1">
{isProject && (
<ariaComponents.DialogTrigger>
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
variant="icon"
aria-label="Compare with latest"
icon={Duplicate}
isDisabled={version.isLatest}
/>
<ariaComponents.Tooltip>Compare with latest</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
<ariaComponents.Dialog type="fullscreen" title={`Compare ${versionName} with latest`}>
<assetDiffView.AssetDiffView
latestVersionId={latestVersion.versionId}
versionId={version.versionId}
projectId={item.id}
backend={backend}
/>
</ariaComponents.Dialog>
</ariaComponents.DialogTrigger>
)}
</div> </div>
</div> </div>
) )

View File

@ -1,63 +0,0 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import AssetVersion from '#/layouts/AssetVersion'
import type * as backend from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
// =====================
// === AssetVersions ===
// =====================
/** Props for a {@link AssetVersions}. */
export interface AssetVersionsProps {
readonly hidden: boolean
readonly item: AssetTreeNode
}
/** A list of previous versions of an asset. */
export default function AssetVersions(props: AssetVersionsProps) {
const { hidden, item } = props
const { backend } = backendProvider.useBackend()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [initialized, setInitialized] = React.useState(false)
const [versions, setVersions] = React.useState<backend.S3ObjectVersion[]>([])
React.useEffect(() => {
if (!hidden && !initialized) {
setInitialized(true)
void (async () => {
try {
const assetVersions = await backend.listAssetVersions(item.item.id, item.item.title)
setVersions([...assetVersions.versions].reverse())
} catch (error) {
setInitialized(false)
toastAndLog('Could not list versions', error)
}
})()
}
}, [
hidden,
initialized,
backend,
item.item.id,
item.item.title,
/* should never change */ toastAndLog,
])
return hidden ? (
<></>
) : (
<div className="flex flex-col">
{versions.map((version, i) => (
<AssetVersion key={version.versionId} number={versions.length - i} version={version} />
))}
</div>
)
}

View File

@ -0,0 +1,80 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import AssetVersion from '#/layouts/AssetVersion'
import Spinner from '#/components/Spinner'
import * as spinnerModule from '#/components/Spinner'
import RemoteBackend from '#/services/RemoteBackend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import * as assetVersions from './useAssetVersions'
// =====================
// === AssetVersions ===
// =====================
/** Props for a {@link AssetVersions}. */
export interface AssetVersionsProps {
readonly item: AssetTreeNode
}
/** A list of previous versions of an asset. */
export default function AssetVersions(props: AssetVersionsProps) {
const { item } = props
const { backend } = backendProvider.useBackend()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const isRemote = backend instanceof RemoteBackend
const {
status,
error,
data: versions,
isPending,
} = assetVersions.useAssetVersions({
backend,
assetId: item.item.id,
title: item.item.title,
onError: backendError => toastAndLog('Could not list versions', backendError),
enabled: isRemote,
})
const latestVersion = versions?.find(version => version.isLatest)
return (
<div className="flex flex-1 shrink-0 flex-col items-center overflow-y-auto overflow-x-hidden">
{(() => {
if (!isRemote) {
return <div>Local assets do not have versions</div>
} else if (isPending) {
return <Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
} else if (status === 'error') {
return <div>Error: {error.message}</div>
} else if (versions.length === 0) {
return <div>No versions found</div>
} else if (!latestVersion) {
return <div>Could not fetch the latest version of the file</div>
} else {
return versions.map((version, i) => (
<AssetVersion
key={version.versionId}
number={versions.length - i}
version={version}
item={item.item}
backend={backend}
latestVersion={latestVersion}
/>
))
}
})()}
</div>
)
}

View File

@ -0,0 +1,47 @@
/**
* @file
* Fetches the versions of the selected project asset
*/
import * as reactQuery from '@tanstack/react-query'
import type Backend from '#/services/Backend'
import type * as backendService from '#/services/Backend'
/**
* Parameters for the useAssetVersions hook
*/
export interface UseAssetVersionsParams {
readonly assetId: backendService.AssetId
readonly title: string
readonly backend: Backend
readonly queryKey?: reactQuery.QueryKey
readonly enabled?: boolean
readonly onError?: (error: unknown) => void
}
/**
* Fetches the versions of the selected project asset
*/
export function useAssetVersions(params: UseAssetVersionsParams) {
const {
enabled = true,
title,
assetId,
backend,
onError,
queryKey = ['assetVersions', assetId, title],
} = params
return reactQuery.useQuery({
queryKey,
enabled,
queryFn: () =>
backend
.listAssetVersions(assetId, title)
.then(assetVersions => assetVersions.versions)
.catch(backendError => {
onError?.(backendError)
throw backendError
}),
})
}

View File

@ -1,8 +1,6 @@
/** @file The top-bar of dashboard. */ /** @file The top-bar of dashboard. */
import * as React from 'react' import * as React from 'react'
import * as reactDom from 'react-dom'
import type * as assetSearchBar from '#/layouts/AssetSearchBar' import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import AssetSearchBar from '#/layouts/AssetSearchBar' import AssetSearchBar from '#/layouts/AssetSearchBar'
import BackendSwitcher from '#/layouts/BackendSwitcher' import BackendSwitcher from '#/layouts/BackendSwitcher'
@ -10,6 +8,7 @@ import PageSwitcher, * as pageSwitcher from '#/layouts/PageSwitcher'
import UserBar from '#/layouts/UserBar' import UserBar from '#/layouts/UserBar'
import AssetInfoBar from '#/components/dashboard/AssetInfoBar' import AssetInfoBar from '#/components/dashboard/AssetInfoBar'
import Portal from '#/components/Portal'
import type * as backendModule from '#/services/Backend' import type * as backendModule from '#/services/Backend'
@ -50,7 +49,6 @@ export default function TopBar(props: TopBarProps) {
const { isEditorDisabled, setBackendType, isHelpChatOpen, setIsHelpChatOpen } = props const { isEditorDisabled, setBackendType, isHelpChatOpen, setIsHelpChatOpen } = props
const { query, setQuery, labels, suggestions, isAssetPanelEnabled } = props const { query, setQuery, labels, suggestions, isAssetPanelEnabled } = props
const { isAssetPanelVisible, setIsAssetPanelEnabled, doRemoveSelf, onSignOut } = props const { isAssetPanelVisible, setIsAssetPanelEnabled, doRemoveSelf, onSignOut } = props
const [root] = React.useState(() => document.getElementById('enso-dashboard'))
const supportsCloudBackend = process.env.ENSO_CLOUD_API_URL != null const supportsCloudBackend = process.env.ENSO_CLOUD_API_URL != null
const shouldMakeSpaceForExtendedEditorMenu = page === pageSwitcher.Page.editor const shouldMakeSpaceForExtendedEditorMenu = page === pageSwitcher.Page.editor
@ -98,8 +96,7 @@ export default function TopBar(props: TopBarProps) {
)} )}
</div> </div>
</div> </div>
{root && <Portal>
reactDom.createPortal(
<div <div
className={`fixed right top z-1 m-top-bar text-xs text-primary ${shouldMakeSpaceForExtendedEditorMenu ? 'mr-extended-editor-menu' : ''}`} className={`fixed right top z-1 m-top-bar text-xs text-primary ${shouldMakeSpaceForExtendedEditorMenu ? 'mr-extended-editor-menu' : ''}`}
> >
@ -122,9 +119,8 @@ export default function TopBar(props: TopBarProps) {
onSignOut={onSignOut} onSignOut={onSignOut}
/> />
</div> </div>
</div>, </div>
root </Portal>
)}
</div> </div>
) )
} }

View File

@ -33,6 +33,7 @@ import Settings from '#/layouts/Settings'
import TopBar from '#/layouts/TopBar' import TopBar from '#/layouts/TopBar'
import TheModal from '#/components/dashboard/TheModal' import TheModal from '#/components/dashboard/TheModal'
import Portal from '#/components/Portal'
import type * as spinner from '#/components/Spinner' import type * as spinner from '#/components/Spinner'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
@ -545,9 +546,11 @@ export default function Dashboard(props: DashboardProps) {
)} )}
</div> </div>
</div> </div>
<Portal>
<div className="select-none text-xs text-primary"> <div className="select-none text-xs text-primary">
<TheModal /> <TheModal />
</div> </div>
</Portal>
</> </>
) )
} }

View File

@ -836,6 +836,10 @@ export interface S3ObjectVersion {
versionId: string versionId: string
lastModified: dateTime.Rfc3339DateTime lastModified: dateTime.Rfc3339DateTime
isLatest: boolean isLatest: boolean
/**
* The field points to an archive containing the all the project files object in the S3 bucket,
*/
key: string
} }
/** A list of asset versions. */ /** A list of asset versions. */

View File

@ -420,6 +420,20 @@ export default class RemoteBackend extends Backend {
} }
} }
/**
* Fetches the content of Main.enso file for a given project.
*/
async getFileContent(projectId: backendModule.ProjectId, version: string): Promise<string> {
const path = remoteBackendPaths.getProjectContentPath(projectId, version)
const response = await this.get<string>(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not get content of file with ProjectID '${projectId}'`, response)
} else {
return await response.text()
}
}
/** Change the parent directory of an asset. /** Change the parent directory of an asset.
* @throws An error if a non-successful status code (not 200-299) was received. */ * @throws An error if a non-successful status code (not 200-299) was received. */
override async updateAsset( override async updateAsset(

View File

@ -65,6 +65,14 @@ export const GET_LOG_EVENTS_PATH = 'log_events'
export function listAssetVersionsPath(assetId: backend.AssetId) { export function listAssetVersionsPath(assetId: backend.AssetId) {
return `assets/${assetId}/versions` return `assets/${assetId}/versions`
} }
/**
* Relative HTTP path to the "get Main.enso file" endpoint of the Cloud backend API.
*/
export function getProjectContentPath(projectId: backend.ProjectId, version: string) {
return `projects/${projectId}/files?versionId=${version}`
}
/** Relative HTTP path to the "update asset" endpoint of the Cloud backend API. */ /** Relative HTTP path to the "update asset" endpoint of the Cloud backend API. */
export function updateAssetPath(assetId: backend.AssetId) { export function updateAssetPath(assetId: backend.AssetId) {
return `assets/${assetId}` return `assets/${assetId}`

View File

@ -60,3 +60,22 @@ export function isElementTextInput(element: EventTarget | null) {
(element instanceof HTMLElement && element.isContentEditable)) (element instanceof HTMLElement && element.isContentEditable))
) )
} }
/**
* Whether the element is part of a Monaco editor.
*/
export function isElementPartOfMonaco(element: EventTarget | null) {
const recursiveCheck = (htmlElement: HTMLElement | null): boolean => {
if (htmlElement == null || htmlElement === document.body) {
return false
} else if (
htmlElement instanceof HTMLElement &&
htmlElement.classList.contains('monaco-editor')
) {
return true
} else {
return recursiveCheck(htmlElement.parentElement)
}
}
return element != null && element instanceof HTMLElement && recursiveCheck(element)
}

View File

@ -1,4 +1,5 @@
/** @file Configuration for Tailwind. */ /** @file Configuration for Tailwind. */
import reactAriaComponents from 'tailwindcss-react-aria-components'
import plugin from 'tailwindcss/plugin.js' import plugin from 'tailwindcss/plugin.js'
// The names come from a third-party API and cannot be changed. // The names come from a third-party API and cannot be changed.
@ -409,6 +410,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
}, },
}, },
plugins: [ plugins: [
reactAriaComponents,
plugin(({ addUtilities, matchUtilities, addComponents, theme }) => { plugin(({ addUtilities, matchUtilities, addComponents, theme }) => {
addUtilities( addUtilities(
{ {

View File

@ -16,6 +16,7 @@
"noEmit": false, "noEmit": false,
"outDir": "../../../../node_modules/.cache/tsc", "outDir": "../../../../node_modules/.cache/tsc",
"paths": { "#/*": ["./src/*"] }, "paths": { "#/*": ["./src/*"] },
"plugins": [ "plugins": [
{ {
"name": "ts-plugin-namespace-auto-import" "name": "ts-plugin-namespace-auto-import"

2036
package-lock.json generated

File diff suppressed because it is too large Load Diff