mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 18:38:11 +03:00
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:
parent
6929c03207
commit
5cc21001b1
@ -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: {
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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 = () => {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
/** @file Type definitions common between all backends. */
|
||||
|
||||
import * as dateTime from './dateTime'
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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()
|
||||
await toast.promise(doDelete(), {
|
||||
loading: `Deleting ${assetType}...`,
|
||||
success: `Deleted ${assetType}.`,
|
||||
error: `Could not delete ${assetType}.`,
|
||||
})
|
||||
onSuccess()
|
||||
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>
|
||||
|
@ -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}
|
||||
|
@ -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`}
|
||||
|
@ -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>
|
||||
|
@ -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,63 +104,139 @@ 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])
|
||||
|
||||
react.useEffect(() => {
|
||||
if (!isCheckingStatus) {
|
||||
return
|
||||
} else {
|
||||
let handle: number | null = null
|
||||
let continuePolling = true
|
||||
let previousTimestamp = 0
|
||||
const checkProjectStatus = async () => {
|
||||
const response = await backend.getProjectDetails(project.id)
|
||||
if (response.state.type === backendModule.ProjectState.opened) {
|
||||
setIsCheckingStatus(false)
|
||||
setIsCheckingResources(true)
|
||||
} else {
|
||||
setState(response.state.type)
|
||||
try {
|
||||
const response = await backend.getProjectDetails(project.id)
|
||||
handle = null
|
||||
if (
|
||||
continuePolling &&
|
||||
response.state.type === backendModule.ProjectState.opened
|
||||
) {
|
||||
continuePolling = false
|
||||
setIsCheckingStatus(false)
|
||||
setIsCheckingResources(true)
|
||||
}
|
||||
} finally {
|
||||
if (continuePolling) {
|
||||
const nowTimestamp = Number(new Date())
|
||||
const delay = CHECK_STATUS_INTERVAL_MS - (nowTimestamp - previousTimestamp)
|
||||
previousTimestamp = nowTimestamp
|
||||
handle = window.setTimeout(
|
||||
() => void checkProjectStatus(),
|
||||
Math.max(0, delay)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handle = window.setInterval(
|
||||
() => void checkProjectStatus(),
|
||||
CHECK_STATUS_INTERVAL_MS
|
||||
)
|
||||
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)
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setIsCheckingResources(false)
|
||||
setSpinnerState(SpinnerState.done)
|
||||
handle = null
|
||||
if (continuePolling) {
|
||||
continuePolling = false
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setIsCheckingResources(false)
|
||||
}
|
||||
} catch {
|
||||
// Ignored.
|
||||
if (continuePolling) {
|
||||
const nowTimestamp = Number(new Date())
|
||||
const delay =
|
||||
CHECK_RESOURCES_INTERVAL_MS - (nowTimestamp - previousTimestamp)
|
||||
previousTimestamp = nowTimestamp
|
||||
handle = window.setTimeout(
|
||||
() => void checkProjectResources(),
|
||||
Math.max(0, delay)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const handle = window.setInterval(
|
||||
() => void checkProjectResources(),
|
||||
CHECK_RESOURCES_INTERVAL_MS
|
||||
)
|
||||
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)
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.remote:
|
||||
await backend.openProject(project.id)
|
||||
doRefresh()
|
||||
setIsCheckingStatus(true)
|
||||
break
|
||||
case backendModule.BackendType.local:
|
||||
await backend.openProject(project.id)
|
||||
doRefresh()
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setSpinnerState(SpinnerState.done)
|
||||
break
|
||||
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()
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -27,22 +27,28 @@ 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()
|
||||
await toast.promise(doRename(newName), {
|
||||
loading: `Renaming ${assetType}...`,
|
||||
success: `Renamed ${assetType}.`,
|
||||
// This is UNSAFE, as the original function's parameter is of type `any`.
|
||||
error: (promiseError: Error) =>
|
||||
`Error renaming ${assetType}: ${promiseError.message}`,
|
||||
})
|
||||
onSuccess()
|
||||
} else if (!isSubmitting) {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await toast.promise(doRename(newName), {
|
||||
loading: `Renaming ${assetType}...`,
|
||||
success: `Renamed ${assetType}.`,
|
||||
// This is UNSAFE, as the original function's parameter is of type `any`.
|
||||
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>
|
||||
|
@ -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,11 +129,9 @@ 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">
|
||||
<TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} />
|
||||
</div>
|
||||
<div className="my-2 p-2">
|
||||
<div className="grid gap-2 grid-cols-fill-60 justify-center">
|
||||
<TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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={() => {
|
||||
|
@ -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,7 +179,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
projectId,
|
||||
missingComponentAction: projectManager.MissingComponentAction.install,
|
||||
})
|
||||
LocalBackend.currentlyOpenProject = { id: projectId, project }
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
@ -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.'
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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, \
|
||||
|
@ -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'),
|
||||
|
Loading…
Reference in New Issue
Block a user