From 914b7bca5e26ef69c3baf383641bdc1f278f01b8 Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Tue, 29 Oct 2024 08:06:21 -0400 Subject: [PATCH] feat(import): github import wizard (#6507) This PR adds a Github import wizard, tracking the progress of importing a project from Github, showing errors or warnings. The design is still preliminary, but it is hidden behind a feature switch so that the logic can be reviewed and merges, in order not to create a long living PR. It supports error/warning states: image ## PR Includes: 1. Adding three actions - `SetImportWizardOpen` - controlling the wizard UI - `UpdateImportOperations` - updating the results of individual import operations (desc. to follow). - `UpdateProjectRequirements` - updating the results of checking and fixing Utopia specific requirements 2. Utilities to start/end tracking of import operations, their results and timings. 3. Tracked import operations - tracking hooks are being called at appropriate places in the code. - Loading the code from Github - Parsing the files - Checking specific Utopia requirements - Loading dependencies 4. Utopia checks: - storyboard file (created if not present) - `utopia` entry in package.json - project composition (JS/TS) 5. Not tracked in this PR but will in subsequent ones (can be added modularily): - React version - currently a placeholder - Server side packages - mainly Node builtins - Style frameworks 6. The wizard UI itself - showing progress and results (still preliminary) **Note for reviewers:** there are a lot of changed files due to adding new actions and passing down the `dispatch` parameter, but the main logic is contained in the new services and the wizard React components. **Manual Tests:** I hereby swear that: - [X] I opened a hydrogen project and it loaded - [X] I could navigate to various routes in Play mode --- editor/src/components/editor/action-types.ts | 24 ++ .../editor/actions/action-creators.ts | 35 ++ .../components/editor/actions/action-utils.ts | 3 + .../editor/actions/actions.spec.tsx | 2 + .../src/components/editor/actions/actions.tsx | 82 ++++- .../components/editor/editor-component.tsx | 12 +- .../editor/import-wizard/components.tsx | 320 ++++++++++++++++++ .../editor/import-wizard/import-wizard.tsx | 232 +++++++++++++ .../src/components/editor/store/dispatch.tsx | 2 +- .../components/editor/store/editor-state.ts | 20 ++ .../components/editor/store/editor-update.tsx | 8 +- .../store/store-deep-equality-instances.ts | 69 ++++ .../store/store-hook-substore-helpers.ts | 6 +- .../editor/store/store-hook-substore-types.ts | 8 +- .../components/navigator/dependency-list.tsx | 35 +- .../package-manager/fetch-packages.ts | 113 ++++--- .../package-manager/package-manager.spec.ts | 8 + editor/src/core/shared/dependencies.ts | 34 +- .../shared/github/operations/load-branch.ts | 34 +- .../shared/import/import-operation-service.ts | 149 ++++++++ .../shared/import/import-operation-types.ts | 84 +++++ .../check-utopia-requirements.ts | 154 +++++++++ .../utopia-requirements-service.ts | 134 ++++++++ .../utopia-requirements-types.ts | 76 +++++ editor/src/utils/feature-switches.ts | 3 + 25 files changed, 1569 insertions(+), 78 deletions(-) create mode 100644 editor/src/components/editor/import-wizard/components.tsx create mode 100644 editor/src/components/editor/import-wizard/import-wizard.tsx create mode 100644 editor/src/core/shared/import/import-operation-service.ts create mode 100644 editor/src/core/shared/import/import-operation-types.ts create mode 100644 editor/src/core/shared/import/proejct-health-check/check-utopia-requirements.ts create mode 100644 editor/src/core/shared/import/proejct-health-check/utopia-requirements-service.ts create mode 100644 editor/src/core/shared/import/proejct-health-check/utopia-requirements-types.ts diff --git a/editor/src/components/editor/action-types.ts b/editor/src/components/editor/action-types.ts index f841a7836ecd..97d9ed588648 100644 --- a/editor/src/components/editor/action-types.ts +++ b/editor/src/components/editor/action-types.ts @@ -87,6 +87,11 @@ import type { Optic } from '../../core/shared/optics/optics' import { makeOptic } from '../../core/shared/optics/optics' import type { ElementPathTrees } from '../../core/shared/element-path-tree' import { assertNever } from '../../core/shared/utils' +import type { + ImportOperation, + ImportOperationAction, +} from '../../core/shared/import/import-operation-types' +import type { ProjectRequirements } from '../../core/shared/import/proejct-health-check/utopia-requirements-types' export { isLoggedIn, loggedInUser, notLoggedIn } from '../../common/user' export type { LoginState, UserDetails } from '../../common/user' @@ -997,6 +1002,22 @@ export interface UpdateGithubOperations { type: GithubOperationType } +export interface UpdateImportOperations { + action: 'UPDATE_IMPORT_OPERATIONS' + operations: ImportOperation[] + type: ImportOperationAction +} + +export interface UpdateProjectRequirements { + action: 'UPDATE_PROJECT_REQUIREMENTS' + requirements: Partial +} + +export interface SetImportWizardOpen { + action: 'SET_IMPORT_WIZARD_OPEN' + open: boolean +} + export interface SetRefreshingDependencies { action: 'SET_REFRESHING_DEPENDENCIES' value: boolean @@ -1354,6 +1375,9 @@ export type EditorAction = | UpdateAgainstGithub | SetImageDragSessionState | UpdateGithubOperations + | UpdateImportOperations + | UpdateProjectRequirements + | SetImportWizardOpen | UpdateBranchContents | SetRefreshingDependencies | ApplyCommandsAction diff --git a/editor/src/components/editor/actions/action-creators.ts b/editor/src/components/editor/actions/action-creators.ts index 5061e880e8aa..0f6acf5df9d3 100644 --- a/editor/src/components/editor/actions/action-creators.ts +++ b/editor/src/components/editor/actions/action-creators.ts @@ -237,6 +237,9 @@ import type { ToggleDataCanCondense, UpdateMetadataInEditorState, SetErrorBoundaryHandling, + SetImportWizardOpen, + UpdateImportOperations, + UpdateProjectRequirements, } from '../action-types' import type { InsertionSubjectWrapper, Mode } from '../editor-modes' import { EditorModes, insertionSubject } from '../editor-modes' @@ -268,6 +271,11 @@ import type { Collaborator } from '../../../core/shared/multiplayer' import type { PageTemplate } from '../../canvas/remix/remix-utils' import type { Bounds } from 'utopia-vscode-common' import type { ElementPathTrees } from '../../../core/shared/element-path-tree' +import type { + ImportOperation, + ImportOperationAction, +} from '../../../core/shared/import/import-operation-types' +import type { ProjectRequirements } from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' export function clearSelection(): EditorAction { return { @@ -1591,6 +1599,33 @@ export function resetCanvas(): ResetCanvas { } } +export function updateImportOperations( + operations: ImportOperation[], + type: ImportOperationAction, +): UpdateImportOperations { + return { + action: 'UPDATE_IMPORT_OPERATIONS', + operations: operations, + type: type, + } +} + +export function updateProjectRequirements( + requirements: Partial, +): UpdateProjectRequirements { + return { + action: 'UPDATE_PROJECT_REQUIREMENTS', + requirements: requirements, + } +} + +export function setImportWizardOpen(open: boolean): SetImportWizardOpen { + return { + action: 'SET_IMPORT_WIZARD_OPEN', + open: open, + } +} + export function setFilebrowserDropTarget(target: string | null): SetFilebrowserDropTarget { return { action: 'SET_FILEBROWSER_DROPTARGET', diff --git a/editor/src/components/editor/actions/action-utils.ts b/editor/src/components/editor/actions/action-utils.ts index 2cba7eaa684a..21bf6e0852ba 100644 --- a/editor/src/components/editor/actions/action-utils.ts +++ b/editor/src/components/editor/actions/action-utils.ts @@ -139,6 +139,9 @@ export function isTransientAction(action: EditorAction): boolean { case 'RESET_ONLINE_STATE': case 'INCREASE_ONLINE_STATE_FAILURE_COUNT': case 'SET_ERROR_BOUNDARY_HANDLING': + case 'SET_IMPORT_WIZARD_OPEN': + case 'UPDATE_IMPORT_OPERATIONS': + case 'UPDATE_PROJECT_REQUIREMENTS': return true case 'TRUE_UP_ELEMENTS': diff --git a/editor/src/components/editor/actions/actions.spec.tsx b/editor/src/components/editor/actions/actions.spec.tsx index d30e9ff2d7c2..037c806da975 100644 --- a/editor/src/components/editor/actions/actions.spec.tsx +++ b/editor/src/components/editor/actions/actions.spec.tsx @@ -1133,6 +1133,7 @@ describe('UPDATE_FROM_WORKER', () => { updateToCheck, startingEditorState, defaultUserState, + NO_OP, ) // Check that the model hasn't changed, because of the stale revised time. @@ -1180,6 +1181,7 @@ describe('UPDATE_FROM_WORKER', () => { updateToCheck, startingEditorState, defaultUserState, + NO_OP, ) // Get the same values that we started with but from the updated editor state. diff --git a/editor/src/components/editor/actions/actions.tsx b/editor/src/components/editor/actions/actions.tsx index 9f5793794639..090d109b6898 100644 --- a/editor/src/components/editor/actions/actions.tsx +++ b/editor/src/components/editor/actions/actions.tsx @@ -352,6 +352,9 @@ import type { ToggleDataCanCondense, UpdateMetadataInEditorState, SetErrorBoundaryHandling, + SetImportWizardOpen, + UpdateImportOperations, + UpdateProjectRequirements, } from '../action-types' import { isAlignment, isLoggedIn } from '../action-types' import type { Mode } from '../editor-modes' @@ -626,6 +629,13 @@ import { canCondenseJSXElementChild } from '../../../utils/can-condense' import { getNavigatorTargetsFromEditorState } from '../../navigator/navigator-utils' import { getParseCacheOptions } from '../../../core/shared/parse-cache-utils' import { styleP } from '../../inspector/inspector-common' +import { getUpdateOperationResult } from '../../../core/shared/import/import-operation-service' +import { + notifyCheckingRequirement, + notifyResolveRequirement, + updateRequirements, +} from '../../../core/shared/import/proejct-health-check/utopia-requirements-service' +import { RequirementResolutionResult } from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' import { applyValuesAtPath, deleteValuesAtPath } from '../../canvas/commands/utils/property-utils' export const MIN_CODE_PANE_REOPEN_WIDTH = 100 @@ -1030,6 +1040,9 @@ export function restoreEditorState( githubSettings: currentEditor.githubSettings, imageDragSessionState: currentEditor.imageDragSessionState, githubOperations: currentEditor.githubOperations, + importOperations: currentEditor.importOperations, + projectRequirements: currentEditor.projectRequirements, + importWizardOpen: currentEditor.importWizardOpen, branchOriginContents: currentEditor.branchOriginContents, githubData: currentEditor.githubData, refreshingDependencies: currentEditor.refreshingDependencies, @@ -1627,19 +1640,44 @@ function createStoryboardFileWithPlaceholderContents( } export function createStoryboardFileIfNecessary( + dispatch: EditorDispatch, projectContents: ProjectContentTreeRoot, createPlaceholder: 'create-placeholder' | 'skip-creating-placeholder', ): ProjectContentTreeRoot { + notifyCheckingRequirement(dispatch, 'storyboard', 'Checking for storyboard.js') const storyboardFile = getProjectFileByFilePath(projectContents, StoryboardFilePath) if (storyboardFile != null) { + notifyResolveRequirement( + dispatch, + 'storyboard', + RequirementResolutionResult.Found, + 'Storyboard.js found', + ) return projectContents } - return ( + const result = createStoryboardFileIfRemixProject(projectContents) ?? createStoryboardFileIfMainComponentPresent(projectContents) ?? createStoryboardFileWithPlaceholderContents(projectContents, createPlaceholder) - ) + + if (result == projectContents) { + notifyResolveRequirement( + dispatch, + 'storyboard', + RequirementResolutionResult.Partial, + 'Storyboard.js skipped', + ) + } else { + notifyResolveRequirement( + dispatch, + 'storyboard', + RequirementResolutionResult.Fixed, + 'Storyboard.js created', + ) + } + + return result } // JS Editor Actions: @@ -2229,6 +2267,34 @@ export const UPDATE_FNS = { githubOperations: operations, } }, + UPDATE_IMPORT_OPERATIONS: (action: UpdateImportOperations, editor: EditorModel): EditorModel => { + const resultImportOperations = getUpdateOperationResult( + editor.importOperations, + action.operations, + action.type, + ) + return { + ...editor, + importOperations: resultImportOperations, + } + }, + UPDATE_PROJECT_REQUIREMENTS: ( + action: UpdateProjectRequirements, + editor: EditorModel, + dispatch: EditorDispatch, + ): EditorModel => { + const result = updateRequirements(dispatch, editor.projectRequirements, action.requirements) + return { + ...editor, + projectRequirements: result, + } + }, + SET_IMPORT_WIZARD_OPEN: (action: SetImportWizardOpen, editor: EditorModel): EditorModel => { + return { + ...editor, + importWizardOpen: action.open, + } + }, SET_REFRESHING_DEPENDENCIES: ( action: SetRefreshingDependencies, editor: EditorModel, @@ -3947,6 +4013,7 @@ export const UPDATE_FNS = { action: UpdateFromWorker, editor: EditorModel, userState: UserState, + dispatch: EditorDispatch, ): EditorModel => { let workingProjectContents: ProjectContentTreeRoot = editor.projectContents let anyParsedUpdates: boolean = false @@ -3983,6 +4050,7 @@ export const UPDATE_FNS = { return { ...editor, projectContents: createStoryboardFileIfNecessary( + dispatch, workingProjectContents, // If we are in the process of cloning a Github repository, do not create placeholder Storyboard userState.githubState.gitRepoToLoad != null @@ -4808,7 +4876,11 @@ export const UPDATE_FNS = { propertyControlsInfo: action.propertyControlsInfo, } }, - UPDATE_TEXT: (action: UpdateText, editorStore: EditorStoreUnpatched): EditorStoreUnpatched => { + UPDATE_TEXT: ( + action: UpdateText, + editorStore: EditorStoreUnpatched, + dispatch: EditorDispatch, + ): EditorStoreUnpatched => { const { textProp } = action // This flag is useful when editing conditional expressions: // if the edited element is a js expression AND the content is still between curly brackets after editing, @@ -5006,6 +5078,7 @@ export const UPDATE_FNS = { updateFromWorker(workerUpdates), withFileChanges.unpatchedEditor, withFileChanges.userState, + dispatch, ) return { ...withFileChanges, @@ -5621,7 +5694,7 @@ export const UPDATE_FNS = { requestedNpmDependency('tailwindcss', tailwindVersion.version), requestedNpmDependency('postcss', postcssVersion.version), ] - void fetchNodeModules(updatedNpmDeps, builtInDependencies).then( + void fetchNodeModules(dispatch, updatedNpmDeps, builtInDependencies).then( (fetchNodeModulesResult) => { const loadedPackagesStatus = createLoadedPackageStatusMapFromDependencies( updatedNpmDeps, @@ -6331,6 +6404,7 @@ export async function load( const migratedModel = applyMigrations(model) const npmDependencies = dependenciesWithEditorRequirements(migratedModel.projectContents) const fetchNodeModulesResult = await fetchNodeModules( + dispatch, npmDependencies, builtInDependencies, retryFetchNodeModules, diff --git a/editor/src/components/editor/editor-component.tsx b/editor/src/components/editor/editor-component.tsx index e4d1cfacfe0b..c7bcd6d1cbe1 100644 --- a/editor/src/components/editor/editor-component.tsx +++ b/editor/src/components/editor/editor-component.tsx @@ -96,6 +96,7 @@ import { navigatorTargetsSelector, navigatorTargetsSelectorNavigatorTargets, } from '../navigator/navigator-utils' +import { ImportWizard } from './import-wizard/import-wizard' const liveModeToastId = 'play-mode-toast' @@ -520,6 +521,7 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { + {portalTarget != null ? ReactDOM.createPortal(, portalTarget) @@ -683,6 +685,12 @@ const LockedOverlay = React.memo(() => { 'LockedOverlay refreshingDependencies', ) + const importWizardOpen = useEditorState( + Substores.restOfEditor, + (store) => store.editor.importWizardOpen, + 'LockedOverlay importWizardOpen', + ) + const forking = useEditorState( Substores.restOfEditor, (store) => store.editor.forking, @@ -699,8 +707,8 @@ const LockedOverlay = React.memo(() => { ` const locked = React.useMemo(() => { - return editorLocked || refreshingDependencies || forking - }, [editorLocked, refreshingDependencies, forking]) + return (editorLocked || refreshingDependencies || forking) && !importWizardOpen + }, [editorLocked, refreshingDependencies, forking, importWizardOpen]) const dialogContent = React.useMemo((): string | null => { if (refreshingDependencies) { diff --git a/editor/src/components/editor/import-wizard/components.tsx b/editor/src/components/editor/import-wizard/components.tsx new file mode 100644 index 000000000000..b21b9956e78d --- /dev/null +++ b/editor/src/components/editor/import-wizard/components.tsx @@ -0,0 +1,320 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import type { + ImportCheckRequirementAndFix, + ImportFetchDependency, + ImportOperation, +} from '../../../core/shared/import/import-operation-types' +import { ImportOperationResult } from '../../../core/shared/import/import-operation-types' +import { assertNever } from '../../../core/shared/utils' +import { Icons } from '../../../uuiui' +import { GithubSpinner } from '../../../components/navigator/left-pane/github-pane/github-spinner' +import { RequirementResolutionResult } from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' + +export function OperationLine({ operation }: { operation: ImportOperation }) { + const operationRunningStatus = React.useMemo(() => { + return operation.timeStarted == null + ? 'waiting' + : operation.timeDone == null + ? 'running' + : 'done' + }, [operation.timeStarted, operation.timeDone]) + + const textColor = React.useMemo( + () => getTextColor(operationRunningStatus, operation), + [operationRunningStatus, operation], + ) + + const [childrenShown, serChildrenShown] = React.useState(false) + const shouldShowChildren = React.useMemo( + () => childrenShown || operation.timeDone == null, + [childrenShown, operation.timeDone], + ) + const hasChildren = React.useMemo( + () => operation.children != null && operation.children.length > 0, + [operation.children], + ) + const toggleShowChildren = React.useCallback(() => { + if (hasChildren) { + serChildrenShown((shown) => !shown) + } + }, [hasChildren]) + + return ( + + + +
{getImportOperationText(operation)}
+
+ +
+ {hasChildren ? ( +
+ {shouldShowChildren ? : } +
+ ) : null} +
+ {shouldShowChildren && hasChildren ? : null} +
+ ) +} + +function OperationChildrenList({ operation }: { operation: ImportOperation }) { + if (operation.children == null || operation.children.length === 0) { + return null + } + return ( +
+ {operation.type === 'refreshDependencies' ? ( + + ) : operation.type === 'checkRequirements' ? ( + operation.children.map((childOperation) => ( + + )) + ) : null} +
+ ) +} +const dependenciesSuccessFn = (op: ImportFetchDependency) => + op.result === ImportOperationResult.Success +const dependenciesSuccessTextFn = (successCount: number) => + `${successCount} dependencies fetched successfully` +const requirementsSuccessFn = (op: ImportCheckRequirementAndFix) => + op.resolution === RequirementResolutionResult.Found +const requirementsSuccessTextFn = (successCount: number) => `${successCount} requirements met` + +function AggregatedChildrenStatus({ + childOperations, + successFn, + successTextFn, +}: { + childOperations: T[] + successFn: (operation: T) => boolean + successTextFn: (successCount: number) => string +}) { + const doneDependencies = childOperations.filter(successFn) + const restOfDependencies = childOperations.filter((op) => !successFn(op)) + return ( + + {doneDependencies.length > 0 ? ( + + + +
{successTextFn(doneDependencies.length)}
+
+
+ ) : null} + {restOfDependencies.map((operation) => ( + + ))} +
+ ) +} + +function OperationIcon({ + runningStatus, + result, +}: { + runningStatus: 'waiting' | 'running' | 'done' + result?: ImportOperationResult +}) { + const iconColorStyle = React.useMemo( + () => (result != null ? getIconColorStyle(result) : {}), + [result], + ) + if (runningStatus === 'running') { + return + } else if (runningStatus === 'done' && result === 'success') { + return + } else if (runningStatus === 'done' && result === 'warn') { + return + } else if (runningStatus === 'waiting') { + return + } else { + return + } +} + +function TimeFromInSeconds({ + operation, + runningStatus, +}: { + operation: ImportOperation + runningStatus: 'waiting' | 'running' | 'done' +}) { + const [currentTime, setCurrentTime] = React.useState(Date.now()) + React.useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(Date.now()) + }, 1000) + return () => clearInterval(interval) + }, []) + const operationTime = React.useMemo(() => { + if (operation.timeStarted == null) { + return 0 + } + if (operation.timeDone == null) { + return currentTime - operation.timeStarted + } + return operation.timeDone - operation.timeStarted + }, [operation.timeStarted, operation.timeDone, currentTime]) + const timeInSeconds = + operation.timeDone != null + ? (operationTime / 1000).toFixed(2) + : Math.max(Math.floor(operationTime / 1000), 0) + return operation.timeStarted == null ? null : ( +
+ {timeInSeconds}s +
+ ) +} + +function OperationLineWrapper({ + children, + className, + onClick, +}: { + children: React.ReactNode + className: string + onClick?: () => void +}) { + return ( +
&': { + paddingLeft: 26, + fontSize: 12, + img: { + width: 12, + height: 12, + }, + }, + '.import-wizard-operation-children .operation-done [data-short-time=true]': { + visibility: 'hidden', + }, + }} + onClick={onClick} + > + {children} +
+ ) +} + +function OperationLineContent({ + children, + textColor, +}: { + children: React.ReactNode + textColor: string +}) { + return ( +
+ {children} +
+ ) +} + +function getImportOperationText(operation: ImportOperation): React.ReactNode { + if (operation.text != null) { + return operation.text + } + switch (operation.type) { + case 'loadBranch': + return ( + + Loading branch{' '} + + {operation.githubRepo?.owner}/{operation.githubRepo?.repository}@{operation.branchName} + + + ) + case 'fetchDependency': + return `Fetching ${operation.dependencyName}@${operation.dependencyVersion}` + case 'parseFiles': + return 'Parsing files' + case 'refreshDependencies': + return 'Fetching dependencies' + case 'checkRequirements': + return 'Checking Utopia requirements' + case 'checkRequirementAndFix': + return operation.text + default: + assertNever(operation) + } +} + +function getTextColor( + operationRunningStatus: 'waiting' | 'running' | 'done', + operation: ImportOperation, +) { + if (operationRunningStatus === 'waiting') { + return 'gray' + } else { + return 'black' + } +} + +function getIconColorStyle(result: ImportOperationResult) { + // temp solution since we currently only have black icons + // https://codepen.io/sosuke/pen/Pjoqqp + if (result === ImportOperationResult.Error) { + return { + // our error red + filter: + 'invert(14%) sepia(99%) saturate(4041%) hue-rotate(328deg) brightness(101%) contrast(115%)', + } + } else if (result === ImportOperationResult.Warn) { + return { + // orange + filter: + 'invert(72%) sepia(90%) saturate(3088%) hue-rotate(1deg) brightness(105%) contrast(104%)', + } + } else if (result === ImportOperationResult.Success) { + return { + // green + filter: + 'invert(72%) sepia(60%) saturate(3628%) hue-rotate(126deg) brightness(104%) contrast(76%)', + } + } + return {} +} diff --git a/editor/src/components/editor/import-wizard/import-wizard.tsx b/editor/src/components/editor/import-wizard/import-wizard.tsx new file mode 100644 index 000000000000..ca6282300b4d --- /dev/null +++ b/editor/src/components/editor/import-wizard/import-wizard.tsx @@ -0,0 +1,232 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import React from 'react' +import { jsx } from '@emotion/react' +import { getProjectID } from '../../../common/env-vars' +import { Button, FlexRow, Icons, useColorTheme, UtopiaStyles } from '../../../uuiui' +import { useEditorState, Substores } from '../store/store-hook' +import { when } from '../../../utils/react-conditionals' +import { hideImportWizard } from '../../../core/shared/import/import-operation-service' +import { OperationLine } from './components' +import { ImportOperationResult } from '../../../core/shared/import/import-operation-types' +import { assertNever } from '../../../core/shared/utils' +import { useDispatch } from '../store/dispatch-context' + +export const ImportWizard = React.memo(() => { + const colorTheme = useColorTheme() + const projectId = getProjectID() + + const importWizardOpen: boolean = useEditorState( + Substores.restOfEditor, + (store) => store.editor.importWizardOpen, + 'ImportWizard importWizardOpen', + ) + + const operations = useEditorState( + Substores.github, + (store) => store.editor.importOperations, + 'ImportWizard operations', + ) + + const dispatch = useDispatch() + + const handleDismiss = React.useCallback(() => { + hideImportWizard(dispatch) + }, [dispatch]) + + const stopPropagation = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) + + const totalImportResult: ImportOperationResult | null = React.useMemo(() => { + let result: ImportOperationResult = ImportOperationResult.Success + for (const operation of operations) { + // if one of the operations is still running, we don't know the total result yet + if (operation.timeDone == null || operation.result == null) { + return null + } + // if any operation is an error, the total result is an error + if (operation.result == ImportOperationResult.Error) { + return ImportOperationResult.Error + } + // if any operation is at least a warn, the total result is a warn, + // but we also need to check if there are any errors + if (operation.result == ImportOperationResult.Warn) { + result = ImportOperationResult.Warn + } + } + return result + }, [operations]) + + if (projectId == null) { + return null + } + + return ( +
+ {when( + importWizardOpen, +
+ +
Project Import
+ {when( + totalImportResult != null, + , + )} +
+
+ {operations.map((operation) => ( + + ))} +
+
+ +
+
, + )} +
+ ) +}) +ImportWizard.displayName = 'ImportWizard' + +function ActionButtons({ importResult }: { importResult: ImportOperationResult | null }) { + const textColor = React.useMemo(() => { + switch (importResult) { + case ImportOperationResult.Success: + return 'green' + case ImportOperationResult.Warn: + return 'orange' + case ImportOperationResult.Error: + return 'var(--utopitheme-githubIndicatorFailed)' + case null: + return 'black' + default: + assertNever(importResult) + } + }, [importResult]) + const buttonColor = React.useMemo(() => { + switch (importResult) { + case ImportOperationResult.Success: + return 'var(--utopitheme-green)' + case ImportOperationResult.Warn: + return 'var(--utopitheme-githubMUDModified)' + case ImportOperationResult.Error: + return 'var(--utopitheme-githubIndicatorFailed)' + case null: + return 'black' + default: + assertNever(importResult) + } + }, [importResult]) + const textStyle = { + color: textColor, + fontSize: 16, + } + const buttonStyle = { + backgroundColor: buttonColor, + color: 'white', + padding: 20, + fontSize: 14, + cursor: 'pointer', + } + const dispatch = useDispatch() + const hideWizard = React.useCallback(() => { + hideImportWizard(dispatch) + }, [dispatch]) + if (importResult == ImportOperationResult.Success) { + return ( + +
Project Imported Successfully
+ +
+ ) + } + if (importResult == ImportOperationResult.Warn) { + return ( + +
Project Imported With Warnings
+ +
+ ) + } + if (importResult == ImportOperationResult.Error) { + return ( + +
Error Importing Project
+ +
+ ) + } + return null +} diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index f014fcbd20d6..83633b5b5bbe 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -189,7 +189,7 @@ function processAction( } if (action.action === 'UPDATE_TEXT') { - working = UPDATE_FNS.UPDATE_TEXT(action, working) + working = UPDATE_FNS.UPDATE_TEXT(action, working, dispatchEvent) } if (action.action === 'TRUNCATE_HISTORY') { diff --git a/editor/src/components/editor/store/editor-state.ts b/editor/src/components/editor/store/editor-state.ts index 800ae1ae55a3..bce7097d5f49 100644 --- a/editor/src/components/editor/store/editor-state.ts +++ b/editor/src/components/editor/store/editor-state.ts @@ -189,6 +189,11 @@ import type { OnlineState } from '../online-status' import type { NavigatorRow } from '../../navigator/navigator-row' import type { FancyError } from '../../../core/shared/code-exec-utils' import type { GridCellCoordinates } from '../../canvas/canvas-strategies/strategies/grid-cell-bounds' +import type { ImportOperation } from '../../../core/shared/import/import-operation-types' +import { + emptyProjectRequirements, + type ProjectRequirements, +} from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' const ObjectPathImmutable: any = OPI @@ -1452,6 +1457,9 @@ export interface EditorState { githubSettings: ProjectGithubSettings imageDragSessionState: ImageDragSessionState githubOperations: Array + importOperations: Array + projectRequirements: ProjectRequirements + importWizardOpen: boolean githubData: GithubData refreshingDependencies: boolean colorSwatches: Array @@ -1535,6 +1543,9 @@ export function editorState( githubSettings: ProjectGithubSettings, imageDragSessionState: ImageDragSessionState, githubOperations: Array, + importOperations: Array, + importWizardOpen: boolean, + projectRequirements: ProjectRequirements, branchOriginContents: ProjectContentTreeRoot | null, githubData: GithubData, refreshingDependencies: boolean, @@ -1619,6 +1630,9 @@ export function editorState( githubSettings: githubSettings, imageDragSessionState: imageDragSessionState, githubOperations: githubOperations, + importOperations: importOperations, + importWizardOpen: importWizardOpen, + projectRequirements: projectRequirements, githubData: githubData, refreshingDependencies: refreshingDependencies, colorSwatches: colorSwatches, @@ -2696,6 +2710,9 @@ export function createEditorState(dispatch: EditorDispatch): EditorState { githubSettings: emptyGithubSettings(), imageDragSessionState: notDragging(), githubOperations: [], + importOperations: [], + importWizardOpen: false, + projectRequirements: emptyProjectRequirements(), branchOriginContents: null, githubData: emptyGithubData(), refreshingDependencies: false, @@ -3063,6 +3080,9 @@ export function editorModelFromPersistentModel( githubSettings: persistentModel.githubSettings, imageDragSessionState: notDragging(), githubOperations: [], + importOperations: [], + importWizardOpen: false, + projectRequirements: emptyProjectRequirements(), refreshingDependencies: false, branchOriginContents: null, githubData: emptyGithubData(), diff --git a/editor/src/components/editor/store/editor-update.tsx b/editor/src/components/editor/store/editor-update.tsx index e663daa121e8..2c7ded3d89bc 100644 --- a/editor/src/components/editor/store/editor-update.tsx +++ b/editor/src/components/editor/store/editor-update.tsx @@ -220,6 +220,12 @@ export function runSimpleLocalEditorAction( return UPDATE_FNS.SET_REFRESHING_DEPENDENCIES(action, state) case 'UPDATE_GITHUB_OPERATIONS': return UPDATE_FNS.UPDATE_GITHUB_OPERATIONS(action, state) + case 'UPDATE_IMPORT_OPERATIONS': + return UPDATE_FNS.UPDATE_IMPORT_OPERATIONS(action, state) + case 'SET_IMPORT_WIZARD_OPEN': + return UPDATE_FNS.SET_IMPORT_WIZARD_OPEN(action, state) + case 'UPDATE_PROJECT_REQUIREMENTS': + return UPDATE_FNS.UPDATE_PROJECT_REQUIREMENTS(action, state, dispatch) case 'REMOVE_TOAST': return UPDATE_FNS.REMOVE_TOAST(action, state) case 'SET_HIGHLIGHTED_VIEWS': @@ -287,7 +293,7 @@ export function runSimpleLocalEditorAction( case 'REMOVE_FILE_CONFLICT': return UPDATE_FNS.REMOVE_FILE_CONFLICT(action, state) case 'UPDATE_FROM_WORKER': - return UPDATE_FNS.UPDATE_FROM_WORKER(action, state, userState) + return UPDATE_FNS.UPDATE_FROM_WORKER(action, state, userState, dispatch) case 'UPDATE_FROM_CODE_EDITOR': return UPDATE_FNS.UPDATE_FROM_CODE_EDITOR( action, diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts index 64722a41ff12..912ab4eb153c 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -643,6 +643,15 @@ import type { } from '../../../core/property-controls/component-descriptor-parser' import type { Axis } from '../../../components/canvas/gap-utils' import type { GridCellCoordinates } from '../../canvas/canvas-strategies/strategies/grid-cell-bounds' +import type { ImportOperation } from '../../../core/shared/import/import-operation-types' +import type { + ProjectRequirements, + RequirementResolution, +} from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' +import { + newProjectRequirements, + requirementResolution, +} from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' export function ElementPropertyPathKeepDeepEquality(): KeepDeepEqualityCall { return combine2EqualityCalls( @@ -4781,6 +4790,45 @@ export const ProjectGithubSettingsKeepDeepEquality: KeepDeepEqualityCall = + combine3EqualityCalls( + (resolution) => resolution.status, + createCallWithTripleEquals(), + (resolution) => resolution.value, + createCallWithTripleEquals(), + (resolution) => resolution.resolution, + createCallWithTripleEquals(), + requirementResolution, + ) + +export const ProjectRequirementsKeepDeepEquality: KeepDeepEqualityCall = + combine4EqualityCalls( + (requirements) => requirements.storyboard, + ProjectRequirementResolutionKeepDeepEquality, + (requirements) => requirements.packageJsonEntries, + ProjectRequirementResolutionKeepDeepEquality, + (requirements) => requirements.language, + ProjectRequirementResolutionKeepDeepEquality, + (requirements) => requirements.reactVersion, + ProjectRequirementResolutionKeepDeepEquality, + newProjectRequirements, + ) + +export const ImportOperationKeepDeepEquality: KeepDeepEqualityCall = ( + oldValue, + newValue, +) => { + if (oldValue.type !== newValue.type) { + return keepDeepEqualityResult(newValue, false) + } else if (oldValue.id !== newValue.id) { + return keepDeepEqualityResult(newValue, false) + } + return keepDeepEqualityResult(oldValue, true) +} + +export const ImportOperationsKeepDeepEquality: KeepDeepEqualityCall> = + arrayDeepEquality(ImportOperationKeepDeepEquality) + export const GithubFileChangesKeepDeepEquality: KeepDeepEqualityCall = combine3EqualityCalls( (settings) => settings.modified, @@ -5384,6 +5432,21 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( newValue.githubOperations, ) + const importOperationsResults = arrayDeepEquality(ImportOperationKeepDeepEquality)( + oldValue.importOperations, + newValue.importOperations, + ) + + const importWizardOpenResults = BooleanKeepDeepEquality( + oldValue.importWizardOpen, + newValue.importWizardOpen, + ) + + const projectRequirementsResults = ProjectRequirementsKeepDeepEquality( + oldValue.projectRequirements, + newValue.projectRequirements, + ) + const branchContentsResults = nullableDeepEquality(ProjectContentTreeRootKeepDeepEquality())( oldValue.branchOriginContents, newValue.branchOriginContents, @@ -5505,6 +5568,9 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( githubSettingsResults.areEqual && imageDragSessionStateEqual.areEqual && githubOperationsResults.areEqual && + importOperationsResults.areEqual && + importWizardOpenResults.areEqual && + projectRequirementsResults.areEqual && branchContentsResults.areEqual && githubDataResults.areEqual && refreshingDependenciesResults.areEqual && @@ -5591,6 +5657,9 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( githubSettingsResults.value, imageDragSessionStateEqual.value, githubOperationsResults.value, + importOperationsResults.value, + importWizardOpenResults.value, + projectRequirementsResults.value, branchContentsResults.value, githubDataResults.value, refreshingDependenciesResults.value, diff --git a/editor/src/components/editor/store/store-hook-substore-helpers.ts b/editor/src/components/editor/store/store-hook-substore-helpers.ts index 61e8565823a4..90a8cd6c7d7b 100644 --- a/editor/src/components/editor/store/store-hook-substore-helpers.ts +++ b/editor/src/components/editor/store/store-hook-substore-helpers.ts @@ -1,4 +1,5 @@ -import type { EditorState } from './editor-state' +import { type EditorState } from './editor-state' +import { emptyProjectRequirements } from '../../../core/shared/import/proejct-health-check/utopia-requirements-types' export const EmptyEditorStateForKeysOnly: EditorState = { id: null, @@ -157,6 +158,9 @@ export const EmptyEditorStateForKeysOnly: EditorState = { githubSettings: null as any, imageDragSessionState: null as any, githubOperations: [], + importOperations: [], + importWizardOpen: false, + projectRequirements: emptyProjectRequirements(), branchOriginContents: null, githubData: null as any, refreshingDependencies: false, diff --git a/editor/src/components/editor/store/store-hook-substore-types.ts b/editor/src/components/editor/store/store-hook-substore-types.ts index c6a0fd468326..b08ce4a6da76 100644 --- a/editor/src/components/editor/store/store-hook-substore-types.ts +++ b/editor/src/components/editor/store/store-hook-substore-types.ts @@ -174,7 +174,13 @@ export interface ThemeSubstate { } // GithubSubstate -export const githubSubstateKeys = ['githubSettings', 'githubOperations', 'githubData'] as const +export const githubSubstateKeys = [ + 'githubSettings', + 'githubOperations', + 'githubData', + 'importOperations', + 'projectRequirements', +] as const export const emptyGithubSubstate = { editor: pick(githubSubstateKeys, EmptyEditorStateForKeysOnly), } as const diff --git a/editor/src/components/navigator/dependency-list.tsx b/editor/src/components/navigator/dependency-list.tsx index b17107b3c26a..0460ec7a355c 100644 --- a/editor/src/components/navigator/dependency-list.tsx +++ b/editor/src/components/navigator/dependency-list.tsx @@ -194,22 +194,24 @@ class DependencyListInner extends React.PureComponent { - if (fetchNodeModulesResult.dependenciesWithError.length > 0) { - this.packagesUpdateFailed( - `Failed to download the following dependencies: ${JSON.stringify( - fetchNodeModulesResult.dependenciesWithError.map((d) => d.name), - )}`, - fetchNodeModulesResult.dependenciesWithError[0]?.name, - ) - } - this.setState({ dependencyLoadingStatus: 'not-loading' }) - this.props.editorDispatch([ - EditorActions.updateNodeModulesContents(fetchNodeModulesResult.nodeModules), - ]) - }, - ) + void fetchNodeModules( + this.props.editorDispatch, + npmDependencies, + this.props.builtInDependencies, + ).then((fetchNodeModulesResult) => { + if (fetchNodeModulesResult.dependenciesWithError.length > 0) { + this.packagesUpdateFailed( + `Failed to download the following dependencies: ${JSON.stringify( + fetchNodeModulesResult.dependenciesWithError.map((d) => d.name), + )}`, + fetchNodeModulesResult.dependenciesWithError[0]?.name, + ) + } + this.setState({ dependencyLoadingStatus: 'not-loading' }) + this.props.editorDispatch([ + EditorActions.updateNodeModulesContents(fetchNodeModulesResult.nodeModules), + ]) + }) this.setState({ dependencyLoadingStatus: 'removing' }) } @@ -358,6 +360,7 @@ class DependencyListInner extends React.PureComponent> { + try { + const matchingVersionResponse = await findMatchingVersion(dep.name, dep.version, 'skipFetch') + if (isPackageNotFound(matchingVersionResponse)) { + return left(failNotFound(dep)) + } + + const fetchResolvedDependency = shouldRetry + ? fetchPackagerResponseWithRetry + : fetchPackagerResponse + + const packagerResponse = await fetchResolvedDependency(dep, matchingVersionResponse.version) + + if (packagerResponse != null) { + /** + * to avoid clashing transitive dependencies, + * we "move" all transitive dependencies into a subfolder at + * /node_modules//node_modules// + * + * the module resolution won't mind this, the only downside to this approach is + * that if two main dependencies share the exact same version of a transitive + * dependency, they will not share that transitive dependency in memory, + * so this is wasting a bit of memory. + * + * but it avoids two of the same transitive dependencies with different versions from + * overwriting each other. + * + * the real nice solution would be to apply npm's module resolution logic that + * pulls up shared transitive dependencies to the main /node_modules/ folder. + */ + return right(mangleNodeModulePaths(dep.name, packagerResponse)) + } else { + return left(failError(dep)) + } + } catch (e) { + // TODO: proper error handling, now we don't show error for a missing package. The error will be visible when you try to import + return left(failError(dep)) + } +} + export async function fetchNodeModules( + dispatch: EditorDispatch, newDeps: Array, builtInDependencies: BuiltInDependencies, shouldRetry: boolean = true, @@ -285,54 +335,21 @@ export async function fetchNodeModules( (d) => !isBuiltInDependency(builtInDependencies, d.name), ) const nodeModulesArr = await Promise.all( - dependenciesToDownload.map( - async (newDep): Promise> => { - try { - const matchingVersionResponse = await findMatchingVersion( - newDep.name, - newDep.version, - 'skipFetch', - ) - if (isPackageNotFound(matchingVersionResponse)) { - return left(failNotFound(newDep)) - } - - const fetchResolvedDependency = shouldRetry - ? fetchPackagerResponseWithRetry - : fetchPackagerResponse - - const packagerResponse = await fetchResolvedDependency( - newDep, - matchingVersionResponse.version, - ) - - if (packagerResponse != null) { - /** - * to avoid clashing transitive dependencies, - * we "move" all transitive dependencies into a subfolder at - * /node_modules//node_modules// - * - * the module resolution won't mind this, the only downside to this approach is - * that if two main dependencies share the exact same version of a transitive - * dependency, they will not share that transitive dependency in memory, - * so this is wasting a bit of memory. - * - * but it avoids two of the same transitive dependencies with different versions from - * overwriting each other. - * - * the real nice solution would be to apply npm's module resolution logic that - * pulls up shared transitive dependencies to the main /node_modules/ folder. - */ - return right(mangleNodeModulePaths(newDep.name, packagerResponse)) - } else { - return left(failError(newDep)) - } - } catch (e) { - // TODO: proper error handling, now we don't show error for a missing package. The error will be visible when you try to import - return left(failError(newDep)) - } - }, - ), + dependenciesToDownload.map(async (dep) => { + const fetchDependencyOperation = { + type: 'fetchDependency', + id: `${dep.name}@${dep.version}`, + dependencyName: dep.name, + dependencyVersion: dep.version, + } as const + notifyOperationStarted(dispatch, fetchDependencyOperation) + const fetchResult = await fetchNodeModule(dep, shouldRetry) + const fetchStatus = isLeft(fetchResult) + ? ImportOperationResult.Error + : ImportOperationResult.Success + notifyOperationFinished(dispatch, fetchDependencyOperation, fetchStatus) + return fetchResult + }), ) const errors = nodeModulesArr .filter(isLeft) diff --git a/editor/src/core/es-modules/package-manager/package-manager.spec.ts b/editor/src/core/es-modules/package-manager/package-manager.spec.ts index a587ebe1619d..b8a1b9ccfb4c 100644 --- a/editor/src/core/es-modules/package-manager/package-manager.spec.ts +++ b/editor/src/core/es-modules/package-manager/package-manager.spec.ts @@ -174,6 +174,7 @@ describe('ES Dependency Manager — Real-life packages', () => { }, ) const fetchNodeModulesResult = await fetchNodeModules( + NO_OP, [requestedNpmDependency('react-spring', '8.0.27')], createBuiltInDependenciesList(null), ) @@ -209,6 +210,7 @@ describe('ES Dependency Manager — Real-life packages', () => { }, ) void fetchNodeModules( + NO_OP, [requestedNpmDependency('antd', '4.2.5')], createBuiltInDependenciesList(null), ).then((fetchNodeModulesResult) => { @@ -280,6 +282,7 @@ describe('ES Dependency Manager', () => { }, ) const fetchNodeModulesResult = await fetchNodeModules( + NO_OP, [requestedNpmDependency('broken', '1.0.0')], createBuiltInDependenciesList(null), ) @@ -315,6 +318,7 @@ describe('ES Dependency Manager — d.ts', () => { ) const fetchNodeModulesResult = await fetchNodeModules( + NO_OP, [requestedNpmDependency('react-spring', '8.0.27')], createBuiltInDependenciesList(null), ) @@ -350,6 +354,7 @@ describe('ES Dependency Manager — Downloads extra files as-needed', () => { }, ) void fetchNodeModules( + NO_OP, [requestedNpmDependency('mypackage', '0.0.1')], createBuiltInDependenciesList(null), ).then((fetchNodeModulesResult) => { @@ -416,6 +421,7 @@ describe('ES Dependency manager - retry behavior', () => { ) void fetchNodeModules( + NO_OP, [requestedNpmDependency('react-spring', '8.0.27')], createBuiltInDependenciesList(null), ).then((fetchNodeModulesResult) => { @@ -438,6 +444,7 @@ describe('ES Dependency manager - retry behavior', () => { ) void fetchNodeModules( + NO_OP, [requestedNpmDependency('react-spring', '8.0.27')], createBuiltInDependenciesList(null), ).then((fetchNodeModulesResult) => { @@ -466,6 +473,7 @@ describe('ES Dependency manager - retry behavior', () => { ) void fetchNodeModules( + NO_OP, [requestedNpmDependency('react-spring', '8.0.27')], createBuiltInDependenciesList(null), false, diff --git a/editor/src/core/shared/dependencies.ts b/editor/src/core/shared/dependencies.ts index 345315ab1dba..ff6c48ed11a2 100644 --- a/editor/src/core/shared/dependencies.ts +++ b/editor/src/core/shared/dependencies.ts @@ -13,11 +13,17 @@ import { import type { EditorStorePatched } from '../../components/editor/store/editor-state' import type { BuiltInDependencies } from '../es-modules/package-manager/built-in-dependencies-list' import { fetchNodeModules } from '../es-modules/package-manager/fetch-packages' -import type { RequestedNpmDependency } from './npm-dependency-types' +import type { + PackageDetails, + PackageStatusMap, + RequestedNpmDependency, +} from './npm-dependency-types' import { objectFilter } from './object-utils' import type { NodeModules } from './project-file-types' import { isTextFile } from './project-file-types' import { fastForEach } from './utils' +import { notifyOperationFinished } from './import/import-operation-service' +import { ImportOperationResult } from './import/import-operation-types' export function removeModulesFromNodeModules( modulesToRemove: Array, @@ -76,7 +82,11 @@ export async function refreshDependencies( const depsToFetch = newDeps.concat(updatedDeps) - const fetchNodeModulesResult = await fetchNodeModules(depsToFetch, builtInDependencies) + const fetchNodeModulesResult = await fetchNodeModules( + dispatch, + depsToFetch, + builtInDependencies, + ) const loadedPackagesStatus = createLoadedPackageStatusMapFromDependencies( deps, @@ -91,6 +101,12 @@ export async function refreshDependencies( updateNodeModulesContents(fetchNodeModulesResult.nodeModules), ]) + notifyOperationFinished( + dispatch, + { type: 'refreshDependencies' }, + getDependenciesStatus(loadedPackagesStatus), + ) + return updatedNodeModulesFiles } @@ -100,6 +116,20 @@ export async function refreshDependencies( }) } +function isPackageMissing(status: PackageDetails): boolean { + return status.status === 'error' || status.status === 'not-found' +} + +function getDependenciesStatus(loadedPackagesStatus: PackageStatusMap): ImportOperationResult { + if (Object.values(loadedPackagesStatus).every(isPackageMissing)) { + return ImportOperationResult.Error + } + if (Object.values(loadedPackagesStatus).some(isPackageMissing)) { + return ImportOperationResult.Warn + } + return ImportOperationResult.Success +} + export const projectDependenciesSelector = createSelector( (store: EditorStorePatched) => store.editor.projectContents, (projectContents): Array => { diff --git a/editor/src/core/shared/github/operations/load-branch.ts b/editor/src/core/shared/github/operations/load-branch.ts index 88c4f2d2d8e4..36a63a7d74fc 100644 --- a/editor/src/core/shared/github/operations/load-branch.ts +++ b/editor/src/core/shared/github/operations/load-branch.ts @@ -39,6 +39,14 @@ import type { ExistingAsset } from '../../../../components/editor/server' import { GithubOperations } from '.' import { assertNever } from '../../utils' import { updateProjectContentsWithParseResults } from '../../parser-projectcontents-utils' +import { + notifyOperationFinished, + notifyOperationStarted, + startImportProcess, +} from '../../import/import-operation-service' +import { resetRequirementsResolutions } from '../../import/proejct-health-check/utopia-requirements-service' +import { checkAndFixUtopiaRequirements } from '../../import/proejct-health-check/check-utopia-requirements' +import { ImportOperationResult } from '../../import/import-operation-types' export const saveAssetsToProject = (operationContext: GithubOperationContext) => @@ -115,6 +123,12 @@ export const updateProjectWithBranchContent = currentProjectContents: ProjectContentTreeRoot, initiator: GithubOperationSource, ): Promise => { + startImportProcess(dispatch) + notifyOperationStarted(dispatch, { + type: 'loadBranch', + branchName: branchName, + githubRepo: githubRepo, + }) await runGithubOperation( { name: 'loadBranch', @@ -147,14 +161,29 @@ export const updateProjectWithBranchContent = if (resetBranches) { newGithubData.branches = null } + notifyOperationFinished(dispatch, { type: 'loadBranch' }, ImportOperationResult.Success) + notifyOperationStarted(dispatch, { type: 'parseFiles' }) // Push any code through the parser so that the representations we end up with are in a state of `BOTH_MATCH`. // So that it will override any existing files that might already exist in the project when sending them to VS Code. - const parsedProjectContents = createStoryboardFileIfNecessary( - await updateProjectContentsWithParseResults(workers, responseBody.branch.content), + const parseResults = await updateProjectContentsWithParseResults( + workers, + responseBody.branch.content, + ) + notifyOperationFinished(dispatch, { type: 'parseFiles' }, ImportOperationResult.Success) + + resetRequirementsResolutions(dispatch) + const parsedProjectContentsInitial = createStoryboardFileIfNecessary( + dispatch, + parseResults, 'create-placeholder', ) + const parsedProjectContents = checkAndFixUtopiaRequirements( + dispatch, + parsedProjectContentsInitial, + ) + // Update the editor with everything so that if anything else fails past this point // there's no loss of data from the user's perspective. dispatch( @@ -180,6 +209,7 @@ export const updateProjectWithBranchContent = let dependenciesPromise: Promise = Promise.resolve() const packageJson = packageJsonFileFromProjectContents(parsedProjectContents) if (packageJson != null && isTextFile(packageJson)) { + notifyOperationStarted(dispatch, { type: 'refreshDependencies' }) dependenciesPromise = refreshDependencies( dispatch, packageJson.fileContents.code, diff --git a/editor/src/core/shared/import/import-operation-service.ts b/editor/src/core/shared/import/import-operation-service.ts new file mode 100644 index 000000000000..e04b0cb10103 --- /dev/null +++ b/editor/src/core/shared/import/import-operation-service.ts @@ -0,0 +1,149 @@ +import { assertNever } from '../utils' +import type { EditorAction, EditorDispatch } from '../../../components/editor/action-types' +import { + setImportWizardOpen, + updateImportOperations, +} from '../../../components/editor/actions/action-creators' +import { ImportOperationAction } from './import-operation-types' +import type { + ImportOperation, + ImportOperationResult, + ImportOperationType, +} from './import-operation-types' +import { isFeatureEnabled } from '../../../utils/feature-switches' + +export function startImportProcess(dispatch: EditorDispatch) { + const actions: EditorAction[] = [ + updateImportOperations( + [ + { type: 'loadBranch' }, + { type: 'parseFiles' }, + { type: 'checkRequirements' }, + { type: 'refreshDependencies' }, + ], + ImportOperationAction.Replace, + ), + ] + if (isFeatureEnabled('Import Wizard')) { + actions.push(setImportWizardOpen(true)) + } + dispatch(actions) +} + +export function hideImportWizard(dispatch: EditorDispatch) { + dispatch([setImportWizardOpen(false)]) +} + +export function notifyOperationStarted(dispatch: EditorDispatch, operation: ImportOperation) { + const operationWithTime = { + ...operation, + timeStarted: Date.now(), + timeDone: null, + } + if (!isFeatureEnabled('Import Wizard')) { + return + } + setTimeout(() => { + dispatch([updateImportOperations([operationWithTime], ImportOperationAction.Update)]) + }, 0) +} + +export function notifyOperationFinished( + dispatch: EditorDispatch, + operation: ImportOperation, + result: ImportOperationResult, +) { + const timeDone = Date.now() + const operationWithTime = { + ...operation, + timeDone: timeDone, + result: result, + } + if (!isFeatureEnabled('Import Wizard')) { + return + } + setTimeout(() => { + dispatch([updateImportOperations([operationWithTime], ImportOperationAction.Update)]) + }, 0) +} + +export function areSameOperation(existing: ImportOperation, incoming: ImportOperation): boolean { + if (existing.id == null || incoming.id == null) { + return existing.type === incoming.type + } + return existing.id === incoming.id +} + +export const defaultParentTypes: Partial> = { + checkRequirementAndFix: 'checkRequirements', + fetchDependency: 'refreshDependencies', +} as const + +function getParentArray(root: ImportOperation[], operation: ImportOperation): ImportOperation[] { + const parentOperationType = defaultParentTypes[operation.type] + if (parentOperationType == null) { + return root + } + const parentIndex = root.findIndex((op) => op.type === parentOperationType) + if (parentIndex === -1) { + return root + } + const parent = root[parentIndex] + if (parent.children == null) { + root[parentIndex] = { + ...parent, + children: [], + } + } + return root[parentIndex].children ?? [] +} + +export function getUpdateOperationResult( + existingOperations: ImportOperation[], + incomingOperations: ImportOperation[], + type: ImportOperationAction, +): ImportOperation[] { + let operations: ImportOperation[] = existingOperations.map((operation) => ({ + ...operation, + children: [...(operation.children ?? [])], + })) + switch (type) { + case ImportOperationAction.Add: + incomingOperations.forEach((operation) => { + const parent = getParentArray(operations, operation) + parent.push(operation) + }) + break + case ImportOperationAction.Remove: + incomingOperations.forEach((operation) => { + const parent = getParentArray(operations, operation) + const idx = parent.findIndex((op) => areSameOperation(op, operation)) + if (idx >= 0) { + parent.splice(idx, 1) + } + }) + break + case ImportOperationAction.Update: + incomingOperations.forEach((operation) => { + const parent = getParentArray(operations, operation) + const idx = parent.findIndex((op) => areSameOperation(op, operation)) + if (idx >= 0) { + parent[idx] = { + ...parent[idx], + ...operation, + } + } + // if not found, add it + if (idx === -1) { + parent.push(operation) + } + }) + break + case ImportOperationAction.Replace: + operations = [...incomingOperations] + break + default: + assertNever(type) + } + return operations +} diff --git a/editor/src/core/shared/import/import-operation-types.ts b/editor/src/core/shared/import/import-operation-types.ts new file mode 100644 index 000000000000..4a8fccb83539 --- /dev/null +++ b/editor/src/core/shared/import/import-operation-types.ts @@ -0,0 +1,84 @@ +import type { GithubRepo } from '../../../components/editor/store/editor-state' +import type { RequirementResolutionResult } from './proejct-health-check/utopia-requirements-types' + +type ImportOperationData = { + text?: string + id?: string | null + timeStarted?: number | null + timeDone?: number | null + result?: ImportOperationResult + error?: string + children?: ImportOperation[] +} + +export const ImportOperationResult = { + Success: 'success', + Error: 'error', + Warn: 'warn', +} as const + +export type ImportOperationResult = + (typeof ImportOperationResult)[keyof typeof ImportOperationResult] + +type ImportLoadBranch = { + type: 'loadBranch' + branchName?: string + githubRepo?: GithubRepo +} & ImportOperationData + +type ImportRefreshDependencies = ImportOperationData & { + type: 'refreshDependencies' +} + +export type ImportFetchDependency = ImportOperationData & { + type: 'fetchDependency' + dependencyName: string + dependencyVersion: string + id: string +} + +type ImportParseFiles = { + type: 'parseFiles' +} & ImportOperationData + +export type ImportCheckRequirementAndFix = ImportOperationData & { + type: 'checkRequirementAndFix' + resolution?: RequirementResolutionResult + text: string + id: string +} + +export function importCheckRequirementAndFix( + id: string, + text: string, +): ImportCheckRequirementAndFix { + return { + type: 'checkRequirementAndFix', + text: text, + id: id, + } +} + +type ImportCheckRequirements = ImportOperationData & { + type: 'checkRequirements' +} + +export type ImportOperation = + | ImportLoadBranch + | ImportRefreshDependencies + | ImportParseFiles + | ImportFetchDependency + | ImportCheckRequirementAndFix + | ImportCheckRequirements + +export type ImportOperationType = ImportOperation['type'] + +export const ImportOperationAction = { + Add: 'add', + Remove: 'remove', + Update: 'update', + Replace: 'replace', +} as const + +export type ImportOperationAction = + (typeof ImportOperationAction)[keyof typeof ImportOperationAction] diff --git a/editor/src/core/shared/import/proejct-health-check/check-utopia-requirements.ts b/editor/src/core/shared/import/proejct-health-check/check-utopia-requirements.ts new file mode 100644 index 000000000000..ec78bdcb05e6 --- /dev/null +++ b/editor/src/core/shared/import/proejct-health-check/check-utopia-requirements.ts @@ -0,0 +1,154 @@ +import { + addFileToProjectContents, + packageJsonFileFromProjectContents, +} from '../../../../components/assets' +import type { ProjectContentTreeRoot } from 'utopia-shared/src/types' +import { codeFile, isTextFile, RevisionsState } from '../../project-file-types' +import { notifyCheckingRequirement, notifyResolveRequirement } from './utopia-requirements-service' +import { RequirementResolutionResult } from './utopia-requirements-types' +import { applyToAllUIJSFiles } from '../../../../core/model/project-file-utils' +import type { EditorDispatch } from '../../../../components/editor/action-types' + +export function checkAndFixUtopiaRequirements( + dispatch: EditorDispatch, + parsedProjectContents: ProjectContentTreeRoot, +): ProjectContentTreeRoot { + let projectContents = parsedProjectContents + // check and fix package.json + projectContents = checkAndFixPackageJson(dispatch, projectContents) + // check language + checkProjectLanguage(dispatch, projectContents) + // check react version + checkReactVersion(dispatch, projectContents) + return projectContents +} + +function getPackageJson( + projectContents: ProjectContentTreeRoot, +): { utopia?: Record; dependencies?: Record } | null { + const packageJson = packageJsonFileFromProjectContents(projectContents) + if (packageJson != null && isTextFile(packageJson)) { + return JSON.parse(packageJson.fileContents.code) + } + return null +} + +function checkAndFixPackageJson( + dispatch: EditorDispatch, + projectContents: ProjectContentTreeRoot, +): ProjectContentTreeRoot { + notifyCheckingRequirement(dispatch, 'packageJsonEntries', 'Checking package.json') + const parsedPackageJson = getPackageJson(projectContents) + if (parsedPackageJson == null) { + notifyResolveRequirement( + dispatch, + 'packageJsonEntries', + RequirementResolutionResult.Critical, + 'The file package.json was not found', + ) + return projectContents + } + if (parsedPackageJson.utopia == null) { + parsedPackageJson.utopia = { + 'main-ui': 'utopia/storyboard.js', + } + const result = addFileToProjectContents( + projectContents, + '/package.json', + codeFile( + JSON.stringify(parsedPackageJson, null, 2), + null, + 0, + RevisionsState.CodeAheadButPleaseTellVSCodeAboutIt, + ), + ) + notifyResolveRequirement( + dispatch, + 'packageJsonEntries', + RequirementResolutionResult.Fixed, + 'Fixed utopia entry in package.json', + ) + return result + } else { + notifyResolveRequirement( + dispatch, + 'packageJsonEntries', + RequirementResolutionResult.Found, + 'Valid package.json found', + ) + } + + return projectContents +} + +function checkProjectLanguage( + dispatch: EditorDispatch, + projectContents: ProjectContentTreeRoot, +): void { + notifyCheckingRequirement(dispatch, 'language', 'Checking project language') + let jsCount = 0 + let tsCount = 0 + applyToAllUIJSFiles(projectContents, (filename, uiJSFile) => { + if ((filename.endsWith('.ts') || filename.endsWith('.tsx')) && !filename.endsWith('.d.ts')) { + tsCount++ + } else if (filename.endsWith('.js') || filename.endsWith('.jsx')) { + jsCount++ + } + return uiJSFile + }) + if (tsCount > 0) { + notifyResolveRequirement( + dispatch, + 'language', + RequirementResolutionResult.Critical, + 'There are Typescript files in the project', + 'typescript', + ) + } else if (jsCount == 0) { + // in case it's a .coffee project, python, etc + notifyResolveRequirement( + dispatch, + 'language', + RequirementResolutionResult.Critical, + 'No JS/JSX files found', + 'javascript', + ) + } else { + notifyResolveRequirement( + dispatch, + 'language', + RequirementResolutionResult.Found, + 'Project uses JS/JSX', + 'javascript', + ) + } +} + +function checkReactVersion( + dispatch: EditorDispatch, + projectContents: ProjectContentTreeRoot, +): void { + notifyCheckingRequirement(dispatch, 'reactVersion', 'Checking React version') + const parsedPackageJson = getPackageJson(projectContents) + if ( + parsedPackageJson == null || + parsedPackageJson.dependencies == null || + parsedPackageJson.dependencies.react == null + ) { + return notifyResolveRequirement( + dispatch, + 'reactVersion', + RequirementResolutionResult.Critical, + 'React is not in dependencies', + ) + } + const reactVersion = parsedPackageJson.dependencies.react + // TODO: check react version + return notifyResolveRequirement( + dispatch, + 'reactVersion', + RequirementResolutionResult.Found, + 'React version is ok', + reactVersion, + ) +} diff --git a/editor/src/core/shared/import/proejct-health-check/utopia-requirements-service.ts b/editor/src/core/shared/import/proejct-health-check/utopia-requirements-service.ts new file mode 100644 index 000000000000..eb0d17b0dda8 --- /dev/null +++ b/editor/src/core/shared/import/proejct-health-check/utopia-requirements-service.ts @@ -0,0 +1,134 @@ +import { importCheckRequirementAndFix, ImportOperationResult } from '../import-operation-types' +import { notifyOperationFinished, notifyOperationStarted } from '../import-operation-service' +import type { EditorDispatch } from '../../../../components/editor/action-types' +import { updateProjectRequirements } from '../../../../components/editor/actions/action-creators' +import type { ProjectRequirement, ProjectRequirements } from './utopia-requirements-types' +import { + emptyProjectRequirements, + RequirementResolutionResult, + RequirementResolutionStatus, +} from './utopia-requirements-types' +import { isFeatureEnabled } from '../../../../utils/feature-switches' + +const initialTexts: Record = { + storyboard: 'Checking storyboard.js', + packageJsonEntries: 'Checking package.json', + language: 'Checking project language', + reactVersion: 'Checking React version', +} + +export function updateProjectRequirementsStatus( + dispatch: EditorDispatch, + projectRequirements: Partial, +) { + if (!isFeatureEnabled('Import Wizard')) { + return + } + setTimeout(() => { + dispatch([updateProjectRequirements(projectRequirements)]) + }, 0) +} + +export function resetRequirementsResolutions(dispatch: EditorDispatch) { + let projectRequirements = emptyProjectRequirements() + updateProjectRequirementsStatus(dispatch, projectRequirements) + notifyOperationStarted(dispatch, { + type: 'checkRequirements', + children: Object.keys(projectRequirements).map((key) => + importCheckRequirementAndFix( + key as ProjectRequirement, + initialTexts[key as ProjectRequirement], + ), + ), + }) +} + +export function notifyCheckingRequirement( + dispatch: EditorDispatch, + requirement: ProjectRequirement, + text: string, +) { + updateProjectRequirementsStatus(dispatch, { + [requirement]: { + status: RequirementResolutionStatus.Pending, + }, + }) + notifyOperationStarted(dispatch, { + type: 'checkRequirementAndFix', + id: requirement, + text: text, + }) +} + +export function notifyResolveRequirement( + dispatch: EditorDispatch, + requirementName: ProjectRequirement, + resolution: RequirementResolutionResult, + text: string, + value?: string, +) { + updateProjectRequirementsStatus(dispatch, { + [requirementName]: { + status: RequirementResolutionStatus.Done, + resolution: resolution, + value: value, + }, + }) + const result = + resolution === RequirementResolutionResult.Found || + resolution === RequirementResolutionResult.Fixed + ? ImportOperationResult.Success + : resolution === RequirementResolutionResult.Partial + ? ImportOperationResult.Warn + : ImportOperationResult.Error + notifyOperationFinished( + dispatch, + { + type: 'checkRequirementAndFix', + id: requirementName, + text: text, + resolution: resolution, + }, + result, + ) +} + +export function updateRequirements( + dispatch: EditorDispatch, + existingRequirements: ProjectRequirements, + incomingRequirements: Partial, +): ProjectRequirements { + let result = { ...existingRequirements } + for (const incomingRequirement of Object.keys(incomingRequirements)) { + const incomingRequirementName = incomingRequirement as ProjectRequirement + result[incomingRequirementName] = { + ...result[incomingRequirementName], + ...incomingRequirements[incomingRequirementName], + } + } + + const aggregatedDoneStatus = getAggregatedStatus(result) + if (aggregatedDoneStatus != null) { + notifyOperationFinished(dispatch, { type: 'checkRequirements' }, aggregatedDoneStatus) + } + + return result +} + +function getAggregatedStatus( + requirementsResolutions: ProjectRequirements, +): ImportOperationResult | null { + for (const resolution of Object.values(requirementsResolutions)) { + if (resolution.status != RequirementResolutionStatus.Done) { + return null + } + if (resolution.resolution == RequirementResolutionResult.Critical) { + return ImportOperationResult.Error + } + if (resolution.resolution == RequirementResolutionResult.Partial) { + return ImportOperationResult.Warn + } + } + return ImportOperationResult.Success +} +export { RequirementResolutionResult } diff --git a/editor/src/core/shared/import/proejct-health-check/utopia-requirements-types.ts b/editor/src/core/shared/import/proejct-health-check/utopia-requirements-types.ts new file mode 100644 index 000000000000..6af5548346f4 --- /dev/null +++ b/editor/src/core/shared/import/proejct-health-check/utopia-requirements-types.ts @@ -0,0 +1,76 @@ +export const RequirementResolutionResult = { + Found: 'found', + Fixed: 'fixed', + Partial: 'partial', + Critical: 'critical', +} as const + +export type RequirementResolutionResult = + (typeof RequirementResolutionResult)[keyof typeof RequirementResolutionResult] + +export const RequirementResolutionStatus = { + NotStarted: 'not-started', + Pending: 'pending', + Done: 'done', +} + +export type RequirementResolutionStatus = + (typeof RequirementResolutionStatus)[keyof typeof RequirementResolutionStatus] + +export function emptyRequirementResolution(): RequirementResolution { + return { + status: RequirementResolutionStatus.NotStarted, + value: null, + resolution: null, + } +} + +export function emptyProjectRequirements(): ProjectRequirements { + return newProjectRequirements( + emptyRequirementResolution(), + emptyRequirementResolution(), + emptyRequirementResolution(), + emptyRequirementResolution(), + ) +} + +export interface RequirementResolution { + status: RequirementResolutionStatus + value?: string | null + resolution?: RequirementResolutionResult | null +} + +export function requirementResolution( + status: RequirementResolutionStatus, + value?: string | null, + resolution?: RequirementResolutionResult | null, +): RequirementResolution { + return { + status, + value, + resolution, + } +} + +export interface ProjectRequirements { + storyboard: RequirementResolution + packageJsonEntries: RequirementResolution + language: RequirementResolution + reactVersion: RequirementResolution +} + +export type ProjectRequirement = keyof ProjectRequirements + +export function newProjectRequirements( + storyboard: RequirementResolution, + packageJsonEntries: RequirementResolution, + language: RequirementResolution, + reactVersion: RequirementResolution, +): ProjectRequirements { + return { + storyboard, + packageJsonEntries, + language, + reactVersion, + } +} diff --git a/editor/src/utils/feature-switches.ts b/editor/src/utils/feature-switches.ts index 4f996e50e1df..0044642a5ccf 100644 --- a/editor/src/utils/feature-switches.ts +++ b/editor/src/utils/feature-switches.ts @@ -22,6 +22,7 @@ export type FeatureName = | 'Debug - Arbitrary Code Cache' | 'Canvas Fast Selection Hack' | 'Tailwind' + | 'Import Wizard' | 'Show Debug Features' export const AllFeatureNames: FeatureName[] = [ @@ -46,6 +47,7 @@ export const AllFeatureNames: FeatureName[] = [ 'Condensed Navigator Entries', 'Canvas Fast Selection Hack', 'Tailwind', + 'Import Wizard', ] let FeatureSwitches: { [feature in FeatureName]: boolean } = { @@ -68,6 +70,7 @@ let FeatureSwitches: { [feature in FeatureName]: boolean } = { 'Condensed Navigator Entries': !IS_TEST_ENVIRONMENT, 'Use Parsing Cache': false, 'Canvas Fast Selection Hack': true, + 'Import Wizard': false, 'Show Debug Features': false, }