diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index e389d3cfad34..ee53fbefdf3b 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -34,7 +34,9 @@ const FETCH_TIMEOUT = 300 if (IS_DEV_MODE) { new EventSource(ESBUILD_PATH).addEventListener(ESBUILD_EVENT_NAME, () => { - location.reload() + // This acts like `location.reload`, but it preserves the query-string. + // The `toString()` is to bypass a lint without using a comment. + location.href = location.href.toString() }) void navigator.serviceWorker.register(SERVICE_WORKER_PATH) } diff --git a/app/ide-desktop/lib/content/src/serviceWorker.ts b/app/ide-desktop/lib/content/src/serviceWorker.ts index 4ccd2ddb0028..e51a59ef3e95 100644 --- a/app/ide-desktop/lib/content/src/serviceWorker.ts +++ b/app/ide-desktop/lib/content/src/serviceWorker.ts @@ -16,7 +16,7 @@ declare const self: ServiceWorkerGlobalScope self.addEventListener('fetch', event => { const url = new URL(event.request.url) if (url.hostname === 'localhost' && url.pathname !== '/esbuild') { - const responsePromise = /\/[^.]+$/.test(event.request.url) + const responsePromise = /\/[^.]+$/.test(new URL(event.request.url).pathname) ? fetch('/index.html') : fetch(event.request.url) event.respondWith( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index 3884dec26976..9de5aa889dfb 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -15,7 +15,7 @@ import * as errorModule from '../../error' import * as http from '../../http' import * as loggerProvider from '../../providers/logger' import * as newtype from '../../newtype' -import * as platform from '../../platform' +import * as platformModule from '../../platform' import * as remoteBackend from '../../dashboard/remoteBackend' import * as sessionProvider from './session' @@ -35,25 +35,29 @@ const MESSAGES = { pleaseWait: 'Please wait...', } as const -// ============= -// === Types === -// ============= - +// =================== // === UserSession === +// =================== -/** A user session for a user that may be either fully registered, - * or in the process of registering. */ -export type UserSession = FullUserSession | PartialUserSession +/** Possible types of {@link BaseUserSession}. */ +export enum UserSessionType { + partial = 'partial', + awaitingAcceptance = 'awaitingAcceptance', + full = 'full', +} -/** Object containing the currently signed-in user's session data. */ -export interface FullUserSession { - /** A discriminator for TypeScript to be able to disambiguate between this interface and other - * `UserSession` variants. */ - variant: 'full' +/** Properties common to all {@link UserSession}s. */ +interface BaseUserSession { + /** A discriminator for TypeScript to be able to disambiguate between `UserSession` variants. */ + type: Type /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ accessToken: string /** User's email address. */ email: string +} + +/** Object containing the currently signed-in user's session data. */ +export interface FullUserSession extends BaseUserSession { /** User's organization information. */ organization: backendModule.UserOrOrganization } @@ -64,15 +68,11 @@ export interface FullUserSession { * If a user has not yet set their username, they do not yet have an organization associated with * their account. Otherwise, this type is identical to the `Session` type. This type should ONLY be * used by the `SetUsername` component. */ -export interface PartialUserSession { - /** A discriminator for TypeScript to be able to disambiguate between this interface and other - * `UserSession` variants. */ - variant: 'partial' - /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ - accessToken: string - /** User's email address. */ - email: string -} +export interface PartialUserSession extends BaseUserSession {} + +/** A user session for a user that may be either fully registered, + * or in the process of registering. */ +export type UserSession = FullUserSession | PartialUserSession // =================== // === AuthContext === @@ -140,6 +140,7 @@ const AuthContext = react.createContext({} as AuthContextType) /** Props for an {@link AuthProvider}. */ export interface AuthProviderProps { authService: authServiceModule.AuthService + platform: platformModule.Platform /** Callback to execute once the user has authenticated successfully. */ onAuthenticated: () => void children: react.ReactNode @@ -147,7 +148,7 @@ export interface AuthProviderProps { /** A React provider for the Cognito API. */ export function AuthProvider(props: AuthProviderProps) { - const { authService, children } = props + const { authService, platform, children } = props const { cognito } = authService const { session } = sessionProvider.useSession() const { setBackend } = backendProvider.useSetBackend() @@ -173,20 +174,21 @@ export function AuthProvider(props: AuthProviderProps) { headers.append('Authorization', `Bearer ${accessToken}`) const client = new http.Client(headers) const backend = new remoteBackend.RemoteBackend(client, logger) - setBackend(backend) + if (platform === platformModule.Platform.cloud) { + setBackend(backend) + } const organization = await backend.usersMe().catch(() => null) let newUserSession: UserSession + const sharedSessionData = { email, accessToken } if (!organization) { newUserSession = { - variant: 'partial', - email, - accessToken, + type: UserSessionType.partial, + ...sharedSessionData, } } else { newUserSession = { - variant: 'full', - email, - accessToken, + type: UserSessionType.full, + ...sharedSessionData, organization, } @@ -272,7 +274,7 @@ export function AuthProvider(props: AuthProviderProps) { username: string, email: string ) => { - if (backend.platform === platform.Platform.desktop) { + if (backend.platform === platformModule.Platform.desktop) { toast.error('You cannot set your username on the local backend.') return false } else { @@ -395,7 +397,7 @@ export function ProtectedLayout() { if (!session) { return - } else if (session.variant === 'partial') { + } else if (session.type === UserSessionType.partial) { return } else { return @@ -411,7 +413,7 @@ export function ProtectedLayout() { export function SemiProtectedLayout() { const { session } = useAuth() - if (session?.variant === 'full') { + if (session?.type === UserSessionType.full) { return } else { return @@ -427,9 +429,9 @@ export function SemiProtectedLayout() { export function GuestLayout() { const { session } = useAuth() - if (session?.variant === 'partial') { + if (session?.type === UserSessionType.partial) { return - } else if (session?.variant === 'full') { + } else if (session?.type === UserSessionType.full) { return } else { return diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index cc1f34e22ffc..34888dc6007a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -39,6 +39,7 @@ import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' import * as authService from '../authentication/service' +import * as localBackend from '../dashboard/localBackend' import * as platformModule from '../platform' import * as authProvider from '../authentication/providers/auth' @@ -121,7 +122,7 @@ function App(props: AppProps) { * because the {@link AppRouter} relies on React hooks, which can't be used in the same React * component as the component that defines the provider. */ function AppRouter(props: AppProps) { - const { logger, showDashboard, onAuthenticated } = props + const { logger, platform, showDashboard, onAuthenticated } = props const navigate = router.useNavigate() const mainPageUrl = new URL(window.location.href) const memoizedAuthService = react.useMemo(() => { @@ -163,11 +164,20 @@ function AppRouter(props: AppProps) { userSession={userSession} registerAuthEventListener={registerAuthEventListener} > - {/* @ts-expect-error Auth will always set this before dashboard is rendered. */} - + {routes} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index 6e40dcf8c1b4..7c1a914a44e9 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -48,6 +48,9 @@ export interface UserOrOrganization { id: UserOrOrganizationId name: string email: EmailAddress + /** If `false`, this account is awaiting acceptance from an admin, and endpoints other than + * `usersMe` will not work. */ + isEnabled: boolean } /** Possible states that a project can be in. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index f8627fc0a1d0..038c780402a3 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -39,10 +39,6 @@ function ChangePasswordModal() { onClick={event => { event.stopPropagation() }} - onSubmit={async event => { - event.preventDefault() - await onSubmit() - }} className="flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full max-w-md" >
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx index 979921883271..830dc9e21589 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx @@ -604,7 +604,6 @@ function Dashboard(props: DashboardProps) { hooks.useAsyncEffect( null, async signal => { - setIsLoadingAssets(true) const assets = await backend.listDirectory({ parentId: directoryId }) if (!signal.aborted) { setIsLoadingAssets(false) @@ -673,7 +672,7 @@ function Dashboard(props: DashboardProps) { return (
{ @@ -730,403 +729,463 @@ function Dashboard(props: DashboardProps) { query={query} setQuery={setQuery} /> - -
-

Drive

-
-
- {directory && ( - <> - - {svg.SMALL_RIGHT_ARROW_ICON} - - )} - {directory?.title ?? '/'} -
-
-
Shared with
-
+ {backend.platform === platformModule.Platform.cloud && !organization.isEnabled ? ( +
+
+ We will review your user details and enable the cloud experience for you + shortly.
-
- - -
- {EXPERIMENTAL.columnModeSwitcher && ( - <> -
- - +
+ ) : ( + <> + +
+

Drive

+
+ {backend.platform === platformModule.Platform.cloud && ( + <> +
+ {directory && ( + <> + + {svg.SMALL_RIGHT_ARROW_ICON} + + )} + {directory?.title ?? '/'} +
+
+
Shared with
+
+
+ + )} +
- - )} -
-
- - - - {columnsFor(columnDisplayMode, backend.platform).map(column => ( - - > - items={visibleProjectAssets} - getKey={proj => proj.id} - isLoading={isLoadingAssets} - placeholder={ - - You have no project yet. Go ahead and create one using the form - above. - - } - columns={columnsFor(columnDisplayMode, backend.platform).map(column => ({ - id: column, - heading: ColumnHeading(column, backendModule.AssetType.project), - render: renderer(column, backendModule.AssetType.project), - }))} - onClick={(projectAsset, event) => { - event.stopPropagation() - setSelectedAssets( - event.shiftKey ? [...selectedAssets, projectAsset] : [projectAsset] - ) - }} - onContextMenu={(projectAsset, event) => { - event.preventDefault() - event.stopPropagation() - const doOpenForEditing = () => { - // FIXME[sb]: Switch to IDE tab - // once merged with `show-and-open-workspace` branch. - } - const doOpenAsFolder = () => { - // FIXME[sb]: Uncomment once backend support - // is in place. - // The following code does not typecheck - // since `ProjectId`s are not `DirectoryId`s. - // enterDirectory(projectAsset) - } - // This is not a React component even though it contains JSX. - // eslint-disable-next-line no-restricted-syntax - const doRename = () => { - setModal(() => ( - { - await backend.projectUpdate(projectAsset.id, { - ami: null, - ideVersion: null, - projectName: name, - }) - }} - onSuccess={doRefresh} - /> - )) - } - // This is not a React component even though it contains JSX. - // eslint-disable-next-line no-restricted-syntax - const doDelete = () => { - setModal(() => ( - backend.deleteProject(projectAsset.id)} - onSuccess={doRefresh} - /> - )) - } - setModal(() => ( - - - Open for editing - - - Open as folder - - Rename - - Delete - - - )) - }} - /> - {backend.platform === platformModule.Platform.cloud && - (remoteBackend => ( - <> - - > - items={visibleDirectoryAssets} - getKey={dir => dir.id} - isLoading={isLoadingAssets} - placeholder={ - - This directory does not contain any subdirectories - {query ? ' matching your query' : ''}. - + {EXPERIMENTAL.columnModeSwitcher && ( + <> +
+ + + + +
+ + )} + + +
- ))} -
+ + + {columnsFor(columnDisplayMode, backend.platform).map(column => ( + + > + items={visibleProjectAssets} + getKey={proj => proj.id} + isLoading={isLoadingAssets} + placeholder={ + + You have no project yet. Go ahead and create one using the + form above. + + } + columns={columnsFor(columnDisplayMode, backend.platform).map( + column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.project + ), + render: renderer(column, backendModule.AssetType.project), + }) + )} + onClick={(projectAsset, event) => { + event.stopPropagation() + unsetModal() + setSelectedAssets( + event.shiftKey + ? [...selectedAssets, projectAsset] + : [projectAsset] + ) + }} + onContextMenu={(projectAsset, event) => { + event.preventDefault() + event.stopPropagation() + const doOpenForEditing = () => { + // FIXME[sb]: Switch to IDE tab + // once merged with `show-and-open-workspace` branch. } - columns={columnsFor(columnDisplayMode, backend.platform).map( - column => ({ - id: column, - heading: ColumnHeading( - column, - backendModule.AssetType.directory - ), - render: renderer( - column, - backendModule.AssetType.directory - ), - }) - )} - onClick={(directoryAsset, event) => { - event.stopPropagation() - setSelectedAssets( - event.shiftKey - ? [...selectedAssets, directoryAsset] - : [directoryAsset] - ) - }} - onContextMenu={(_directory, event) => { - event.preventDefault() - event.stopPropagation() - setModal(() => ) - }} - /> - - > - items={visibleSecretAssets} - getKey={secret => secret.id} - isLoading={isLoadingAssets} - placeholder={ - - This directory does not contain any secrets - {query ? ' matching your query' : ''}. - + const doOpenAsFolder = () => { + // FIXME[sb]: Uncomment once backend support + // is in place. + // The following code does not typecheck + // since `ProjectId`s are not `DirectoryId`s. + // enterDirectory(projectAsset) } - columns={columnsFor(columnDisplayMode, backend.platform).map( - column => ({ - id: column, - heading: ColumnHeading( - column, - backendModule.AssetType.secret - ), - render: renderer( - column, - backendModule.AssetType.secret - ), - }) - )} - onClick={(secret, event) => { - event.stopPropagation() - setSelectedAssets( - event.shiftKey ? [...selectedAssets, secret] : [secret] - ) - }} - onContextMenu={(secret, event) => { - event.preventDefault() - event.stopPropagation() - // This is not a React component even though it contains JSX. - // eslint-disable-next-line no-restricted-syntax - const doDelete = () => { - setModal(() => ( - - remoteBackend.deleteSecret(secret.id) - } - onSuccess={doRefresh} - /> - )) - } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + const doRename = () => { setModal(() => ( - - - Delete - - + { + await backend.projectUpdate(projectAsset.id, { + ami: null, + ideVersion: null, + projectName: name, + }) + }} + onSuccess={doRefresh} + /> )) - }} - /> - - > - items={visibleFileAssets} - getKey={file => file.id} - isLoading={isLoadingAssets} - placeholder={ - - This directory does not contain any files - {query ? ' matching your query' : ''}. - } - columns={columnsFor(columnDisplayMode, backend.platform).map( - column => ({ - id: column, - heading: ColumnHeading( - column, - backendModule.AssetType.file - ), - render: renderer(column, backendModule.AssetType.file), - }) - )} - onClick={(file, event) => { - event.stopPropagation() - setSelectedAssets( - event.shiftKey ? [...selectedAssets, file] : [file] - ) - }} - onContextMenu={(file, event) => { - event.preventDefault() - event.stopPropagation() - const doCopy = () => { - /** TODO: Wait for backend endpoint. */ - } - const doCut = () => { - /** TODO: Wait for backend endpoint. */ - } - // This is not a React component even though it contains JSX. - // eslint-disable-next-line no-restricted-syntax - const doDelete = () => { - setModal(() => ( - - remoteBackend.deleteFile(file.id) - } - onSuccess={doRefresh} - /> - )) - } - const doDownload = () => { - /** TODO: Wait for backend endpoint. */ - } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + const doDelete = () => { setModal(() => ( - - - Copy - - - Cut - - - Delete - - - Download - - + + backend.deleteProject(projectAsset.id) + } + onSuccess={doRefresh} + /> )) - }} - /> - - ))(backend)} - -
+ ))} +
- {isFileBeingDragged && backend.platform === platformModule.Platform.cloud ? ( -
{ - setIsFileBeingDragged(false) - }} - onDragOver={event => { - event.preventDefault() - }} - onDrop={async event => { - event.preventDefault() - setIsFileBeingDragged(false) - await uploadMultipleFiles.uploadMultipleFiles( - backend, - directoryId, - Array.from(event.dataTransfer.files) - ) - doRefresh() - }} - > - Drop to upload files. -
- ) : null} + } + setModal(() => ( + + + Open for editing + + {backend.platform !== + platformModule.Platform.desktop && ( + + Open as folder + + )} + + Rename + + + Delete + + + )) + }} + /> + {backend.platform === platformModule.Platform.cloud && + (remoteBackend => ( + <> + + + > + items={visibleDirectoryAssets} + getKey={dir => dir.id} + isLoading={isLoadingAssets} + placeholder={ + + This directory does not contain any + subdirectories + {query ? ' matching your query' : ''}. + + } + columns={columnsFor( + columnDisplayMode, + backend.platform + ).map(column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.directory + ), + render: renderer( + column, + backendModule.AssetType.directory + ), + }))} + onClick={(directoryAsset, event) => { + event.stopPropagation() + unsetModal() + setSelectedAssets( + event.shiftKey + ? [...selectedAssets, directoryAsset] + : [directoryAsset] + ) + }} + onContextMenu={(_directory, event) => { + event.preventDefault() + event.stopPropagation() + setModal(() => ( + + )) + }} + /> + + > + items={visibleSecretAssets} + getKey={secret => secret.id} + isLoading={isLoadingAssets} + placeholder={ + + This directory does not contain any secrets + {query ? ' matching your query' : ''}. + + } + columns={columnsFor( + columnDisplayMode, + backend.platform + ).map(column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.secret + ), + render: renderer( + column, + backendModule.AssetType.secret + ), + }))} + onClick={(secret, event) => { + event.stopPropagation() + unsetModal() + setSelectedAssets( + event.shiftKey + ? [...selectedAssets, secret] + : [secret] + ) + }} + onContextMenu={(secret, event) => { + event.preventDefault() + event.stopPropagation() + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + const doDelete = () => { + setModal(() => ( + + remoteBackend.deleteSecret( + secret.id + ) + } + onSuccess={doRefresh} + /> + )) + } + setModal(() => ( + + + + Delete + + + + )) + }} + /> + + > + items={visibleFileAssets} + getKey={file => file.id} + isLoading={isLoadingAssets} + placeholder={ + + This directory does not contain any files + {query ? ' matching your query' : ''}. + + } + columns={columnsFor( + columnDisplayMode, + backend.platform + ).map(column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.file + ), + render: renderer( + column, + backendModule.AssetType.file + ), + }))} + onClick={(file, event) => { + event.stopPropagation() + unsetModal() + setSelectedAssets( + event.shiftKey + ? [...selectedAssets, file] + : [file] + ) + }} + onContextMenu={(file, event) => { + event.preventDefault() + event.stopPropagation() + const doCopy = () => { + // TODO: Wait for backend endpoint. + } + const doCut = () => { + // TODO: Wait for backend endpoint. + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + const doDelete = () => { + setModal(() => ( + + remoteBackend.deleteFile(file.id) + } + onSuccess={doRefresh} + /> + )) + } + const doDownload = () => { + /** TODO: Wait for backend endpoint. */ + } + setModal(() => ( + + + Copy + + + Cut + + + + Delete + + + + Download + + + )) + }} + /> + + ))(backend)} + + + {isFileBeingDragged && backend.platform === platformModule.Platform.cloud ? ( +
{ + setIsFileBeingDragged(false) + }} + onDragOver={event => { + event.preventDefault() + }} + onDrop={async event => { + event.preventDefault() + setIsFileBeingDragged(false) + await uploadMultipleFiles.uploadMultipleFiles( + backend, + directoryId, + Array.from(event.dataTransfer.files) + ) + doRefresh() + }} + > + Drop to upload files. +
+ ) : null} + + )} {/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */} {project && } {modal && <>{modal}} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx index 1a2be84e5c5b..d76f48f2556d 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx @@ -45,8 +45,6 @@ function RenameModal(props: RenameModalProps) { } } - console.log('what', namePattern, title) - return (
- {/* FIXME[sb]: Figure out whether this conditional is *actually* needed. */} - {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} - {organization ? ( - <> - - Signed in as {organization.name} - - Your profile - {canChangePassword && ( - { - setModal(() => ) - }} - > - Change your password - - )} - Sign out - - ) : ( - Not logged in currently. + + Signed in as {organization.name} + + Your profile + {canChangePassword && ( + { + setModal(() => ) + }} + > + Change your password + )} + Sign out
) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts index 9cd48cde738c..1c7d4c6c34e1 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts @@ -134,7 +134,9 @@ export class LocalBackend implements Partial { type: projectId === LocalBackend.currentlyOpeningProjectId ? backend.ProjectState.openInProgress - : backend.ProjectState.closed, + : project.lastOpened != null + ? backend.ProjectState.closed + : backend.ProjectState.created, }, }) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts index 096163c3bde4..679b510660f0 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts @@ -13,8 +13,9 @@ import GLOBAL_CONFIG from '../../../../../../../gui/config.yaml' assert { type: /** Duration before the {@link ProjectManager} tries to create a WebSocket again. */ const RETRY_INTERVAL_MS = 1000 -/** Duration after which the {@link ProjectManager} stops re-trying to create a WebSocket. */ -const STOP_TRYING_AFTER_MS = 10000 +/** The number of times in a row the {@link ProjectManager} will try to connect, + * before it throws an error. */ +const MAXIMUM_CONSECUTIVE_FAILS = 10 // ============= // === Types === @@ -157,43 +158,52 @@ export class ProjectManager { /** Create a {@link ProjectManager} */ private constructor(protected readonly connectionUrl: string) { + let lastConnectionStartMs = 0 + let consecutiveFails = 0 + let justErrored = false const createSocket = () => { + lastConnectionStartMs = Number(new Date()) this.resolvers = new Map() const oldRejecters = this.rejecters this.rejecters = new Map() for (const reject of oldRejecters.values()) { reject() } - this.socketPromise = new Promise((resolve, reject) => { - const handle = setInterval(() => { - try { - const socket = new WebSocket(this.connectionUrl) - clearInterval(handle) - socket.onmessage = event => { - // There is no way to avoid this as `JSON.parse` returns `any`. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument - const message: JSONRPCResponse = JSON.parse(event.data) - if ('result' in message) { - this.resolvers.get(message.id)?.(message.result) - } else { - this.rejecters.get(message.id)?.(message.error) - } - } - socket.onopen = () => { - resolve(socket) - } - socket.onerror = createSocket - socket.onclose = createSocket - } catch { - // Ignored; the `setInterval` will retry again eventually. + return new Promise((resolve, reject) => { + const socket = new WebSocket(this.connectionUrl) + socket.onmessage = event => { + // There is no way to avoid this as `JSON.parse` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument + const message: JSONRPCResponse = JSON.parse(event.data) + if ('result' in message) { + this.resolvers.get(message.id)?.(message.result) + } else { + this.rejecters.get(message.id)?.(message.error) } - }, RETRY_INTERVAL_MS) - setTimeout(() => { - clearInterval(handle) - reject() - }, STOP_TRYING_AFTER_MS) + } + socket.onopen = () => { + consecutiveFails = 0 + resolve(socket) + } + socket.onerror = event => { + event.preventDefault() + justErrored = true + consecutiveFails += 1 + if (consecutiveFails > MAXIMUM_CONSECUTIVE_FAILS) { + reject() + } + const delay = RETRY_INTERVAL_MS - (Number(new Date()) - lastConnectionStartMs) + setTimeout(() => { + void createSocket().then(resolve) + }, Math.max(0, delay)) + } + socket.onclose = () => { + if (!justErrored) { + this.socketPromise = createSocket() + } + justErrored = false + } }) - return this.socketPromise } this.socketPromise = createSocket() } diff --git a/app/ide-desktop/lib/dashboard/src/index.tsx b/app/ide-desktop/lib/dashboard/src/index.tsx index 3ae3a09c8b7d..4ef65d6b3015 100644 --- a/app/ide-desktop/lib/dashboard/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/index.tsx @@ -21,7 +21,9 @@ const SERVICE_WORKER_PATH = '/serviceWorker.js' if (IS_DEV_MODE) { new EventSource(ESBUILD_PATH).addEventListener(ESBUILD_EVENT_NAME, () => { - location.reload() + // This acts like `location.reload`, but it preserves the query-string. + // The `toString()` is to bypass a lint without using a comment. + location.href = location.href.toString() }) void navigator.serviceWorker.register(SERVICE_WORKER_PATH) } diff --git a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts index fae380a646ec..315bd281f5db 100644 --- a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts +++ b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts @@ -20,7 +20,7 @@ declare const self: ServiceWorkerGlobalScope self.addEventListener('fetch', event => { const url = new URL(event.request.url) if (url.hostname === 'localhost' && url.pathname !== '/esbuild') { - const responsePromise = /\/[^.]+$/.test(event.request.url) + const responsePromise = /\/[^.]+$/.test(new URL(event.request.url).pathname) ? fetch('/index.html') : fetch(event.request.url) event.respondWith(