diff --git a/editor/src/components/editor/action-types.ts b/editor/src/components/editor/action-types.ts index a67e6e14b5c8..7f2893d16ee4 100644 --- a/editor/src/components/editor/action-types.ts +++ b/editor/src/components/editor/action-types.ts @@ -385,6 +385,11 @@ export interface AddToast { toast: Notice } +export interface SetForking { + action: 'SET_FORKING' + forking: boolean +} + export interface RemoveToast { action: 'REMOVE_TOAST' id: string @@ -1251,6 +1256,7 @@ export type EditorAction = | UpdateImportsFromCollaborationUpdate | UpdateCodeFromCollaborationUpdate | SetCommentFilterMode + | SetForking export type DispatchPriority = | 'everyone' diff --git a/editor/src/components/editor/actions/action-creators.ts b/editor/src/components/editor/actions/action-creators.ts index 113ed45f1267..7742e12894a9 100644 --- a/editor/src/components/editor/actions/action-creators.ts +++ b/editor/src/components/editor/actions/action-creators.ts @@ -218,6 +218,7 @@ import type { UpdateImportsFromCollaborationUpdate, UpdateCodeFromCollaborationUpdate, SetCommentFilterMode, + SetForking, } from '../action-types' import type { InsertionSubjectWrapper, Mode } from '../editor-modes' import { EditorModes, insertionSubject } from '../editor-modes' @@ -518,6 +519,13 @@ export function showToast(toastContent: Notice): AddToast { return addToast(toastContent) } +export function setForking(forking: boolean): SetForking { + return { + action: 'SET_FORKING', + forking: forking, + } +} + let selectionControlTimer: any // TODO maybe this should live inside the editormodel export function hideAndShowSelectionControls(dispatch: EditorDispatch): void { dispatch([CanvasActions.setSelectionControlsVisibility(false)], 'canvas') diff --git a/editor/src/components/editor/actions/action-utils.ts b/editor/src/components/editor/actions/action-utils.ts index 563a72ef14a1..05237d97e607 100644 --- a/editor/src/components/editor/actions/action-utils.ts +++ b/editor/src/components/editor/actions/action-utils.ts @@ -131,6 +131,7 @@ export function isTransientAction(action: EditorAction): boolean { case 'TRUNCATE_HISTORY': case 'UPDATE_PROJECT_SERVER_STATE': case 'SET_COMMENT_FILTER_MODE': + case 'SET_FORKING': return true case 'TRUE_UP_ELEMENTS': diff --git a/editor/src/components/editor/actions/actions.tsx b/editor/src/components/editor/actions/actions.tsx index 7b3bb8292b9c..619618514d2a 100644 --- a/editor/src/components/editor/actions/actions.tsx +++ b/editor/src/components/editor/actions/actions.tsx @@ -316,6 +316,7 @@ import type { UpdateImportsFromCollaborationUpdate, UpdateCodeFromCollaborationUpdate, SetCommentFilterMode, + SetForking, } from '../action-types' import { isLoggedIn } from '../action-types' import type { Mode } from '../editor-modes' @@ -912,6 +913,7 @@ export function restoreEditorState( filesModifiedByAnotherUser: currentEditor.filesModifiedByAnotherUser, activeFrames: currentEditor.activeFrames, commentFilterMode: currentEditor.commentFilterMode, + forking: currentEditor.forking, } } @@ -2074,6 +2076,12 @@ export const UPDATE_FNS = { ADD_TOAST: (action: AddToast, editor: EditorModel): EditorModel => { return addToastToState(editor, action.toast) }, + SET_FORKING: (action: SetForking, editor: EditorModel): EditorModel => { + return { + ...editor, + forking: action.forking, + } + }, UPDATE_GITHUB_OPERATIONS: (action: UpdateGithubOperations, editor: EditorModel): EditorModel => { const operations = [...editor.githubOperations] switch (action.type) { diff --git a/editor/src/components/editor/editor-component.tsx b/editor/src/components/editor/editor-component.tsx index 284f5cccda9f..bef61300239f 100644 --- a/editor/src/components/editor/editor-component.tsx +++ b/editor/src/components/editor/editor-component.tsx @@ -73,15 +73,28 @@ import { GithubRepositoryCloneFlow } from '../github/github-repository-clone-flo import { getPermissions } from './store/permissions' import { CommentMaintainer } from '../../core/commenting/comment-maintainer' import { useIsLoggedIn, useLiveblocksConnectionListener } from '../../core/shared/multiplayer-hooks' +import { ForkSearchParamKey, ProjectForkFlow } from './project-fork-flow' const liveModeToastId = 'play-mode-toast' -function pushProjectURLToBrowserHistory(projectId: string, projectName: string): void { +function pushProjectURLToBrowserHistory( + projectId: string, + projectName: string, + forking: boolean, +): void { // Make sure we don't replace the query params - const queryParams = window.top?.location.search + const queryParams = new URLSearchParams(window.top?.location.search) + if (forking) { + // …but if it's forking, remove the fork param + queryParams.delete(ForkSearchParamKey) + } + + const queryParamsStr = queryParams.size > 0 ? `?${queryParams.toString()}` : '' + const projectURL = projectURLForProject(projectId, projectName) const title = `Utopia ${projectName}` - window.top?.history.pushState({}, title, `${projectURL}${queryParams}`) + + window.top?.history.pushState({}, title, `${projectURL}${queryParamsStr}`) } export interface EditorProps {} @@ -333,15 +346,17 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { document.title = projectName + ' - Utopia' }, [projectName]) + const forking = useEditorState(Substores.restOfEditor, (store) => store.editor.forking, '') + React.useEffect(() => { if (IS_BROWSER_TEST_DEBUG) { return } if (projectId != null) { - pushProjectURLToBrowserHistory(projectId, projectName) + pushProjectURLToBrowserHistory(projectId, projectName, forking) ;(window as any).utopiaProjectID = projectId } - }, [projectName, projectId]) + }, [projectName, projectId, forking]) const onClosePreview = React.useCallback( () => dispatch([EditorActions.setPanelVisibility('preview', false)]), @@ -478,6 +493,7 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { + { const githubOperations = useEditorState( Substores.github, (store) => store.editor.githubOperations.filter((op) => githubOperationLocksEditor(op)), - 'EditorComponentInner githubOperations', + 'LockedOverlay githubOperations', ) const editorLocked = React.useMemo(() => githubOperations.length > 0, [githubOperations]) @@ -634,7 +650,13 @@ const LockedOverlay = React.memo(() => { const refreshingDependencies = useEditorState( Substores.restOfEditor, (store) => store.editor.refreshingDependencies, - 'EditorComponentInner refreshingDependencies', + 'LockedOverlay refreshingDependencies', + ) + + const forking = useEditorState( + Substores.restOfEditor, + (store) => store.editor.forking, + 'LockedOverlay forking', ) const anim = keyframes` @@ -647,8 +669,8 @@ const LockedOverlay = React.memo(() => { ` const locked = React.useMemo(() => { - return editorLocked || refreshingDependencies - }, [editorLocked, refreshingDependencies]) + return editorLocked || refreshingDependencies || forking + }, [editorLocked, refreshingDependencies, forking]) const dialogContent = React.useMemo((): string | null => { if (refreshingDependencies) { @@ -657,8 +679,11 @@ const LockedOverlay = React.memo(() => { if (githubOperations.length > 0) { return `${githubOperationPrettyName(githubOperations[0])}…` } + if (forking) { + return 'Forking project…' + } return null - }, [refreshingDependencies, githubOperations]) + }, [refreshingDependencies, githubOperations, forking]) if (!locked) { return null diff --git a/editor/src/components/editor/persistence-hooks.ts b/editor/src/components/editor/persistence-hooks.ts index 87bdb647427b..7d52fba2432e 100644 --- a/editor/src/components/editor/persistence-hooks.ts +++ b/editor/src/components/editor/persistence-hooks.ts @@ -1,11 +1,16 @@ import React from 'react' import { useRefEditorState } from './store/store-hook' import { persistentModelFromEditorModel } from './store/editor-state' +import { useDispatch } from './store/dispatch-context' +import { setForking } from './actions/action-creators' export function useTriggerForkProject(): () => void { + const dispatch = useDispatch() + const storeRef = useRefEditorState((store) => store) return React.useCallback(async () => { + dispatch([setForking(true)]) const store = storeRef.current store.persistence.fork(store.editor.projectName, persistentModelFromEditorModel(store.editor)) - }, [storeRef]) + }, [storeRef, dispatch]) } diff --git a/editor/src/components/editor/persistence/persistence.ts b/editor/src/components/editor/persistence/persistence.ts index 01bf9a6bec86..13b1a62a35b5 100644 --- a/editor/src/components/editor/persistence/persistence.ts +++ b/editor/src/components/editor/persistence/persistence.ts @@ -6,6 +6,7 @@ import { notice } from '../../common/notice' import type { EditorAction, EditorDispatch } from '../action-types' import { setForkedFromProjectID, + setForking, setProjectID, setProjectName, showToast, @@ -89,6 +90,7 @@ export class PersistenceMachine { this.queuedActions.push(setForkedFromProjectID(state.context.projectId!)) this.queuedActions.push(setProjectName(state.context.project!.name)) this.queuedActions.push(showToast(notice('Project successfully forked!'))) + this.queuedActions.push(setForking(false)) } const updateFileActions = event.downloadAssetsResult.filesWithFileNames.map( diff --git a/editor/src/components/editor/project-fork-flow.tsx b/editor/src/components/editor/project-fork-flow.tsx new file mode 100644 index 000000000000..c887c40e5faf --- /dev/null +++ b/editor/src/components/editor/project-fork-flow.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { useTriggerForkProject } from './persistence-hooks' +import { Substores, useEditorState } from './store/store-hook' + +export const ForkSearchParamKey = 'fork' + +export const ProjectForkFlow = React.memo(() => { + const searchParams = new URLSearchParams(window.location.search) + const shouldFork = searchParams.get(ForkSearchParamKey) === 'true' + + const projectId = useEditorState( + Substores.restOfEditor, + (store) => store.editor.id, + 'ProjectForkFlow projectId', + ) + + const triggerForkProject = useTriggerForkProject() + + React.useEffect(() => { + if (shouldFork && projectId != null) { + triggerForkProject() + } + }, [shouldFork, triggerForkProject, projectId]) + + return null +}) + +ProjectForkFlow.displayName = 'ProjectForkFlow' diff --git a/editor/src/components/editor/store/editor-state.ts b/editor/src/components/editor/store/editor-state.ts index 85d9d83757fa..87064f84c22e 100644 --- a/editor/src/components/editor/store/editor-state.ts +++ b/editor/src/components/editor/store/editor-state.ts @@ -1458,6 +1458,7 @@ export interface EditorState { filesModifiedByAnotherUser: Array activeFrames: ActiveFrame[] commentFilterMode: CommentFilterMode + forking: boolean } export function editorState( @@ -1540,6 +1541,7 @@ export function editorState( filesModifiedByAnotherUser: Array, activeFrames: ActiveFrame[], commentFilterMode: CommentFilterMode, + forking: boolean, ): EditorState { return { id: id, @@ -1609,7 +1611,7 @@ export function editorState( forceParseFiles: forceParseFiles, allElementProps: allElementProps, currentAllElementProps: currentAllElementProps, - variablesInScope, + variablesInScope: variablesInScope, currentVariablesInScope: currentVariablesInScope, githubSettings: githubSettings, imageDragSessionState: imageDragSessionState, @@ -1621,6 +1623,7 @@ export function editorState( filesModifiedByAnotherUser: filesModifiedByAnotherUser, activeFrames: activeFrames, commentFilterMode: commentFilterMode, + forking: forking, } } @@ -2518,6 +2521,7 @@ export function createEditorState(dispatch: EditorDispatch): EditorState { filesModifiedByAnotherUser: [], activeFrames: [], commentFilterMode: 'all', + forking: false, } } @@ -2894,6 +2898,7 @@ export function editorModelFromPersistentModel( filesModifiedByAnotherUser: [], activeFrames: [], commentFilterMode: 'all', + forking: false, } return editor } diff --git a/editor/src/components/editor/store/editor-update.tsx b/editor/src/components/editor/store/editor-update.tsx index 1a73cba81d3d..592bbe303963 100644 --- a/editor/src/components/editor/store/editor-update.tsx +++ b/editor/src/components/editor/store/editor-update.tsx @@ -453,6 +453,8 @@ export function runSimpleLocalEditorAction( ) case 'SET_COMMENT_FILTER_MODE': return UPDATE_FNS.SET_SHOW_RESOLVED_THREADS(action, state) + case 'SET_FORKING': + return UPDATE_FNS.SET_FORKING(action, state) default: return state } 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 4e011ca69ded..46913e4c135b 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -4561,6 +4561,8 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( newValue.commentFilterMode, ) + const forkingResults = BooleanKeepDeepEquality(oldValue.forking, newValue.forking) + const areEqual = idResult.areEqual && forkedFromProjectIdResult.areEqual && @@ -4639,7 +4641,8 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( internalClipboardResults.areEqual && filesModifiedByAnotherUserResults.areEqual && activeFramesResults.areEqual && - commentFilterModeResults.areEqual + commentFilterModeResults.areEqual && + forkingResults.areEqual if (areEqual) { return keepDeepEqualityResult(oldValue, true) @@ -4724,6 +4727,7 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( filesModifiedByAnotherUserResults.value, activeFramesResults.value, commentFilterModeResults.value, + forkingResults.value, ) return keepDeepEqualityResult(newEditorState, false) 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 96eb39a17256..ee6f1eff8120 100644 --- a/editor/src/components/editor/store/store-hook-substore-helpers.ts +++ b/editor/src/components/editor/store/store-hook-substore-helpers.ts @@ -173,4 +173,5 @@ export const EmptyEditorStateForKeysOnly: EditorState = { filesModifiedByAnotherUser: [], activeFrames: [], commentFilterMode: 'all', + forking: false, } diff --git a/utopia-remix/app/components/projectActionContextMenu.tsx b/utopia-remix/app/components/projectActionContextMenu.tsx index 483771f76452..002cff8e4101 100644 --- a/utopia-remix/app/components/projectActionContextMenu.tsx +++ b/utopia-remix/app/components/projectActionContextMenu.tsx @@ -71,6 +71,12 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit // TODO notification toast }, }, + { + text: 'Fork', + onClick: (project) => { + window.open(projectEditorLink(project.proj_id) + '/?fork=true', '_blank') + }, + }, 'separator', { text: 'Rename',