Skip to content

Commit

Permalink
Fix opening projects (#10433)
Browse files Browse the repository at this point in the history
#### Tl;dr
- Closes: enso-org/cloud-v2#1338
This PR fixes bugs with opened projects. Now all projects close/open properly and list of opened projects stored in the single place

---

#### Context:
Few sentences on the high level context for the change. Link to relevant design docs or discussion.

#### This Change:
What this change does in the larger context. Specific details to highlight for review:
1. Removes a bunch of useEffects across the Dashboard page
2. Project status now a react-query state, can be reused across the app
3. Eliminated the need of `waitIntilProjectIsOpened`

---
MrFlashAccount authored Jul 9, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 9229010 commit e4da96e
Showing 34 changed files with 1,228 additions and 1,027 deletions.
8 changes: 2 additions & 6 deletions app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts
Original file line number Diff line number Diff line change
@@ -36,13 +36,9 @@ test.test('create project', ({ page }) =>
async ({ pageActions }) =>
await pageActions
.newEmptyProject()
.do(async thePage => {
await test.expect(actions.locateEditor(thePage)).toBeVisible()
})
.do(thePage => test.expect(actions.locateEditor(thePage)).toBeAttached())
.goToPage.drive()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
})
.driveTable.withRows(rows => test.expect(rows).toHaveCount(1))
)
)

26 changes: 14 additions & 12 deletions app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ test.test('drive view', ({ page }) =>
.driveTable.expectPlaceholderRow()
.newEmptyProject()
.do(async () => {
await test.expect(actions.locateEditor(page)).toBeVisible()
await test.expect(actions.locateEditor(page)).toBeAttached()
})
.goToPage.drive()
.driveTable.withRows(async rows => {
@@ -24,7 +24,7 @@ test.test('drive view', ({ page }) =>
})
.newEmptyProject()
.do(async () => {
await test.expect(actions.locateEditor(page)).toBeVisible()
await test.expect(actions.locateEditor(page)).toBeAttached()
})
.goToPage.drive()
.driveTable.withRows(async rows => {
@@ -36,15 +36,17 @@ test.test('drive view', ({ page }) =>
.driveTable.withRows(async rows => {
await actions.locateStopProjectButton(rows.nth(0)).click()
})
// Project context menu
.driveTable.rightClickRow(0)
.withContextMenus(async menus => {
// actions.locateContextMenus(page)
await test.expect(menus).toBeVisible()
})
.contextMenu.moveToTrash()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
})
// FIXME(#10488): This test fails because the mock endpoint returns the project is opened,
// but it must be stopped first to delete the project.
// Project context menu
// .driveTable.rightClickRow(0)
// .withContextMenus(async menus => {
// // actions.locateContextMenus(page)
// await test.expect(menus).toBeVisible()
// })
// .contextMenu.moveToTrash()
// .driveTable.withRows(async rows => {
// await test.expect(rows).toHaveCount(1)
// })
)
)
2 changes: 1 addition & 1 deletion app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ test.test('create project from template', ({ page }) =>
.openStartModal()
.createProjectFromTemplate(0)
.do(async thePage => {
await test.expect(actions.locateEditor(thePage)).toBeVisible()
await test.expect(actions.locateEditor(thePage)).toBeAttached()
await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
})
)
Original file line number Diff line number Diff line change
@@ -41,7 +41,7 @@ interface PropsWithoutHref {
export interface BaseButtonProps<Render>
extends Omit<twv.VariantProps<typeof BUTTON_STYLES>, 'iconOnly'> {
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactElement | string | false
readonly tooltip?: React.ReactElement | string | false | null
readonly tooltipPlacement?: aria.Placement
/**
* The icon to display in the button
@@ -220,6 +220,12 @@ export const BUTTON_STYLES = twv.tv({
false: {
extraClickZone: '',
},
xxsmall: {
extraClickZone: 'after:inset-[-2px]',
},
xsmall: {
extraClickZone: 'after:inset-[-4px]',
},
small: {
extraClickZone: 'after:inset-[-6px]',
},
Original file line number Diff line number Diff line change
@@ -191,6 +191,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
autoFocus={autoFocus}
size={1}
value={text ?? ''}
autoComplete="off"
placeholder={placeholder == null ? placeholder : placeholder}
className="text grow rounded-full bg-transparent px-button-x"
onFocus={() => {
12 changes: 9 additions & 3 deletions app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@ import * as errorBoundary from 'react-error-boundary'

import * as detect from 'enso-common/src/detect'

import * as offlineHooks from '#/hooks/offlineHooks'

import * as textProvider from '#/providers/TextProvider'

import * as ariaComponents from '#/components/AriaComponents'
@@ -64,22 +66,26 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {

const { getText } = textProvider.useText()

const { isOffline } = offlineHooks.useOffline()

const stack = errorUtils.tryGetStack(error)

return (
<result.Result
className="h-full"
status="error"
status={isOffline ? 'info' : 'error'}
title={getText('arbitraryErrorTitle')}
subtitle={getText('arbitraryErrorSubtitle')}
subtitle={isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle')}
>
<ariaComponents.ButtonGroup align="center">
<ariaComponents.Button
variant="submit"
size="small"
rounded="full"
className="w-24"
onPress={resetErrorBoundary}
onPress={() => {
resetErrorBoundary()
}}
>
{getText('tryAgain')}
</ariaComponents.Button>
16 changes: 10 additions & 6 deletions app/ide-desktop/lib/dashboard/src/components/StatelessSpinner.tsx
Original file line number Diff line number Diff line change
@@ -17,18 +17,22 @@ export interface StatelessSpinnerProps extends spinner.SpinnerProps {}
/** A spinner that does not expose its {@link spinner.SpinnerState}. Instead, it begins at
* {@link spinner.SpinnerState.initial} and immediately changes to the given state. */
export default function StatelessSpinner(props: StatelessSpinnerProps) {
const { size, state: rawState } = props
const { size, state: rawState, ...spinnerProps } = props
const [, startTransition] = React.useTransition()
const [state, setState] = React.useState(spinner.SpinnerState.initial)

React.useEffect(() => {
const timeout = window.setTimeout(() => {
setState(rawState)
React.useLayoutEffect(() => {
const id = requestAnimationFrame(() => {
// consider this as a low-priority update
startTransition(() => {
setState(rawState)
})
})

return () => {
window.clearTimeout(timeout)
cancelAnimationFrame(id)
}
}, [rawState])

return <Spinner state={state} {...(size != null ? { size } : {})} />
return <Spinner state={state} {...(size != null ? { size } : {})} {...spinnerProps} />
}
4 changes: 2 additions & 2 deletions app/ide-desktop/lib/dashboard/src/components/Suspense.tsx
Original file line number Diff line number Diff line change
@@ -39,7 +39,7 @@ const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250
export function Suspense(props: SuspenseProps) {
const { children } = props

return <React.Suspense fallback={<FallbackElement {...props} />}>{children}</React.Suspense>
return <React.Suspense fallback={<Loader {...props} />}>{children}</React.Suspense>
}

/**
@@ -53,7 +53,7 @@ export function Suspense(props: SuspenseProps) {
* We check the fetching status in fallback component because
* we want to know if there are ongoing requests once React renders the fallback in suspense
*/
function FallbackElement(props: SuspenseProps) {
export function Loader(props: SuspenseProps) {
const { loaderProps, fallback, offlineFallbackProps, offlineFallback } = props

const { getText } = textProvider.useText()
Original file line number Diff line number Diff line change
@@ -16,6 +16,8 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'

import type * as dashboard from '#/pages/dashboard/Dashboard'

import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
import Category from '#/layouts/CategorySwitcher/Category'
@@ -74,6 +76,7 @@ export interface AssetRowInnerProps {
/** Props for an {@link AssetRow}. */
export interface AssetRowProps
extends Readonly<Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'>> {
readonly isOpened: boolean
readonly item: assetTreeNode.AnyAssetTreeNode
readonly state: assetsTable.AssetsTableState
readonly hidden: boolean
@@ -89,13 +92,24 @@ export interface AssetRowProps
props: AssetRowInnerProps,
event: React.MouseEvent<HTMLTableRowElement>
) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
}

/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
const {
item: rawItem,
hidden: hiddenRaw,
selected,
isSoleSelected,
isKeyboardSelected,
isOpened,
updateAssetRef,
} = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { grabKeyboardFocus } = props
const { grabKeyboardFocus, doOpenProject, doCloseProject } = props
const { backend, visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId } = state
@@ -167,6 +181,10 @@ export default function AssetRow(props: AssetRowProps) {
}
}, [isKeyboardSelected])

React.useImperativeHandle(updateAssetRef, () => newItem => {
setAsset(newItem)
})

const doCopyOnBackend = React.useCallback(
async (newParentId: backendModule.DirectoryId | null) => {
try {
@@ -879,6 +897,8 @@ export default function AssetRow(props: AssetRowProps) {
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
<Render
keyProp={key}
isOpened={isOpened}
backendType={backend.type}
item={item}
setItem={setItem}
selected={selected}
@@ -888,6 +908,8 @@ export default function AssetRow(props: AssetRowProps) {
rowState={rowState}
setRowState={setRowState}
isEditable={state.category !== Category.trash}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
/>
</td>
)
Original file line number Diff line number Diff line change
@@ -38,7 +38,8 @@ const ASSET_TYPE_TO_TEXT_ID: Readonly<Record<backendModule.AssetType, text.TextI
/** Props for a {@link Permission}. */
export interface PermissionProps {
readonly backend: Backend
readonly asset: backendModule.Asset
readonly asset: Pick<backendModule.Asset, 'id' | 'permissions' | 'type'>

readonly self: backendModule.UserPermission
readonly isOnlyOwner: boolean
readonly permission: backendModule.AssetPermission
338 changes: 86 additions & 252 deletions app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -44,13 +44,26 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.ProjectAsset}.
* This should never happen. */
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const { item, setItem, selected, rowState, setRowState, state, isEditable } = props
const { backend, selectedKeys, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { nodeMap, setProjectStartupInfo, doOpenEditor, doCloseEditor } = state
const {
item,
setItem,
selected,
rowState,
setRowState,
state,
isEditable,
doCloseProject,
doOpenProject,
backendType,
isOpened,
} = props
const { backend, selectedKeys, assetEvents, dispatchAssetListEvent } = state
const { nodeMap, doOpenEditor } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()

if (item.type !== backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`ProjectNameColumn` can only display projects.')
@@ -175,10 +188,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}),
})
)
dispatchAssetEvent({
type: AssetEventType.openProject,
doOpenProject({
id: createdProject.projectId,
runInBackground: false,
type: backendType,
parentId: asset.parentId,
title: asset.title,
})
} catch (error) {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
@@ -298,10 +312,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
) {
setIsEditing(true)
} else if (eventModule.isDoubleClick(event)) {
dispatchAssetEvent({
type: AssetEventType.openProject,
doOpenProject({
id: asset.id,
runInBackground: false,
type: backendType,
parentId: asset.parentId,
title: asset.title,
})
}
}}
@@ -310,16 +325,18 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
<SvgMask src={NetworkIcon} className="m-name-column-icon size-4" />
) : (
<ProjectIcon
isOpened={isOpened}
backend={backend}
// This is a workaround for a temporary bad state in the backend causing the
// `projectState` key to be absent.
item={object.merge(asset, { projectState })}
setItem={setAsset}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
setProjectStartupInfo={setProjectStartupInfo}
doOpenEditor={doOpenEditor}
doCloseEditor={doCloseEditor}
doCloseProject={id => {
doCloseProject({ id, parentId: asset.parentId, title: asset.title, type: backendType })
}}
doOpenProject={id => {
doOpenProject({ id, type: backendType, parentId: asset.parentId, title: asset.title })
}}
openProjectTab={doOpenEditor}
/>
)}
<EditableSpan
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** @file Column types and column display modes. */
import type * as React from 'react'

import type * as dashboard from '#/pages/dashboard/Dashboard'

import type * as assetsTable from '#/layouts/AssetsTable'

import * as columnUtils from '#/components/dashboard/column/columnUtils'
@@ -22,7 +24,9 @@ import type * as assetTreeNode from '#/utilities/AssetTreeNode'
/** Props for an arbitrary variant of {@link backendModule.Asset}. */
export interface AssetColumnProps {
readonly keyProp: backendModule.AssetId
readonly isOpened: boolean
readonly item: assetTreeNode.AnyAssetTreeNode
readonly backendType: backendModule.BackendType
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly selected: boolean
readonly setSelected: (selected: boolean) => void
@@ -31,6 +35,8 @@ export interface AssetColumnProps {
readonly rowState: assetsTable.AssetRowState
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
readonly isEditable: boolean
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
}

/** Props for a {@link AssetColumn}. */
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ export interface AssetNameColumnProps extends column.AssetColumnProps {}
/** The icon and name of an {@link backendModule.Asset}. */
export default function AssetNameColumn(props: AssetNameColumnProps) {
const { item } = props

switch (item.item.type) {
case backendModule.AssetType.directory: {
return <DirectoryNameColumn {...props} />
Original file line number Diff line number Diff line change
@@ -30,7 +30,7 @@ import * as uniqueString from '#/utilities/uniqueString'

/** The type of the `state` prop of a {@link SharedWithColumn}. */
interface SharedWithColumnStateProp
extends Pick<column.AssetColumnProps['state'], 'backend' | 'category' | 'dispatchAssetEvent'> {
extends Pick<column.AssetColumnProps['state'], 'category' | 'dispatchAssetEvent'> {
readonly setQuery: column.AssetColumnProps['state']['setQuery'] | null
}

@@ -43,7 +43,7 @@ interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'i
/** A column listing the users with which this asset is shared. */
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, setItem, state, isReadonly = false } = props
const { backend, category, dispatchAssetEvent, setQuery } = state
const { category, dispatchAssetEvent, setQuery } = state
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()

@@ -117,7 +117,6 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
setModal(
<ManagePermissionsModal
key={uniqueString.uniqueString()}
backend={backend}
item={asset}
setItem={setAsset}
self={self}
6 changes: 6 additions & 0 deletions app/ide-desktop/lib/dashboard/src/events/assetEvent.ts
Original file line number Diff line number Diff line change
@@ -96,12 +96,18 @@ export interface AssetNewSecretEvent extends AssetBaseEvent<AssetEventType.newSe
/** A signal to open the specified project. */
export interface AssetOpenProjectEvent extends AssetBaseEvent<AssetEventType.openProject> {
readonly id: backend.ProjectId
readonly backendType: backend.BackendType
readonly title: string
readonly parentId: backend.DirectoryId
readonly runInBackground: boolean
}

/** A signal to close the specified project. */
export interface AssetCloseProjectEvent extends AssetBaseEvent<AssetEventType.closeProject> {
readonly id: backend.ProjectId
readonly backendType: backend.BackendType
readonly title: string
readonly parentId: backend.DirectoryId
}

/** A signal that multiple assets should be copied. `ids` are the `Id`s of the newly created
11 changes: 6 additions & 5 deletions app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts
Original file line number Diff line number Diff line change
@@ -24,18 +24,19 @@ export function useGtagEvent() {
*
* Also sends the close event when the window is unloaded. */
export function gtagOpenCloseCallback(
gtagEventRef: React.MutableRefObject<ReturnType<typeof useGtagEvent>>,
gtagEvent: ReturnType<typeof useGtagEvent>,
openEvent: string,
closeEvent: string
) {
const gtagEventCurrent = gtagEventRef.current
gtagEventCurrent(openEvent)
gtagEvent(openEvent)

const onBeforeUnload = () => {
gtagEventCurrent(closeEvent)
gtagEvent(closeEvent)
}
window.addEventListener('beforeunload', onBeforeUnload)

return () => {
window.removeEventListener('beforeunload', onBeforeUnload)
gtagEventCurrent(closeEvent)
gtagEvent(closeEvent)
}
}
30 changes: 27 additions & 3 deletions app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @file The context menu for an arbitrary {@link backendModule.Asset}. */
import * as React from 'react'

import * as reactQuery from '@tanstack/react-query'
import * as toast from 'react-toastify'

import * as billingHooks from '#/hooks/billing'
@@ -16,6 +17,8 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'

import * as dashboard from '#/pages/dashboard/Dashboard'

import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import GlobalContextMenu from '#/layouts/GlobalContextMenu'

@@ -91,19 +94,32 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const systemApi = window.systemApi
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin

const canEditThisAsset =
managesThisAsset || self?.permission === permissions.PermissionAction.edit

const { data } = reactQuery.useQuery(
item.item.type === backendModule.AssetType.project
? dashboard.createGetProjectDetailsQuery.createPassiveListener(item.item.id)
: { queryKey: ['__IGNORED__'] }
)

const isRunningProject =
asset.type === backendModule.AssetType.project &&
backendModule.IS_OPENING_OR_OPENED[asset.projectState.type]
(asset.type === backendModule.AssetType.project &&
data &&
backendModule.IS_OPENING_OR_OPENED[data.state.type]) ??
false

const canExecute =
!isCloud ||
(self?.permission != null && permissions.PERMISSION_ACTION_CAN_EXECUTE[self.permission])

const isOtherUserUsingProject =
isCloud &&
backendModule.assetIsProject(asset) &&
asset.projectState.openedBy != null &&
asset.projectState.openedBy !== user.email

const setAsset = setAssetHooks.useSetAsset(asset, setItem)

return category === Category.trash ? (
@@ -170,6 +186,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
dispatchAssetEvent({
type: AssetEventType.openProject,
id: asset.id,
title: asset.title,
parentId: item.directoryId,
backendType: state.backend.type,
runInBackground: false,
})
}}
@@ -184,6 +203,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
dispatchAssetEvent({
type: AssetEventType.openProject,
id: asset.id,
title: asset.title,
parentId: item.directoryId,
backendType: state.backend.type,
runInBackground: true,
})
}}
@@ -211,6 +233,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
dispatchAssetEvent({
type: AssetEventType.closeProject,
id: asset.id,
title: asset.title,
parentId: item.directoryId,
backendType: state.backend.type,
})
}}
/>
@@ -343,7 +368,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
doAction={() => {
setModal(
<ManagePermissionsModal
backend={backend}
item={asset}
setItem={setAsset}
self={self}
12 changes: 6 additions & 6 deletions app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx
Original file line number Diff line number Diff line change
@@ -200,7 +200,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</form>
)}
</div>
</div>
</div>{' '}
{!isCloud && (
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<aria.Heading
@@ -237,23 +237,23 @@ export default function AssetProperties(props: AssetPropertiesProps) {
<table>
<tbody>
<tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0">
<td className="text my-auto min-w-side-panel-label p">
<aria.Label className="text inline-block">{getText('sharedWith')}</aria.Label>
</td>
<td className="w-full p-0">
<td className="w-full p">
<SharedWithColumn
isReadonly={isReadonly}
item={item}
setItem={setItem}
state={{ backend, category, dispatchAssetEvent, setQuery: () => {} }}
state={{ category, dispatchAssetEvent, setQuery: () => {} }}
/>
</td>
</tr>
<tr data-testid="asset-panel-labels" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0">
<td className="text my-auto min-w-side-panel-label p">
<aria.Label className="text inline-block">{getText('labels')}</aria.Label>
</td>
<td className="w-full p-0">
<td className="w-full p">
{item.item.labels?.map(value => {
const label = labels.find(otherLabel => otherLabel.value === value)
return label == null ? null : (
118 changes: 90 additions & 28 deletions app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx
Original file line number Diff line number Diff line change
@@ -26,6 +26,8 @@ import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'

import type * as dashboard from '#/pages/dashboard/Dashboard'

import type * as assetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
@@ -313,7 +315,6 @@ export interface AssetsTableState {
readonly setSortInfo: (sortInfo: sorting.SortInfo<columnUtils.SortableColumn> | null) => void
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
@@ -329,8 +330,7 @@ export interface AssetsTableState {
title?: string | null,
override?: boolean
) => void
readonly doOpenEditor: () => void
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
readonly doOpenEditor: (id: backendModule.ProjectId) => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (
@@ -349,13 +349,13 @@ export interface AssetRowState {

/** Props for a {@link AssetsTable}. */
export interface AssetsTableProps {
readonly openedProjects: dashboard.Project[]
readonly hidden: boolean
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly setSuggestions: React.Dispatch<
React.SetStateAction<readonly assetSearchBar.Suggestion[]>
>
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
readonly setCanDownload: (canDownload: boolean) => void
readonly category: Category
readonly initialProjectName: string | null
@@ -366,16 +366,37 @@ export interface AssetsTableProps {
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
readonly doOpenEditor: () => void
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (
project: dashboard.Project,
options?: dashboard.OpenProjectOptions
) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly assetManagementApiRef: React.Ref<AssetManagementApi>
}

/**
* The API for managing assets in the table.
*/
export interface AssetManagementApi {
readonly getAsset: (id: backendModule.AssetId) => backendModule.AnyAsset | null
readonly setAsset: (id: backendModule.AssetId, asset: backendModule.AnyAsset) => void
}

/** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) {
const { hidden, query, setQuery, setProjectStartupInfo, setCanDownload, category } = props
const {
hidden,
query,
setQuery,
setCanDownload,
category,
openedProjects,
assetManagementApiRef,
} = props
const { setSuggestions, initialProjectName } = props
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
const { doOpenEditor, doCloseEditor } = props
const { doOpenEditor, doOpenProject, doCloseProject } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props

const { user } = authProvider.useNonPartialUserSession()
@@ -398,6 +419,9 @@ export default function AssetsTable(props: AssetsTableProps) {
() => new Set()
)
const selectedKeysRef = React.useRef(selectedKeys)
const updateAssetRef = React.useRef<
Record<backendModule.AnyAsset['id'], (asset: backendModule.AnyAsset) => void>
>({})
const [pasteData, setPasteData] = React.useState<pasteDataModule.PasteData<
ReadonlySet<backendModule.AssetId>
> | null>(null)
@@ -882,12 +906,11 @@ export default function AssetsTable(props: AssetsTableProps) {
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
window.setTimeout(() => {
dispatchAssetEvent({
type: AssetEventType.openProject,
id: projectToLoad.id,
runInBackground: false,
})
doOpenProject({
type: backendModule.BackendType.local,
id: projectToLoad.id,
title: projectToLoad.title,
parentId: projectToLoad.parentId,
})
} else if (initialProjectName != null) {
toastAndLog('findProjectError', null, initialProjectName)
@@ -969,13 +992,15 @@ export default function AssetsTable(props: AssetsTableProps) {
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
window.setTimeout(() => {
dispatchAssetEvent({
type: AssetEventType.openProject,
doOpenProject(
{
type: backendModule.BackendType.local,
id: projectToLoad.id,
runInBackground: false,
})
})
title: projectToLoad.title,
parentId: projectToLoad.parentId,
},
{ openInBackground: false }
)
} else {
toastAndLog('findProjectError', null, oldNameOfProjectToImmediatelyOpen)
}
@@ -993,7 +1018,7 @@ export default function AssetsTable(props: AssetsTableProps) {
return null
})
},
[rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog]
[doOpenProject, rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog]
)
const overwriteNodesRef = React.useRef(overwriteNodes)
overwriteNodesRef.current = overwriteNodes
@@ -1220,11 +1245,14 @@ export default function AssetsTable(props: AssetsTableProps) {
case backendModule.AssetType.project: {
event.preventDefault()
event.stopPropagation()
dispatchAssetEvent({
type: AssetEventType.openProject,

doOpenProject({
type: backend.type,
id: item.item.id,
runInBackground: false,
title: item.item.title,
parentId: item.item.parentId,
})

break
}
case backendModule.AssetType.datalink: {
@@ -1918,7 +1946,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setSortInfo,
query,
setQuery,
setProjectStartupInfo,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
@@ -1928,7 +1955,6 @@ export default function AssetsTable(props: AssetsTableProps) {
hideColumn,
doToggleDirectoryExpansion,
doOpenEditor,
doCloseEditor,
doCopy,
doCut,
doPaste,
@@ -1944,15 +1970,13 @@ export default function AssetsTable(props: AssetsTableProps) {
query,
doToggleDirectoryExpansion,
doOpenEditor,
doCloseEditor,
doCopy,
doCut,
doPaste,
hideColumn,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
setQuery,
setProjectStartupInfo,
dispatchAssetEvent,
dispatchAssetListEvent,
]
@@ -2180,6 +2204,26 @@ export default function AssetsTable(props: AssetsTableProps) {
[visibleItems, calculateNewKeys, setSelectedKeys, setMostRecentlySelectedIndex]
)

const getAsset = React.useCallback(
(key: backendModule.AssetId) => nodeMapRef.current.get(key)?.item ?? null,
[nodeMapRef]
)

const setAsset = React.useCallback(
(key: backendModule.AssetId, asset: backendModule.AnyAsset) => {
setAssetTree(oldAssetTree =>
oldAssetTree.map(item => (item.key === key ? item.with({ item: asset }) : item))
)
updateAssetRef.current[asset.id]?.(asset)
},
[]
)

React.useImperativeHandle(assetManagementApiRef, () => ({
getAsset,
setAsset,
}))

const columns = columnUtils.getColumnList(backend.type, enabledColumns)

const headerRow = (
@@ -2210,13 +2254,27 @@ export default function AssetsTable(props: AssetsTableProps) {
const key = AssetTreeNode.getKey(item)
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
const isSoleSelected = selectedKeys.size === 1 && isSelected

return (
<AssetRow
key={key}
updateAssetRef={instance => {
if (instance != null) {
updateAssetRef.current[item.item.id] = instance
} else {
// Hacky way to clear the reference to the asset on unmount.
// eventually once we pull the assets up in the tree, we can remove this.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete updateAssetRef.current[item.item.id]
}
}}
isOpened={openedProjects.some(({ id }) => item.item.id === id)}
columns={columns}
item={item}
state={state}
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
selected={isSelected}
setSelected={selected => {
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
@@ -2272,8 +2330,10 @@ export default function AssetsTable(props: AssetsTableProps) {
{nodes.map(node => (
<NameColumn
key={node.key}
isOpened={false}
keyProp={node.key}
item={node.with({ depth: 0 })}
backendType={backend.type}
state={state}
// Default states.
isSoleSelected={false}
@@ -2284,6 +2344,8 @@ export default function AssetsTable(props: AssetsTableProps) {
setItem={() => {}}
setRowState={() => {}}
isEditable={false}
doCloseProject={doCloseProject}
doOpenProject={doOpenProject}
/>
))}
</DragModal>
6 changes: 2 additions & 4 deletions app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx
Original file line number Diff line number Diff line change
@@ -412,16 +412,14 @@ export default function Chat(props: ChatProps) {
},
})
const gtagEvent = gtagHooks.useGtagEvent()
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent

React.useEffect(() => {
if (!isOpen) {
return
} else {
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'cloud_open_chat', 'cloud_close_chat')
return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'cloud_open_chat', 'cloud_close_chat')
}
}, [isOpen])
}, [isOpen, gtagEvent])

/** This is SAFE, because this component is only rendered when `accessToken` is present.
* See `dashboard.tsx` for its sole usage. */
35 changes: 27 additions & 8 deletions app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx
Original file line number Diff line number Diff line change
@@ -15,9 +15,12 @@ import type * as assetEvent from '#/events/assetEvent'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'

import type * as dashboard from '#/pages/dashboard/Dashboard'

import type * as assetPanel from '#/layouts/AssetPanel'
import AssetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import type * as assetsTable from '#/layouts/AssetsTable'
import AssetsTable from '#/layouts/AssetsTable'
import CategorySwitcher from '#/layouts/CategorySwitcher'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
@@ -60,6 +63,7 @@ enum DriveStatus {

/** Props for a {@link Drive}. */
export interface DriveProps {
readonly openedProjects: dashboard.Project[]
readonly category: Category
readonly setCategory: (category: Category) => void
readonly hidden: boolean
@@ -68,16 +72,29 @@ export interface DriveProps {
readonly dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
readonly doOpenEditor: () => void
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly assetsManagementApiRef: React.Ref<assetsTable.AssetManagementApi>
}

/** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function Drive(props: DriveProps) {
const { hidden, initialProjectName } = props
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
const { setProjectStartupInfo, doOpenEditor, doCloseEditor, category, setCategory } = props
const {
openedProjects,
doOpenEditor,
doCloseProject,
category,
setCategory,
hidden,
initialProjectName,
doOpenProject,
assetListEvents,
dispatchAssetListEvent,
assetEvents,
dispatchAssetEvent,
assetsManagementApiRef,
} = props

const { isOffline } = offlineHooks.useOffline()
const { localStorage } = localStorageProvider.useLocalStorage()
@@ -321,11 +338,12 @@ export default function Drive(props: DriveProps) {
</result.Result>
) : (
<AssetsTable
assetManagementApiRef={assetsManagementApiRef}
openedProjects={openedProjects}
hidden={hidden}
query={query}
setQuery={setQuery}
setCanDownload={setCanDownload}
setProjectStartupInfo={setProjectStartupInfo}
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
@@ -337,7 +355,8 @@ export default function Drive(props: DriveProps) {
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef}
doOpenEditor={doOpenEditor}
doCloseEditor={doCloseEditor}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
/>
)}
</div>
174 changes: 76 additions & 98 deletions app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx
Original file line number Diff line number Diff line change
@@ -6,18 +6,18 @@ import * as reactQuery from '@tanstack/react-query'
import * as appUtils from '#/appUtils'

import * as gtagHooks from '#/hooks/gtagHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'

import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'

import * as dashboard from '#/pages/dashboard/Dashboard'

import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as suspense from '#/components/Suspense'

import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'

import * as object from '#/utilities/object'
import * as twMerge from '#/utilities/tailwindMerge'

import type * as types from '../../../types/types'

@@ -33,27 +33,69 @@ const IGNORE_PARAMS_REGEX = new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`)

/** Props for an {@link Editor}. */
export interface EditorProps {
readonly isOpening: boolean
readonly startProject: (project: dashboard.Project) => void
readonly project: dashboard.Project
readonly hidden: boolean
readonly ydocUrl: string | null
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
readonly appRunner: types.EditorRunner | null
readonly renameProject: (newName: string) => void
readonly projectId: backendModule.ProjectAsset['id']
}

/** The container that launches the IDE. */
export default function Editor(props: EditorProps) {
const { hidden, projectStartupInfo } = props
const { project, hidden, isOpening, startProject } = props

const editor = projectStartupInfo && (
<EditorInternal {...props} projectStartupInfo={projectStartupInfo} />
)
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()

const projectStatusQuery = dashboard.createGetProjectDetailsQuery({
type: project.type,
assetId: project.id,
parentId: project.parentId,
title: project.title,
remoteBackend,
localBackend,
})

const projectQuery = reactQuery.useQuery({
...projectStatusQuery,
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
})

if (!isOpening && projectQuery.data?.state.type === backendModule.ProjectState.closed) {
startProject(project)
}

return (
<React.Suspense fallback={hidden ? undefined : <loader.Loader minHeight="full" />}>
{/* eslint-disable-next-line @typescript-eslint/naming-convention */}
<errorBoundary.ErrorBoundary {...(hidden ? { FallbackComponent: () => null } : {})}>
{editor}
</errorBoundary.ErrorBoundary>
</React.Suspense>
<div
className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testid="gui-editor-root"
data-testvalue={project.id}
>
{(() => {
if (projectQuery.isError) {
return (
<errorBoundary.ErrorDisplay
error={projectQuery.error}
resetErrorBoundary={projectQuery.refetch}
/>
)
} else if (
projectQuery.isLoading ||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
) {
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
} else {
return (
<suspense.Suspense>
<EditorInternal {...props} openedProject={projectQuery.data} />{' '}
</suspense.Suspense>
)
}
})()}
</div>
)
}

@@ -62,30 +104,18 @@ export default function Editor(props: EditorProps) {
// ======================

/** Props for an {@link EditorInternal}. */
interface EditorInternalProps extends EditorProps {
readonly projectStartupInfo: backendModule.ProjectStartupInfo
interface EditorInternalProps extends Omit<EditorProps, 'project'> {
readonly openedProject: backendModule.Project
}

/** An internal editor. */
function EditorInternal(props: EditorInternalProps) {
const { hidden, ydocUrl, projectStartupInfo, appRunner: AppRunner } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { hidden, ydocUrl, appRunner: AppRunner, renameProject, openedProject } = props

const { getText } = textProvider.useText()
const gtagEvent = gtagHooks.useGtagEvent()
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()

const projectQuery = reactQuery.useSuspenseQuery({
queryKey: ['editorProject', projectStartupInfo.projectAsset.id],
// Wrap in an unresolved promise, otherwise React Suspense breaks.
queryFn: () => Promise.resolve(projectStartupInfo.project),
staleTime: 0,
gcTime: 0,
meta: { persist: false },
})
const project = projectQuery.data
const remoteBackend = backendProvider.useRemoteBackend()

const logEvent = React.useCallback(
(message: string, projectId?: string | null, metadata?: object | null) => {
@@ -96,92 +126,40 @@ function EditorInternal(props: EditorInternalProps) {
[remoteBackend]
)

const renameProject = React.useCallback(
(newName: string) => {
let backend: Backend | null
switch (projectStartupInfo.backendType) {
case backendModule.BackendType.local:
backend = localBackend
break
case backendModule.BackendType.remote:
backend = remoteBackend
break
}
const { id: projectId, parentId, title } = projectStartupInfo.projectAsset
backend
?.updateProject(
projectId,
{ projectName: newName, ami: null, ideVersion: null, parentId },
title
)
.then(
() => {
projectStartupInfo.setProjectAsset?.(object.merger({ title: newName }))
},
e => toastAndLog('renameProjectError', e)
)
},
[remoteBackend, localBackend, projectStartupInfo, toastAndLog]
)

React.useEffect(() => {
if (hidden) {
return
} else {
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_workflow', 'close_workflow')
return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'open_workflow', 'close_workflow')
}
}, [projectStartupInfo, hidden])
}, [hidden, gtagEvent])

const appProps: types.EditorProps | null = React.useMemo(() => {
const projectId = project.projectId
const jsonAddress = project.jsonAddress
const binaryAddress = project.binaryAddress
const jsonAddress = openedProject.jsonAddress
const binaryAddress = openedProject.binaryAddress
const ydocAddress = ydocUrl ?? ''

if (jsonAddress == null) {
throw new Error(getText('noJSONEndpointError'))
} else if (binaryAddress == null) {
throw new Error(getText('noBinaryEndpointError'))
} else {
return {
config: {
engine: {
rpcUrl: jsonAddress,
dataUrl: binaryAddress,
ydocUrl: ydocAddress,
},
startup: {
project: project.packageName,
displayedProjectName: project.name,
},
window: {
topBarOffset: '0',
},
engine: { rpcUrl: jsonAddress, dataUrl: binaryAddress, ydocUrl: ydocAddress },
startup: { project: openedProject.packageName, displayedProjectName: openedProject.name },
window: { topBarOffset: '0' },
},
projectId,
projectId: openedProject.projectId,
hidden,
ignoreParamsRegex: IGNORE_PARAMS_REGEX,
logEvent,
renameProject,
}
}
}, [
project.projectId,
project.jsonAddress,
project.binaryAddress,
project.packageName,
project.name,
ydocUrl,
getText,
hidden,
logEvent,
renameProject,
])

if (AppRunner == null) {
return null
} else {
// Currently the GUI component needs to be fully rerendered whenever the project is changed. Once
// this is no longer necessary, the `key` could be removed.
return <AppRunner key={appProps.projectId} {...appProps} />
}
}, [openedProject, ydocUrl, getText, hidden, logEvent, renameProject])

// Currently the GUI component needs to be fully rerendered whenever the project is changed. Once
// this is no longer necessary, the `key` could be removed.
return AppRunner == null ? null : <AppRunner key={appProps.projectId} {...appProps} />
}
41 changes: 23 additions & 18 deletions app/ide-desktop/lib/dashboard/src/layouts/TabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
/** @file Switcher to choose the currently visible full-screen page. */
import * as React from 'react'

import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'

import type * as text from '#/text'

import * as textProvider from '#/providers/TextProvider'

import * as dashboard from '#/pages/dashboard/Dashboard'

import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'

import * as backend from '#/services/Backend'

import * as tailwindMerge from '#/utilities/tailwindMerge'

// =================
@@ -162,22 +167,22 @@ const Tabs = React.forwardRef(TabsInternal)

/** Props for a {@link Tab}. */
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly project?: dashboard.Project
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
readonly onLoadEnd?: () => void
}

/** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) {
const { isActive, icon, labelId, loadingPromise, children, onPress, onClose } = props
const { isActive, icon, labelId, children, onPress, onClose, project, onLoadEnd } = props
const { updateClipPath, observeElement } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null)
const isLoadingRef = React.useRef(true)
const { getText } = textProvider.useText()
const [isLoading, setIsLoading] = React.useState(loadingPromise != null)

React.useLayoutEffect(() => {
if (isActive) {
@@ -193,21 +198,21 @@ export function Tab(props: InternalTabProps) {
}
}, [observeElement])

const { isLoading, data } = reactQuery.useQuery<backend.Project>(
project?.id
? dashboard.createGetProjectDetailsQuery.createPassiveListener(project.id)
: { queryKey: ['__IGNORE__'], queryFn: reactQuery.skipToken }
)

const isFetching =
(isLoading || (data && data.state.type !== backend.ProjectState.opened)) ?? false

React.useEffect(() => {
if (loadingPromise) {
setIsLoading(true)
loadingPromise.then(
() => {
setIsLoading(false)
},
() => {
setIsLoading(false)
}
)
} else {
setIsLoading(false)
if (!isFetching && isLoadingRef.current) {
isLoadingRef.current = false
onLoadEnd?.()
}
}, [loadingPromise])
}, [isFetching, onLoadEnd])

return (
<div
@@ -224,7 +229,7 @@ export function Tab(props: InternalTabProps) {
icon={icon}
isDisabled={false}
isActive={isActive}
loading={isActive ? false : isLoading}
loading={isActive ? false : isFetching}
aria-label={getText(labelId)}
className={tailwindMerge.twMerge('h-full', onClose ? 'pl-4' : 'px-4')}
contentClassName="gap-3"
42 changes: 7 additions & 35 deletions app/ide-desktop/lib/dashboard/src/layouts/UserBar.tsx
Original file line number Diff line number Diff line change
@@ -20,10 +20,6 @@ import Button from '#/components/styled/Button'
import FocusArea from '#/components/styled/FocusArea'

import InviteUsersModal from '#/modals/InviteUsersModal'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'

import * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'

// =================
// === Constants ===
@@ -38,40 +34,27 @@ const SHOULD_SHOW_CHAT_BUTTON: boolean = false

/** Props for a {@link UserBar}. */
export interface UserBarProps {
readonly backend: Backend | null
/** When `true`, the element occupies space in the layout but is not visible.
* Defaults to `false`. */
readonly invisible?: boolean
readonly isOnEditorPage: boolean
readonly setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
readonly projectAsset: backendModule.ProjectAsset | null
readonly setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>> | null
readonly doRemoveSelf: () => void
readonly goToSettingsPage: () => void
readonly onSignOut: () => void
readonly onShareClick?: (() => void) | null | undefined
}

/** A toolbar containing chat and the user menu. */
export default function UserBar(props: UserBarProps) {
const { backend, invisible = false, isOnEditorPage, setIsHelpChatOpen } = props
const { projectAsset, setProjectAsset, doRemoveSelf, goToSettingsPage, onSignOut } = props
const { invisible = false, setIsHelpChatOpen, onShareClick, goToSettingsPage, onSignOut } = props

const { user } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan })
const self =
projectAsset?.permissions?.find(
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
) ?? null
const shouldShowShareButton =
backend?.type === backendModule.BackendType.remote &&
isOnEditorPage &&
projectAsset != null &&
setProjectAsset != null &&
self != null

const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
const shouldShowInviteButton =
backend != null && !shouldShowShareButton && !shouldShowUpgradeButton
const shouldShowShareButton = onShareClick != null
const shouldShowInviteButton = !shouldShowShareButton && !shouldShowUpgradeButton

return (
<FocusArea active={!invisible} direction="horizontal">
@@ -119,18 +102,7 @@ export default function UserBar(props: UserBarProps) {
size="medium"
variant="tertiary"
aria-label={getText('shareButtonAltText')}
onPress={() => {
setModal(
<ManagePermissionsModal
backend={backend}
item={projectAsset}
setItem={setProjectAsset}
self={self}
doRemoveSelf={doRemoveSelf}
eventTarget={null}
/>
)
}}
onPress={onShareClick}
>
{getText('share')}
</ariaComponents.Button>
564 changes: 278 additions & 286 deletions app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx

Large diffs are not rendered by default.

631 changes: 422 additions & 209 deletions app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx

Large diffs are not rendered by default.

8 changes: 2 additions & 6 deletions app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -48,13 +48,9 @@ export enum UserSessionType {
}

/** Properties common to all {@link UserSession}s. */
interface BaseUserSession {
interface BaseUserSession extends cognitoModule.UserSession {
/** A discriminator for TypeScript to be able to disambiguate between `UserSession` variants. */
readonly type: UserSessionType
/** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */
readonly accessToken: string
/** User's email address. */
readonly email: string
}

/** Object containing the currently signed-in user's session data, if the user has not yet set their
@@ -519,7 +515,7 @@ export default function AuthProvider(props: AuthProviderProps) {

React.useEffect(() => {
gtag.gtag('set', { platform: detect.platform(), architecture: detect.architecture() })
return gtagHooks.gtagOpenCloseCallback({ current: gtagEvent }, 'open_app', 'close_app')
return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'open_app', 'close_app')
}, [gtagEvent])

React.useEffect(() => {
27 changes: 22 additions & 5 deletions app/ide-desktop/lib/dashboard/src/providers/SessionProvider.tsx
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
import * as React from 'react'

import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'

import * as eventCallback from '#/hooks/eventCallbackHooks'

@@ -106,6 +107,8 @@ export default function SessionProvider(props: SessionProviderProps) {
reactQuery.useQuery({
queryKey: ['refreshUserSession'],
queryFn: () => refreshUserSessionMutation.mutateAsync(),
meta: { persist: false },
networkMode: 'online',
initialData: null,
initialDataUpdatedAt: Date.now(),
refetchOnWindowFocus: true,
@@ -172,9 +175,23 @@ export default function SessionProvider(props: SessionProviderProps) {
* @throws {Error} when used outside a {@link SessionProvider}. */
export function useSession() {
const context = React.useContext(SessionContext)
if (context == null) {
throw new Error('`useSession` can only be used inside an `<SessionProvider />`.')
} else {
return context
}

invariant(context != null, '`useSession` can only be used inside an `<SessionProvider />`.')

return context
}

/**
* React context hook returning the session of the authenticated user.
* @throws {invariant} if the session is not defined.
*/
export function useSessionStrict() {
const { session, sessionQueryKey } = useSession()

invariant(session != null, 'Session must be defined')

return {
session,
sessionQueryKey,
} as const
}
14 changes: 5 additions & 9 deletions app/ide-desktop/lib/dashboard/src/services/RemoteBackend.ts
Original file line number Diff line number Diff line change
@@ -737,6 +737,7 @@ export default class RemoteBackend extends Backend {
cognitoCredentials: exactCredentials,
}
const response = await this.post(path, filteredBody)

if (!responseIsSuccessful(response)) {
return this.throw(response, 'openProjectBackendError', title)
} else {
@@ -1118,17 +1119,12 @@ export default class RemoteBackend extends Backend {
abortSignal?: AbortSignal
) {
let project = await this.getProjectDetails(projectId, directory, title)
while (project.state.type !== backend.ProjectState.opened) {
if (abortSignal?.aborted === true) {
// The operation was cancelled, do not return.
// eslint-disable-next-line no-restricted-syntax
throw new Error()
}
await new Promise<void>(resolve => {
setTimeout(resolve, CHECK_STATUS_INTERVAL_MS)
})

while (project.state.type !== backend.ProjectState.opened && abortSignal?.aborted !== true) {
await new Promise<void>(resolve => setTimeout(resolve, CHECK_STATUS_INTERVAL_MS))
project = await this.getProjectDetails(projectId, directory, title)
}

return project
}

1 change: 1 addition & 0 deletions app/ide-desktop/lib/dashboard/src/text/english.json
Original file line number Diff line number Diff line change
@@ -308,6 +308,7 @@
"openInfoMenu": "Open info menu",
"noProjectIsCurrentlyOpen": "No project is currently open.",
"offlineTitle": "You are offline",
"offlineErrorMessage": "It seems like you are offline. Please make sure you are connected to the internet and try again",
"offlineToastMessage": "You are offline. Some features may be unavailable.",
"onlineToastMessage": "You are back online.",
"unavailableOffline": "This feature is unavailable offline.",
22 changes: 22 additions & 0 deletions app/ide-desktop/lib/dashboard/src/utilities/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** @file Contains useful error types common across the module. */
import isNetworkErrorLib from 'is-network-error'
import type * as toastify from 'react-toastify'

// =====================
@@ -143,3 +144,24 @@ export function isJSError(error: unknown): boolean {
return false
}
}

/**
* Checks if the given error is a network error.
* Wraps the `is-network-error` library to add additional network errors to the check.
*/
export function isNetworkError(error: unknown): boolean {
const customNetworkErrors = new Set([
// aws amplify network error
'Network error',
])

if (error instanceof Error) {
if (customNetworkErrors.has(error.message)) {
return true
} else {
return isNetworkErrorLib(error)
}
} else {
return false
}
}
7 changes: 4 additions & 3 deletions app/ide-desktop/lib/dashboard/src/utilities/newtype.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
// ===============

/** An interface specifying the variant of a newtype. */
interface NewtypeVariant<TypeName extends string> {
export interface NewtypeVariant<TypeName extends string> {
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly _$type: TypeName
}
@@ -14,7 +14,7 @@ interface NewtypeVariant<TypeName extends string> {
* This is safe, as the discriminator should be a string literal type anyway. */
// This is required for compatibility with the dependency `enso-chat`.
// eslint-disable-next-line no-restricted-syntax
interface MutableNewtypeVariant<TypeName extends string> {
export interface MutableNewtypeVariant<TypeName extends string> {
// eslint-disable-next-line @typescript-eslint/naming-convention
_$type: TypeName
}
@@ -36,7 +36,8 @@ export type Newtype<T, TypeName extends string> = NewtypeVariant<TypeName> & T

/** Extracts the original type out of a {@link Newtype}.
* Its only use is in {@link newtypeConstructor}. */
type UnNewtype<T extends Newtype<unknown, string>> = T extends infer U & NewtypeVariant<T['_$type']>
export type UnNewtype<T extends Newtype<unknown, string>> = T extends infer U &
NewtypeVariant<T['_$type']>
? U extends infer V & MutableNewtypeVariant<T['_$type']>
? V
: U
3 changes: 3 additions & 0 deletions app/ide-desktop/lib/dashboard/src/utilities/tailwindMerge.ts
Original file line number Diff line number Diff line change
@@ -28,3 +28,6 @@ export const TAILWIND_MERGE_CONFIG = {
// This is a function, even though it does not contain function syntax.
// eslint-disable-next-line no-restricted-syntax
export const twMerge = tailwindMerge.extendTailwindMerge(TAILWIND_MERGE_CONFIG)
// reexporting twJoin from the original library for convenience.
// eslint-disable-next-line no-restricted-syntax
export const twJoin = tailwindMerge.twJoin

0 comments on commit e4da96e

Please sign in to comment.