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',