From e4da96e943d7ed669c48f58ce805c993a853865f Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 9 Jul 2024 17:47:46 +0300 Subject: [PATCH] Fix opening projects (#10433) #### 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` --- --- .../lib/dashboard/e2e/createAsset.spec.ts | 8 +- .../lib/dashboard/e2e/driveView.spec.ts | 26 +- .../lib/dashboard/e2e/startModal.spec.ts | 2 +- .../AriaComponents/Button/Button.tsx | 8 +- .../dashboard/src/components/Autocomplete.tsx | 1 + .../src/components/ErrorBoundary.tsx | 12 +- .../src/components/StatelessSpinner.tsx | 16 +- .../lib/dashboard/src/components/Suspense.tsx | 4 +- .../src/components/dashboard/AssetRow.tsx | 26 +- .../src/components/dashboard/Permission.tsx | 3 +- .../src/components/dashboard/ProjectIcon.tsx | 338 +++------- .../dashboard/ProjectNameColumn.tsx | 47 +- .../src/components/dashboard/column.ts | 6 + .../dashboard/column/NameColumn.tsx | 1 + .../dashboard/column/SharedWithColumn.tsx | 5 +- .../lib/dashboard/src/events/assetEvent.ts | 6 + .../lib/dashboard/src/hooks/gtagHooks.ts | 11 +- .../src/layouts/AssetContextMenu.tsx | 30 +- .../dashboard/src/layouts/AssetProperties.tsx | 12 +- .../lib/dashboard/src/layouts/AssetsTable.tsx | 118 +++- .../lib/dashboard/src/layouts/Chat.tsx | 6 +- .../lib/dashboard/src/layouts/Drive.tsx | 35 +- .../lib/dashboard/src/layouts/Editor.tsx | 174 +++-- .../lib/dashboard/src/layouts/TabBar.tsx | 41 +- .../lib/dashboard/src/layouts/UserBar.tsx | 42 +- .../src/modals/ManagePermissionsModal.tsx | 564 ++++++++-------- .../src/pages/dashboard/Dashboard.tsx | 631 ++++++++++++------ .../dashboard/src/providers/AuthProvider.tsx | 8 +- .../src/providers/SessionProvider.tsx | 27 +- .../dashboard/src/services/RemoteBackend.ts | 14 +- .../lib/dashboard/src/text/english.json | 1 + .../lib/dashboard/src/utilities/error.ts | 22 + .../lib/dashboard/src/utilities/newtype.ts | 7 +- .../dashboard/src/utilities/tailwindMerge.ts | 3 + 34 files changed, 1228 insertions(+), 1027 deletions(-) diff --git a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts index 11580fa17948..fda606496c29 100644 --- a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts @@ -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)) ) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts b/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts index c6c3adbc013a..18a428cad9a6 100644 --- a/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts @@ -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) + // }) ) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts b/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts index 9cf000cea827..53c55b40ec26 100644 --- a/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts @@ -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() }) ) diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx index fdb95ba1bfa1..8808a67b3b08 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -41,7 +41,7 @@ interface PropsWithoutHref { export interface BaseButtonProps extends Omit, '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]', }, diff --git a/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx b/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx index e3c87640bc20..e751cd332496 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx @@ -191,6 +191,7 @@ export default function Autocomplete(props: AutocompleteProps) { autoFocus={autoFocus} size={1} value={text ?? ''} + autoComplete="off" placeholder={placeholder == null ? placeholder : placeholder} className="text grow rounded-full bg-transparent px-button-x" onFocus={() => { diff --git a/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx b/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx index 17a6009693bb..33a94aadaa7d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx @@ -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,14 +66,16 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element { const { getText } = textProvider.useText() + const { isOffline } = offlineHooks.useOffline() + const stack = errorUtils.tryGetStack(error) return ( { + resetErrorBoundary() + }} > {getText('tryAgain')} diff --git a/app/ide-desktop/lib/dashboard/src/components/StatelessSpinner.tsx b/app/ide-desktop/lib/dashboard/src/components/StatelessSpinner.tsx index 61bba7f221e1..308a2d4d6aae 100644 --- a/app/ide-desktop/lib/dashboard/src/components/StatelessSpinner.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/StatelessSpinner.tsx @@ -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 + return } diff --git a/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx b/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx index c4de442de42d..3a52ac9b0aef 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Suspense.tsx @@ -39,7 +39,7 @@ const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 export function Suspense(props: SuspenseProps) { const { children } = props - return }>{children} + return }>{children} } /** @@ -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() diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx index 19b38337477d..b7e45d1a6b74 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx @@ -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> { + 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 ) => 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) { ) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx index e8f119ea31f6..15a7941b4968 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/Permission.tsx @@ -38,7 +38,8 @@ const ASSET_TYPE_TO_TEXT_ID: Readonly + readonly self: backendModule.UserPermission readonly isOnlyOwner: boolean readonly permission: backendModule.AssetPermission diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx index 2d3ad8e04fba..892f29b6fd34 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx @@ -7,24 +7,18 @@ import ArrowUpIcon from 'enso-assets/arrow_up.svg' import PlayIcon from 'enso-assets/play.svg' import StopIcon from 'enso-assets/stop.svg' -import * as backendHooks from '#/hooks/backendHooks' -import * as eventHooks from '#/hooks/eventHooks' -import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' - import * as authProvider from '#/providers/AuthProvider' -import * as sessionProvider from '#/providers/SessionProvider' import * as textProvider from '#/providers/TextProvider' -import type * as assetEvent from '#/events/assetEvent' -import AssetEventType from '#/events/AssetEventType' +import * as dashboard from '#/pages/dashboard/Dashboard' import * as ariaComponents from '#/components/AriaComponents' -import Spinner, * as spinner from '#/components/Spinner' +import Spinner from '#/components/Spinner' +import StatelessSpinner, * as spinner from '#/components/StatelessSpinner' import * as backendModule from '#/services/Backend' import type Backend from '#/services/Backend' -import * as object from '#/utilities/object' import * as tailwindMerge from '#/utilities/tailwindMerge' // ================= @@ -34,10 +28,10 @@ import * as tailwindMerge from '#/utilities/tailwindMerge' /** The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState}, * when using the remote backend. */ const REMOTE_SPINNER_STATE: Readonly> = { - [backendModule.ProjectState.closed]: spinner.SpinnerState.initial, - [backendModule.ProjectState.closing]: spinner.SpinnerState.initial, - [backendModule.ProjectState.created]: spinner.SpinnerState.initial, - [backendModule.ProjectState.new]: spinner.SpinnerState.initial, + [backendModule.ProjectState.closed]: spinner.SpinnerState.loadingSlow, + [backendModule.ProjectState.closing]: spinner.SpinnerState.loadingMedium, + [backendModule.ProjectState.created]: spinner.SpinnerState.loadingSlow, + [backendModule.ProjectState.new]: spinner.SpinnerState.loadingSlow, [backendModule.ProjectState.placeholder]: spinner.SpinnerState.loadingSlow, [backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow, [backendModule.ProjectState.provisioned]: spinner.SpinnerState.loadingSlow, @@ -47,12 +41,12 @@ const REMOTE_SPINNER_STATE: Readonly> = { - [backendModule.ProjectState.closed]: spinner.SpinnerState.initial, - [backendModule.ProjectState.closing]: spinner.SpinnerState.initial, - [backendModule.ProjectState.created]: spinner.SpinnerState.initial, - [backendModule.ProjectState.new]: spinner.SpinnerState.initial, + [backendModule.ProjectState.closed]: spinner.SpinnerState.loadingSlow, + [backendModule.ProjectState.closing]: spinner.SpinnerState.loadingMedium, + [backendModule.ProjectState.created]: spinner.SpinnerState.loadingSlow, + [backendModule.ProjectState.new]: spinner.SpinnerState.loadingSlow, [backendModule.ProjectState.placeholder]: spinner.SpinnerState.loadingMedium, - [backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingMedium, + [backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow, [backendModule.ProjectState.provisioned]: spinner.SpinnerState.loadingMedium, [backendModule.ProjectState.scheduled]: spinner.SpinnerState.loadingMedium, [backendModule.ProjectState.opened]: spinner.SpinnerState.done, @@ -65,227 +59,71 @@ const LOCAL_SPINNER_STATE: Readonly> - readonly assetEvents: assetEvent.AssetEvent[] - readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void - readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void - readonly doCloseEditor: (id: backendModule.ProjectId) => void - readonly doOpenEditor: () => void + readonly doOpenProject: (id: backendModule.ProjectId, runInBackground: boolean) => void + readonly doCloseProject: (id: backendModule.ProjectId) => void + readonly openProjectTab: (projectId: backendModule.ProjectId) => void } /** An interactive icon indicating the status of a project. */ export default function ProjectIcon(props: ProjectIconProps) { - const { backend, item, setItem, assetEvents, setProjectStartupInfo, dispatchAssetEvent } = props - const { doCloseEditor, doOpenEditor } = props - const { session } = sessionProvider.useSession() + const { backend, item, isOpened } = props + const { openProjectTab, doOpenProject, doCloseProject } = props + const { user } = authProvider.useNonPartialUserSession() - const toastAndLog = toastAndLogHooks.useToastAndLog() const { getText } = textProvider.useText() - const state = item.projectState.type - const setState = React.useCallback( - (stateOrUpdater: React.SetStateAction) => { - setItem(oldItem => { - let newState: backendModule.ProjectState - if (typeof stateOrUpdater === 'function') { - newState = stateOrUpdater(oldItem.projectState.type) - } else { - newState = stateOrUpdater - } - let newProjectState: backendModule.ProjectStateType = object.merge(oldItem.projectState, { - type: newState, - }) - if (!backendModule.IS_OPENING_OR_OPENED[newState]) { - newProjectState = object.omit(newProjectState, 'openedBy') - } else { - newProjectState = object.merge(newProjectState, { - openedBy: user.email, - }) - } - return object.merge(oldItem, { projectState: newProjectState }) - }) - }, - [user, setItem] - ) - const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial) - const shouldOpenWhenReadyRef = React.useRef(false) - const [isRunningInBackground, setIsRunningInBackground] = React.useState( - item.projectState.executeAsync ?? false - ) - const doAbortOpeningRef = React.useRef(() => {}) - const doOpenEditorRef = React.useRef(doOpenEditor) - doOpenEditorRef.current = doOpenEditor - const isCloud = backend.type === backendModule.BackendType.remote - const isOtherUserUsingProject = - isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user.email - - const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject') - const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject') - const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails') - const waitUntilProjectIsReadyMutation = backendHooks.useBackendMutation( - backend, - 'waitUntilProjectIsReady' - ) - const openProjectMutate = openProjectMutation.mutateAsync - const getProjectDetailsMutate = getProjectDetailsMutation.mutateAsync - const openEditorMutation = reactQuery.useMutation({ - mutationKey: ['openEditor'], - networkMode: 'always', - mutationFn: async (item2: backendModule.ProjectAsset) => { - const abortController = new AbortController() - doAbortOpeningRef.current = () => { - abortController.abort() - } - const projectPromise = openProjectMutation - .mutateAsync([ - item2.id, - { executeAsync: false, parentId: item2.parentId, cognitoCredentials: session }, - item2.title, - ]) - .then(async () => { - const proj = await waitUntilProjectIsReadyMutation.mutateAsync([ - item2.id, - item2.parentId, - item2.title, - abortController.signal, - ]) - return proj - }) - setProjectStartupInfo({ - project: projectPromise, - projectAsset: item2, - setProjectAsset: setItem, - backendType: backend.type, - accessToken: session?.accessToken ?? null, - }) - await projectPromise - if (!abortController.signal.aborted) { - setState(backendModule.ProjectState.opened) - if (shouldOpenWhenReadyRef.current) { - doOpenEditor() - } - } - }, + const isRunningInBackground = item.projectState.executeAsync ?? false + const { + data: status, + isLoading, + isError, + } = reactQuery.useQuery({ + ...dashboard.createGetProjectDetailsQuery.createPassiveListener(item.id), + select: data => data.state.type, + enabled: isOpened, }) - const openEditorMutate = openEditorMutation.mutate - const openProject = React.useCallback( - async (shouldRunInBackground: boolean) => { - if (state !== backendModule.ProjectState.opened) { - try { - if (!shouldRunInBackground) { - setState(backendModule.ProjectState.openInProgress) - openEditorMutate(item) - } else { - setState(backendModule.ProjectState.opened) - await openProjectMutate([ - item.id, - { - executeAsync: shouldRunInBackground, - parentId: item.parentId, - cognitoCredentials: session, - }, - item.title, - ]) - } - } catch (error) { - const project = await getProjectDetailsMutate([item.id, item.parentId, item.title]) - // `setState` is not used here as `project` contains the full state information, - // not just the state type. - setItem(object.merger({ projectState: project.state })) - toastAndLog('openProjectError', error, item.title) - } - } - }, - [ - state, - item, - session, - toastAndLog, - openProjectMutate, - openEditorMutate, - getProjectDetailsMutate, - setState, - setItem, - ] - ) + const isCloud = backend.type === backendModule.BackendType.remote - React.useEffect(() => { - // Ensure that the previous spinner state is visible for at least one frame. - requestAnimationFrame(() => { - const newSpinnerState = - backend.type === backendModule.BackendType.remote - ? REMOTE_SPINNER_STATE[state] - : LOCAL_SPINNER_STATE[state] - setSpinnerState(newSpinnerState) - }) - }, [state, backend.type]) + const isOtherUserUsingProject = + isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user.email - eventHooks.useEventHandler(assetEvents, event => { - switch (event.type) { - case AssetEventType.openProject: { - if (event.id !== item.id) { - if (!event.runInBackground && !isRunningInBackground) { - shouldOpenWhenReadyRef.current = false - if (!isOtherUserUsingProject && backendModule.IS_OPENING_OR_OPENED[state]) { - doAbortOpeningRef.current() - void closeProject() - } - } - } else { - if ( - backendModule.IS_OPENING_OR_OPENED[state] && - state !== backendModule.ProjectState.placeholder - ) { - const projectPromise = waitUntilProjectIsReadyMutation.mutateAsync([ - item.id, - item.parentId, - item.title, - ]) - setProjectStartupInfo({ - project: projectPromise, - projectAsset: item, - setProjectAsset: setItem, - backendType: backend.type, - accessToken: session?.accessToken ?? null, - }) - if (!isRunningInBackground) { - doOpenEditor() - } - } else { - shouldOpenWhenReadyRef.current = !event.runInBackground - setIsRunningInBackground(event.runInBackground) - void openProject(event.runInBackground) - } - } - break - } - case AssetEventType.closeProject: { - if (event.id === item.id) { - shouldOpenWhenReadyRef.current = false - void closeProject() - } - break - } - default: { - // Ignored. Any missing project-related events should be handled by `ProjectNameColumn`. - // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` - // are handled by`AssetRow`. - break - } + const state = (() => { + // Project is closed, show open button + if (!isOpened) { + return backendModule.ProjectState.closed + } else if (!isLoading && status == null) { + // Project is opened, but not yet queried. + return backendModule.ProjectState.openInProgress + } else if (isLoading) { + return backendModule.ProjectState.openInProgress + } else if (status == null) { + return backendModule.ProjectState.openInProgress + } else if (status === backendModule.ProjectState.closed) { + // Project is opened locally, but not on the backend yet. + return backendModule.ProjectState.openInProgress + } else { + return status } - }) - - const closeProject = async () => { - if (!isRunningInBackground) { - doCloseEditor(item.id) + })() + + const spinnerState = (() => { + if (!isOpened) { + return spinner.SpinnerState.initial + } else if (isLoading) { + return spinner.SpinnerState.loadingSlow + } else if (isError) { + return spinner.SpinnerState.initial + } else if (status == null) { + return spinner.SpinnerState.loadingSlow + } else { + return backend.type === backendModule.BackendType.remote + ? REMOTE_SPINNER_STATE[status] + : LOCAL_SPINNER_STATE[status] } - shouldOpenWhenReadyRef.current = false - setState(backendModule.ProjectState.closing) - await closeProjectMutation.mutateAsync([item.id, item.title]) - setState(backendModule.ProjectState.closed) - } + })() switch (state) { case null: @@ -300,13 +138,9 @@ export default function ProjectIcon(props: ProjectIconProps) { icon={PlayIcon} aria-label={getText('openInEditor')} tooltipPlacement="left" - className="h-6 border-0" + extraClickZone="xsmall" onPress={() => { - dispatchAssetEvent({ - type: AssetEventType.openProject, - id: item.id, - runInBackground: false, - }) + doOpenProject(item.id, false) }} /> ) @@ -317,21 +151,23 @@ export default function ProjectIcon(props: ProjectIconProps) { return (
{ + doCloseProject(item.id) + }} /> - @@ -342,40 +178,38 @@ export default function ProjectIcon(props: ProjectIconProps) {
{ + doCloseProject(item.id) + }} />
+ {!isOtherUserUsingProject && !isRunningInBackground && ( { - doOpenEditor() + openProjectTab(item.id) }} /> )} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index e5957d24e7d6..8549a63973c4 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -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) { ) : ( { + doCloseProject({ id, parentId: asset.parentId, title: asset.title, type: backendType }) + }} + doOpenProject={id => { + doOpenProject({ id, type: backendType, parentId: asset.parentId, title: asset.title }) + }} + openProjectTab={doOpenEditor} /> )} > readonly selected: boolean readonly setSelected: (selected: boolean) => void @@ -31,6 +35,8 @@ export interface AssetColumnProps { readonly rowState: assetsTable.AssetRowState readonly setRowState: React.Dispatch> readonly isEditable: boolean + readonly doOpenProject: (project: dashboard.Project) => void + readonly doCloseProject: (project: dashboard.Project) => void } /** Props for a {@link AssetColumn}. */ diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/NameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/NameColumn.tsx index f19a9e4b7d57..c2b8caca0775 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/NameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/NameColumn.tsx @@ -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 diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index dc04feaf8f68..7a9f53e7f3f9 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -30,7 +30,7 @@ import * as uniqueString from '#/utilities/uniqueString' /** The type of the `state` prop of a {@link SharedWithColumn}. */ interface SharedWithColumnStateProp - extends Pick { + extends Pick { readonly setQuery: column.AssetColumnProps['state']['setQuery'] | null } @@ -43,7 +43,7 @@ interface SharedWithColumnPropsInternal extends Pick { 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 { 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 diff --git a/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts index 8443a39308b0..21658d73a809 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/gtagHooks.ts @@ -24,18 +24,19 @@ export function useGtagEvent() { * * Also sends the close event when the window is unloaded. */ export function gtagOpenCloseCallback( - gtagEventRef: React.MutableRefObject>, + gtagEvent: ReturnType, 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) } } diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx index 1675a49a3c2c..647858f1843d 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx @@ -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( )}
-
+ {' '} {!isCloud && (
- + {getText('sharedWith')} - + {} }} + state={{ category, dispatchAssetEvent, setQuery: () => {} }} /> - + {getText('labels')} - + {item.item.labels?.map(value => { const label = labels.find(otherLabel => otherLabel.value === value) return label == null ? null : ( diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx index 7e47cf576da6..32f044cb296b 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx @@ -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 | null) => void readonly query: AssetQuery readonly setQuery: React.Dispatch> - 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> readonly setSuggestions: React.Dispatch< React.SetStateAction > - 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 | 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 +} + +/** + * 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 void> + >({}) const [pasteData, setPasteData] = React.useState > | 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,7 +1970,6 @@ export default function AssetsTable(props: AssetsTableProps) { query, doToggleDirectoryExpansion, doOpenEditor, - doCloseEditor, doCopy, doCut, doPaste, @@ -1952,7 +1977,6 @@ export default function AssetsTable(props: AssetsTableProps) { 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 ( { + 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 => ( {}} setRowState={() => {}} isEditable={false} + doCloseProject={doCloseProject} + doOpenProject={doOpenProject} /> ))} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx index 37ad3c4ca3d7..74ecd0e9f820 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Chat.tsx @@ -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. */ diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx index 7f6df43d872f..37bd9b13878a 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx @@ -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 } /** 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) { ) : (
diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx index a1031a3ccbc9..0041f4577b36 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Editor.tsx @@ -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 && ( - - ) + 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 ( - }> - {/* eslint-disable-next-line @typescript-eslint/naming-convention */} - null } : {})}> - {editor} - - + ) } @@ -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 { + 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,47 +126,19 @@ 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) { @@ -144,44 +146,20 @@ function EditorInternal(props: EditorInternalProps) { } 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 - } + }, [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 : } diff --git a/app/ide-desktop/lib/dashboard/src/layouts/TabBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/TabBar.tsx index d5e6d080cae4..c5f64ab4fdfb 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/TabBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/TabBar.tsx @@ -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 { + 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 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(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( + 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 (
void - readonly projectAsset: backendModule.ProjectAsset | null - readonly setProjectAsset: React.Dispatch> | 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 ( @@ -119,18 +102,7 @@ export default function UserBar(props: UserBarProps) { size="medium" variant="tertiary" aria-label={getText('shareButtonAltText')} - onPress={() => { - setModal( - - ) - }} + onPress={onShareClick} > {getText('share')} diff --git a/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx index b90c405f382e..321fe0378528 100644 --- a/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/modals/ManagePermissionsModal.tsx @@ -10,6 +10,7 @@ import * as billingHooks from '#/hooks/billing' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as authProvider from '#/providers/AuthProvider' +import * as backendProvider from '#/providers/BackendProvider' import * as modalProvider from '#/providers/ModalProvider' import * as textProvider from '#/providers/TextProvider' @@ -23,7 +24,6 @@ import * as paywall from '#/components/Paywall' import FocusArea from '#/components/styled/FocusArea' import * as backendModule from '#/services/Backend' -import type Backend from '#/services/Backend' import * as object from '#/utilities/object' import * as permissionsModule from '#/utilities/permissions' @@ -44,8 +44,7 @@ const TYPE_SELECTOR_Y_OFFSET_PX = 32 export interface ManagePermissionsModalProps< Asset extends backendModule.AnyAsset = backendModule.AnyAsset, > { - readonly backend: Backend - readonly item: Asset + readonly item: Pick readonly setItem: React.Dispatch> readonly self: backendModule.UserPermission /** Remove the current user's permissions from this asset. This MUST be a prop because it should @@ -61,7 +60,8 @@ export interface ManagePermissionsModalProps< export default function ManagePermissionsModal< Asset extends backendModule.AnyAsset = backendModule.AnyAsset, >(props: ManagePermissionsModalProps) { - const { backend, item, setItem, self, doRemoveSelf, eventTarget } = props + const { item, setItem, self, doRemoveSelf, eventTarget } = props + const remoteBackend = backendProvider.useRemoteBackendStrict() const { user } = authProvider.useFullUserSession() const { unsetModal } = modalProvider.useSetModal() const toastAndLog = toastAndLogHooks.useToastAndLog() @@ -72,14 +72,14 @@ export default function ManagePermissionsModal< const listedUsers = reactQuery.useQuery({ queryKey: ['listUsers'], - queryFn: () => backend.listUsers(), + queryFn: () => remoteBackend.listUsers(), enabled: !isUnderPaywall, select: data => (isUnderPaywall ? [] : data), }) const listedUserGroups = reactQuery.useQuery({ queryKey: ['listUserGroups'], - queryFn: () => backend.listUserGroups(), + queryFn: () => remoteBackend.listUserGroups(), }) const [permissions, setPermissions] = React.useState(item.permissions ?? []) @@ -122,8 +122,11 @@ export default function ManagePermissionsModal< [user.userId, permissions, self.permission] ) - const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser') - const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission') + const inviteUserMutation = backendHooks.useBackendMutation(remoteBackend, 'inviteUser') + const createPermissionMutation = backendHooks.useBackendMutation( + remoteBackend, + 'createPermission' + ) React.useEffect(() => { // This is SAFE, as the type of asset is not being changed. @@ -131,308 +134,297 @@ export default function ManagePermissionsModal< setItem(object.merger({ permissions } as Partial)) }, [permissions, setItem]) - if (backend.type === backendModule.BackendType.local) { - // This should never happen - the local backend does not have the "shared with" column, - // and `organization` is absent only when offline - in which case the user should only - // be able to access the local backend. - // This MUST be an error, otherwise the hooks below are considered as conditionally called. - throw new Error('Cannot share assets on the local backend.') - } else { - const canAdd = React.useMemo( - () => [ - ...(listedUsers.data ?? []).filter( - listedUser => - !permissionsHoldersNames.has(listedUser.name) && - !emailsOfUsersWithPermission.has(listedUser.email) - ), - ...(listedUserGroups.data ?? []).filter( - userGroup => !permissionsHoldersNames.has(userGroup.groupName) - ), - ], - [emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups] - ) - const willInviteNewUser = React.useMemo(() => { - if (usersAndUserGroups.length !== 0 || email == null || email === '') { - return false - } else { - const lowercase = email.toLowerCase() - return ( - lowercase !== '' && - !permissionsHoldersNames.has(lowercase) && - !emailsOfUsersWithPermission.has(lowercase) && - !canAdd.some( - userOrGroup => - ('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) || - ('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) || - ('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase) - ) + const canAdd = React.useMemo( + () => [ + ...(listedUsers.data ?? []).filter( + listedUser => + !permissionsHoldersNames.has(listedUser.name) && + !emailsOfUsersWithPermission.has(listedUser.email) + ), + ...(listedUserGroups.data ?? []).filter( + userGroup => !permissionsHoldersNames.has(userGroup.groupName) + ), + ], + [emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups] + ) + const willInviteNewUser = React.useMemo(() => { + if (usersAndUserGroups.length !== 0 || email == null || email === '') { + return false + } else { + const lowercase = email.toLowerCase() + return ( + lowercase !== '' && + !permissionsHoldersNames.has(lowercase) && + !emailsOfUsersWithPermission.has(lowercase) && + !canAdd.some( + userOrGroup => + ('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) || + ('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) || + ('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase) ) - } - }, [ - usersAndUserGroups.length, - email, - emailsOfUsersWithPermission, - permissionsHoldersNames, - canAdd, - ]) + ) + } + }, [ + usersAndUserGroups.length, + email, + emailsOfUsersWithPermission, + permissionsHoldersNames, + canAdd, + ]) - const doSubmit = async () => { - if (willInviteNewUser) { - try { - setUserAndUserGroups([]) - setEmail('') - if (email != null) { - await inviteUserMutation.mutateAsync([ - { - organizationId: user.organizationId, - userEmail: backendModule.EmailAddress(email), - }, - ]) - toast.toast.success(getText('inviteSuccess', email)) - } - } catch (error) { - toastAndLog('couldNotInviteUser', error, email ?? '(unknown)') - } - } else { + const doSubmit = async () => { + if (willInviteNewUser) { + try { setUserAndUserGroups([]) - const addedPermissions = usersAndUserGroups.map( - newUserOrUserGroup => - 'userId' in newUserOrUserGroup - ? { user: newUserOrUserGroup, permission: action } - : { userGroup: newUserOrUserGroup, permission: action } - ) - const addedUsersIds = new Set( - addedPermissions.flatMap(permission => - backendModule.isUserPermission(permission) ? [permission.user.userId] : [] - ) + setEmail('') + if (email != null) { + await inviteUserMutation.mutateAsync([ + { + organizationId: user.organizationId, + userEmail: backendModule.EmailAddress(email), + }, + ]) + toast.toast.success(getText('inviteSuccess', email)) + } + } catch (error) { + toastAndLog('couldNotInviteUser', error, email ?? '(unknown)') + } + } else { + setUserAndUserGroups([]) + const addedPermissions = usersAndUserGroups.map( + newUserOrUserGroup => + 'userId' in newUserOrUserGroup + ? { user: newUserOrUserGroup, permission: action } + : { userGroup: newUserOrUserGroup, permission: action } + ) + const addedUsersIds = new Set( + addedPermissions.flatMap(permission => + backendModule.isUserPermission(permission) ? [permission.user.userId] : [] ) - const addedUserGroupsIds = new Set( - addedPermissions.flatMap(permission => - backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : [] - ) + ) + const addedUserGroupsIds = new Set( + addedPermissions.flatMap(permission => + backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : [] ) - const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) => - backendModule.isUserPermission(permission) - ? !addedUsersIds.has(permission.user.userId) - : !addedUserGroupsIds.has(permission.userGroup.id) + ) + const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) => + backendModule.isUserPermission(permission) + ? !addedUsersIds.has(permission.user.userId) + : !addedUserGroupsIds.has(permission.userGroup.id) - try { - setPermissions(oldPermissions => - [...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort( - backendModule.compareAssetPermissions - ) + try { + setPermissions(oldPermissions => + [...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort( + backendModule.compareAssetPermissions ) - await createPermissionMutation.mutateAsync([ - { - actorsIds: addedPermissions.map(permission => - backendModule.isUserPermission(permission) - ? permission.user.userId - : permission.userGroup.id - ), - resourceId: item.id, - action: action, - }, - ]) - } catch (error) { - setPermissions(oldPermissions => - [...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort( - backendModule.compareAssetPermissions - ) + ) + await createPermissionMutation.mutateAsync([ + { + actorsIds: addedPermissions.map(permission => + backendModule.isUserPermission(permission) + ? permission.user.userId + : permission.userGroup.id + ), + resourceId: item.id, + action: action, + }, + ]) + } catch (error) { + setPermissions(oldPermissions => + [...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort( + backendModule.compareAssetPermissions ) - toastAndLog('setPermissionsError', error) - } + ) + toastAndLog('setPermissionsError', error) } } + } - const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => { - if (permissionId === self.user.userId) { - doRemoveSelf() - } else { - const oldPermission = permissions.find( - permission => backendModule.getAssetPermissionId(permission) === permissionId + const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => { + if (permissionId === self.user.userId) { + doRemoveSelf() + } else { + const oldPermission = permissions.find( + permission => backendModule.getAssetPermissionId(permission) === permissionId + ) + try { + setPermissions(oldPermissions => + oldPermissions.filter( + permission => backendModule.getAssetPermissionId(permission) !== permissionId + ) ) - try { + await createPermissionMutation.mutateAsync([ + { + actorsIds: [permissionId], + resourceId: item.id, + action: null, + }, + ]) + } catch (error) { + if (oldPermission != null) { setPermissions(oldPermissions => - oldPermissions.filter( - permission => backendModule.getAssetPermissionId(permission) !== permissionId - ) + [...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions) ) - await createPermissionMutation.mutateAsync([ - { - actorsIds: [permissionId], - resourceId: item.id, - action: null, - }, - ]) - } catch (error) { - if (oldPermission != null) { - setPermissions(oldPermissions => - [...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions) - ) - } - toastAndLog('setPermissionsError', error) } + toastAndLog('setPermissionsError', error) } } + } - return ( - +
{ + mouseEvent.stopPropagation() + }} + onContextMenu={mouseEvent => { + mouseEvent.stopPropagation() + mouseEvent.preventDefault() + }} > -
{ - mouseEvent.stopPropagation() - }} - onContextMenu={mouseEvent => { - mouseEvent.stopPropagation() - mouseEvent.preventDefault() - }} - > -
-
- - {getText('invite')} - - {/* Space reserved for other tabs. */} -
- - {innerProps => ( -
{ - event.preventDefault() - void doSubmit() - }} - {...innerProps} - > -
- +
+ + {getText('invite')} + + {/* Space reserved for other tabs. */} +
+ + {innerProps => ( + { + event.preventDefault() + void doSubmit() + }} + {...innerProps} + > +
+ +
+ 1 + ? getText('inviteUserPlaceholder') + : getText('inviteFirstUserPlaceholder') + } + type="text" + itemsToString={items => + items.length === 1 && items[0] != null + ? 'email' in items[0] + ? items[0].email + : items[0].groupName + : getText('xUsersAndGroupsSelected', items.length) + } + values={usersAndUserGroups} + setValues={setUserAndUserGroups} + items={canAdd} + itemToKey={userOrGroup => + 'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id + } + itemToString={userOrGroup => + 'name' in userOrGroup + ? `${userOrGroup.name} (${userOrGroup.email})` + : userOrGroup.groupName + } + matches={(userOrGroup, text) => + ('email' in userOrGroup && + userOrGroup.email.toLowerCase().includes(text.toLowerCase())) || + ('name' in userOrGroup && + userOrGroup.name.toLowerCase().includes(text.toLowerCase())) || + ('groupName' in userOrGroup && + userOrGroup.groupName.toLowerCase().includes(text.toLowerCase())) + } + text={email} + setText={setEmail} /> -
- 1 - ? getText('inviteUserPlaceholder') - : getText('inviteFirstUserPlaceholder') - } - type="text" - itemsToString={items => - items.length === 1 && items[0] != null - ? 'email' in items[0] - ? items[0].email - : items[0].groupName - : getText('xUsersAndGroupsSelected', items.length) - } - values={usersAndUserGroups} - setValues={setUserAndUserGroups} - items={canAdd} - itemToKey={userOrGroup => - 'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id - } - itemToString={userOrGroup => - 'name' in userOrGroup - ? `${userOrGroup.name} (${userOrGroup.email})` - : userOrGroup.groupName - } - matches={(userOrGroup, text) => - ('email' in userOrGroup && - userOrGroup.email.toLowerCase().includes(text.toLowerCase())) || - ('name' in userOrGroup && - userOrGroup.name.toLowerCase().includes(text.toLowerCase())) || - ('groupName' in userOrGroup && - userOrGroup.groupName.toLowerCase().includes(text.toLowerCase())) - } - text={email} - setText={setEmail} - /> -
- - {willInviteNewUser ? getText('invite') : getText('share')} - - - )} - -
- {editablePermissions.map(permission => ( -
+ - { - const permissionId = backendModule.getAssetPermissionId(newPermission) - setPermissions(oldPermissions => - oldPermissions.map(oldPermission => - backendModule.getAssetPermissionId(oldPermission) === permissionId - ? newPermission - : oldPermission - ) + {willInviteNewUser ? getText('invite') : getText('share')} + + + )} + +
+ {editablePermissions.map(permission => ( +
+ { + const permissionId = backendModule.getAssetPermissionId(newPermission) + setPermissions(oldPermissions => + oldPermissions.map(oldPermission => + backendModule.getAssetPermissionId(oldPermission) === permissionId + ? newPermission + : oldPermission ) - if (permissionId === self.user.userId) { - // This must run only after the permissions have - // been updated through `setItem`. - setTimeout(() => { - unsetModal() - }, 0) - } - }} - doDelete={id => { - if (id === self.user.userId) { + ) + if (permissionId === self.user.userId) { + // This must run only after the permissions have + // been updated through `setItem`. + setTimeout(() => { unsetModal() - } - void doDelete(id) - }} - /> -
- ))} -
- - {isUnderPaywall && ( - - )} + }, 0) + } + }} + doDelete={id => { + if (id === self.user.userId) { + unsetModal() + } + void doDelete(id) + }} + /> +
+ ))}
+ + {isUnderPaywall && ( + + )}
- - ) - } +
+ + ) } diff --git a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx index cf1e04aaefa1..5000efc5a9a9 100644 --- a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx @@ -2,13 +2,17 @@ * interactive components. */ import * as React from 'react' +import * as reactQuery from '@tanstack/react-query' +import invariant from 'tiny-invariant' import * as validator from 'validator' +import * as z from 'zod' import DriveIcon from 'enso-assets/drive.svg' import EditorIcon from 'enso-assets/network.svg' import SettingsIcon from 'enso-assets/settings.svg' import * as detect from 'enso-common/src/detect' +import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as eventHooks from '#/hooks/eventHooks' import * as searchParamsState from '#/hooks/searchParamsStateHooks' @@ -24,6 +28,7 @@ import AssetEventType from '#/events/AssetEventType' import type * as assetListEvent from '#/events/assetListEvent' import AssetListEventType from '#/events/AssetListEventType' +import type * as assetTable from '#/layouts/AssetsTable' import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category' import Chat from '#/layouts/Chat' import ChatPlaceholder from '#/layouts/ChatPlaceholder' @@ -36,9 +41,13 @@ import UserBar from '#/layouts/UserBar' import Page from '#/components/Page' +import ManagePermissionsModal from '#/modals/ManagePermissionsModal' + import * as backendModule from '#/services/Backend' +import type LocalBackend from '#/services/LocalBackend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' +import type RemoteBackend from '#/services/RemoteBackend' import * as array from '#/utilities/array' import LocalStorage from '#/utilities/LocalStorage' @@ -53,7 +62,6 @@ import type * as types from '../../../../types/types' /** Main content of the screen. Only one should be visible at a time. */ enum TabType { drive = 'drive', - editor = 'editor', settings = 'settings', } @@ -61,46 +69,39 @@ declare module '#/utilities/LocalStorage' { /** */ interface LocalStorageData { readonly isAssetPanelVisible: boolean - readonly page: TabType - readonly projectStartupInfo: Omit + readonly page: z.infer + readonly launchedProjects: z.infer } } -LocalStorage.registerKey('isAssetPanelVisible', { - tryParse: value => (value === true ? value : null), -}) +LocalStorage.registerKey('isAssetPanelVisible', { schema: z.boolean() }) -const PAGES = Object.values(TabType) -LocalStorage.registerKey('page', { - tryParse: value => (array.includes(PAGES, value) ? value : null), +const PROJECT_SCHEMA = z.object({ + id: z.custom(), + parentId: z.custom(), + title: z.string(), + type: z.nativeEnum(backendModule.BackendType), }) - -const BACKEND_TYPES = Object.values(backendModule.BackendType) -LocalStorage.registerKey('projectStartupInfo', { +const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA) + +/** + * Launched project information. + */ +export type Project = z.infer +/** + * Launched project ID. + */ +export type ProjectId = Project['id'] + +LocalStorage.registerKey('launchedProjects', { isUserSpecific: true, - tryParse: value => { - if (typeof value !== 'object' || value == null) { - return null - } else if ( - !('accessToken' in value) || - (typeof value.accessToken !== 'string' && value.accessToken != null) - ) { - return null - } else if (!('backendType' in value) || !array.includes(BACKEND_TYPES, value.backendType)) { - return null - } else if (!('projectAsset' in value)) { - return null - } else { - return { - // These type assertions are UNSAFE, however correctly type-checking these - // would be very complicated. - // eslint-disable-next-line no-restricted-syntax - projectAsset: value.projectAsset as backendModule.ProjectAsset, - backendType: value.backendType, - accessToken: value.accessToken ?? null, - } - } - }, + schema: LAUNCHED_PROJECT_SCHEMA, +}) + +const PAGES_SCHEMA = z.nativeEnum(TabType).or(z.custom()) + +LocalStorage.registerKey('page', { + schema: PAGES_SCHEMA, }) // ================= @@ -116,41 +117,114 @@ export interface DashboardProps { readonly ydocUrl: string | null } +/** + * + */ +export interface OpenProjectOptions { + /** + * Whether to open the project in the background. + * Set to `false` to navigate to the project tab. + * @default true + */ + readonly openInBackground?: boolean +} + +/** + * + */ +export interface CreateOpenedProjectQueryOptions { + readonly type: backendModule.BackendType + readonly assetId: backendModule.Asset['id'] + readonly parentId: backendModule.Asset['parentId'] + readonly title: backendModule.Asset['title'] + readonly remoteBackend: RemoteBackend + readonly localBackend: LocalBackend | null +} + +/** + * Project status query. + */ +export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) { + const { assetId, parentId, title, remoteBackend, localBackend, type } = options + + const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend + const isLocal = type === backendModule.BackendType.local + + return reactQuery.queryOptions({ + queryKey: createGetProjectDetailsQuery.getQueryKey(assetId), + meta: { persist: false }, + refetchInterval: ({ state }) => { + /** + * Default interval for refetching project status when the project is opened. + */ + const openedIntervalMS = 30_000 + /** + * Interval when we open a cloud project. + * Since opening a cloud project is a long operation, we want to check the status less often. + */ + const cloudOpeningIntervalMS = 5_000 + /** + * Interval when we open a local project or when we want to sync the project status as soon as possible. + */ + const activeSyncIntervalMS = 100 + const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed] + + if (isLocal) { + if (state.data?.state.type === backendModule.ProjectState.opened) { + return openedIntervalMS + } else { + return activeSyncIntervalMS + } + } else if (state.data == null) { + return activeSyncIntervalMS + } else if (states.includes(state.data.state.type)) { + return openedIntervalMS + } else { + return cloudOpeningIntervalMS + } + }, + refetchIntervalInBackground: true, + refetchOnWindowFocus: true, + refetchOnMount: true, + gcTime: 0, + queryFn: () => { + invariant(backend != null, 'Backend is null') + + return backend.getProjectDetails(assetId, parentId, title) + }, + }) +} +createGetProjectDetailsQuery.getQueryKey = (id: Project['id']) => ['project', id] as const +createGetProjectDetailsQuery.createPassiveListener = (id: Project['id']) => + reactQuery.queryOptions({ + queryKey: createGetProjectDetailsQuery.getQueryKey(id), + }) + /** The component that contains the entire UI. */ export default function Dashboard(props: DashboardProps) { - const { appRunner, ydocUrl, initialProjectName: initialProjectNameRaw } = props - const session = authProvider.useNonPartialUserSession() - const remoteBackend = backendProvider.useRemoteBackend() + const { appRunner, initialProjectName: initialProjectNameRaw, ydocUrl } = props + + const { user, ...session } = authProvider.useFullUserSession() + + const remoteBackend = backendProvider.useRemoteBackendStrict() const localBackend = backendProvider.useLocalBackend() const { getText } = textProvider.useText() const { modalRef } = modalProvider.useModalRef() - const { updateModal, unsetModal } = modalProvider.useSetModal() + const { updateModal, unsetModal, setModal } = modalProvider.useSetModal() const { localStorage } = localStorageProvider.useLocalStorage() const inputBindings = inputBindingsProvider.useInputBindings() - const [initialized, setInitialized] = React.useState(false) - const initializedRef = React.useRef(initialized) - initializedRef.current = initialized const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false) - // These pages MUST be ROUTER PAGES. - const [page, setPage] = searchParamsState.useSearchParamsState( - 'page', - () => localStorage.get('page') ?? TabType.drive, - (value: unknown): value is TabType => array.includes(Object.values(TabType), value) - ) - const [projectStartupInfo, setProjectStartupInfo] = - React.useState(null) - const openProjectAbortControllerRef = React.useRef(null) - const [assetListEvents, dispatchAssetListEvent] = - eventHooks.useEvent() - const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent() + const assetManagementApiRef = React.useRef(null) + const initialLocalProjectId = initialProjectNameRaw != null && validator.isUUID(initialProjectNameRaw) ? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw)) : null const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw - const defaultCategory = - remoteBackend != null && initialLocalProjectId == null ? Category.cloud : Category.local + + const defaultCategory = initialLocalProjectId == null ? Category.cloud : Category.local + const [category, setCategory] = searchParamsState.useSearchParamsState( 'driveCategory', () => defaultCategory, @@ -163,8 +237,58 @@ export default function Dashboard(props: DashboardProps) { } ) + const [launchedProjects, privateSetLaunchedProjects] = React.useState( + () => localStorage.get('launchedProjects') ?? [] + ) + + // These pages MUST be ROUTER PAGES. + const [page, privateSetPage] = searchParamsState.useSearchParamsState( + 'page', + () => localStorage.get('page') ?? TabType.drive, + (value: unknown): value is Project['id'] | TabType => { + return ( + array.includes(Object.values(TabType), value) || launchedProjects.some(p => p.id === value) + ) + } + ) + + const setLaunchedProjects = eventCallbacks.useEventCallback( + (fn: (currentState: Project[]) => Project[]) => { + React.startTransition(() => { + privateSetLaunchedProjects(currentState => { + const nextState = fn(currentState) + localStorage.set('launchedProjects', nextState) + return nextState + }) + }) + } + ) + + const addLaunchedProject = eventCallbacks.useEventCallback((project: Project) => { + setLaunchedProjects(currentState => [...currentState, project]) + }) + + const removeLaunchedProject = eventCallbacks.useEventCallback((projectId: Project['id']) => { + setLaunchedProjects(currentState => currentState.filter(({ id }) => id !== projectId)) + }) + + const clearLaunchedProjects = eventCallbacks.useEventCallback(() => { + setLaunchedProjects(() => []) + }) + + const setPage = eventCallbacks.useEventCallback((nextPage: Project['id'] | TabType) => { + privateSetPage(nextPage) + localStorage.set('page', nextPage) + }) + + const [assetListEvents, dispatchAssetListEvent] = + eventHooks.useEvent() + const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent() + const isCloud = categoryModule.isCloud(category) - const isUserEnabled = session.user.isEnabled + const isUserEnabled = user.isEnabled + + const selectedProject = launchedProjects.find(p => p.id === page) ?? null if (isCloud && !isUserEnabled && localBackend != null) { setTimeout(() => { @@ -173,115 +297,111 @@ export default function Dashboard(props: DashboardProps) { }) } - React.useEffect(() => { - setInitialized(true) - }, []) + const openProjectMutation = reactQuery.useMutation({ + mutationKey: ['openProject'], + networkMode: 'always', + mutationFn: ({ title, id, type, parentId }: Project) => { + const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend + + invariant(backend != null, 'Backend is null') + + return backend.openProject( + id, + { + executeAsync: false, + cognitoCredentials: { + accessToken: session.accessToken, + refreshToken: session.accessToken, + clientId: session.clientId, + expireAt: session.expireAt, + refreshUrl: session.refreshUrl, + }, + parentId, + }, + title + ) + }, + onMutate: ({ id }) => { + const queryKey = createGetProjectDetailsQuery.getQueryKey(id) - React.useEffect(() => { - const savedProjectStartupInfo = localStorage.get('projectStartupInfo') - if (initialProjectName != null) { - if (page === TabType.editor) { - setPage(TabType.drive) - } - } else if (savedProjectStartupInfo != null) { - switch (savedProjectStartupInfo.backendType) { - case backendModule.BackendType.remote: { - if (remoteBackend != null) { - setPage(TabType.drive) - void (async () => { - const abortController = new AbortController() - openProjectAbortControllerRef.current = abortController - try { - const oldProject = await remoteBackend.getProjectDetails( - savedProjectStartupInfo.projectAsset.id, - savedProjectStartupInfo.projectAsset.parentId, - savedProjectStartupInfo.projectAsset.title - ) - if (backendModule.IS_OPENING_OR_OPENED[oldProject.state.type]) { - const project = remoteBackend.waitUntilProjectIsReady( - savedProjectStartupInfo.projectAsset.id, - savedProjectStartupInfo.projectAsset.parentId, - savedProjectStartupInfo.projectAsset.title, - abortController.signal - ) - setProjectStartupInfo({ ...savedProjectStartupInfo, project }) - if (page === TabType.editor) { - setPage(page) - } - } - } catch { - setProjectStartupInfo(null) - } - })() - } - break - } - case backendModule.BackendType.local: { - if (localBackend != null) { - const project = localBackend - .openProject( - savedProjectStartupInfo.projectAsset.id, - { - executeAsync: false, - cognitoCredentials: null, - parentId: savedProjectStartupInfo.projectAsset.parentId, - }, - savedProjectStartupInfo.projectAsset.title - ) - .then(() => - localBackend.getProjectDetails( - savedProjectStartupInfo.projectAsset.id, - savedProjectStartupInfo.projectAsset.parentId, - savedProjectStartupInfo.projectAsset.title - ) - ) - .catch(error => { - setProjectStartupInfo(null) - throw error - }) - setProjectStartupInfo({ ...savedProjectStartupInfo, project }) - if (page === TabType.editor) { - setPage(page) - } - } - } - } - } - // This MUST only run when the component is mounted. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } }) + + void client.cancelQueries({ queryKey }) + void client.invalidateQueries({ queryKey }) + }, + onError: async (_, { id }) => { + await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }) + }, + }) + + const closeProjectMutation = reactQuery.useMutation({ + mutationKey: ['closeProject'], + mutationFn: async ({ type, id, title }: Project) => { + const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend + + invariant(backend != null, 'Backend is null') + + return backend.closeProject(id, title) + }, + onMutate: ({ id }) => { + const queryKey = createGetProjectDetailsQuery.getQueryKey(id) + + client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } }) + + void client.cancelQueries({ queryKey }) + void client.invalidateQueries({ queryKey }) + }, + onSuccess: (_, { id }) => + client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }), + onError: (_, { id }) => + client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }), + }) + + const client = reactQuery.useQueryClient() + + const renameProjectMutation = reactQuery.useMutation({ + mutationFn: ({ newName, project }: { newName: string; project: Project }) => { + const { parentId, type, id, title } = project + const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend + + invariant(backend != null, 'Backend is null') + + return backend.updateProject( + id, + { projectName: newName, ami: null, ideVersion: null, parentId }, + title + ) + }, + onSuccess: (_, { project }) => + client.invalidateQueries({ + queryKey: createGetProjectDetailsQuery.getQueryKey(project.id), + }), + }) eventHooks.useEventHandler(assetEvents, event => { switch (event.type) { case AssetEventType.openProject: { - openProjectAbortControllerRef.current?.abort() - openProjectAbortControllerRef.current = null + const { title, parentId, backendType, id, runInBackground } = event + doOpenProject( + { title, parentId, type: backendType, id }, + { openInBackground: runInBackground } + ) + break + } + case AssetEventType.closeProject: { + const { title, parentId, backendType, id } = event + doCloseProject({ title, parentId, type: backendType, id }) break } default: { - // Ignored. + // Ignored. Any missing project-related events should be handled by `ProjectNameColumn`. + // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` + // are handled by`AssetRow`. break } } }) - React.useEffect(() => { - if (initializedRef.current) { - if (projectStartupInfo != null) { - // This is INTENTIONAL - `project` is intentionally omitted from this object. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { project, ...rest } = projectStartupInfo - localStorage.set('projectStartupInfo', rest) - } else { - localStorage.delete('projectStartupInfo') - } - } - }, [projectStartupInfo, localStorage]) - - React.useEffect(() => { - localStorage.set('page', page) - }, [page, localStorage]) - React.useEffect( () => inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { @@ -320,40 +440,125 @@ export default function Dashboard(props: DashboardProps) { } }, [inputBindings]) - const doOpenEditor = React.useCallback(() => { - setPage(TabType.editor) - }, [setPage]) - - const doCloseEditor = React.useCallback( - (id: backendModule.ProjectId) => { - if (id === projectStartupInfo?.projectAsset.id) { - setProjectStartupInfo(currentInfo => { - if (id === currentInfo?.projectAsset.id) { - setPage(TabType.drive) - return null - } else { - return currentInfo - } + const doOpenProject = eventCallbacks.useEventCallback( + (project: Project, options: OpenProjectOptions = {}) => { + const { openInBackground = true } = options + + // since we don't support multitabs, we need to close opened project first + if (launchedProjects.length > 0) { + doCloseAllProjects() + } + + const isOpeningTheSameProject = + client.getMutationCache().find({ + mutationKey: ['openProject'], + predicate: mutation => mutation.options.scope?.id === project.id, + })?.state.status === 'pending' + + if (!isOpeningTheSameProject) { + openProjectMutation.mutate(project) + + const openingProjectMutation = client.getMutationCache().find({ + mutationKey: ['openProject'], + // this is unsafe, but we can't do anything about it + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + predicate: mutation => mutation.state.variables?.id === project.id, + }) + + openingProjectMutation?.setOptions({ + ...openingProjectMutation.options, + scope: { id: project.id }, }) + + addLaunchedProject(project) + + if (!openInBackground) { + doOpenEditor(project.id) + } } - }, - [projectStartupInfo?.projectAsset.id, setPage] + } ) - const doRemoveSelf = React.useCallback(() => { - if (projectStartupInfo?.projectAsset != null) { - const id = projectStartupInfo.projectAsset.id - dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id }) - setProjectStartupInfo(null) + const doOpenEditor = eventCallbacks.useEventCallback((projectId: Project['id']) => { + React.startTransition(() => { + setPage(projectId) + }) + }) + + const doCloseProject = eventCallbacks.useEventCallback((project: Project) => { + client + .getMutationCache() + .findAll({ + mutationKey: ['openProject'], + predicate: mutation => mutation.options.scope?.id === project.id, + }) + .forEach(mutation => { + mutation.setOptions({ ...mutation.options, retry: false }) + mutation.destroy() + }) + + closeProjectMutation.mutate(project) + + client + .getMutationCache() + .findAll({ + mutationKey: ['closeProject'], + // this is unsafe, but we can't do anything about it + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + predicate: mutation => mutation.state.variables?.id === project.id, + }) + .forEach(mutation => { + mutation.setOptions({ ...mutation.options, scope: { id: project.id } }) + }) + + removeLaunchedProject(project.id) + + setPage(TabType.drive) + }) + + const doCloseAllProjects = eventCallbacks.useEventCallback(() => { + for (const launchedProject of launchedProjects) { + doCloseProject(launchedProject) } - }, [projectStartupInfo?.projectAsset, dispatchAssetListEvent]) + }) - const onSignOut = React.useCallback(() => { - if (page === TabType.editor) { - setPage(TabType.drive) + const doRemoveSelf = eventCallbacks.useEventCallback((project: Project) => { + dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id: project.id }) + doCloseProject(project) + }) + + const onSignOut = eventCallbacks.useEventCallback(() => { + setPage(TabType.drive) + doCloseAllProjects() + clearLaunchedProjects() + }) + + const doOpenShareModal = eventCallbacks.useEventCallback(() => { + if (assetManagementApiRef.current != null && selectedProject != null) { + const asset = assetManagementApiRef.current.getAsset(selectedProject.id) + const self = + asset?.permissions?.find( + backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId) + ) ?? null + + if (asset != null && self != null) { + setModal( + { + const nextAsset = updater instanceof Function ? updater(asset) : updater + assetManagementApiRef.current?.setAsset(asset.id, nextAsset) + }} + self={self} + doRemoveSelf={() => { + doRemoveSelf(selectedProject) + }} + eventTarget={null} + /> + ) + } } - setProjectStartupInfo(null) - }, [page, setPage]) + }) return ( @@ -377,27 +582,28 @@ export default function Dashboard(props: DashboardProps) { > {getText('drivePageName')} - {projectStartupInfo != null && ( + + {launchedProjects.map(project => ( { - setPage(TabType.editor) + setPage(project.id) }} onClose={() => { - dispatchAssetEvent({ - type: AssetEventType.closeProject, - id: projectStartupInfo.projectAsset.id, - }) - setProjectStartupInfo(null) - setPage(TabType.drive) + doCloseProject(project) + }} + onLoadEnd={() => { + doOpenEditor(project.id) }} > - {projectStartupInfo.projectAsset.title} + {project.title} - )} + ))} + {page === TabType.settings && ( )} + { setPage(TabType.settings) }} onSignOut={onSignOut} />
+