mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 17:11:31 +03:00
parent
6665c22eb9
commit
7c3e316239
@ -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',
|
||||
|
6
app/ide-desktop/lib/assets/dismiss.svg
Normal file
6
app/ide-desktop/lib/assets/dismiss.svg
Normal 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 |
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Re-exports the Dialog component.
|
||||
*/
|
||||
export * from './Dialog'
|
||||
export * from './types'
|
||||
export * from './DialogTrigger'
|
@ -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 {}
|
@ -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'
|
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* Index file for Aria Components
|
||||
*/
|
||||
export * from './Button/Button'
|
||||
export * from './Tooltip/Tooltip'
|
||||
export * from './Dialog'
|
@ -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 => {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
12
app/ide-desktop/lib/dashboard/src/components/Portal/index.ts
Normal file
12
app/ide-desktop/lib/dashboard/src/components/Portal/index.ts
Normal 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
|
27
app/ide-desktop/lib/dashboard/src/components/Portal/types.ts
Normal file
27
app/ide-desktop/lib/dashboard/src/components/Portal/types.ts
Normal 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
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
35
app/ide-desktop/lib/dashboard/src/components/Root.tsx
Normal file
35
app/ide-desktop/lib/dashboard/src/components/Root.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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(
|
||||
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}
|
||||
/>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
document.getElementById('enso-dashboard')!
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
@ -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} />
|
||||
|
@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @file
|
||||
* Re-exports the AssetDiffView component.
|
||||
*/
|
||||
export * from './AssetDiffView'
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Fetches the content of a project’s 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
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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">
|
||||
<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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
}),
|
||||
})
|
||||
}
|
@ -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,8 +96,7 @@ export default function TopBar(props: TopBarProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{root &&
|
||||
reactDom.createPortal(
|
||||
<Portal>
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
root
|
||||
)}
|
||||
</div>
|
||||
</Portal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
<Portal>
|
||||
<div className="select-none text-xs text-primary">
|
||||
<TheModal />
|
||||
</div>
|
||||
</Portal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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. */
|
||||
|
@ -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(
|
||||
|
@ -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}`
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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
2036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user