Skip to content

Commit

Permalink
Diff editor (#9458)
Browse files Browse the repository at this point in the history
Closes  enso-org/cloud#940
  • Loading branch information
MrFlashAccount authored Mar 23, 2024
1 parent 6665c22 commit 7c3e316
Show file tree
Hide file tree
Showing 39 changed files with 2,888 additions and 537 deletions.
17 changes: 4 additions & 13 deletions app/ide-desktop/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]+)*$)/`
Expand Down Expand Up @@ -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)))`,
Expand Down Expand Up @@ -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',
},
]

// ============================
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion app/ide-desktop/lib/assets/close.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions app/ide-desktop/lib/assets/close_large.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/ide-desktop/lib/assets/dismiss.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion app/ide-desktop/lib/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 19 additions & 3 deletions app/ide-desktop/lib/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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"
Expand All @@ -160,7 +164,7 @@ export default function App(props: AppProps) {
<AppRouter {...props} />
</LocalStorageProvider>
</Router>
</>
</reactQuery.QueryClientProvider>
)
}

Expand All @@ -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')
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -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}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @file
*
* Re-exports the Dialog component.
*/
export * from './Dialog'
export * from './types'
export * from './DialogTrigger'
Loading

0 comments on commit 7c3e316

Please sign in to comment.