Save backend type in localStorage, and automatically open projects when ready (#6728)

* Save backend type; fix `-startup.project`

* Attempt to fix for cloud

* Fix for cloud and implement automatic opening

* Fixes

* Add missing functionality

* Switch default backend to local backend

* Fix type error

* Fix saving backend

* Make loading message appear instantly

* Fix context menu positioning

* Fixes and QoL improvements

* Style scrollbar on `document.body`

* Fix `-startup.project`; other minor fixes

* Open project immediately when creating from template; minor fix

* Finally fix spinner bugs

* Fix some minor bugs

* Fix bugs when closing project

* Disallow deleting local projects while they are still running

* Close modals when buttons are clicked
This commit is contained in:
somebody1234 2023-06-06 22:00:07 +10:00 committed by GitHub
parent 6929c03207
commit 5cc21001b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 701 additions and 194 deletions

View File

@ -433,6 +433,30 @@ export default [
'no-undef': 'off',
},
},
{
files: [
'lib/dashboard/src/**/*.ts',
'lib/dashboard/src/**/*.mts',
'lib/dashboard/src/**/*.cts',
'lib/dashboard/src/**/*.tsx',
'lib/dashboard/src/**/*.mtsx',
'lib/dashboard/src/**/*.ctsx',
],
rules: {
'no-restricted-properties': [
'error',
{
object: 'console',
message: 'Avoid leaving debugging statements when committing code',
},
{
object: 'hooks',
property: 'useDebugState',
message: 'Avoid leaving debugging statements when committing code',
},
],
},
},
{
files: ['**/*.d.ts'],
rules: {

View File

@ -219,13 +219,13 @@ class Main implements AppRunner {
const isOpeningMainEntryPoint =
contentConfig.OPTIONS.groups.startup.options.entry.value ===
contentConfig.OPTIONS.groups.startup.options.entry.default
const isNotOpeningProject =
contentConfig.OPTIONS.groups.startup.options.project.value === ''
if (
(isUsingAuthentication || isUsingNewDashboard) &&
isOpeningMainEntryPoint &&
isNotOpeningProject
) {
// This MUST be removed as it would otherwise override the `startup.project` passed
// explicitly in `ide.tsx`.
if (isOpeningMainEntryPoint && url.searchParams.has('startup.project')) {
url.searchParams.delete('startup.project')
history.replaceState(null, '', url.toString())
}
if ((isUsingAuthentication || isUsingNewDashboard) && isOpeningMainEntryPoint) {
this.runAuthentication(isInAuthenticationFlow, inputConfig)
} else {
void this.runApp(inputConfig)
@ -235,6 +235,8 @@ class Main implements AppRunner {
/** Begins the authentication UI flow. */
runAuthentication(isInAuthenticationFlow: boolean, inputConfig?: StringConfig) {
const initialProjectName =
contentConfig.OPTIONS.groups.startup.options.project.value || null
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345
* `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE
* should only have one entry point. Right now, we have two. One for the cloud
@ -250,6 +252,7 @@ class Main implements AppRunner {
supportsLocalBackend: SUPPORTS_LOCAL_BACKEND,
supportsDeepLinks: SUPPORTS_DEEP_LINKS,
showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value,
initialProjectName,
onAuthenticated: () => {
if (isInAuthenticationFlow) {
const initialUrl = localStorage.getItem(INITIAL_URL_KEY)

View File

@ -81,6 +81,7 @@ function Registration() {
<SvgIcon svg={svg.LOCK} />
<Input
required
id="password"
type="password"
name="password"
@ -103,6 +104,7 @@ function Registration() {
<SvgIcon svg={svg.LOCK} />
<Input
required
id="password_confirmation"
type="password"
name="password_confirmation"

View File

@ -173,7 +173,7 @@ export function AuthProvider(props: AuthProviderProps) {
const { authService, onAuthenticated, children } = props
const { cognito } = authService
const { session, deinitializeSession } = sessionProvider.useSession()
const { setBackend } = backendProvider.useSetBackend()
const { setBackendWithoutSavingType } = backendProvider.useSetBackend()
const logger = loggerProvider.useLogger()
// This must not be `hooks.useNavigate` as `goOffline` would be inaccessible,
// and the function call would error.
@ -211,7 +211,7 @@ export function AuthProvider(props: AuthProviderProps) {
// The backend MUST be the remote backend before login is finished.
// This is because the "set username" flow requires the remote backend.
if (!initialized || userSession == null) {
setBackend(backend)
setBackendWithoutSavingType(backend)
}
let organization
// eslint-disable-next-line no-restricted-syntax
@ -286,7 +286,7 @@ export function AuthProvider(props: AuthProviderProps) {
const goOfflineInternal = () => {
setInitialized(true)
setUserSession(OFFLINE_USER_SESSION)
setBackend(new localBackend.LocalBackend())
setBackendWithoutSavingType(new localBackend.LocalBackend())
}
const goOffline = () => {

View File

@ -89,6 +89,8 @@ export interface AppProps {
supportsDeepLinks: boolean
/** Whether the dashboard should be rendered. */
showDashboard: boolean
/** The name of the project to open on startup, if any. */
initialProjectName: string | null
onAuthenticated: () => void
appRunner: AppRunner
}

View File

@ -1,4 +1,5 @@
/** @file Type definitions common between all backends. */
import * as dateTime from './dateTime'
import * as newtype from '../newtype'

View File

@ -72,6 +72,8 @@ function ChangePasswordModal() {
type="password"
name="old_password"
placeholder="Old Password"
pattern={validation.PREVIOUS_PASSWORD_PATTERN}
title={validation.PREVIOUS_PASSWORD_TITLE}
value={oldPassword}
setValue={setOldPassword}
className="text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 w-full py-2 focus:outline-none focus:border-blue-400"

View File

@ -1,4 +1,5 @@
/** @file Modal for confirming delete of any type of asset. */
import * as react from 'react'
import toast from 'react-hot-toast'
import * as modalProvider from '../../providers/modal'
@ -23,14 +24,23 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
const { assetType, name, doDelete, onSuccess } = props
const { unsetModal } = modalProvider.useSetModal()
const [isSubmitting, setIsSubmitting] = react.useState(false)
const onSubmit = async () => {
unsetModal()
if (!isSubmitting) {
try {
setIsSubmitting(true)
await toast.promise(doDelete(), {
loading: `Deleting ${assetType}...`,
success: `Deleted ${assetType}.`,
error: `Could not delete ${assetType}.`,
})
unsetModal()
onSuccess()
} finally {
setIsSubmitting(false)
}
}
}
return (
@ -52,18 +62,25 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
</button>
Are you sure you want to delete the {assetType} '{name}'?
<div className="m-1">
<div
className="hover:cursor-pointer inline-block text-white bg-red-500 rounded-full px-4 py-1 m-1"
onClick={onSubmit}
<button
type="submit"
disabled={isSubmitting}
className={`hover:cursor-pointer inline-block text-white bg-red-500 rounded-full px-4 py-1 m-1 ${
isSubmitting ? 'opacity-50' : ''
}`}
>
Delete
</div>
<div
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
</button>
<button
type="button"
disabled={isSubmitting}
className={`hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1 ${
isSubmitting ? 'opacity-50' : ''
}`}
onClick={unsetModal}
>
Cancel
</div>
</button>
</div>
</form>
</Modal>

View File

@ -25,9 +25,13 @@ export interface ContextMenuProps {
function ContextMenu(props: react.PropsWithChildren<ContextMenuProps>) {
const { children, event } = props
const contextMenuRef = react.useRef<HTMLDivElement>(null)
const [top, setTop] = react.useState(event.pageY)
// This must be the original height before the returned element affects the `scrollHeight`.
const [bodyHeight] = react.useState(document.body.scrollHeight)
react.useEffect(() => {
if (contextMenuRef.current != null) {
setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight))
const boundingBox = contextMenuRef.current.getBoundingClientRect()
const scrollBy = boundingBox.bottom - innerHeight + SCROLL_MARGIN
if (scrollBy > 0) {
@ -39,7 +43,8 @@ function ContextMenu(props: react.PropsWithChildren<ContextMenuProps>) {
return (
<div
ref={contextMenuRef}
style={{ left: event.pageX, top: event.pageY }}
// The location must be offset by -0.5rem to balance out the `m-2`.
style={{ left: `calc(${event.pageX}px - 0.5rem)`, top: `calc(${top}px - 0.5rem)` }}
className="absolute bg-white rounded-lg shadow-soft flex flex-col flex-nowrap m-2"
>
{children}

View File

@ -8,16 +8,18 @@ import * as react from 'react'
/** Props for a {@link ContextMenuEntry}. */
export interface ContextMenuEntryProps {
disabled?: boolean
title?: string
onClick: (event: react.MouseEvent<HTMLButtonElement>) => void
}
// This component MUST NOT use `useState` because it is not rendered directly.
/** An item in a `ContextMenu`. */
function ContextMenuEntry(props: react.PropsWithChildren<ContextMenuEntryProps>) {
const { children, disabled, onClick } = props
const { children, disabled, title, onClick } = props
return (
<button
disabled={disabled}
title={title}
className={`${
disabled ? 'opacity-50' : ''
} p-1 hover:bg-gray-200 first:rounded-t-lg last:rounded-b-lg text-left`}

View File

@ -17,16 +17,16 @@ import * as remoteBackendModule from '../remoteBackend'
import * as svg from '../../components/svg'
import * as uploadMultipleFiles from '../../uploadMultipleFiles'
import * as auth from '../../authentication/providers/auth'
import * as authProvider from '../../authentication/providers/auth'
import * as backendProvider from '../../providers/backend'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import PermissionDisplay, * as permissionDisplay from './permissionDisplay'
import ProjectActionButton, * as projectActionButton from './projectActionButton'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import Ide from './ide'
import ProjectActionButton from './projectActionButton'
import Rows from './rows'
import Templates from './templates'
import TopBar from './topBar'
@ -98,7 +98,7 @@ const EXPERIMENTAL = {
/** The `id` attribute of the element into which the IDE will be rendered. */
const IDE_ELEMENT_ID = 'root'
/** The `localStorage` key under which the ID of the current directory is stored. */
const DIRECTORY_STACK_KEY = 'enso-dashboard-directory-stack'
const DIRECTORY_STACK_KEY = `${common.PRODUCT_NAME.toLowerCase()}-dashboard-directory-stack`
/** English names for the name column. */
const ASSET_TYPE_NAME: Record<backendModule.AssetType, string> = {
@ -218,6 +218,11 @@ function columnsFor(displayMode: ColumnDisplayMode, backendType: backendModule.B
: columns
}
/** Sanitizes a string for use as a regex. */
function regexEscape(string: string) {
return string.replace(/[\\^$.|?*+()[{]/g, '\\$&')
}
// =================
// === Dashboard ===
// =================
@ -226,6 +231,7 @@ function columnsFor(displayMode: ColumnDisplayMode, backendType: backendModule.B
export interface DashboardProps {
supportsLocalBackend: boolean
appRunner: AppRunner
initialProjectName: string | null
}
// TODO[sb]: Implement rename when clicking name of a selected row.
@ -233,10 +239,10 @@ export interface DashboardProps {
/** The component that contains the entire UI. */
function Dashboard(props: DashboardProps) {
const { supportsLocalBackend, appRunner } = props
const { supportsLocalBackend, appRunner, initialProjectName } = props
const logger = loggerProvider.useLogger()
const session = auth.useNonPartialUserSession()
const session = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setBackend } = backendProvider.useSetBackend()
const { modal } = modalProvider.useModal()
@ -244,6 +250,30 @@ function Dashboard(props: DashboardProps) {
const [refresh, doRefresh] = hooks.useRefresh()
const [onDirectoryNextLoaded, setOnDirectoryNextLoaded] = react.useState<
((assets: backendModule.Asset[]) => void) | null
>(() =>
initialProjectName != null
? (assets: backendModule.Asset[]) => {
if (
!assets.some(
asset =>
asset.type === backendModule.AssetType.project &&
asset.title === initialProjectName
)
) {
const errorMessage = `No project named '${initialProjectName}' was found.`
toast.error(errorMessage)
logger.error(`Error opening project on startup: ${errorMessage}`)
}
}
: null
)
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
react.useState(initialProjectName)
const [projectEvent, setProjectEvent] = react.useState<projectActionButton.ProjectEvent | null>(
null
)
const [query, setQuery] = react.useState('')
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = react.useState(false)
const [directoryId, setDirectoryId] = react.useState(
@ -284,13 +314,16 @@ function Dashboard(props: DashboardProps) {
const [visibleFileAssets, setVisibleFileAssets] = react.useState<
backendModule.Asset<backendModule.AssetType.file>[]
>([])
const [projectDatas, setProjectDatas] = react.useState<
Record<backendModule.ProjectId, projectActionButton.ProjectData>
>({})
const isListingLocalDirectoryAndWillFail =
backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail
const isListingRemoteDirectoryAndWillFail =
backend.type === backendModule.BackendType.remote && !session.organization?.isEnabled
const isListingRemoteDirectoryWhileOffline =
session.type === auth.UserSessionType.offline &&
session.type === authProvider.UserSessionType.offline &&
backend.type === backendModule.BackendType.remote
const directory = directoryStack[directoryStack.length - 1]
const parentDirectory = directoryStack[directoryStack.length - 2]
@ -337,7 +370,11 @@ function Dashboard(props: DashboardProps) {
}, [isLoadingAssets, loadingProjectManagerDidFail, backend.type])
react.useEffect(() => {
if (supportsLocalBackend) {
if (
supportsLocalBackend &&
localStorage.getItem(backendProvider.BACKEND_TYPE_KEY) !==
backendModule.BackendType.remote
) {
setBackend(new localBackend.LocalBackend())
}
}, [])
@ -349,27 +386,70 @@ function Dashboard(props: DashboardProps) {
}
}, [])
react.useEffect(() => {
if (projectEvent != null) {
setProjectEvent(null)
}
}, [projectEvent])
const openIde = async (projectId: backendModule.ProjectId) => {
switchToIdeTab()
if (project?.projectId !== projectId) {
setProject(await backend.getProjectDetails(projectId))
}
}
const closeIde = () => {
setProject(null)
}
const setBackendType = (newBackendType: backendModule.BackendType) => {
if (newBackendType !== backend.type) {
setIsLoadingAssets(true)
setProjectAssets([])
setDirectoryAssets([])
setSecretAssets([])
setFileAssets([])
switch (newBackendType) {
case backendModule.BackendType.local:
setBackend(new localBackend.LocalBackend())
break
case backendModule.BackendType.remote: {
const headers = new Headers()
headers.append('Authorization', `Bearer ${session.accessToken ?? ''}`)
const client = new http.Client(headers)
setBackend(new remoteBackendModule.RemoteBackend(client, logger))
break
}
}
}
}
const setProjectAssets = (
newProjectAssets: backendModule.Asset<backendModule.AssetType.project>[]
) => {
setProjectAssetsRaw(newProjectAssets)
setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query)))
const queryRegex = new RegExp(regexEscape(query), 'i')
setVisibleProjectAssets(newProjectAssets.filter(asset => queryRegex.test(asset.title)))
}
const setDirectoryAssets = (
newDirectoryAssets: backendModule.Asset<backendModule.AssetType.directory>[]
) => {
setDirectoryAssetsRaw(newDirectoryAssets)
setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query)))
const queryRegex = new RegExp(regexEscape(query), 'i')
setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => queryRegex.test(asset.title)))
}
const setSecretAssets = (
newSecretAssets: backendModule.Asset<backendModule.AssetType.secret>[]
) => {
setSecretAssetsRaw(newSecretAssets)
setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query)))
const queryRegex = new RegExp(regexEscape(query), 'i')
setVisibleSecretAssets(newSecretAssets.filter(asset => queryRegex.test(asset.title)))
}
const setFileAssets = (newFileAssets: backendModule.Asset<backendModule.AssetType.file>[]) => {
setFileAssetsRaw(newFileAssets)
setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query)))
const queryRegex = new RegExp(regexEscape(query), 'i')
setVisibleFileAssets(newFileAssets.filter(asset => queryRegex.test(asset.title)))
}
const exitDirectory = () => {
@ -424,7 +504,18 @@ function Dashboard(props: DashboardProps) {
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
if (event.detail === 2 && event.target === event.currentTarget) {
// It is a double click; open the project.
setProjectEvent({
type: projectActionButton.ProjectEventType.open,
projectId: projectAsset.id,
})
} else if (
event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!event.metaKey
) {
setModal(() => (
<RenameModal
assetType={projectAsset.type}
@ -447,17 +538,41 @@ function Dashboard(props: DashboardProps) {
>
<ProjectActionButton
project={projectAsset}
appRunner={appRunner}
doRefresh={doRefresh}
onClose={() => {
setProject(null)
}}
openIde={async () => {
switchToIdeTab()
if (project?.projectId !== projectAsset.id) {
setProject(await backend.getProjectDetails(projectAsset.id))
projectData={
projectDatas[projectAsset.id] ?? projectActionButton.DEFAULT_PROJECT_DATA
}
setProjectData={newProjectData => {
if (typeof newProjectData === 'function') {
setProjectDatas(oldProjectDatas => ({
...oldProjectDatas,
[projectAsset.id]: newProjectData(
oldProjectDatas[projectAsset.id] ??
projectActionButton.DEFAULT_PROJECT_DATA
),
}))
} else {
setProjectDatas({
...projectDatas,
[projectAsset.id]: newProjectData,
})
}
}}
appRunner={appRunner}
event={projectEvent}
doRefresh={doRefresh}
doOpenManually={() => {
setProjectEvent({
type: projectActionButton.ProjectEventType.open,
projectId: projectAsset.id,
})
}}
onClose={() => {
setProjectEvent({
type: projectActionButton.ProjectEventType.cancelOpeningAll,
})
closeIde()
}}
openIde={() => openIde(projectAsset.id)}
/>
<span className="px-2">{projectAsset.title}</span>
</div>
@ -556,7 +671,9 @@ function Dashboard(props: DashboardProps) {
event.preventDefault()
event.stopPropagation()
setModal(() => (
<ContextMenu event={event}>
// This is a placeholder key. It should be replaced with label ID when labels
// are implemented.
<ContextMenu key={'label'} event={event}>
<ContextMenuEntry
disabled
onClick={() => {
@ -632,12 +749,13 @@ function Dashboard(props: DashboardProps) {
<>{COLUMN_NAME[column]}</>
)
// The purpose of this effect is to enable search action.
react.useEffect(() => {
setVisibleProjectAssets(projectAssets.filter(asset => asset.title.includes(query)))
setVisibleDirectoryAssets(directoryAssets.filter(asset => asset.title.includes(query)))
setVisibleSecretAssets(secretAssets.filter(asset => asset.title.includes(query)))
setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query)))
const queryRegex = new RegExp(regexEscape(query), 'i')
const doesItMatchQuery = (asset: backendModule.Asset) => queryRegex.test(asset.title)
setVisibleProjectAssets(projectAssets.filter(doesItMatchQuery))
setVisibleDirectoryAssets(directoryAssets.filter(doesItMatchQuery))
setVisibleSecretAssets(secretAssets.filter(doesItMatchQuery))
setVisibleFileAssets(fileAssets.filter(doesItMatchQuery))
}, [query])
const setAssets = (assets: backendModule.Asset[]) => {
@ -655,6 +773,20 @@ function Dashboard(props: DashboardProps) {
setDirectoryAssets(newDirectoryAssets)
setSecretAssets(newSecretAssets)
setFileAssets(newFileAssets)
if (nameOfProjectToImmediatelyOpen != null) {
const projectToLoad = newProjectAssets.find(
projectAsset => projectAsset.title === nameOfProjectToImmediatelyOpen
)
if (projectToLoad != null) {
setProjectEvent({
type: projectActionButton.ProjectEventType.open,
projectId: projectToLoad.id,
})
}
setNameOfProjectToImmediatelyOpen(null)
}
onDirectoryNextLoaded?.(assets)
setOnDirectoryNextLoaded(null)
}
hooks.useAsyncEffect(
@ -744,6 +876,10 @@ function Dashboard(props: DashboardProps) {
parentDirectoryId: directoryId,
}
await backend.createProject(body)
// `newProject.projectId` cannot be used directly in a `ProjectEvet` as the project
// does not yet exist in the project list. Opening the project would work, but the project
// would display as closed as it would be created after the event is sent.
setNameOfProjectToImmediatelyOpen(projectName)
doRefresh()
}
@ -774,31 +910,7 @@ function Dashboard(props: DashboardProps) {
switchToDashboardTab()
}
}}
setBackendType={newBackendType => {
if (newBackendType !== backend.type) {
setIsLoadingAssets(true)
setProjectAssets([])
setDirectoryAssets([])
setSecretAssets([])
setFileAssets([])
switch (newBackendType) {
case backendModule.BackendType.local:
setBackend(new localBackend.LocalBackend())
break
case backendModule.BackendType.remote: {
const headers = new Headers()
// If `accessToken` is null, then there is no internet connection.
headers.append(
'Authorization',
`Bearer ${session.accessToken ?? ''}`
)
const client = new http.Client(headers)
setBackend(new remoteBackendModule.RemoteBackend(client, logger))
break
}
}
}
}}
setBackendType={setBackendType}
query={query}
setQuery={setQuery}
/>
@ -939,7 +1051,7 @@ function Dashboard(props: DashboardProps) {
)}
</div>
</div>
<table className="table-fixed items-center border-collapse mt-2">
<table className="table-fixed items-center border-collapse mt-2 w-0">
<tbody>
<tr className="h-10">
{columnsFor(columnDisplayMode, backend.type).map(column => (
@ -948,7 +1060,7 @@ function Dashboard(props: DashboardProps) {
</tr>
<Rows<backendModule.Asset<backendModule.AssetType.project>>
items={visibleProjectAssets}
getKey={proj => proj.id}
getKey={projectAsset => projectAsset.id}
isLoading={isLoadingAssets}
placeholder={
<span className="opacity-75">
@ -979,8 +1091,11 @@ function Dashboard(props: DashboardProps) {
event.preventDefault()
event.stopPropagation()
const doOpenForEditing = () => {
// FIXME[sb]: Switch to IDE tab
// once merged with `show-and-open-workspace` branch.
unsetModal()
setProjectEvent({
type: projectActionButton.ProjectEventType.open,
projectId: projectAsset.id,
})
}
const doOpenAsFolder = () => {
// FIXME[sb]: Uncomment once backend support
@ -1031,9 +1146,12 @@ function Dashboard(props: DashboardProps) {
/>
))
}
const isDisabled =
backend.type === backendModule.BackendType.local &&
(projectDatas[projectAsset.id]?.isRunning ?? false)
setModal(() => (
<ContextMenu event={event}>
<ContextMenuEntry disabled onClick={doOpenForEditing}>
<ContextMenu key={projectAsset.id} event={event}>
<ContextMenuEntry onClick={doOpenForEditing}>
Open for editing
</ContextMenuEntry>
{backend.type !== backendModule.BackendType.local && (
@ -1044,7 +1162,15 @@ function Dashboard(props: DashboardProps) {
<ContextMenuEntry onClick={doRename}>
Rename
</ContextMenuEntry>
<ContextMenuEntry onClick={doDelete}>
<ContextMenuEntry
disabled={isDisabled}
{...(isDisabled
? {
title: 'A running local project cannot be removed.',
}
: {})}
onClick={doDelete}
>
<span className="text-red-700">Delete</span>
</ContextMenuEntry>
</ContextMenu>
@ -1059,7 +1185,7 @@ function Dashboard(props: DashboardProps) {
backendModule.Asset<backendModule.AssetType.directory>
>
items={visibleDirectoryAssets}
getKey={dir => dir.id}
getKey={directoryAsset => directoryAsset.id}
isLoading={isLoadingAssets}
placeholder={
<span className="opacity-75">
@ -1091,11 +1217,14 @@ function Dashboard(props: DashboardProps) {
: [directoryAsset]
)
}}
onContextMenu={(_directory, event) => {
onContextMenu={(directoryAsset, event) => {
event.preventDefault()
event.stopPropagation()
setModal(() => (
<ContextMenu event={event}></ContextMenu>
<ContextMenu
key={directoryAsset.id}
event={event}
></ContextMenu>
))
}}
/>
@ -1153,7 +1282,7 @@ function Dashboard(props: DashboardProps) {
))
}
setModal(() => (
<ContextMenu event={event}>
<ContextMenu key={secret.id} event={event}>
<ContextMenuEntry onClick={doDelete}>
<span className="text-red-700">
Delete
@ -1224,7 +1353,7 @@ function Dashboard(props: DashboardProps) {
/** TODO: Wait for backend endpoint. */
}
setModal(() => (
<ContextMenu event={event}>
<ContextMenu key={file.id} event={event}>
<ContextMenuEntry disabled onClick={doCopy}>
Copy
</ContextMenuEntry>

View File

@ -1,15 +1,51 @@
/** @file An interactive button displaying the status of a project. */
import * as react from 'react'
import toast from 'react-hot-toast'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as localBackend from '../localBackend'
import * as modalProvider from '../../providers/modal'
import * as svg from '../../components/svg'
// =============
// === Types ===
// =============
/** Data associated with a project, used for rendering.
* FIXME[sb]: This is a hack that is required because each row does not carry its own extra state.
* It will be obsoleted by the implementation in https://github.com/enso-org/enso/pull/6546. */
export interface ProjectData {
isRunning: boolean
}
/** Possible types of project state change. */
export enum ProjectEventType {
open = 'open',
cancelOpeningAll = 'cancelOpeningAll',
}
/** Properties common to all project state change events. */
interface ProjectBaseEvent<Type extends ProjectEventType> {
type: Type
}
/** Requests the specified project to be opened. */
export interface ProjectOpenEvent extends ProjectBaseEvent<ProjectEventType.open> {
// FIXME: provide projectId instead
/** This must be a name because it may be specified by name on the command line.
* Note that this will not work properly with the cloud backend if there are multiple projects
* with the same name. */
projectId: backendModule.ProjectId
}
/** Requests the specified project to be opened. */
export interface ProjectCancelOpeningAllEvent
extends ProjectBaseEvent<ProjectEventType.cancelOpeningAll> {}
/** Every possible type of project event. */
export type ProjectEvent = ProjectCancelOpeningAllEvent | ProjectOpenEvent
/** The state of the spinner. It should go from initial, to loading, to done. */
enum SpinnerState {
initial = 'initial',
@ -21,10 +57,26 @@ enum SpinnerState {
// === Constants ===
// =================
/** The default {@link ProjectData} associated with a {@link backendModule.Project}. */
export const DEFAULT_PROJECT_DATA: ProjectData = Object.freeze({
isRunning: false,
})
const LOADING_MESSAGE =
'Your environment is being created. It will take some time, please be patient.'
/** The interval between requests checking whether the IDE is ready. */
const CHECK_STATUS_INTERVAL_MS = 5000
/** The interval between requests checking whether the VM is ready. */
const CHECK_RESOURCES_INTERVAL_MS = 1000
/** The fallback project state, when it is set to `null` before it is first set. */
const DEFAULT_PROJECT_STATE = backendModule.ProjectState.created
/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState}. */
const SPINNER_STATE: Record<backendModule.ProjectState, SpinnerState> = {
[backendModule.ProjectState.closed]: SpinnerState.initial,
[backendModule.ProjectState.created]: SpinnerState.initial,
[backendModule.ProjectState.new]: SpinnerState.initial,
[backendModule.ProjectState.openInProgress]: SpinnerState.loading,
[backendModule.ProjectState.opened]: SpinnerState.done,
}
const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
[SpinnerState.initial]: 'dasharray-5 ease-linear',
@ -39,7 +91,12 @@ const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
/** Props for a {@link ProjectActionButton}. */
export interface ProjectActionButtonProps {
project: backendModule.Asset<backendModule.AssetType.project>
projectData: ProjectData
setProjectData: react.Dispatch<react.SetStateAction<ProjectData>>
appRunner: AppRunner | null
event: ProjectEvent | null
/** Called when the project is opened via the {@link ProjectActionButton}. */
doOpenManually: () => void
onClose: () => void
openIde: () => void
doRefresh: () => void
@ -47,40 +104,99 @@ export interface ProjectActionButtonProps {
/** An interactive button displaying the status of a project. */
function ProjectActionButton(props: ProjectActionButtonProps) {
const { project, onClose, appRunner, openIde, doRefresh } = props
const {
project,
setProjectData,
event,
appRunner,
doOpenManually,
onClose,
openIde,
doRefresh,
} = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const [state, setState] = react.useState(backendModule.ProjectState.created)
const [state, setState] = react.useState<backendModule.ProjectState | null>(null)
const [isCheckingStatus, setIsCheckingStatus] = react.useState(false)
const [isCheckingResources, setIsCheckingResources] = react.useState(false)
const [spinnerState, setSpinnerState] = react.useState(SpinnerState.done)
const [spinnerState, setSpinnerState] = react.useState(SpinnerState.initial)
const [shouldOpenWhenReady, setShouldOpenWhenReady] = react.useState(false)
const [toastId, setToastId] = react.useState<string | null>(null)
react.useEffect(() => {
if (toastId != null) {
return () => {
toast.dismiss(toastId)
}
} else {
return
}
}, [toastId])
react.useEffect(() => {
// Ensure that the previous spinner state is visible for at least one frame.
requestAnimationFrame(() => {
setSpinnerState(SPINNER_STATE[state ?? DEFAULT_PROJECT_STATE])
})
}, [state])
react.useEffect(() => {
if (toastId != null && state !== backendModule.ProjectState.openInProgress) {
toast.dismiss(toastId)
}
}, [state])
react.useEffect(() => {
switch (project.projectState.type) {
case backendModule.ProjectState.opened:
setState(backendModule.ProjectState.openInProgress)
setSpinnerState(SpinnerState.initial)
setIsCheckingResources(true)
break
case backendModule.ProjectState.openInProgress:
setState(backendModule.ProjectState.openInProgress)
setSpinnerState(SpinnerState.initial)
setIsCheckingStatus(true)
break
default:
setState(project.projectState.type)
// Some functions below set the state to something different to
// the backend state. In that case, the state should not be overridden.
setState(oldState => oldState ?? project.projectState.type)
break
}
}, [])
react.useEffect(() => {
if (backend.type === backendModule.BackendType.local) {
if (project.id !== localBackend.LocalBackend.currentlyOpeningProjectId) {
setIsCheckingResources(false)
setIsCheckingStatus(false)
setState(backendModule.ProjectState.closed)
setSpinnerState(SpinnerState.done)
if (event != null) {
switch (event.type) {
case ProjectEventType.open: {
if (event.projectId !== project.id) {
setShouldOpenWhenReady(false)
} else {
setShouldOpenWhenReady(true)
void openProject()
}
break
}
case ProjectEventType.cancelOpeningAll: {
setShouldOpenWhenReady(false)
}
}
}
}, [event])
react.useEffect(() => {
if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) {
openIde()
setShouldOpenWhenReady(false)
}
}, [shouldOpenWhenReady, state])
react.useEffect(() => {
if (
backend.type === backendModule.BackendType.local &&
project.id !== localBackend.LocalBackend.currentlyOpeningProjectId
) {
setState(backendModule.ProjectState.closed)
}
}, [project, state, localBackend.LocalBackend.currentlyOpeningProjectId])
@ -88,22 +204,39 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
if (!isCheckingStatus) {
return
} else {
let handle: number | null = null
let continuePolling = true
let previousTimestamp = 0
const checkProjectStatus = async () => {
try {
const response = await backend.getProjectDetails(project.id)
if (response.state.type === backendModule.ProjectState.opened) {
handle = null
if (
continuePolling &&
response.state.type === backendModule.ProjectState.opened
) {
continuePolling = false
setIsCheckingStatus(false)
setIsCheckingResources(true)
} else {
setState(response.state.type)
}
}
const handle = window.setInterval(
} finally {
if (continuePolling) {
const nowTimestamp = Number(new Date())
const delay = CHECK_STATUS_INTERVAL_MS - (nowTimestamp - previousTimestamp)
previousTimestamp = nowTimestamp
handle = window.setTimeout(
() => void checkProjectStatus(),
CHECK_STATUS_INTERVAL_MS
Math.max(0, delay)
)
}
}
}
void checkProjectStatus()
return () => {
clearInterval(handle)
continuePolling = false
if (handle != null) {
clearTimeout(handle)
}
}
}
}, [isCheckingStatus])
@ -112,85 +245,144 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
if (!isCheckingResources) {
return
} else {
let handle: number | null = null
let continuePolling = true
let previousTimestamp = 0
const checkProjectResources = async () => {
if (!('checkResources' in backend)) {
if (backend.type === backendModule.BackendType.local) {
setState(backendModule.ProjectState.opened)
setIsCheckingResources(false)
setSpinnerState(SpinnerState.done)
} else {
try {
// This call will error if the VM is not ready yet.
await backend.checkResources(project.id)
handle = null
if (continuePolling) {
continuePolling = false
setState(backendModule.ProjectState.opened)
setIsCheckingResources(false)
setSpinnerState(SpinnerState.done)
}
} catch {
// Ignored.
}
}
}
const handle = window.setInterval(
if (continuePolling) {
const nowTimestamp = Number(new Date())
const delay =
CHECK_RESOURCES_INTERVAL_MS - (nowTimestamp - previousTimestamp)
previousTimestamp = nowTimestamp
handle = window.setTimeout(
() => void checkProjectResources(),
CHECK_RESOURCES_INTERVAL_MS
Math.max(0, delay)
)
}
}
}
}
void checkProjectResources()
return () => {
clearInterval(handle)
continuePolling = false
if (handle != null) {
clearTimeout(handle)
}
}
}
}, [isCheckingResources])
const closeProject = () => {
const closeProject = async () => {
onClose()
setShouldOpenWhenReady(false)
setState(backendModule.ProjectState.closed)
appRunner?.stopApp()
void backend.closeProject(project.id)
setIsCheckingStatus(false)
setIsCheckingResources(false)
onClose()
try {
await backend.closeProject(project.id)
} finally {
// This is not 100% correct, but it is better than never setting `isRunning` to `false`,
// which would prevent the project from ever being deleted.
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: false }))
}
}
const openProject = async () => {
setState(backendModule.ProjectState.openInProgress)
setSpinnerState(SpinnerState.initial)
// The `setTimeout` is required so that the completion percentage goes from
// the `initial` fraction to the `loading` fraction,
// rather than starting at the `loading` fraction.
setTimeout(() => {
setSpinnerState(SpinnerState.loading)
}, 0)
try {
switch (backend.type) {
case backendModule.BackendType.remote:
setToastId(toast.loading(LOADING_MESSAGE))
await backend.openProject(project.id)
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true }))
doRefresh()
setIsCheckingStatus(true)
break
case backendModule.BackendType.local:
await backend.openProject(project.id)
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true }))
setState(oldState => {
if (oldState === backendModule.ProjectState.openInProgress) {
doRefresh()
setState(backendModule.ProjectState.opened)
setSpinnerState(SpinnerState.done)
return backendModule.ProjectState.opened
} else {
return oldState
}
})
break
}
} catch {
setIsCheckingStatus(false)
setIsCheckingResources(false)
toast.error(`Error opening project '${project.title}'.`)
setState(backendModule.ProjectState.closed)
}
}
switch (state) {
case null:
case backendModule.ProjectState.created:
case backendModule.ProjectState.new:
case backendModule.ProjectState.closed:
return <button onClick={openProject}>{svg.PLAY_ICON}</button>
return (
<button
onClick={clickEvent => {
clickEvent.stopPropagation()
unsetModal()
doOpenManually()
}}
>
{svg.PLAY_ICON}
</button>
)
case backendModule.ProjectState.openInProgress:
return (
<button onClick={closeProject}>
<button
onClick={async clickEvent => {
clickEvent.stopPropagation()
unsetModal()
await closeProject()
}}
>
<svg.StopIcon className={SPINNER_CSS_CLASSES[spinnerState]} />
</button>
)
case backendModule.ProjectState.opened:
return (
<>
<button onClick={closeProject}>
<button
onClick={async clickEvent => {
clickEvent.stopPropagation()
unsetModal()
await closeProject()
}}
>
<svg.StopIcon className={SPINNER_CSS_CLASSES[spinnerState]} />
</button>
<button onClick={openIde}>{svg.ARROW_UP_ICON}</button>
<button
onClick={clickEvent => {
clickEvent.stopPropagation()
unsetModal()
openIde()
}}
>
{svg.ARROW_UP_ICON}
</button>
</>
)
}

View File

@ -27,14 +27,16 @@ function RenameModal(props: RenameModalProps) {
const { assetType, name, namePattern, title, doRename, onSuccess } = props
const { unsetModal } = modalProvider.useSetModal()
const [isSubmitting, setIsSubmitting] = react.useState(false)
const [newName, setNewName] = react.useState<string | null>(null)
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (newName == null) {
toast.error('Please provide a new name.')
} else {
unsetModal()
} else if (!isSubmitting) {
try {
setIsSubmitting(true)
await toast.promise(doRename(newName), {
loading: `Renaming ${assetType}...`,
success: `Renamed ${assetType}.`,
@ -42,7 +44,11 @@ function RenameModal(props: RenameModalProps) {
error: (promiseError: Error) =>
`Error renaming ${assetType}: ${promiseError.message}`,
})
unsetModal()
onSuccess()
} finally {
setIsSubmitting(false)
}
}
}
@ -65,9 +71,10 @@ function RenameModal(props: RenameModalProps) {
</label>
<Input
autoFocus
required
// Never disabled, as disabling unfocuses the input.
id="renamed_file_name"
type="text"
required
pattern={namePattern}
title={title}
className="border-primary bg-gray-200 rounded-full w-2/3 px-2 mx-2"
@ -78,16 +85,23 @@ function RenameModal(props: RenameModalProps) {
<div className="m-1">
<button
type="submit"
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1 m-1"
disabled={isSubmitting}
className={`hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1 m-1 ${
isSubmitting ? 'opacity-50' : ''
}`}
>
Rename
</button>
<div
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
<button
type="button"
disabled={isSubmitting}
className={`hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1 ${
isSubmitting ? 'opacity-50' : ''
}`}
onClick={unsetModal}
>
Cancel
</div>
</button>
</div>
</form>
</Modal>

View File

@ -74,7 +74,7 @@ function TemplatesRender(props: TemplatesRenderProps) {
onClick={() => {
onTemplateClick(null)
}}
className="h-40 cursor-pointer"
className="h-40 w-60 cursor-pointer"
>
<div className="flex h-full w-full border-dashed-custom rounded-2xl text-primary">
<div className="m-auto text-center">
@ -91,7 +91,7 @@ function TemplatesRender(props: TemplatesRenderProps) {
{templates.map(template => (
<button
key={template.title}
className="h-40 cursor-pointer"
className="h-40 w-60 cursor-pointer"
onClick={() => {
onTemplateClick(template.id)
}}
@ -129,13 +129,11 @@ function Templates(props: TemplatesProps) {
const { onTemplateClick } = props
return (
<div className="bg-white my-2">
<div className="mx-auto py-2 px-4 sm:py-4 sm:px-6 lg:px-8">
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<div className="my-2 p-2">
<div className="grid gap-2 grid-cols-fill-60 justify-center">
<TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} />
</div>
</div>
</div>
)
}
export default Templates

View File

@ -30,17 +30,24 @@ export interface TopBarProps {
function TopBar(props: TopBarProps) {
const { supportsLocalBackend, projectName, tab, toggleTab, setBackendType, query, setQuery } =
props
const [userMenuVisible, setUserMenuVisible] = react.useState(false)
const [isUserMenuVisible, setIsUserMenuVisible] = react.useState(false)
const { modal } = modalProvider.useModal()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
react.useEffect(() => {
if (userMenuVisible) {
if (!modal) {
setIsUserMenuVisible(false)
}
}, [modal])
react.useEffect(() => {
if (isUserMenuVisible) {
setModal(() => <UserMenu />)
} else {
unsetModal()
}
}, [userMenuVisible])
}, [isUserMenuVisible])
return (
<div className="flex mb-2 h-8">
@ -97,6 +104,7 @@ function TopBar(props: TopBarProps) {
<div>{svg.MAGNIFYING_GLASS_ICON}</div>
<input
type="text"
size={1}
placeholder="Click here or start typing to search for projects, data connectors, users, and more ..."
value={query}
onChange={event => {
@ -118,7 +126,7 @@ function TopBar(props: TopBarProps) {
<div
onClick={event => {
event.stopPropagation()
setUserMenuVisible(!userMenuVisible)
setIsUserMenuVisible(!isUserMenuVisible)
}}
className="rounded-full w-8 h-8 bg-cover cursor-pointer"
>

View File

@ -15,18 +15,19 @@ import ChangePasswordModal from './changePasswordModal'
/** This is the UI component for a `UserMenu` list item.
* The main interaction logic is in the `onClick` injected by `UserMenu`. */
export interface UserMenuItemProps {
disabled?: boolean
onClick?: React.MouseEventHandler<HTMLDivElement>
}
/** User menu item. */
function UserMenuItem(props: react.PropsWithChildren<UserMenuItemProps>) {
const { children, onClick } = props
const { children, disabled, onClick } = props
return (
<div
className={`whitespace-nowrap px-4 py-2 ${
onClick ? 'hover:bg-blue-500 hover:text-white cursor-pointer' : ''
}`}
className={`whitespace-nowrap px-4 py-2 ${disabled ? 'opacity-50' : ''} ${
onClick ? 'hover:bg-blue-500 hover:text-white' : ''
} ${onClick && !disabled ? 'cursor-pointer' : ''}`}
onClick={onClick}
>
{children}
@ -66,11 +67,12 @@ function UserMenu() {
>
{organization != null ? (
<>
{' '}
<UserMenuItem>
Signed in as <span className="font-bold">{organization.name}</span>
</UserMenuItem>
<UserMenuItem onClick={goToProfile}>Your profile</UserMenuItem>
<UserMenuItem disabled onClick={goToProfile}>
Your profile
</UserMenuItem>
{canChangePassword && (
<UserMenuItem
onClick={() => {

View File

@ -102,11 +102,10 @@ export class LocalBackend implements Partial<backend.Backend> {
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpeningProjectId = null
}
await this.projectManager.closeProject({ projectId })
if (projectId === LocalBackend.currentlyOpeningProjectId) {
LocalBackend.currentlyOpeningProjectId = null
if (LocalBackend.currentlyOpenProject?.id === projectId) {
LocalBackend.currentlyOpenProject = null
}
await this.projectManager.closeProject({ projectId })
}
/** Close the project identified by the given project ID.
@ -180,8 +179,10 @@ export class LocalBackend implements Partial<backend.Backend> {
projectId,
missingComponentAction: projectManager.MissingComponentAction.install,
})
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpenProject = { id: projectId, project }
}
}
/** Change the name of a project.
*
@ -232,9 +233,9 @@ export class LocalBackend implements Partial<backend.Backend> {
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpeningProjectId = null
}
await this.projectManager.deleteProject({ projectId })
if (LocalBackend.currentlyOpenProject?.id === projectId) {
LocalBackend.currentlyOpenProject = null
}
await this.projectManager.deleteProject({ projectId })
}
}

View File

@ -8,3 +8,11 @@ export const PASSWORD_PATTERN =
export const PASSWORD_TITLE =
'Your password must include numbers, letters (both lowercase and uppercase) and symbols, ' +
'and must be between 6 and 256 characters long.'
/** Regex pattern used by the backend for validating the previous password,
* when changing password. */
export const PREVIOUS_PASSWORD_PATTERN = '^[\\S]+.*[\\S]+$'
/** Human readable explanation of password requirements. */
export const PREVIOUS_PASSWORD_TITLE =
'Your password must neither start nor end with whitespace, and must contain ' +
'at least two characters.'

View File

@ -99,3 +99,52 @@ export function useNavigate() {
return navigate
}
// =====================
// === useDebugState ===
// =====================
/** A modified `useState` that logs the old and new values when `setState` is called. */
export function useDebugState<T>(
initialState: T | (() => T),
name?: string
): [state: T, setState: (valueOrUpdater: react.SetStateAction<T>, source?: string) => void] {
const [state, rawSetState] = react.useState(initialState)
const description = name != null ? `state for '${name}'` : 'state'
const setState = react.useCallback(
(valueOrUpdater: react.SetStateAction<T>, source?: string) => {
const fullDescription = `${description}${source != null ? ` from '${source}'` : ''}`
if (typeof valueOrUpdater === 'function') {
// This is UNSAFE, however React makes the same assumption.
// eslint-disable-next-line no-restricted-syntax
const updater = valueOrUpdater as (prevState: T) => T
// `console.*` is allowed because this is for debugging purposes only.
/* eslint-disable no-restricted-properties */
rawSetState(oldState => {
console.group(description)
console.log(`Old ${fullDescription}:`, oldState)
const newState = updater(oldState)
console.log(`New ${fullDescription}:`, newState)
console.groupEnd()
return newState
})
} else {
rawSetState(oldState => {
if (!Object.is(oldState, valueOrUpdater)) {
console.group(description)
console.log(`Old ${fullDescription}:`, oldState)
console.log(`New ${fullDescription}:`, valueOrUpdater)
console.groupEnd()
}
return valueOrUpdater
})
/* eslint-enable no-restricted-properties */
}
},
[]
)
return [state, setState]
}

View File

@ -2,9 +2,18 @@
* provider via the shared React context. */
import * as react from 'react'
import * as common from 'enso-common'
import * as localBackend from '../dashboard/localBackend'
import * as remoteBackend from '../dashboard/remoteBackend'
// =================
// === Constants ===
// =================
/** The `localStorage` key under which the type of the current backend is stored. */
export const BACKEND_TYPE_KEY = `${common.PRODUCT_NAME.toLowerCase()}-dashboard-backend-type`
// =============
// === Types ===
// =============
@ -20,6 +29,7 @@ export type AnyBackendAPI = localBackend.LocalBackend | remoteBackend.RemoteBack
export interface BackendContextType {
backend: AnyBackendAPI
setBackend: (backend: AnyBackendAPI) => void
setBackendWithoutSavingType: (backend: AnyBackendAPI) => void
}
// @ts-expect-error The default value will never be exposed
@ -37,12 +47,17 @@ export interface BackendProviderProps extends React.PropsWithChildren<object> {
/** A React Provider that lets components get and set the current backend. */
export function BackendProvider(props: BackendProviderProps) {
const { initialBackend, children } = props
const [backend, setBackend] = react.useState<
const { children } = props
const [backend, setBackendWithoutSavingType] = react.useState<
localBackend.LocalBackend | remoteBackend.RemoteBackend
>(initialBackend)
>(() => new localBackend.LocalBackend())
const setBackend = react.useCallback((newBackend: AnyBackendAPI) => {
setBackendWithoutSavingType(newBackend)
localStorage.setItem(BACKEND_TYPE_KEY, newBackend.type)
}, [])
return (
<BackendContext.Provider value={{ backend, setBackend }}>
<BackendContext.Provider value={{ backend, setBackend, setBackendWithoutSavingType }}>
{children}
</BackendContext.Provider>
)
@ -56,6 +71,6 @@ export function useBackend() {
/** Exposes a property to set the current backend. */
export function useSetBackend() {
const { setBackend } = react.useContext(BackendContext)
return { setBackend }
const { setBackend, setBackendWithoutSavingType } = react.useContext(BackendContext)
return { setBackend, setBackendWithoutSavingType }
}

View File

@ -40,6 +40,7 @@ authentication.run({
supportsLocalBackend: false,
supportsDeepLinks: false,
showDashboard: true,
initialProjectName: null,
/** The `onAuthenticated` option is mandatory but is not needed here,
* so this function is empty. */
onAuthenticated() {

View File

@ -16,6 +16,34 @@ body {
font-feature-settings: normal;
}
/* Must be kept in sync with app/gui/view/graph-editor/src/builtin/visualization/java_script/helpers/scrollable.js. */
::-webkit-scrollbar {
-webkit-appearance: none;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: none;
}
::-webkit-scrollbar:vertical {
width: 11px;
}
::-webkit-scrollbar:horizontal {
height: 11px;
}
::-webkit-scrollbar-thumb {
border-radius: 8px;
border: 1px solid rgba(220, 220, 220, 0.5);
background-color: rgba(190, 190, 190, 0.5);
}
::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
.enso-dashboard {
@tailwind base;
@tailwind components;
@ -72,7 +100,7 @@ body {
}
.border-dashed-custom {
background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%233e515f' stroke-width='4' stroke-dasharray='15%2c 15' stroke-dashoffset='0' stroke-linecap='butt'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%233e515f' stroke-width='4' stroke-dasharray='11 11' stroke-linecap='butt'/%3e%3c/svg%3e");
}
}
}

View File

@ -44,6 +44,9 @@ export const theme = {
spacing: {
'140': '35rem',
},
gridTemplateColumns: {
'fill-60': 'repeat(auto-fill, 15rem)',
},
boxShadow: {
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \

View File

@ -21,6 +21,7 @@ const HTTP_STATUS_OK = 200
// However, the path should still be non-empty in order for `esbuild.serve` to work properly.
const ARGS: bundler.Arguments = { outputPath: '/', devMode: true }
const OPTS = bundler.bundlerOptions(ARGS)
OPTS.define.REDIRECT_OVERRIDE = JSON.stringify(`http://localhost:${PORT}`)
OPTS.entryPoints.push(
path.resolve(THIS_PATH, 'src', 'index.html'),
path.resolve(THIS_PATH, 'src', 'index.tsx'),