Fix opening cloud projects (#10351)

- Fix https://github.com/enso-org/cloud-v2/issues/1324

# Important Notes
None
This commit is contained in:
somebody1234 2024-06-26 00:13:10 +10:00 committed by GitHub
parent 5f2c9b32f0
commit b425c9d1a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 105 additions and 63 deletions

View File

@ -5,7 +5,7 @@ import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import Spinner, * as spinnerModule from '#/components/Spinner' import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
import * as twv from '#/utilities/tailwindVariants' import * as twv from '#/utilities/tailwindVariants'
@ -62,6 +62,9 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
readonly testId?: string readonly testId?: string
readonly formnovalidate?: boolean readonly formnovalidate?: boolean
/** Defaults to `full`. When `full`, the entire button will be replaced with the loader.
* When `icon`, only the icon will be replaced with the loader. */
readonly loaderPosition?: 'full' | 'icon'
} }
export const BUTTON_STYLES = twv.tv({ export const BUTTON_STYLES = twv.tv({
@ -286,6 +289,7 @@ export const Button = React.forwardRef(function Button(
tooltip, tooltip,
tooltipPlacement, tooltipPlacement,
testId, testId,
loaderPosition = 'full',
onPress = () => {}, onPress = () => {},
...ariaProps ...ariaProps
} = props } = props
@ -326,12 +330,15 @@ export const Button = React.forwardRef(function Button(
[{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }],
{ duration: delay, easing: 'linear', delay: 0, fill: 'forwards' } { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }
) )
const contentAnimation = contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { const contentAnimation =
duration: 0, loaderPosition !== 'full'
easing: 'linear', ? null
delay, : contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], {
fill: 'forwards', duration: 0,
}) easing: 'linear',
delay,
fill: 'forwards',
})
return () => { return () => {
loaderAnimation?.cancel() loaderAnimation?.cancel()
@ -340,7 +347,7 @@ export const Button = React.forwardRef(function Button(
} else { } else {
return () => {} return () => {}
} }
}, [isLoading]) }, [isLoading, loaderPosition])
const handlePress = (event: aria.PressEvent): void => { const handlePress = (event: aria.PressEvent): void => {
if (!isLoading) { if (!isLoading) {
@ -381,6 +388,12 @@ export const Button = React.forwardRef(function Button(
const iconComponent = (() => { const iconComponent = (() => {
if (icon == null) { if (icon == null) {
return null return null
} else if (isLoading && loaderPosition === 'icon') {
return (
<span className={iconClasses()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span>
)
} else if (typeof icon === 'string') { } else if (typeof icon === 'string') {
return <SvgMask src={icon} className={iconClasses()} /> return <SvgMask src={icon} className={iconClasses()} />
} else { } else {
@ -425,9 +438,9 @@ export const Button = React.forwardRef(function Button(
{childrenFactory()} {childrenFactory()}
</span> </span>
{isLoading && ( {isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={loader()}> <span ref={loaderRef} className={loader()}>
<Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} /> <StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span> </span>
)} )}
</span> </span>

View File

@ -12,8 +12,6 @@ import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import Portal from '#/components/Portal' import Portal from '#/components/Portal'
import * as mergeRefs from '#/utilities/mergeRefs'
/** /**
* Props for {@link useVisualTooltip}. * Props for {@link useVisualTooltip}.
*/ */
@ -123,7 +121,7 @@ export function useVisualTooltip(props: VisualTooltipProps) {
const createTooltipElement = () => ( const createTooltipElement = () => (
<Portal onMount={updatePosition}> <Portal onMount={updatePosition}>
<span <span
ref={mergeRefs.mergeRefs(popoverRef, ref => ref?.showPopover())} ref={popoverRef}
{...aria.mergeProps<React.HTMLAttributes<HTMLDivElement>>()( {...aria.mergeProps<React.HTMLAttributes<HTMLDivElement>>()(
overlayProps, overlayProps,
tooltipProps, tooltipProps,

View File

@ -113,6 +113,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
item.projectState.executeAsync ?? false item.projectState.executeAsync ?? false
) )
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false) const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
const doAbortOpeningRef = React.useRef(() => {})
const doOpenEditorRef = React.useRef(doOpenEditor) const doOpenEditorRef = React.useRef(doOpenEditor)
doOpenEditorRef.current = doOpenEditor doOpenEditorRef.current = doOpenEditor
const isCloud = backend.type === backendModule.BackendType.remote const isCloud = backend.type === backendModule.BackendType.remote
@ -133,14 +134,22 @@ export default function ProjectIcon(props: ProjectIconProps) {
mutationKey: ['openEditor', item.id], mutationKey: ['openEditor', item.id],
networkMode: 'always', networkMode: 'always',
mutationFn: async () => { mutationFn: async () => {
const projectPromise = waitUntilProjectIsReadyMutation.mutateAsync([ const abortController = new AbortController()
item.id, doAbortOpeningRef.current = () => {
item.parentId, abortController.abort()
item.title,
])
if (shouldOpenWhenReady) {
doOpenEditor()
} }
const projectPromise = openProjectMutate([
item.id,
{ executeAsync: false, parentId: item.parentId, cognitoCredentials: session },
item.title,
]).then(() =>
waitUntilProjectIsReadyMutation.mutateAsync([
item.id,
item.parentId,
item.title,
abortController.signal,
])
)
setProjectStartupInfo({ setProjectStartupInfo({
project: projectPromise, project: projectPromise,
projectAsset: item, projectAsset: item,
@ -149,6 +158,12 @@ export default function ProjectIcon(props: ProjectIconProps) {
accessToken: session?.accessToken ?? null, accessToken: session?.accessToken ?? null,
}) })
await projectPromise await projectPromise
if (!abortController.signal.aborted) {
setState(backendModule.ProjectState.opened)
if (shouldOpenWhenReady) {
doOpenEditor()
}
}
}, },
}) })
const openEditorMutate = openEditorMutation.mutate const openEditorMutate = openEditorMutation.mutate
@ -156,19 +171,21 @@ export default function ProjectIcon(props: ProjectIconProps) {
const openProject = React.useCallback( const openProject = React.useCallback(
async (shouldRunInBackground: boolean) => { async (shouldRunInBackground: boolean) => {
if (state !== backendModule.ProjectState.opened) { if (state !== backendModule.ProjectState.opened) {
setState(backendModule.ProjectState.openInProgress)
try { try {
await openProjectMutate([
item.id,
{
executeAsync: shouldRunInBackground,
parentId: item.parentId,
cognitoCredentials: session,
},
item.title,
])
if (!shouldRunInBackground) { if (!shouldRunInBackground) {
setState(backendModule.ProjectState.openInProgress)
openEditorMutate() openEditorMutate()
} else {
setState(backendModule.ProjectState.opened)
await openProjectMutate([
item.id,
{
executeAsync: shouldRunInBackground,
parentId: item.parentId,
cognitoCredentials: session,
},
item.title,
])
} }
} catch (error) { } catch (error) {
const project = await getProjectDetailsMutate([item.id, item.parentId, item.title]) const project = await getProjectDetailsMutate([item.id, item.parentId, item.title])
@ -176,7 +193,6 @@ export default function ProjectIcon(props: ProjectIconProps) {
// not just the state type. // not just the state type.
setItem(object.merger({ projectState: project.state })) setItem(object.merger({ projectState: project.state }))
toastAndLog('openProjectError', error, item.title) toastAndLog('openProjectError', error, item.title)
setState(backendModule.ProjectState.closed)
} }
} }
}, },
@ -211,6 +227,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
if (!event.runInBackground && !isRunningInBackground) { if (!event.runInBackground && !isRunningInBackground) {
setShouldOpenWhenReady(false) setShouldOpenWhenReady(false)
if (!isOtherUserUsingProject && backendModule.IS_OPENING_OR_OPENED[state]) { if (!isOtherUserUsingProject && backendModule.IS_OPENING_OR_OPENED[state]) {
doAbortOpeningRef.current()
void closeProject() void closeProject()
} }
} }
@ -272,6 +289,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
setShouldOpenWhenReady(false) setShouldOpenWhenReady(false)
setState(backendModule.ProjectState.closing) setState(backendModule.ProjectState.closing)
await closeProjectMutation.mutateAsync([item.id, item.title]) await closeProjectMutation.mutateAsync([item.id, item.title])
setState(backendModule.ProjectState.closed)
} }
switch (state) { switch (state) {

View File

@ -113,9 +113,9 @@ export default function Settings(props: SettingsProps) {
/> />
</aria.Popover> </aria.Popover>
</aria.MenuTrigger> </aria.MenuTrigger>
<ariaComponents.Text.Heading className="font-bold"> <ariaComponents.Text variant="h1" className="font-bold">
<span>{getText('settingsFor')}</span> <span>{getText('settingsFor')}</span>
</ariaComponents.Text.Heading> </ariaComponents.Text>
<ariaComponents.Text <ariaComponents.Text
variant="h1" variant="h1"

View File

@ -153,15 +153,34 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly isActive: boolean readonly isActive: boolean
readonly icon: string readonly icon: string
readonly labelId: text.TextId readonly labelId: text.TextId
/** When the promise is in flight, the tab icon will instead be a loading spinner. */
readonly loadingPromise?: Promise<unknown>
readonly onPress: () => void readonly onPress: () => void
readonly onClose?: () => void readonly onClose?: () => void
} }
/** A tab in a {@link TabBar}. */ /** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) { export function Tab(props: InternalTabProps) {
const { isActive, icon, labelId, children, onPress, onClose } = props const { isActive, icon, labelId, loadingPromise, children, onPress, onClose } = props
const { updateClipPath } = useTabBarContext() const { updateClipPath } = useTabBarContext()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const [isLoading, setIsLoading] = React.useState(loadingPromise != null)
React.useEffect(() => {
if (loadingPromise) {
setIsLoading(true)
loadingPromise.then(
() => {
setIsLoading(false)
},
() => {
setIsLoading(false)
}
)
} else {
setIsLoading(false)
}
}, [loadingPromise])
return ( return (
<div <div
@ -174,9 +193,11 @@ export function Tab(props: InternalTabProps) {
<ariaComponents.Button <ariaComponents.Button
size="custom" size="custom"
variant="custom" variant="custom"
loaderPosition="icon"
icon={icon} icon={icon}
isDisabled={isActive} isDisabled={isActive}
isActive={isActive} isActive={isActive}
loading={isLoading}
aria-label={getText(labelId)} aria-label={getText(labelId)}
tooltip={false} tooltip={false}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(

View File

@ -39,7 +39,6 @@ import type * as projectManager from '#/services/ProjectManager'
import * as array from '#/utilities/array' import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import type * as types from '../../../../types/types' import type * as types from '../../../../types/types'
@ -60,7 +59,7 @@ declare module '#/utilities/LocalStorage' {
interface LocalStorageData { interface LocalStorageData {
readonly isAssetPanelVisible: boolean readonly isAssetPanelVisible: boolean
readonly page: TabType readonly page: TabType
readonly projectStartupInfo: backendModule.ProjectStartupInfo<backendModule.Project> readonly projectStartupInfo: Omit<backendModule.ProjectStartupInfo, 'project'>
} }
} }
@ -86,15 +85,13 @@ LocalStorage.registerKey('projectStartupInfo', {
return null return null
} else if (!('backendType' in value) || !array.includes(BACKEND_TYPES, value.backendType)) { } else if (!('backendType' in value) || !array.includes(BACKEND_TYPES, value.backendType)) {
return null return null
} else if (!('project' in value) || !('projectAsset' in value)) { } else if (!('projectAsset' in value)) {
return null return null
} else { } else {
return { return {
// These type assertions are UNSAFE, however correctly type-checking these // These type assertions are UNSAFE, however correctly type-checking these
// would be very complicated. // would be very complicated.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
project: value.project as backendModule.Project,
// eslint-disable-next-line no-restricted-syntax
projectAsset: value.projectAsset as backendModule.ProjectAsset, projectAsset: value.projectAsset as backendModule.ProjectAsset,
backendType: value.backendType, backendType: value.backendType,
accessToken: value.accessToken ?? null, accessToken: value.accessToken ?? null,
@ -143,8 +140,7 @@ export default function Dashboard(props: DashboardProps) {
) )
const [projectStartupInfo, setProjectStartupInfo] = const [projectStartupInfo, setProjectStartupInfo] =
React.useState<backendModule.ProjectStartupInfo | null>(null) React.useState<backendModule.ProjectStartupInfo | null>(null)
const [openProjectAbortController, setOpenProjectAbortController] = const openProjectAbortControllerRef = React.useRef<AbortController | null>(null)
React.useState<AbortController | null>(null)
const [assetListEvents, dispatchAssetListEvent] = const [assetListEvents, dispatchAssetListEvent] =
eventHooks.useEvent<assetListEvent.AssetListEvent>() eventHooks.useEvent<assetListEvent.AssetListEvent>()
const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent<assetEvent.AssetEvent>() const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent<assetEvent.AssetEvent>()
@ -187,7 +183,7 @@ export default function Dashboard(props: DashboardProps) {
setPage(TabType.drive) setPage(TabType.drive)
void (async () => { void (async () => {
const abortController = new AbortController() const abortController = new AbortController()
setOpenProjectAbortController(abortController) openProjectAbortControllerRef.current = abortController
try { try {
const oldProject = await remoteBackend.getProjectDetails( const oldProject = await remoteBackend.getProjectDetails(
savedProjectStartupInfo.projectAsset.id, savedProjectStartupInfo.projectAsset.id,
@ -199,13 +195,9 @@ export default function Dashboard(props: DashboardProps) {
savedProjectStartupInfo.projectAsset.id, savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId, savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title, savedProjectStartupInfo.projectAsset.title,
abortController abortController.signal
)
setProjectStartupInfo(
object.merge<backendModule.ProjectStartupInfo>(savedProjectStartupInfo, {
project,
})
) )
setProjectStartupInfo({ ...savedProjectStartupInfo, project })
if (page === TabType.editor) { if (page === TabType.editor) {
setPage(page) setPage(page)
} }
@ -232,9 +224,7 @@ export default function Dashboard(props: DashboardProps) {
savedProjectStartupInfo.projectAsset.parentId, savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title savedProjectStartupInfo.projectAsset.title
) )
setProjectStartupInfo( setProjectStartupInfo({ ...savedProjectStartupInfo, project })
object.merge<backendModule.ProjectStartupInfo>(savedProjectStartupInfo, { project })
)
if (page === TabType.editor) { if (page === TabType.editor) {
setPage(page) setPage(page)
} }
@ -249,8 +239,8 @@ export default function Dashboard(props: DashboardProps) {
eventHooks.useEventHandler(assetEvents, event => { eventHooks.useEventHandler(assetEvents, event => {
switch (event.type) { switch (event.type) {
case AssetEventType.openProject: { case AssetEventType.openProject: {
openProjectAbortController?.abort() openProjectAbortControllerRef.current?.abort()
setOpenProjectAbortController(null) openProjectAbortControllerRef.current = null
break break
} }
default: { default: {
@ -263,9 +253,10 @@ export default function Dashboard(props: DashboardProps) {
React.useEffect(() => { React.useEffect(() => {
if (initializedRef.current) { if (initializedRef.current) {
if (projectStartupInfo != null) { if (projectStartupInfo != null) {
void Promise.resolve(projectStartupInfo.project).then(project => { // This is INTENTIONAL - `project` is intentionally omitted from this object.
localStorage.set('projectStartupInfo', { ...projectStartupInfo, project }) // eslint-disable-next-line @typescript-eslint/no-unused-vars
}) const { project, ...rest } = projectStartupInfo
localStorage.set('projectStartupInfo', rest)
} else { } else {
localStorage.delete('projectStartupInfo') localStorage.delete('projectStartupInfo')
} }
@ -376,6 +367,7 @@ export default function Dashboard(props: DashboardProps) {
isActive={page === TabType.editor} isActive={page === TabType.editor}
icon={WorkspaceIcon} icon={WorkspaceIcon}
labelId="editorPageName" labelId="editorPageName"
loadingPromise={projectStartupInfo.project}
onPress={() => { onPress={() => {
setPage(TabType.editor) setPage(TabType.editor)
}} }}
@ -384,6 +376,7 @@ export default function Dashboard(props: DashboardProps) {
type: AssetEventType.closeProject, type: AssetEventType.closeProject,
id: projectStartupInfo.projectAsset.id, id: projectStartupInfo.projectAsset.id,
}) })
setProjectStartupInfo(null)
setPage(TabType.drive) setPage(TabType.drive)
}} }}
> >

View File

@ -292,10 +292,8 @@ export interface BackendProject extends Project {
} }
/** Information required to open a project. */ /** Information required to open a project. */
export interface ProjectStartupInfo< export interface ProjectStartupInfo {
ProjectType extends Project | Promise<Project> = Project | Promise<Project>, readonly project: Promise<Project>
> {
readonly project: ProjectType
readonly projectAsset: ProjectAsset readonly projectAsset: ProjectAsset
// This MUST BE optional because it is lost when `JSON.stringify`ing to put in `localStorage`. // This MUST BE optional because it is lost when `JSON.stringify`ing to put in `localStorage`.
readonly setProjectAsset?: React.Dispatch<React.SetStateAction<ProjectAsset>> readonly setProjectAsset?: React.Dispatch<React.SetStateAction<ProjectAsset>>
@ -1428,6 +1426,6 @@ export default abstract class Backend {
projectId: ProjectId, projectId: ProjectId,
directory: DirectoryId | null, directory: DirectoryId | null,
title: string, title: string,
abortController?: AbortController abortSignal?: AbortSignal
): Promise<Project> ): Promise<Project>
} }

View File

@ -1067,11 +1067,12 @@ export default class RemoteBackend extends Backend {
projectId: backend.ProjectId, projectId: backend.ProjectId,
directory: backend.DirectoryId | null, directory: backend.DirectoryId | null,
title: string, title: string,
abortController: AbortController = new AbortController() abortSignal?: AbortSignal
) { ) {
let project = await this.getProjectDetails(projectId, directory, title) let project = await this.getProjectDetails(projectId, directory, title)
while (project.state.type !== backend.ProjectState.opened) { while (project.state.type !== backend.ProjectState.opened) {
if (abortController.signal.aborted) { if (abortSignal?.aborted === true) {
// The operation was cancelled, do not return.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
throw new Error() throw new Error()
} }