mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 10:11:37 +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',
|
||||
|
@ -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 |
@ -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 |
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(
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -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">
|
||||
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>
|
||||
)
|
||||
|
@ -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,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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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