diff --git a/app/gui/src/dashboard/assets/cross.svg b/app/gui/src/dashboard/assets/cross.svg
index 3190ad3d1e04..a9e60f676baa 100644
--- a/app/gui/src/dashboard/assets/cross.svg
+++ b/app/gui/src/dashboard/assets/cross.svg
@@ -1,4 +1,4 @@
\ No newline at end of file
diff --git a/app/gui/src/dashboard/assets/cross2.svg b/app/gui/src/dashboard/assets/cross2.svg
index 920ef74ad3d0..0dc9486b155b 100644
--- a/app/gui/src/dashboard/assets/cross2.svg
+++ b/app/gui/src/dashboard/assets/cross2.svg
@@ -1,7 +1,7 @@
\ No newline at end of file
diff --git a/app/gui/src/dashboard/assets/error_filled.svg b/app/gui/src/dashboard/assets/error_filled.svg
new file mode 100644
index 000000000000..7012571bae24
--- /dev/null
+++ b/app/gui/src/dashboard/assets/error_filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/gui/src/dashboard/assets/error_outline.svg b/app/gui/src/dashboard/assets/error_outline.svg
new file mode 100644
index 000000000000..550619be3a84
--- /dev/null
+++ b/app/gui/src/dashboard/assets/error_outline.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/gui/src/dashboard/assets/warning.svg b/app/gui/src/dashboard/assets/warning.svg
new file mode 100644
index 000000000000..ff83234b968b
--- /dev/null
+++ b/app/gui/src/dashboard/assets/warning.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/gui/src/dashboard/hooks/projectHooks.ts b/app/gui/src/dashboard/hooks/projectHooks.ts
index 711262067b02..c47f420ef6ff 100644
--- a/app/gui/src/dashboard/hooks/projectHooks.ts
+++ b/app/gui/src/dashboard/hooks/projectHooks.ts
@@ -81,6 +81,51 @@ function useSetProjectAsset() {
)
}
+export const OPENING_PROJECT_STATES = new Set([
+ backendModule.ProjectState.provisioned,
+ backendModule.ProjectState.scheduled,
+ backendModule.ProjectState.openInProgress,
+ backendModule.ProjectState.closing,
+])
+export const OPENED_PROJECT_STATES = new Set([backendModule.ProjectState.opened])
+export const CLOSED_PROJECT_STATES = new Set([backendModule.ProjectState.closed])
+export const STATIC_PROJECT_STATES = new Set([
+ backendModule.ProjectState.opened,
+ backendModule.ProjectState.closed,
+])
+export const CREATED_PROJECT_STATES = new Set([
+ backendModule.ProjectState.created,
+ backendModule.ProjectState.new,
+])
+
+/** Stale time for local projects, set to 10 seconds. */
+// eslint-disable-next-line @typescript-eslint/no-magic-numbers
+export const LOCAL_PROJECT_OPEN_TIMEOUT_MS = 10 * 1_000
+/** Stale time for cloud projects, set to 5 minutes. */
+// eslint-disable-next-line @typescript-eslint/no-magic-numbers
+export const CLOUD_PROJECT_OPEN_TIMEOUT_MS = 5 * 60 * 1_000
+
+/**
+ * Get the timeout based on the backend type.
+ * @param backendType - The backend type.
+ * @throws If the backend type is not supported.
+ * @returns The timeout in milliseconds.
+ */
+export function getTimeoutBasedOnTheBackendType(backendType: backendModule.BackendType) {
+ switch (backendType) {
+ case backendModule.BackendType.local: {
+ return LOCAL_PROJECT_OPEN_TIMEOUT_MS
+ }
+ case backendModule.BackendType.remote: {
+ return CLOUD_PROJECT_OPEN_TIMEOUT_MS
+ }
+
+ default: {
+ throw new Error('Unsupported backend type')
+ }
+ }
+}
+
/** Project status query. */
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, backend } = options
@@ -90,22 +135,19 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
queryFn: () => backend.getProjectDetails(assetId, parentId),
- meta: { persist: false },
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always',
- refetchInterval: ({ state }): number | false => {
- const staticStates = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
+ meta: { persist: false },
+ refetchInterval: (query): number | false => {
+ const { state } = query
+
+ const staticStates = STATIC_PROJECT_STATES
- const openingStates = [
- backendModule.ProjectState.provisioned,
- backendModule.ProjectState.scheduled,
- backendModule.ProjectState.openInProgress,
- backendModule.ProjectState.closing,
- ]
+ const openingStates = OPENING_PROJECT_STATES
- const createdStates = [backendModule.ProjectState.created, backendModule.ProjectState.new]
+ const createdStates = CREATED_PROJECT_STATES
if (state.status === 'error') {
return false
@@ -118,28 +160,28 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
const currentState = state.data.state.type
if (isLocal) {
- if (createdStates.includes(currentState)) {
+ if (createdStates.has(currentState)) {
return LOCAL_OPENING_INTERVAL_MS
}
- if (staticStates.includes(state.data.state.type)) {
+ if (staticStates.has(state.data.state.type)) {
return OPENED_INTERVAL_MS
}
- if (openingStates.includes(state.data.state.type)) {
+ if (openingStates.has(state.data.state.type)) {
return LOCAL_OPENING_INTERVAL_MS
}
}
- if (createdStates.includes(currentState)) {
+ if (createdStates.has(currentState)) {
return CLOUD_OPENING_INTERVAL_MS
}
// Cloud project
- if (staticStates.includes(state.data.state.type)) {
+ if (staticStates.has(state.data.state.type)) {
return OPENED_INTERVAL_MS
}
- if (openingStates.includes(state.data.state.type)) {
+ if (openingStates.has(state.data.state.type)) {
return CLOUD_OPENING_INTERVAL_MS
}
diff --git a/app/gui/src/dashboard/hooks/timeoutHooks.ts b/app/gui/src/dashboard/hooks/timeoutHooks.ts
new file mode 100644
index 000000000000..5c48968559cb
--- /dev/null
+++ b/app/gui/src/dashboard/hooks/timeoutHooks.ts
@@ -0,0 +1,124 @@
+/**
+ * @file Timeout related hooks.
+ */
+import { useEffect, useRef, useState, type DependencyList } from 'react'
+import { useEventCallback } from './eventCallbackHooks'
+import { useUnmount } from './unmountHooks'
+
+/**
+ * Options for {@link useTimeoutCallback}.
+ */
+export interface UseTimeoutCallbackOptions {
+ /**
+ * Callback to execute after the timeout.
+ */
+ readonly callback: () => void
+ /**
+ * Timeout in milliseconds.
+ */
+ readonly ms: number
+ /**
+ * Dependencies for {@link useEventCallback}.
+ * Reset the timeout when the dependencies change.
+ */
+ readonly deps?: DependencyList
+ /**
+ * Whether the timeout is disabled.
+ */
+ readonly isDisabled?: boolean
+}
+
+const STABLE_DEPS_ARRAY: DependencyList = []
+
+/**
+ * Hook that executes a callback after a timeout.
+ */
+export function useTimeoutCallback(options: UseTimeoutCallbackOptions) {
+ const { callback, ms, deps = STABLE_DEPS_ARRAY, isDisabled = false } = options
+
+ const timeoutRef = useRef | null>(null)
+ const stableCallback = useEventCallback(callback)
+
+ /**
+ * Restarts the timer.
+ */
+ const restartTimer = useEventCallback(() => {
+ stopTimer()
+ startTimer()
+ })
+
+ /**
+ * Starts the timer.
+ */
+ const startTimer = useEventCallback(() => {
+ stopTimer()
+ timeoutRef.current = setTimeout(stableCallback, ms)
+ })
+
+ /**
+ * Stops the timer.
+ */
+ const stopTimer = useEventCallback(() => {
+ if (timeoutRef.current != null) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+ })
+
+ useEffect(() => {
+ if (isDisabled) {
+ return
+ }
+
+ startTimer()
+
+ return () => {
+ stopTimer()
+ }
+ // There is no way to enable compiler, but it's not needed here
+ // as everything is stable.
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ms, isDisabled, ...deps])
+
+ useUnmount(() => {
+ stopTimer()
+ })
+
+ return [restartTimer, stopTimer, startTimer] as const
+}
+
+/**
+ * Hook that returns a boolean indicating whether the timeout has expired.
+ */
+export function useTimeout(params: Pick) {
+ const { ms, deps = STABLE_DEPS_ARRAY } = params
+
+ /**
+ * Get the default value for the timeout.
+ */
+ const getDefaultValue = useEventCallback(() => {
+ return ms === 0
+ })
+
+ const [isTimeout, setIsTimeout] = useState(getDefaultValue)
+
+ const [restartTimer] = useTimeoutCallback({
+ callback: () => {
+ setIsTimeout(true)
+ },
+ ms,
+ deps,
+ isDisabled: false,
+ })
+
+ /**
+ * Resets the timeout and restarts it.
+ */
+ const restart = useEventCallback(() => {
+ setIsTimeout(getDefaultValue)
+ restartTimer()
+ })
+
+ return [isTimeout, restart] as const
+}
diff --git a/app/gui/src/dashboard/layouts/Editor.tsx b/app/gui/src/dashboard/layouts/Editor.tsx
index dc41ac689926..073a28951a89 100644
--- a/app/gui/src/dashboard/layouts/Editor.tsx
+++ b/app/gui/src/dashboard/layouts/Editor.tsx
@@ -20,6 +20,7 @@ import * as backendModule from '#/services/Backend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as twMerge from '#/utilities/tailwindMerge'
+import { useTimeoutCallback } from '../hooks/timeoutHooks'
// ====================
// === StringConfig ===
@@ -91,9 +92,20 @@ function Editor(props: EditorProps) {
backend,
})
- const projectQuery = reactQuery.useQuery(projectStatusQuery)
+ const queryClient = reactQuery.useQueryClient()
- const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
+ const projectQuery = reactQuery.useSuspenseQuery({
+ ...projectStatusQuery,
+ select: (data) => {
+ const isOpeningProject = projectHooks.OPENING_PROJECT_STATES.has(data.state.type)
+ const isProjectClosed = projectHooks.CLOSED_PROJECT_STATES.has(data.state.type)
+
+ return { ...data, isOpeningProject, isProjectClosed }
+ },
+ })
+
+ const isProjectClosed = projectQuery.data.isProjectClosed
+ const isOpeningProject = projectQuery.data.isOpeningProject
React.useEffect(() => {
if (isProjectClosed) {
@@ -101,12 +113,28 @@ function Editor(props: EditorProps) {
}
}, [isProjectClosed, startProject, project])
+ useTimeoutCallback({
+ callback: () => {
+ const queryState = queryClient.getQueryCache().find({ queryKey: projectStatusQuery.queryKey })
+
+ queryState?.setState({
+ error: new Error('Timeout opening the project'),
+ status: 'error',
+ })
+ },
+ ms: projectHooks.getTimeoutBasedOnTheBackendType(backend.type),
+ deps: [],
+ isDisabled: !isOpeningProject || projectQuery.isError,
+ })
+
if (isOpeningFailed) {
return (
{
- startProject(project)
+ if (isProjectClosed) {
+ startProject(project)
+ }
}}
/>
)
@@ -128,20 +156,17 @@ function Editor(props: EditorProps) {
/>
)
- case projectQuery.isLoading ||
- projectQuery.data?.state.type !== backendModule.ProjectState.opened:
+ case isOpeningProject:
return
default:
return (
-
-
-
+
)
}
@@ -178,9 +203,7 @@ function EditorInternal(props: EditorInternalProps) {
)
React.useEffect(() => {
- if (hidden) {
- return
- } else {
+ if (!hidden) {
return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'open_workflow', 'close_workflow')
}
}, [hidden, gtagEvent])
diff --git a/app/gui/src/dashboard/layouts/TabBar.tsx b/app/gui/src/dashboard/layouts/TabBar.tsx
index 422b3977db2e..00c48ebc5857 100644
--- a/app/gui/src/dashboard/layouts/TabBar.tsx
+++ b/app/gui/src/dashboard/layouts/TabBar.tsx
@@ -5,8 +5,8 @@ import * as reactQuery from '@tanstack/react-query'
import type * as text from 'enso-common/src/text'
+import ErrorFilledIcon from '#/assets/warning.svg'
import * as projectHooks from '#/hooks/projectHooks'
-
import type { LaunchedProject } from '#/providers/ProjectsProvider'
import * as textProvider from '#/providers/TextProvider'
@@ -19,7 +19,6 @@ import { AnimatedBackground } from '#/components/AnimatedBackground'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useBackendForProjectType } from '#/providers/BackendProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
-import { ProjectState } from '#/services/Backend'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { motion } from 'framer-motion'
@@ -163,13 +162,17 @@ export function ProjectTab(props: ProjectTabProps) {
onClose?.(project)
})
- const { data: isOpened, isSuccess } = reactQuery.useQuery({
+ const {
+ data: isOpened,
+ isSuccess,
+ isError,
+ } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery({
assetId: project.id,
parentId: project.parentId,
backend,
}),
- select: (data) => data.state.type === ProjectState.opened,
+ select: (data) => projectHooks.OPENED_PROJECT_STATES.has(data.state.type),
})
const isReady = isSuccess && isOpened
@@ -187,7 +190,17 @@ export function ProjectTab(props: ProjectTabProps) {
}
}, [isReady])
- const icon = isReady ? iconRaw : SPINNER
+ const icon = (() => {
+ if (isReady) {
+ return iconRaw
+ }
+
+ if (isError) {
+ return
+ }
+
+ return SPINNER
+ })()
return
}
diff --git a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx
index 8ddf9130a14c..ecf77ccc5704 100644
--- a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx
+++ b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx
@@ -102,11 +102,11 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) {
return (
{(tabPanelProps) => (
-
-
-
-
-
+
+
+ {tabPanelProps.children}
+
+
)}
)