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

View File

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

View File

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

View File

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

View File

@ -153,15 +153,34 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly isActive: boolean
readonly icon: string
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 onClose?: () => void
}
/** A tab in a {@link TabBar}. */
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 { 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 (
<div
@ -174,9 +193,11 @@ export function Tab(props: InternalTabProps) {
<ariaComponents.Button
size="custom"
variant="custom"
loaderPosition="icon"
icon={icon}
isDisabled={isActive}
isActive={isActive}
loading={isLoading}
aria-label={getText(labelId)}
tooltip={false}
className={tailwindMerge.twMerge(

View File

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

View File

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

View File

@ -1067,11 +1067,12 @@ export default class RemoteBackend extends Backend {
projectId: backend.ProjectId,
directory: backend.DirectoryId | null,
title: string,
abortController: AbortController = new AbortController()
abortSignal?: AbortSignal
) {
let project = await this.getProjectDetails(projectId, directory, title)
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
throw new Error()
}