Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix restoring cloud projects that have been closed (e.g. from inactivity) #7584

Merged
merged 12 commits into from
Aug 25, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: true,
})
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export interface AssetsTableState {
) => void
/** Called when the project is opened via the {@link ProjectActionButton}. */
doOpenManually: (projectId: backendModule.ProjectId) => void
doOpenIde: (project: backendModule.ProjectAsset) => void
doOpenIde: (project: backendModule.ProjectAsset, switchPage: boolean) => void
doCloseIde: () => void
}

Expand All @@ -146,11 +146,14 @@ export interface AssetsTableProps {
appRunner: AppRunner | null
query: string
initialProjectName: string | null
/** These events will be dispatched the next time the assets list is refreshed, rather than
* immediately. */
queuedAssetEvents: assetEventModule.AssetEvent[]
assetEvents: assetEventModule.AssetEvent[]
dispatchAssetEvent: (event: assetEventModule.AssetEvent) => void
assetListEvents: assetListEventModule.AssetListEvent[]
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
doOpenIde: (project: backendModule.ProjectAsset) => void
doOpenIde: (project: backendModule.ProjectAsset, switchPage: boolean) => void
doCloseIde: () => void
loadingProjectManagerDidFail: boolean
isListingRemoteDirectoryWhileOffline: boolean
Expand All @@ -164,6 +167,7 @@ export default function AssetsTable(props: AssetsTableProps) {
appRunner,
query,
initialProjectName,
queuedAssetEvents: rawQueuedAssetEvents,
assetEvents,
dispatchAssetEvent,
assetListEvents,
Expand All @@ -189,6 +193,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const [sortColumn, setSortColumn] = React.useState<columnModule.SortableColumn | null>(null)
const [sortDirection, setSortDirection] = React.useState<sorting.SortDirection | null>(null)
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<backendModule.AssetId>())
const [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEventModule.AssetEvent[]>(
[]
)
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
React.useState(initialProjectName)
const nodeMap = React.useMemo(
Expand Down Expand Up @@ -241,6 +248,12 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [assetTree, sortColumn, sortDirection])

React.useEffect(() => {
if (rawQueuedAssetEvents.length !== 0) {
setQueuedAssetEvents(oldEvents => [...oldEvents, ...rawQueuedAssetEvents])
}
}, [rawQueuedAssetEvents])

React.useEffect(() => {
setIsLoading(true)
}, [backend])
Expand Down Expand Up @@ -273,10 +286,17 @@ export default function AssetsTable(props: AssetsTableProps) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: projectToLoad.id,
shouldAutomaticallySwitchPage: true,
})
}
setNameOfProjectToImmediatelyOpen(null)
}
if (queuedAssetEvents.length !== 0) {
for (const event of queuedAssetEvents) {
dispatchAssetEvent(event)
}
setQueuedAssetEvents([])
}
if (!initialized) {
setInitialized(true)
if (initialProjectName != null) {
Expand All @@ -293,6 +313,7 @@ export default function AssetsTable(props: AssetsTableProps) {
initialProjectName,
logger,
nameOfProjectToImmediatelyOpen,
queuedAssetEvents,
/* should never change */ setNameOfProjectToImmediatelyOpen,
/* should never change */ dispatchAssetEvent,
]
Expand Down Expand Up @@ -658,6 +679,7 @@ export default function AssetsTable(props: AssetsTableProps) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: projectId,
shouldAutomaticallySwitchPage: true,
})
},
[/* should never change */ dispatchAssetEvent]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react'

import * as common from 'enso-common'

import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as backendModule from '../backend'
import * as hooks from '../../hooks'
Expand All @@ -23,6 +24,7 @@ import * as shortcutsProvider from '../../providers/shortcuts'

import * as app from '../../components/app'
import * as pageSwitcher from './pageSwitcher'
import * as projectIcon from './projectIcon'
import * as spinner from './spinner'
import Chat, * as chat from './chat'
import DriveView from './driveView'
Expand All @@ -31,13 +33,6 @@ import Templates from './templates'
import TheModal from './theModal'
import TopBar from './topBar'

// =================
// === Constants ===
// =================

/** The `id` attribute of the loading spinner element. */
const LOADER_ELEMENT_ID = 'loader'

// =================
// === Dashboard ===
// =================
Expand All @@ -61,13 +56,17 @@ export default function Dashboard(props: DashboardProps) {
const { unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const { shortcuts } = shortcutsProvider.useShortcuts()
const [initialized, setInitialized] = React.useState(false)
const [query, setQuery] = React.useState('')
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
const [page, setPage] = React.useState(
() => localStorage.get(localStorageModule.LocalStorageKey.page) ?? pageSwitcher.Page.drive
)
const [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEventModule.AssetEvent[]>(
[]
)
const [projectStartupInfo, setProjectStartupInfo] =
React.useState<backendModule.ProjectStartupInfo | null>(null)
const [assetListEvents, dispatchAssetListEvent] =
Expand All @@ -82,6 +81,10 @@ export default function Dashboard(props: DashboardProps) {
session.type === authProvider.UserSessionType.offline &&
backend.type === backendModule.BackendType.remote

React.useEffect(() => {
setInitialized(true)
}, [])

React.useEffect(() => {
unsetModal()
if (page === pageSwitcher.Page.editor) {
Expand All @@ -92,48 +95,112 @@ export default function Dashboard(props: DashboardProps) {
}, [page, /* should never change */ unsetModal])

React.useEffect(() => {
let currentBackend = backend
if (
supportsLocalBackend &&
session.type !== authProvider.UserSessionType.offline &&
localStorage.get(localStorageModule.LocalStorageKey.backendType) ===
backendModule.BackendType.local
) {
currentBackend = new localBackend.LocalBackend(
localStorage.get(localStorageModule.LocalStorageKey.projectStartupInfo) ?? null
)
setBackend(currentBackend)
}
const savedProjectStartupInfo = localStorage.get(
localStorageModule.LocalStorageKey.projectStartupInfo
)
if (savedProjectStartupInfo != null) {
setProjectStartupInfo(savedProjectStartupInfo)
if (page !== pageSwitcher.Page.editor) {
// A workaround to hide the spinner, when the previous project is being loaded in
// the background. This `MutationObserver` is disconnected when the loader is
// removed from the DOM.
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof HTMLElement && node.id === LOADER_ELEMENT_ID) {
document.body.style.cursor = 'auto'
node.style.display = 'none'
if (savedProjectStartupInfo.backendType === backendModule.BackendType.remote) {
if (session.accessToken != null) {
if (currentBackend.type === backendModule.BackendType.remote) {
// `projectStartupInfo` is still `null`, so the `editor` page will be empty.
setPage(pageSwitcher.Page.drive)
setQueuedAssetEvents([
{
type: assetEventModule.AssetEventType.openProject,
id: savedProjectStartupInfo.project.projectId,
shouldAutomaticallySwitchPage: page === pageSwitcher.Page.editor,
},
])
} else {
setPage(pageSwitcher.Page.drive)
const httpClient = new http.Client(
new Headers([['Authorization', `Bearer ${session.accessToken}`]])
)
const remoteBackend = new remoteBackendModule.RemoteBackend(
httpClient,
logger
)
void (async () => {
const projectId = savedProjectStartupInfo.project.projectId
const projectName = savedProjectStartupInfo.project.packageName
let project = await remoteBackend.getProjectDetails(
projectId,
projectName
)
if (
project.state.type !== backendModule.ProjectState.openInProgress &&
project.state.type !== backendModule.ProjectState.opened
) {
await remoteBackend.openProject(projectId, null, projectName)
}
}
for (const node of Array.from(mutation.removedNodes)) {
if (node instanceof HTMLElement && node.id === LOADER_ELEMENT_ID) {
document.body.style.cursor = 'auto'
observer.disconnect()
let nextCheckTimestamp = 0
while (project.state.type !== backendModule.ProjectState.opened) {
await new Promise<void>(resolve => {
const delayMs = nextCheckTimestamp - Number(new Date())
setTimeout(resolve, Math.max(0, delayMs))
})
nextCheckTimestamp =
Number(new Date()) + projectIcon.CHECK_STATUS_INTERVAL_MS
project = await remoteBackend.getProjectDetails(
projectId,
projectName
)
}
}
nextCheckTimestamp = 0
while (true) {
try {
await new Promise<void>(resolve => {
const delayMs = nextCheckTimestamp - Number(new Date())
setTimeout(resolve, Math.max(0, delayMs))
})
nextCheckTimestamp =
Number(new Date()) + projectIcon.CHECK_RESOURCES_INTERVAL_MS
await remoteBackend.checkResources(projectId, projectName)
break
} catch {
// Ignored.
}
}
setProjectStartupInfo({ ...savedProjectStartupInfo, project })
if (page === pageSwitcher.Page.editor) {
setPage(page)
}
})()
}
})
observer.observe(document.body, { childList: true })
}
} else {
setProjectStartupInfo(savedProjectStartupInfo)
}
}
// This MUST only run when the component is mounted.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

React.useEffect(() => {
if (projectStartupInfo != null) {
localStorage.set(
localStorageModule.LocalStorageKey.projectStartupInfo,
projectStartupInfo
)
}
return () => {
localStorage.delete(localStorageModule.LocalStorageKey.projectStartupInfo)
if (initialized) {
if (projectStartupInfo != null) {
localStorage.set(
localStorageModule.LocalStorageKey.projectStartupInfo,
projectStartupInfo
)
} else {
localStorage.delete(localStorageModule.LocalStorageKey.projectStartupInfo)
}
}
// `initialized` is NOT a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectStartupInfo, /* should never change */ localStorage])

React.useEffect(() => {
Expand All @@ -152,23 +219,6 @@ export default function Dashboard(props: DashboardProps) {
}
}, [/* should never change */ unsetModal])

React.useEffect(() => {
if (
supportsLocalBackend &&
session.type !== authProvider.UserSessionType.offline &&
localStorage.get(localStorageModule.LocalStorageKey.backendType) ===
backendModule.BackendType.local
) {
setBackend(
new localBackend.LocalBackend(
localStorage.get(localStorageModule.LocalStorageKey.projectStartupInfo) ?? null
)
)
}
// This hook MUST only run once, on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

React.useEffect(() => {
// The types come from a third-party API and cannot be changed.
// eslint-disable-next-line no-restricted-syntax
Expand Down Expand Up @@ -244,8 +294,10 @@ export default function Dashboard(props: DashboardProps) {
)

const openEditor = React.useCallback(
async (newProject: backendModule.ProjectAsset) => {
setPage(pageSwitcher.Page.editor)
async (newProject: backendModule.ProjectAsset, switchPage: boolean) => {
if (switchPage) {
setPage(pageSwitcher.Page.editor)
}
if (projectStartupInfo?.project.projectId !== newProject.id) {
setProjectStartupInfo({
project: await backend.getProjectDetails(newProject.id, newProject.title),
Expand Down Expand Up @@ -329,6 +381,7 @@ export default function Dashboard(props: DashboardProps) {
hidden={page !== pageSwitcher.Page.drive}
page={page}
initialProjectName={initialProjectName}
queuedAssetEvents={queuedAssetEvents}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
query={query}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ export interface DriveViewProps {
page: pageSwitcher.Page
hidden: boolean
initialProjectName: string | null
/** These events will be dispatched the next time the assets list is refreshed, rather than
* immediately. */
queuedAssetEvents: assetEventModule.AssetEvent[]
assetListEvents: assetListEventModule.AssetListEvent[]
dispatchAssetListEvent: (directoryEvent: assetListEventModule.AssetListEvent) => void
query: string
doCreateProject: (templateId: string | null) => void
doOpenEditor: (project: backendModule.ProjectAsset) => void
doOpenEditor: (project: backendModule.ProjectAsset, switchPage: boolean) => void
doCloseEditor: () => void
appRunner: AppRunner | null
loadingProjectManagerDidFail: boolean
Expand All @@ -41,6 +44,7 @@ export default function DriveView(props: DriveViewProps) {
page,
hidden,
initialProjectName,
queuedAssetEvents,
query,
assetListEvents,
dispatchAssetListEvent,
Expand Down Expand Up @@ -138,6 +142,7 @@ export default function DriveView(props: DriveViewProps) {
query={query}
appRunner={appRunner}
initialProjectName={initialProjectName}
queuedAssetEvents={queuedAssetEvents}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}
Expand Down
Loading
Loading