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.
* `node:process` is here because `process.on` does not exist on the namespace import. */
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 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.*'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|ajv\\u002Fdist\\u002F2020|${RELATIVE_MODULES}`
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
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 WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+'
const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/`
@ -117,11 +116,6 @@ const RESTRICTED_SYNTAXES = [
message:
'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.
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=/\\.$/]',
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-requiring-type-checking']?.rules,
...tsEslint.configs.strict?.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
eqeqeq: ['error', 'always', { null: 'never' }],
'jsdoc/require-jsdoc': [
'error',
@ -303,8 +292,10 @@ export default [
'prefer-const': 'error',
// Not relevant because TypeScript checks types.
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
'react/jsx-pascal-case': ['error', { allowNamespace: true }],
// Prefer `interface` over `type`.
'@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/consistent-type-imports': 'error',

View File

@ -3,4 +3,4 @@
d="M10.7031 5.29813C10.3126 4.90761 9.67944 4.90761 9.28892 5.29813L7.99609 6.59096L6.70718 5.30204C6.31665 4.91151 5.68349 4.91151 5.29296 5.30204C4.90244 5.69256 4.90244 6.32573 5.29296 6.71625L6.58188 8.00517L5.29298 9.29408C4.90245 9.6846 4.90245 10.3178 5.29298 10.7083C5.6835 11.0988 6.31666 11.0988 6.70719 10.7083L7.99609 9.41938L9.28695 10.7102C9.67748 11.1008 10.3106 11.1008 10.7012 10.7102C11.0917 10.3197 11.0917 9.68655 10.7012 9.29603L9.41031 8.00517L10.7031 6.71235C11.0937 6.32182 11.0937 5.68866 10.7031 5.29813Z"
fill="black" />
<circle cx="8" cy="8" r="7" stroke="black" stroke-width="2" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 783 B

After

Width:  |  Height:  |  Size: 784 B

View File

@ -1,7 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="12" fill="black" fill-opacity="0.22" />
<g opacity="0.66">
<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>
</svg>

Before

Width:  |  Height:  |  Size: 438 B

After

Width:  |  Height:  |  Size: 440 B

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-toastify": "^9.1.3",
"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": {
"@babel/plugin-syntax-import-assertions": "^7.23.3",
@ -76,6 +83,7 @@
"prettier-plugin-tailwindcss": "^0.5.11",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.4.1",
"tailwindcss-react-aria-components": "^1.1.1",
"ts-plugin-namespace-auto-import": "^1.0.0",
"typescript": "~5.2.2",
"vite": "^4.4.9",

View File

@ -35,6 +35,7 @@
* {@link authProvider.FullUserSession}). */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
@ -63,6 +64,8 @@ import SetUsername from '#/pages/authentication/SetUsername'
import Dashboard from '#/pages/dashboard/Dashboard'
import Subscribe from '#/pages/subscribe/Subscribe'
import * as rootComponent from '#/components/Root'
import type Backend from '#/services/Backend'
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.
// eslint-disable-next-line no-restricted-syntax
const Router = detect.isOnElectron() ? router.HashRouter : router.BrowserRouter
const queryClient = React.useMemo(() => new reactQuery.QueryClient(), [])
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// 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.
return (
<>
<reactQuery.QueryClientProvider client={queryClient}>
<toastify.ToastContainer
position="top-center"
theme="light"
@ -160,7 +164,7 @@ export default function App(props: AppProps) {
<AppRouter {...props} />
</LocalStorageProvider>
</Router>
</>
</reactQuery.QueryClientProvider>
)
}
@ -186,6 +190,9 @@ function AppRouter(props: AppProps) {
window.navigate = navigate
}
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
const [root] = React.useState<React.RefObject<HTMLElement>>(() => ({
current: document.getElementById('enso-dashboard'),
}))
React.useEffect(() => {
const savedInputBindings = localStorage.get('inputBindings')
@ -274,7 +281,11 @@ function AppRouter(props: AppProps) {
isClick = true
}
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 app = document.getElementById('app')
const appContainsSelection =
@ -359,5 +370,10 @@ function AppRouter(props: AppProps) {
</SessionProvider>
)
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
result = (
<rootComponent.Root rootRef={root} navigate={navigate}>
{result}
</rootComponent.Root>
)
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
disabled={disabled}
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' : ''
}`}
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. */
import * as React from 'react'
import * as reactDom from 'react-dom'
import * as animationHooks from '#/hooks/animationHooks'
import * as modalProvider from '#/providers/ModalProvider'
import Portal from '#/components/Portal'
import * as eventModule from '#/utilities/event'
import type * as geometry from '#/utilities/geometry'
@ -169,15 +169,14 @@ export default function SelectionBrush(props: SelectionBrushProps) {
width: `${rectangle.width}px`,
height: `${rectangle.height}px`,
}
return reactDom.createPortal(
<div
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'
}`}
style={brushStyle}
/>,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
document.getElementById('enso-dashboard')!
return (
<Portal>
<div
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'
}`}
style={brushStyle}
/>
</Portal>
)
}

View File

@ -21,7 +21,7 @@ export default function SubmitButton(props: SubmitButtonProps) {
<button
disabled={disabled}
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}
<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 AssetProperties from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions'
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
import type Category from '#/layouts/CategorySwitcher/Category'
import * as backend from '#/services/Backend'
@ -139,7 +139,7 @@ export default function AssetPanel(props: AssetPanelProps) {
dispatchAssetEvent={dispatchAssetEvent}
/>
)}
<AssetVersions hidden={tab !== AssetPanelTab.versions} item={item} />
{tab === AssetPanelTab.versions && <AssetVersions item={item} />}
</>
)}
</div>

View File

@ -1,29 +1,71 @@
/** @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 assetDiffView from './AssetDiffView'
// ====================
// === AssetVersion ===
// ====================
/** Props for a {@link AssetVersion}. */
export interface AssetVersionProps {
readonly item: backendService.AnyAsset
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. */
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 (
<div className="flex cursor-pointer select-none flex-col overflow-y-auto rounded-default p-version transition-colors hover:bg-frame">
<div>version {number}</div>
<div className="text-xs text-not-selected">
on {dateTime.formatDateTime(new Date(version.lastModified))}
<div className="flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2">
<div className="flex flex-1 flex-col">
<div>
{versionName} {version.isLatest && `(Latest)`}
</div>
<time className="text-xs text-not-selected">
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>
)

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. */
import * as React from 'react'
import * as reactDom from 'react-dom'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import AssetSearchBar from '#/layouts/AssetSearchBar'
import BackendSwitcher from '#/layouts/BackendSwitcher'
@ -10,6 +8,7 @@ import PageSwitcher, * as pageSwitcher from '#/layouts/PageSwitcher'
import UserBar from '#/layouts/UserBar'
import AssetInfoBar from '#/components/dashboard/AssetInfoBar'
import Portal from '#/components/Portal'
import type * as backendModule from '#/services/Backend'
@ -50,7 +49,6 @@ export default function TopBar(props: TopBarProps) {
const { isEditorDisabled, setBackendType, isHelpChatOpen, setIsHelpChatOpen } = props
const { query, setQuery, labels, suggestions, isAssetPanelEnabled } = 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 shouldMakeSpaceForExtendedEditorMenu = page === pageSwitcher.Page.editor
@ -98,33 +96,31 @@ export default function TopBar(props: TopBarProps) {
)}
</div>
</div>
{root &&
reactDom.createPortal(
<div
className={`fixed right top z-1 m-top-bar text-xs text-primary ${shouldMakeSpaceForExtendedEditorMenu ? 'mr-extended-editor-menu' : ''}`}
>
<div className="flex gap-top-bar-right">
{page === pageSwitcher.Page.drive && (
<AssetInfoBar
isAssetPanelEnabled={isAssetPanelEnabled}
setIsAssetPanelEnabled={setIsAssetPanelEnabled}
/>
)}
<UserBar
supportsLocalBackend={supportsLocalBackend}
page={page}
setPage={setPage}
isHelpChatOpen={isHelpChatOpen}
setIsHelpChatOpen={setIsHelpChatOpen}
projectAsset={projectAsset}
setProjectAsset={setProjectAsset}
doRemoveSelf={doRemoveSelf}
onSignOut={onSignOut}
<Portal>
<div
className={`fixed right top z-1 m-top-bar text-xs text-primary ${shouldMakeSpaceForExtendedEditorMenu ? 'mr-extended-editor-menu' : ''}`}
>
<div className="flex gap-top-bar-right">
{page === pageSwitcher.Page.drive && (
<AssetInfoBar
isAssetPanelEnabled={isAssetPanelEnabled}
setIsAssetPanelEnabled={setIsAssetPanelEnabled}
/>
</div>
</div>,
root
)}
)}
<UserBar
supportsLocalBackend={supportsLocalBackend}
page={page}
setPage={setPage}
isHelpChatOpen={isHelpChatOpen}
setIsHelpChatOpen={setIsHelpChatOpen}
projectAsset={projectAsset}
setProjectAsset={setProjectAsset}
doRemoveSelf={doRemoveSelf}
onSignOut={onSignOut}
/>
</div>
</div>
</Portal>
</div>
)
}

View File

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

View File

@ -836,6 +836,10 @@ export interface S3ObjectVersion {
versionId: string
lastModified: dateTime.Rfc3339DateTime
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. */

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.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async updateAsset(

View File

@ -65,6 +65,14 @@ export const GET_LOG_EVENTS_PATH = 'log_events'
export function listAssetVersionsPath(assetId: backend.AssetId) {
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. */
export function updateAssetPath(assetId: backend.AssetId) {
return `assets/${assetId}`

View File

@ -60,3 +60,22 @@ export function isElementTextInput(element: EventTarget | null) {
(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. */
import reactAriaComponents from 'tailwindcss-react-aria-components'
import plugin from 'tailwindcss/plugin.js'
// The names come from a third-party API and cannot be changed.
@ -409,6 +410,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
},
},
plugins: [
reactAriaComponents,
plugin(({ addUtilities, matchUtilities, addComponents, theme }) => {
addUtilities(
{

View File

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

2036
package-lock.json generated

File diff suppressed because it is too large Load Diff